feat(selectors): proximity selectors (#4923)

This commit is contained in:
Dmitry Gozman 2021-01-07 14:12:59 -08:00 committed by GitHub
parent ffa169ba92
commit eb9ea20511
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 130 additions and 40 deletions

View File

@ -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)`, `:within(selector)` - Match elements based on their relative position to another element. Learn more about [proximity selectors](./selectors.md#css-extension-proximity).
-->
* `: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)');
```
<!--
#### CSS extension: proximity
Playwright provides a few proximity selectors based on the page layout. These can be combined with regular CSS for better results, for example `input:right-of(:text("Password"))` matches an input field that is to the right of text "Password".
Note that Playwright uses some heuristics to determine whether one element should be considered to the left/right/above/below/near/within another. Therefore, using proximity selectors may produce unpredictable results. For example, selector could stop matching when element moves by one pixel.
Note that proximity selectors depend on the page layout and may produce unexpected results. For example, a different element could be matched when layout changes by one pixel.
* `:right-of(css > selector)` - Matches elements that are to the right of any element matching the inner selector.
* `:left-of(css > selector)` - Matches elements that are to the left of any element matching the inner selector.
* `:above(css > selector)` - Matches elements that are above any of the elements matching the inner selector.
* `:below(css > selector)` - Matches elements that are below any of the elements matching the inner selector.
* `:near(css > selector)` - Matches elements that are near any of the elements matching the inner selector.
* `:within(css > selector)` - Matches elements that are within any of the elements matching the inner selector.
Proximity selectors use [bounding client rect](https://developer.mozilla.org/en-US/docs/Web/API/Element/getBoundingClientRect) to compute distance and relative position of the elements.
* `:right-of(inner > selector)` - Matches elements that are to the right of any element matching the inner selector.
* `:left-of(inner > selector)` - Matches elements that are to the left of any element matching the inner selector.
* `:above(inner > selector)` - Matches elements that are above any of the elements matching the inner selector.
* `:below(inner > selector)` - Matches elements that are below any of the elements matching the inner selector.
* `:near(inner > selector)` - Matches elements that are near (within 50 CSS pixels) any of the elements matching the inner selector.
```js
// Fill an input to the right of "Username".
@ -306,7 +303,6 @@ await page.fill('input:right-of(:text("Username"))');
// Click a button near the promo card.
await page.click('button:near(.promo-card)');
```
-->
### xpath

View File

@ -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);

View File

@ -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<Element, number> | undefined;
constructor(extraEngines: Map<string, SelectorEngine>) {
// 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<Element[]>(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<boolean>(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;

View File

@ -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 }) => {