From 1e754a4d805118f0cb1847d91ba8cf3a7222deba Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Mon, 7 Dec 2020 16:07:47 -0800 Subject: [PATCH] feat(selectors): proximity selectors (#4614) This includes 'left-of', 'right-of', 'above', 'below', 'near' and 'within'. --- src/server/injected/selectorEvaluator.ts | 70 ++++++++++++++++ src/server/selectors.ts | 2 +- test/selectors-misc.spec.ts | 100 +++++++++++++++++++++++ 3 files changed, 171 insertions(+), 1 deletion(-) diff --git a/src/server/injected/selectorEvaluator.ts b/src/server/injected/selectorEvaluator.ts index 46ea3d71e9..192f7b6c1a 100644 --- a/src/server/injected/selectorEvaluator.ts +++ b/src/server/injected/selectorEvaluator.ts @@ -60,6 +60,12 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { this._engines.set('xpath', xpathEngine); for (const attr of ['id', 'data-testid', 'data-test-id', 'data-test']) this._engines.set(attr, createAttributeEngine(attr)); + 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)); + this._engines.set('within', createProximityEngine('within', boxWithin)); } // This is the only function we should use for querying, because it does @@ -454,6 +460,70 @@ function createAttributeEngine(attr: string): SelectorEngine { }; } +function areCloseRanges(from1: number, to1: number, from2: number, to2: number, threshold: number) { + return to1 >= from2 - threshold && to2 >= from1 - threshold; +} + +function boxSize(box: DOMRect) { + return Math.sqrt(box.width * box.height); +} + +function boxesProximityThreshold(box1: DOMRect, box2: DOMRect) { + return (boxSize(box1) + boxSize(box2)) / 2; +} + +function boxRightOf(box1: DOMRect, box2: DOMRect): boolean { + // To the right, but not too far, and vertically intersects. + const distance = box1.left - box2.right; + return distance >= 0 && distance <= boxesProximityThreshold(box1, box2) && + areCloseRanges(box1.top, box1.bottom, box2.top, box2.bottom, 0); +} + +function boxLeftOf(box1: DOMRect, box2: DOMRect): boolean { + // To the left, but not too far, and vertically intersects. + const distance = box2.left - box1.right; + return distance >= 0 && distance <= boxesProximityThreshold(box1, box2) && + areCloseRanges(box1.top, box1.bottom, box2.top, box2.bottom, 0); +} + +function boxAbove(box1: DOMRect, box2: DOMRect): boolean { + // Above, but not too far, and horizontally intersects. + const distance = box2.top - box1.bottom; + return distance >= 0 && distance <= boxesProximityThreshold(box1, box2) && + areCloseRanges(box1.left, box1.right, box2.left, box2.right, 0); +} + +function boxBelow(box1: DOMRect, box2: DOMRect): boolean { + // Below, but not too far, and horizontally intersects. + const distance = box1.top - box2.bottom; + return distance >= 0 && distance <= boxesProximityThreshold(box1, box2) && + areCloseRanges(box1.left, box1.right, box2.left, box2.right, 0); +} + +function boxWithin(box1: DOMRect, box2: DOMRect): boolean { + return box1.left >= box2.left && box1.right <= box2.right && box1.top >= box2.top && box1.bottom <= box2.bottom; +} + +function boxNear(box1: DOMRect, box2: DOMRect): boolean { + const intersects = !(box1.left >= box2.right || box2.left >= box1.right || box1.top >= box2.bottom || box2.top >= box1.bottom); + if (intersects) + return false; + const threshold = boxesProximityThreshold(box1, box2); + return areCloseRanges(box1.left, box1.right, box2.left, box2.right, threshold) && + areCloseRanges(box1.top, box1.bottom, box2.top, box2.bottom, threshold); +} + +function createProximityEngine(name: string, predicate: (box1: DOMRect, box2: DOMRect) => boolean): 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(); + return evaluator.query(context, args).some(e => e !== element && predicate(box, e.getBoundingClientRect())); + }, + }; +} + export function parentElementOrShadowHost(element: Element): Element | undefined { if (element.parentElement) return element.parentElement; diff --git a/src/server/selectors.ts b/src/server/selectors.ts index 17300feee6..4f08e2ce66 100644 --- a/src/server/selectors.ts +++ b/src/server/selectors.ts @@ -42,7 +42,7 @@ export class Selectors { 'data-test', 'data-test:light', ]); if (selectorsV2Enabled()) { - for (const name of ['not', 'is', 'where', 'has', 'scope', 'light', 'index', 'visible', 'matches-text']) + for (const name of ['not', 'is', 'where', 'has', 'scope', 'light', 'index', 'visible', 'matches-text', 'above', 'below', 'right-of', 'left-of', 'near', 'within']) this._builtinEngines.add(name); } this._engines = new Map(); diff --git a/test/selectors-misc.spec.ts b/test/selectors-misc.spec.ts index d2ab7feeaf..bdb54dbf37 100644 --- a/test/selectors-misc.spec.ts +++ b/test/selectors-misc.spec.ts @@ -75,3 +75,103 @@ it('should work with :visible', async ({page}) => { expect(await page.$eval('div:visible', div => div.id)).toBe('target2'); }); + +it('should work with proximity selectors', async ({page}) => { + if (!selectorsV2Enabled()) + return; // Selectors v1 do not support this. + + /* + + +--+ +--+ + | 1| | 2| + +--+ ++-++ + | 3| | 4| + +-------+ ++-++ + | 0 | | 5| + | +--+ +--+--+ + | | 6| | 7| + | +--+ +--+ + | | + O-------+ + +--+ + | 8| + +--++--+ + | 9| + +--+ + + */ + + const boxes = [ + // x, y, width, height + [0, 0, 150, 150], + [100, 200, 50, 50], + [200, 200, 50, 50], + [100, 150, 50, 50], + [201, 150, 50, 50], + [200, 100, 50, 50], + [50, 50, 50, 50], + [150, 50, 50, 50], + [150, -51, 50, 50], + [201, -101, 50, 50], + ]; + await page.setContent(``); + await page.$eval('container', (container, boxes) => { + for (let i = 0; i < boxes.length; i++) { + const div = document.createElement('div'); + div.style.position = 'absolute'; + div.style.overflow = 'hidden'; + div.style.boxSizing = 'border-box'; + div.style.border = '1px solid black'; + div.id = 'id' + i; + div.textContent = 'id' + i; + const box = boxes[i]; + div.style.left = box[0] + 'px'; + // Note that top is a flipped y coordinate. + div.style.top = (250 - box[1] - box[3]) + 'px'; + div.style.width = box[2] + 'px'; + div.style.height = box[3] + 'px'; + container.appendChild(div); + } + }, 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.$('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(#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: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: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(#id8)', e => e.id)).toBe('id0'); + 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: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.$('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: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'); +});