feat(selectors): support max distance in layout selectors (#6172)

Supports `div:near(button, 120)` with configurable distance in pixels.
This commit is contained in:
Dmitry Gozman 2021-04-10 15:20:26 -07:00 committed by GitHub
parent 82e8c7226d
commit b62a436041
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 38 additions and 19 deletions

View File

@ -523,6 +523,9 @@ page.fill('input:right-of(:text("Username"))', 'value')
page.click('button:near(.promo-card)')
```
All layout selectors support optional maximum pixel distance as the last argument. For example
`button:near(:text("Username"), 120)` matches a button that is at most 120 pixels away from the element with the text "Username".
## XPath selectors
XPath selectors are equivalent to calling [`Document.evaluate`](https://developer.mozilla.org/en/docs/Web/API/Document/evaluate).

View File

@ -536,32 +536,36 @@ export function elementMatchesText(evaluator: SelectorEvaluatorImpl, element: El
return 'self';
}
function boxRightOf(box1: DOMRect, box2: DOMRect): number | undefined {
if (box1.left < box2.right)
function boxRightOf(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined {
const distance = box1.left - box2.right;
if (distance < 0 || (maxDistance !== undefined && distance > maxDistance))
return;
return (box1.left - box2.right) + Math.max(box2.bottom - box1.bottom, 0) + Math.max(box1.top - box2.top, 0);
return distance + 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)
function boxLeftOf(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined {
const distance = box2.left - box1.right;
if (distance < 0 || (maxDistance !== undefined && distance > maxDistance))
return;
return (box2.left - box1.right) + Math.max(box2.bottom - box1.bottom, 0) + Math.max(box1.top - box2.top, 0);
return distance + 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)
function boxAbove(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined {
const distance = box2.top - box1.bottom;
if (distance < 0 || (maxDistance !== undefined && distance > maxDistance))
return;
return (box2.top - box1.bottom) + Math.max(box1.left - box2.left, 0) + Math.max(box2.right - box1.right, 0);
return distance + 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)
function boxBelow(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined {
const distance = box1.top - box2.bottom;
if (distance < 0 || (maxDistance !== undefined && distance > maxDistance))
return;
return (box1.top - box2.bottom) + Math.max(box1.left - box2.left, 0) + Math.max(box2.right - box1.right, 0);
return distance + 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;
function boxNear(box1: DOMRect, box2: DOMRect, maxDistance: number | undefined): number | undefined {
const kThreshold = maxDistance === undefined ? 50 : maxDistance;
let score = 0;
if (box1.left - box2.right >= 0)
score += box1.left - box2.right;
@ -574,17 +578,19 @@ function boxNear(box1: DOMRect, box2: DOMRect): number | undefined {
return score > kThreshold ? undefined : score;
}
function createPositionEngine(name: string, scorer: (box1: DOMRect, box2: DOMRect) => number | undefined): SelectorEngine {
function createPositionEngine(name: string, scorer: (box1: DOMRect, box2: DOMRect, maxDistance: number | undefined) => 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 maxDistance = args.length && typeof args[args.length - 1] === 'number' ? args[args.length - 1] : undefined;
const queryArgs = maxDistance === undefined ? args : args.slice(0, args.length - 1);
if (args.length < 1 + (maxDistance === undefined ? 0 : 1))
throw new Error(`"${name}" engine expects a selector list and optional maximum distance in pixels`);
const box = element.getBoundingClientRect();
let bestScore: number | undefined;
for (const e of evaluator.query(context, args)) {
for (const e of evaluator.query(context, queryArgs)) {
if (e === element)
continue;
const score = scorer(box, e.getBoundingClientRect());
const score = scorer(box, e.getBoundingClientRect(), maxDistance);
if (score === undefined)
continue;
if (bestScore === undefined || score < bestScore)

View File

@ -155,6 +155,8 @@ it('should work with position selectors', async ({page}) => {
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('id4,id2,id5,id7,id8,id9');
expect(await page.$$eval('div:right-of(#id3, 50)', els => els.map(e => e.id).join(','))).toBe('id2,id5,id7,id8');
expect(await page.$$eval('div:right-of(#id3, 49)', els => els.map(e => e.id).join(','))).toBe('id7,id8');
expect(await page.$eval('div:left-of(#id2)', e => e.id)).toBe('id1');
expect(await page.$('div:left-of(#id0)')).toBe(null);
@ -162,6 +164,7 @@ it('should work with position selectors', async ({page}) => {
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('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:left-of(#id5, 3)', els => els.map(e => e.id).join(','))).toBe('id7,id8');
expect(await page.$eval('div:above(#id0)', e => e.id)).toBe('id3');
expect(await page.$eval('div:above(#id5)', e => e.id)).toBe('id4');
@ -170,6 +173,7 @@ it('should work with position selectors', async ({page}) => {
expect(await page.$eval('div:above(#id9)', e => e.id)).toBe('id8');
expect(await page.$('div:above(#id2)')).toBe(null);
expect(await page.$$eval('div:above(#id5)', els => els.map(e => e.id).join(','))).toBe('id4,id2,id3,id1');
expect(await page.$$eval('div:above(#id5, 20)', els => els.map(e => e.id).join(','))).toBe('id4,id3');
expect(await page.$eval('div:below(#id4)', e => e.id)).toBe('id5');
expect(await page.$eval('div:below(#id3)', e => e.id)).toBe('id0');
@ -179,16 +183,22 @@ it('should work with position selectors', async ({page}) => {
expect(await page.$eval('div:below(#id8)', e => e.id)).toBe('id9');
expect(await page.$('div:below(#id9)')).toBe(null);
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:below(#id3, 105)', els => els.map(e => e.id).join(','))).toBe('id0,id5,id6,id7');
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:near(#id6, 10)', els => els.map(e => e.id).join(','))).toBe('id0');
expect(await page.$$eval('div:near(#id0, 100)', els => els.map(e => e.id).join(','))).toBe('id3,id6,id7,id8,id1,id5,id4,id2');
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');
const error = await page.$(':near(50)').catch(e => e);
expect(error.message).toContain('"near" engine expects a selector list and optional maximum distance in pixels');
});
it('should escape the scope with >>', async ({ page }) => {