diff --git a/docs/src/selectors.md b/docs/src/selectors.md index ae3ba79f40..13de228e6f 100644 --- a/docs/src/selectors.md +++ b/docs/src/selectors.md @@ -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). diff --git a/src/server/injected/selectorEvaluator.ts b/src/server/injected/selectorEvaluator.ts index 86d2505b6c..3be98f3368 100644 --- a/src/server/injected/selectorEvaluator.ts +++ b/src/server/injected/selectorEvaluator.ts @@ -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) diff --git a/tests/selectors-misc.spec.ts b/tests/selectors-misc.spec.ts index f334bb92a4..18764a60c8 100644 --- a/tests/selectors-misc.spec.ts +++ b/tests/selectors-misc.spec.ts @@ -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 }) => {