feat(selectors): add visible and index engines (#4595)

This commit is contained in:
Dmitry Gozman 2020-12-06 15:03:36 -08:00 committed by GitHub
parent a3a31bc837
commit 1e0ab79f9a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 90 additions and 26 deletions

View File

@ -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<T> = (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<T>(predicate: Predicate<T>): InjectedScriptPoll<T> {
@ -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;

View File

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

View File

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

View File

@ -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(`
<section>
<div id=target1></div>
<div id=target2></div>
<span id=target3></span>
<div id=target4></div>
</section>
`);
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(`
<section>
<div id=target1></div>
<div id=target2></div>
</section>
`);
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');
});