From eb9ea20511313bfcc86a65e73f9a6e079206ef48 Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Thu, 7 Jan 2021 14:12:59 -0800 Subject: [PATCH] feat(selectors): proximity selectors (#4923) --- docs/src/selectors.md | 20 ++--- src/server/common/selectorParser.ts | 2 +- src/server/injected/selectorEvaluator.ts | 99 +++++++++++++++++++++++- test/selectors-misc.spec.ts | 49 ++++++------ 4 files changed, 130 insertions(+), 40 deletions(-) diff --git a/docs/src/selectors.md b/docs/src/selectors.md index 7efaeb25ed..80b58fd082 100644 --- a/docs/src/selectors.md +++ b/docs/src/selectors.md @@ -17,9 +17,7 @@ Playwright also supports the following CSS extensions: * `:text("string")` - Matches elements that contain specific text node. Learn more about [text selector](./selectors.md#css-extension-text). * `:visible` - Matches only visible elements. Learn more about [visible selector](./selectors.md#css-extension-visible). * `:light(selector)` - Matches in the light DOM only as opposite to piercing open shadow roots. Learn more about [shadow piercing](./selectors.md#shadow-piercing). - +* `:right-of(selector)`, `:left-of(selector)`, `:above(selector)`, `:below(selector)`, `:near(selector)` - Match elements based on their relative position to another element. Learn more about [proximity selectors](./selectors.md#css-extension-proximity). For convenience, selectors in the wrong format are heuristically converted to the right format: - selector starting with `//` or `..` is assumed to be `xpath=selector`; @@ -285,19 +283,18 @@ await page.click('button:text("Sign in")'); await page.click(':light(.article > .header)'); ``` - ### xpath diff --git a/src/server/common/selectorParser.ts b/src/server/common/selectorParser.ts index a238f74a2f..05c8925c62 100644 --- a/src/server/common/selectorParser.ts +++ b/src/server/common/selectorParser.ts @@ -26,7 +26,7 @@ export type ParsedSelector = { capture?: number, }; -const customCSSNames = new Set(['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text', 'text-matches', 'text-is']); +const customCSSNames = new Set(['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text', 'text-matches', 'text-is', 'above', 'below', 'right-of', 'left-of', 'near']); export function parseSelector(selector: string): ParsedSelector { const result = parseSelectorV1(selector); diff --git a/src/server/injected/selectorEvaluator.ts b/src/server/injected/selectorEvaluator.ts index 245fef8f50..a6ac111d12 100644 --- a/src/server/injected/selectorEvaluator.ts +++ b/src/server/injected/selectorEvaluator.ts @@ -42,6 +42,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { private _cacheCallMatches: QueryCache = new Map(); private _cacheCallQuery: QueryCache = new Map(); private _cacheQuerySimple: QueryCache = new Map(); + private _scoreMap: Map | undefined; constructor(extraEngines: Map) { // Note: keep predefined names in sync with Selectors class. @@ -58,6 +59,11 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { this._engines.set('text-is', textIsEngine); this._engines.set('text-matches', textMatchesEngine); this._engines.set('xpath', xpathEngine); + this._engines.set('right-of', createProximityEngine('right-of', boxRightOf)); + this._engines.set('left-of', createProximityEngine('left-of', boxLeftOf)); + this._engines.set('above', createProximityEngine('above', boxAbove)); + this._engines.set('below', createProximityEngine('below', boxBelow)); + this._engines.set('near', createProximityEngine('near', boxNear)); for (const attr of ['id', 'data-testid', 'data-test-id', 'data-test']) this._engines.set(attr, createAttributeEngine(attr)); } @@ -113,11 +119,38 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { return this._cached(this._cacheQuery, selector, [context], () => { if (Array.isArray(selector)) return this._queryEngine(isEngine, context, selector); - const elements = this._querySimple(context, selector.simples[selector.simples.length - 1].selector); - return elements.filter(element => this._matchesParents(element, selector, selector.simples.length - 2, context)); + + // query() recursively calls itself, so we set up a new map for this particular query() call. + const previousScoreMap = this._scoreMap; + this._scoreMap = new Map(); + let elements = this._querySimple(context, selector.simples[selector.simples.length - 1].selector); + elements = elements.filter(element => this._matchesParents(element, selector, selector.simples.length - 2, context)); + if (this._scoreMap.size) { + elements.sort((a, b) => { + const aScore = this._scoreMap!.get(a); + const bScore = this._scoreMap!.get(b); + if (aScore === bScore) + return 0; + if (aScore === undefined) + return 1; + if (bScore === undefined) + return -1; + return aScore - bScore; + }); + } + this._scoreMap = previousScoreMap; + + return elements; }); } + _markScore(element: Element, score: number) { + // HACK ALERT: temporary marks an element with a score, to be used + // for sorting at the end of the query(). + if (this._scoreMap) + this._scoreMap.set(element, score); + } + private _matchesSimple(element: Element, simple: CSSSimpleSelector, context: QueryContext): boolean { return this._cached(this._cacheMatchesSimple, element, [simple, context], () => { const isPossiblyScopeClause = simple.functions.some(f => f.name === 'scope' || f.name === 'is'); @@ -455,6 +488,68 @@ function createAttributeEngine(attr: string): SelectorEngine { }; } +function boxRightOf(box1: DOMRect, box2: DOMRect): number | undefined { + if (box1.left < box2.right) + return; + return (box1.left - box2.right) + Math.max(box2.bottom - box1.bottom, 0) + Math.max(box1.top - box2.top, 0); +} + +function boxLeftOf(box1: DOMRect, box2: DOMRect): number | undefined { + if (box1.right > box2.left) + return; + return (box2.left - box1.right) + Math.max(box2.bottom - box1.bottom, 0) + Math.max(box1.top - box2.top, 0); +} + +function boxAbove(box1: DOMRect, box2: DOMRect): number | undefined { + if (box1.bottom > box2.top) + return; + return (box2.top - box1.bottom) + Math.max(box1.left - box2.left, 0) + Math.max(box2.right - box1.right, 0); +} + +function boxBelow(box1: DOMRect, box2: DOMRect): number | undefined { + if (box1.top < box2.bottom) + return; + return (box1.top - box2.bottom) + Math.max(box1.left - box2.left, 0) + Math.max(box2.right - box1.right, 0); +} + +function boxNear(box1: DOMRect, box2: DOMRect): number | undefined { + const kThreshold = 50; + let score = 0; + if (box1.left - box2.right >= 0) + score += box1.left - box2.right; + if (box2.left - box1.right >= 0) + score += box2.left - box1.right; + if (box2.top - box1.bottom >= 0) + score += box2.top - box1.bottom; + if (box1.top - box2.bottom >= 0) + score += box1.top - box2.bottom; + return score > kThreshold ? undefined : score; +} + +function createProximityEngine(name: string, scorer: (box1: DOMRect, box2: DOMRect) => number | undefined): SelectorEngine { + return { + matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean { + if (!args.length) + throw new Error(`"${name}" engine expects a selector list`); + const box = element.getBoundingClientRect(); + let bestScore: number | undefined; + for (const e of evaluator.query(context, args)) { + if (e === element) + continue; + const score = scorer(box, e.getBoundingClientRect()); + if (score === undefined) + continue; + if (bestScore === undefined || score < bestScore) + bestScore = score; + } + if (bestScore === undefined) + return false; + (evaluator as SelectorEvaluatorImpl)._markScore(element, bestScore); + return true; + } + }; +} + export function parentElementOrShadowHost(element: Element): Element | undefined { if (element.parentElement) return element.parentElement; diff --git a/test/selectors-misc.spec.ts b/test/selectors-misc.spec.ts index 7a7f01a20a..a6258ba61a 100644 --- a/test/selectors-misc.spec.ts +++ b/test/selectors-misc.spec.ts @@ -47,9 +47,7 @@ it('should work with :visible', async ({page}) => { expect(await page.$eval('div:visible', div => div.id)).toBe('target2'); }); -it('should work with proximity selectors', test => { - test.skip('Not ready yet'); -}, async ({page}) => { +it('should work with proximity selectors', async ({page}) => { /* +--+ +--+ @@ -104,46 +102,47 @@ it('should work with proximity selectors', test => { } }, boxes); - expect(await page.$eval('div:within(#id0)', e => e.id)).toBe('id6'); - expect(await page.$eval('div:within(div)', e => e.id)).toBe('id6'); - expect(await page.$('div:within(#id6)')).toBe(null); - expect(await page.$$eval('div:within(#id0)', els => els.map(e => e.id).join(','))).toBe('id6'); - expect(await page.$eval('div:right-of(#id6)', e => e.id)).toBe('id7'); expect(await page.$eval('div:right-of(#id1)', e => e.id)).toBe('id2'); - expect(await page.$eval('div:right-of(#id3)', e => e.id)).toBe('id2'); + expect(await page.$eval('div:right-of(#id3)', e => e.id)).toBe('id4'); expect(await page.$('div:right-of(#id4)')).toBe(null); - expect(await page.$eval('div:right-of(#id0)', e => e.id)).toBe('id4'); + expect(await page.$eval('div:right-of(#id0)', e => e.id)).toBe('id7'); expect(await page.$eval('div:right-of(#id8)', e => e.id)).toBe('id9'); - expect(await page.$$eval('div:right-of(#id3)', els => els.map(e => e.id).join(','))).toBe('id2,id5'); + expect(await page.$$eval('div:right-of(#id3)', els => els.map(e => e.id).join(','))).toBe('id4,id2,id5,id7,id8,id9'); expect(await page.$eval('div:left-of(#id2)', e => e.id)).toBe('id1'); expect(await page.$('div:left-of(#id0)')).toBe(null); expect(await page.$eval('div:left-of(#id5)', e => e.id)).toBe('id0'); expect(await page.$eval('div:left-of(#id9)', e => e.id)).toBe('id8'); - expect(await page.$eval('div:left-of(#id4)', e => e.id)).toBe('id0'); - expect(await page.$$eval('div:left-of(#id5)', els => els.map(e => e.id).join(','))).toBe('id0,id3,id7'); + expect(await page.$eval('div:left-of(#id4)', e => e.id)).toBe('id3'); + expect(await page.$$eval('div:left-of(#id5)', els => els.map(e => e.id).join(','))).toBe('id0,id7,id3,id1,id6,id8'); - expect(await page.$eval('div:above(#id0)', e => e.id)).toBe('id1'); - expect(await page.$eval('div:above(#id5)', e => e.id)).toBe('id2'); - expect(await page.$eval('div:above(#id7)', e => e.id)).toBe('id3'); + expect(await page.$eval('div:above(#id0)', e => e.id)).toBe('id3'); + expect(await page.$eval('div:above(#id5)', e => e.id)).toBe('id4'); + expect(await page.$eval('div:above(#id7)', e => e.id)).toBe('id5'); expect(await page.$eval('div:above(#id8)', e => e.id)).toBe('id0'); + expect(await page.$eval('div:above(#id9)', e => e.id)).toBe('id8'); expect(await page.$('div:above(#id2)')).toBe(null); - expect(await page.$('div:above(#id9)')).toBe(null); - expect(await page.$$eval('div:above(#id5)', els => els.map(e => e.id).join(','))).toBe('id2,id4'); + expect(await page.$$eval('div:above(#id5)', els => els.map(e => e.id).join(','))).toBe('id4,id2,id3,id1'); expect(await page.$eval('div:below(#id4)', e => e.id)).toBe('id5'); expect(await page.$eval('div:below(#id3)', e => e.id)).toBe('id0'); expect(await page.$eval('div:below(#id2)', e => e.id)).toBe('id4'); + expect(await page.$eval('div:below(#id6)', e => e.id)).toBe('id8'); + expect(await page.$eval('div:below(#id7)', e => e.id)).toBe('id8'); + expect(await page.$eval('div:below(#id8)', e => e.id)).toBe('id9'); expect(await page.$('div:below(#id9)')).toBe(null); - expect(await page.$('div:below(#id7)')).toBe(null); - expect(await page.$('div:below(#id8)')).toBe(null); - expect(await page.$('div:below(#id6)')).toBe(null); - expect(await page.$$eval('div:below(#id3)', els => els.map(e => e.id).join(','))).toBe('id0,id6,id7'); + expect(await page.$$eval('div:below(#id3)', els => els.map(e => e.id).join(','))).toBe('id0,id5,id6,id7,id8,id9'); - expect(await page.$eval('div:near(#id0)', e => e.id)).toBe('id1'); - expect(await page.$$eval('div:near(#id7)', els => els.map(e => e.id).join(','))).toBe('id0,id3,id4,id5,id6'); - expect(await page.$$eval('div:near(#id0)', els => els.map(e => e.id).join(','))).toBe('id1,id2,id3,id4,id5,id7,id8,id9'); + expect(await page.$eval('div:near(#id0)', e => e.id)).toBe('id3'); + expect(await page.$$eval('div:near(#id7)', els => els.map(e => e.id).join(','))).toBe('id0,id5,id3,id6'); + expect(await page.$$eval('div:near(#id0)', els => els.map(e => e.id).join(','))).toBe('id3,id6,id7,id8,id1,id5'); + expect(await page.$$eval('div:near(#id6)', els => els.map(e => e.id).join(','))).toBe('id0,id3,id7'); + + expect(await page.$$eval('div:below(#id5):above(#id8)', els => els.map(e => e.id).join(','))).toBe('id7,id6'); + expect(await page.$eval('div:below(#id5):above(#id8)', e => e.id)).toBe('id7'); + + expect(await page.$$eval('div:right-of(#id0) + div:above(#id8)', els => els.map(e => e.id).join(','))).toBe('id5,id6,id3'); }); it('should escape the scope with >>', async ({ page }) => {