From 1e0ab79f9a5b7923cd70dd11d997d7ee1edd1b7f Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Sun, 6 Dec 2020 15:03:36 -0800 Subject: [PATCH] feat(selectors): add visible and index engines (#4595) --- src/server/injected/injectedScript.ts | 24 ++---------- src/server/injected/selectorEvaluator.ts | 35 +++++++++++++++-- src/server/selectors.ts | 8 ++-- test/selectors-misc.spec.ts | 49 ++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 26 deletions(-) diff --git a/src/server/injected/injectedScript.ts b/src/server/injected/injectedScript.ts index f8e8fe7b18..d111cac270 100644 --- a/src/server/injected/injectedScript.ts +++ b/src/server/injected/injectedScript.ts @@ -21,7 +21,7 @@ import { createTextSelector } from './textSelectorEngine'; import { XPathEngine } from './xpathSelectorEngine'; import { ParsedSelector, ParsedSelectorV1, parseSelector } from '../common/selectorParser'; import { FatalDOMError } from '../common/domErrors'; -import { SelectorEvaluatorImpl, SelectorEngine as SelectorEngineV2, QueryContext } from './selectorEvaluator'; +import { SelectorEvaluatorImpl, SelectorEngine as SelectorEngineV2, QueryContext, isVisible, parentElementOrShadowHost } from './selectorEvaluator'; type Predicate = (progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol; @@ -132,14 +132,7 @@ export class InjectedScript { } isVisible(element: Element): boolean { - // Note: this logic should be similar to waitForDisplayedAtStablePosition() to avoid surprises. - if (!element.ownerDocument || !element.ownerDocument.defaultView) - return true; - const style = element.ownerDocument.defaultView.getComputedStyle(element); - if (!style || style.visibility === 'hidden') - return false; - const rect = element.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; + return isVisible(element); } pollRaf(predicate: Predicate): InjectedScriptPoll { @@ -573,7 +566,7 @@ export class InjectedScript { const hitParents: Element[] = []; while (hitElement && hitElement !== element) { hitParents.push(hitElement); - hitElement = this._parentElementOrShadowHost(hitElement); + hitElement = parentElementOrShadowHost(hitElement); } if (hitElement === element) return 'done'; @@ -589,7 +582,7 @@ export class InjectedScript { rootHitTargetDescription = this.previewNode(hitParents[index - 1]); break; } - element = this._parentElementOrShadowHost(element); + element = parentElementOrShadowHost(element); } if (rootHitTargetDescription) return { hitTargetDescription: `${hitTargetDescription} from ${rootHitTargetDescription} subtree` }; @@ -616,15 +609,6 @@ export class InjectedScript { return ['BUTTON', 'INPUT', 'SELECT'].includes(elementOrButton.nodeName) && elementOrButton.hasAttribute('disabled'); } - private _parentElementOrShadowHost(element: Element): Element | undefined { - if (element.parentElement) - return element.parentElement; - if (!element.parentNode) - return; - if (element.parentNode.nodeType === Node.DOCUMENT_FRAGMENT_NODE && (element.parentNode as ShadowRoot).host) - return (element.parentNode as ShadowRoot).host; - } - deepElementFromPoint(document: Document, x: number, y: number): Element | undefined { let container: Document | ShadowRoot | null = document; let element: Element | undefined; diff --git a/src/server/injected/selectorEvaluator.ts b/src/server/injected/selectorEvaluator.ts index 1ca42b007c..1d51207184 100644 --- a/src/server/injected/selectorEvaluator.ts +++ b/src/server/injected/selectorEvaluator.ts @@ -45,13 +45,13 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { this._engines.set('has', hasEngine); this._engines.set('scope', scopeEngine); this._engines.set('light', lightEngine); + this._engines.set('index', indexEngine); + this._engines.set('visible', visibleEngine); this._engines.set('text', textEngine); this._engines.set('matches-text', matchesTextEngine); this._engines.set('xpath', xpathEngine); for (const attr of ['id', 'data-testid', 'data-test-id', 'data-test']) this._engines.set(attr, createAttributeEngine(attr)); - // TODO: host - // TODO: host-context? } // This is the only function we should use for querying, because it does @@ -335,6 +335,24 @@ const lightEngine: SelectorEngine = { } }; +const indexEngine: SelectorEngine = { + query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] { + if (args.length < 2 || typeof args[0] !== 'number') + throw new Error(`"index" engine expects a number and non-empty selector list`); + const list = evaluator.query(context, args.slice(1)); + const index = (args[0] as number) - 1; + return [list[index]]; + }, +}; + +const visibleEngine: SelectorEngine = { + matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean { + if (args.length) + throw new Error(`"visible" engine expects no arguments`); + return isVisible(element); + } +}; + const textEngine: SelectorEngine = { matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean { if (args.length === 0 || typeof args[0] !== 'string' || args.length > 2 || (args.length === 2 && typeof args[1] !== 'string')) @@ -424,7 +442,7 @@ function createAttributeEngine(attr: string): SelectorEngine { }; } -function parentElementOrShadowHost(element: Element): Element | undefined { +export function parentElementOrShadowHost(element: Element): Element | undefined { if (element.parentElement) return element.parentElement; if (!element.parentNode) @@ -447,6 +465,17 @@ function previousSiblingInContext(element: Element, context: QueryContext): Elem return element.previousElementSibling || undefined; } +export function isVisible(element: Element): boolean { + // Note: this logic should be similar to waitForDisplayedAtStablePosition() to avoid surprises. + if (!element.ownerDocument || !element.ownerDocument.defaultView) + return true; + const style = element.ownerDocument.defaultView.getComputedStyle(element); + if (!style || style.visibility === 'hidden') + return false; + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +} + function sortInDOMOrder(elements: Element[]): Element[] { type SortEntry = { children: Element[], taken: boolean }; diff --git a/src/server/selectors.ts b/src/server/selectors.ts index 7018b52d00..17300feee6 100644 --- a/src/server/selectors.ts +++ b/src/server/selectors.ts @@ -18,7 +18,7 @@ import * as dom from './dom'; import * as frames from './frames'; import * as js from './javascript'; import * as types from './types'; -import { ParsedSelector, parseSelector } from './common/selectorParser'; +import { ParsedSelector, parseSelector, selectorsV2Enabled } from './common/selectorParser'; export type SelectorInfo = { parsed: ParsedSelector, @@ -40,9 +40,11 @@ export class Selectors { 'data-testid', 'data-testid:light', 'data-test-id', 'data-test-id:light', 'data-test', 'data-test:light', - // v2 engines: - 'not', 'is', 'where', 'has', 'scope', 'light', 'matches-text', ]); + if (selectorsV2Enabled()) { + for (const name of ['not', 'is', 'where', 'has', 'scope', 'light', 'index', 'visible', 'matches-text']) + this._builtinEngines.add(name); + } this._engines = new Map(); } diff --git a/test/selectors-misc.spec.ts b/test/selectors-misc.spec.ts index 6dd66d8418..d2ab7feeaf 100644 --- a/test/selectors-misc.spec.ts +++ b/test/selectors-misc.spec.ts @@ -16,6 +16,9 @@ */ import { it, expect } from './fixtures'; +import * as path from 'path'; + +const { selectorsV2Enabled } = require(path.join(__dirname, '..', 'lib', 'server', 'common', 'selectorParser')); it('should work for open shadow roots', async ({page, server}) => { await page.goto(server.PREFIX + '/deep-shadow.html'); @@ -26,3 +29,49 @@ it('should work for open shadow roots', async ({page, server}) => { expect(await page.$(`data-testid:light=foo`)).toBe(null); expect(await page.$$(`data-testid:light=foo`)).toEqual([]); }); + +it('should work with :index', async ({page}) => { + if (!selectorsV2Enabled()) + return; // Selectors v1 do not support this. + await page.setContent(` +
+
+
+ +
+
+ `); + expect(await page.$$eval(`:index(1, div, span)`, els => els.map(e => e.id).join(';'))).toBe('target1'); + expect(await page.$$eval(`:index(2, div, span)`, els => els.map(e => e.id).join(';'))).toBe('target2'); + expect(await page.$$eval(`:index(3, div, span)`, els => els.map(e => e.id).join(';'))).toBe('target3'); + + const error = await page.waitForSelector(`:index(5, div, span)`, { timeout: 100 }).catch(e => e); + expect(error.message).toContain('100ms'); + + const promise = page.waitForSelector(`:index(5, div, span)`, { state: 'attached' }); + await page.$eval('section', section => section.appendChild(document.createElement('span'))); + const element = await promise; + expect(await element.evaluate(e => e.tagName)).toBe('SPAN'); +}); + +it('should work with :visible', async ({page}) => { + if (!selectorsV2Enabled()) + return; // Selectors v1 do not support this. + await page.setContent(` +
+
+
+
+ `); + expect(await page.$('div:visible')).toBe(null); + + const error = await page.waitForSelector(`div:visible`, { timeout: 100 }).catch(e => e); + expect(error.message).toContain('100ms'); + + const promise = page.waitForSelector(`div:visible`, { state: 'attached' }); + await page.$eval('#target2', div => div.textContent = 'Now visible'); + const element = await promise; + expect(await element.evaluate(e => e.id)).toBe('target2'); + + expect(await page.$eval('div:visible', div => div.id)).toBe('target2'); +});