mirror of
https://github.com/microsoft/playwright.git
synced 2024-12-14 21:53:35 +03:00
feat(selectors): add visible and index engines (#4595)
This commit is contained in:
parent
a3a31bc837
commit
1e0ab79f9a
@ -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;
|
||||
|
@ -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 };
|
||||
|
||||
|
@ -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();
|
||||
}
|
||||
|
||||
|
@ -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');
|
||||
});
|
||||
|
Loading…
Reference in New Issue
Block a user