fix(selectors): refactor chaining logic (#13764)
This fixes a few issues: - strict mode was producing false negatives if multiple query paths lead to the same element being picked; - in some cases the number of intermediate items in the list was exponential and crashed quickly. What changed: - `visible` engine is a real engine now; - `capture` selectors are transformed to `has=` selectors for easier implementation; - chained querying switched from a list to a set to avoid exponential size.
This commit is contained in:
Родитель
5208f96d7f
Коммит
38fdc5fe24
|
@ -63,11 +63,6 @@ export interface SelectorEngineV2 {
|
|||
queryAll(root: SelectorRoot, body: any): Element[];
|
||||
}
|
||||
|
||||
export type ElementMatch = {
|
||||
element: Element;
|
||||
capture: Element | undefined;
|
||||
};
|
||||
|
||||
export type HitTargetInterceptionResult = {
|
||||
stop: () => 'done' | { hitTargetDescription: string };
|
||||
};
|
||||
|
@ -105,7 +100,7 @@ export class InjectedScript {
|
|||
this._engines.set('data-test:light', this._createAttributeEngine('data-test', false));
|
||||
this._engines.set('css', this._createCSSEngine());
|
||||
this._engines.set('nth', { queryAll: () => [] });
|
||||
this._engines.set('visible', { queryAll: () => [] });
|
||||
this._engines.set('visible', this._createVisibleEngine());
|
||||
this._engines.set('control', this._createControlEngine());
|
||||
this._engines.set('has', this._createHasEngine());
|
||||
|
||||
|
@ -140,93 +135,70 @@ export class InjectedScript {
|
|||
}
|
||||
|
||||
querySelector(selector: ParsedSelector, root: Node, strict: boolean): Element | undefined {
|
||||
if (!(root as any)['querySelector'])
|
||||
throw this.createStacklessError('Node is not queryable.');
|
||||
this._evaluator.begin();
|
||||
try {
|
||||
const result = this._querySelectorRecursively([{ element: root as Element, capture: undefined }], selector, 0, new Map());
|
||||
if (strict && result.length > 1)
|
||||
throw this.strictModeViolationError(selector, result.map(r => r.element));
|
||||
return result[0]?.capture || result[0]?.element;
|
||||
} finally {
|
||||
this._evaluator.end();
|
||||
}
|
||||
const result = this.querySelectorAll(selector, root);
|
||||
if (strict && result.length > 1)
|
||||
throw this.strictModeViolationError(selector, result);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
private _querySelectorRecursively(roots: ElementMatch[], selector: ParsedSelector, index: number, queryCache: Map<Element, Element[][]>): ElementMatch[] {
|
||||
if (index === selector.parts.length)
|
||||
return roots;
|
||||
|
||||
const part = selector.parts[index];
|
||||
if (part.name === 'nth') {
|
||||
let filtered: ElementMatch[] = [];
|
||||
if (part.body === '0') {
|
||||
filtered = roots.slice(0, 1);
|
||||
} else if (part.body === '-1') {
|
||||
if (roots.length)
|
||||
filtered = roots.slice(roots.length - 1);
|
||||
} else {
|
||||
if (typeof selector.capture === 'number')
|
||||
throw this.createStacklessError(`Can't query n-th element in a request with the capture.`);
|
||||
const nth = +part.body;
|
||||
const set = new Set<Element>();
|
||||
for (const root of roots) {
|
||||
set.add(root.element);
|
||||
if (nth + 1 === set.size)
|
||||
filtered = [root];
|
||||
}
|
||||
}
|
||||
return this._querySelectorRecursively(filtered, selector, index + 1, queryCache);
|
||||
}
|
||||
|
||||
if (part.name === 'visible') {
|
||||
const visible = Boolean(part.body);
|
||||
const filtered = roots.filter(match => visible === isVisible(match.element));
|
||||
return this._querySelectorRecursively(filtered, selector, index + 1, queryCache);
|
||||
}
|
||||
|
||||
const result: ElementMatch[] = [];
|
||||
for (const root of roots) {
|
||||
const capture = index - 1 === selector.capture ? root.element : root.capture;
|
||||
|
||||
// Do not query engine twice for the same element.
|
||||
let queryResults = queryCache.get(root.element);
|
||||
if (!queryResults) {
|
||||
queryResults = [];
|
||||
queryCache.set(root.element, queryResults);
|
||||
}
|
||||
let all = queryResults[index];
|
||||
if (!all) {
|
||||
all = this._queryEngineAll(part, root.element);
|
||||
queryResults[index] = all;
|
||||
}
|
||||
|
||||
for (const element of all) {
|
||||
if (!('nodeName' in element))
|
||||
throw this.createStacklessError(`Expected a Node but got ${Object.prototype.toString.call(element)}`);
|
||||
result.push({ element, capture });
|
||||
}
|
||||
}
|
||||
return this._querySelectorRecursively(result, selector, index + 1, queryCache);
|
||||
private _queryNth(roots: Set<Element>, part: ParsedSelectorPart): Set<Element> {
|
||||
const list = [...roots];
|
||||
let nth = +part.body;
|
||||
if (nth === -1)
|
||||
nth = list.length - 1;
|
||||
return new Set<Element>(list.slice(nth, nth + 1));
|
||||
}
|
||||
|
||||
querySelectorAll(selector: ParsedSelector, root: Node): Element[] {
|
||||
if (selector.capture !== undefined) {
|
||||
if (selector.parts.some(part => part.name === 'nth'))
|
||||
throw this.createStacklessError(`Can't query n-th element in a request with the capture.`);
|
||||
const withHas: ParsedSelector = { parts: selector.parts.slice(0, selector.capture + 1) };
|
||||
if (selector.capture < selector.parts.length - 1) {
|
||||
const body = { parts: selector.parts.slice(selector.capture + 1) };
|
||||
const has: ParsedSelectorPart = { name: 'has', body, source: stringifySelector(body) };
|
||||
withHas.parts.push(has);
|
||||
}
|
||||
return this.querySelectorAll(withHas, root);
|
||||
}
|
||||
|
||||
if (!(root as any)['querySelectorAll'])
|
||||
throw this.createStacklessError('Node is not queryable.');
|
||||
|
||||
if (selector.capture !== undefined) {
|
||||
// We should have handled the capture above.
|
||||
throw this.createStacklessError('Internal error: there should not be a capture in the selector.');
|
||||
}
|
||||
|
||||
this._evaluator.begin();
|
||||
try {
|
||||
const result = this._querySelectorRecursively([{ element: root as Element, capture: undefined }], selector, 0, new Map());
|
||||
const set = new Set<Element>();
|
||||
for (const r of result)
|
||||
set.add(r.capture || r.element);
|
||||
return [...set];
|
||||
let roots = new Set<Element>([root as Element]);
|
||||
for (const part of selector.parts) {
|
||||
if (part.name === 'nth') {
|
||||
roots = this._queryNth(roots, part);
|
||||
} else {
|
||||
const next = new Set<Element>();
|
||||
for (const root of roots) {
|
||||
const all = this._queryEngineAll(part, root);
|
||||
for (const one of all)
|
||||
next.add(one);
|
||||
}
|
||||
roots = next;
|
||||
}
|
||||
}
|
||||
return [...roots];
|
||||
} finally {
|
||||
this._evaluator.end();
|
||||
}
|
||||
}
|
||||
|
||||
private _queryEngineAll(part: ParsedSelectorPart, root: SelectorRoot): Element[] {
|
||||
return this._engines.get(part.name)!.queryAll(root, part.body);
|
||||
const result = this._engines.get(part.name)!.queryAll(root, part.body);
|
||||
for (const element of result) {
|
||||
if (!('nodeName' in element))
|
||||
throw this.createStacklessError(`Expected a Node but got ${Object.prototype.toString.call(element)}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private _createAttributeEngine(attribute: string, shadow: boolean): SelectorEngine {
|
||||
|
@ -304,6 +276,15 @@ export class InjectedScript {
|
|||
return { queryAll };
|
||||
}
|
||||
|
||||
private _createVisibleEngine(): SelectorEngineV2 {
|
||||
const queryAll = (root: SelectorRoot, body: string) => {
|
||||
if (root.nodeType !== 1 /* Node.ELEMENT_NODE */)
|
||||
return [];
|
||||
return isVisible(root as Element) === Boolean(body) ? [root as Element] : [];
|
||||
};
|
||||
return { queryAll };
|
||||
}
|
||||
|
||||
extend(source: string, params: any): any {
|
||||
const constrFunction = globalThis.eval(`
|
||||
(() => {
|
||||
|
|
|
@ -135,6 +135,31 @@ it('should work with nth=', async ({ page }) => {
|
|||
});
|
||||
const element = await promise;
|
||||
expect(await element.evaluate(e => e.id)).toBe('target3');
|
||||
|
||||
await page.setContent(`
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<span>hi</span>
|
||||
<span>hello</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
expect(await page.locator('div >> div >> span >> nth=1').textContent()).toBe('hello');
|
||||
});
|
||||
|
||||
it('should work with strict mode and chaining', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<div>
|
||||
<div>
|
||||
<div>
|
||||
<span>hi</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
expect(await page.locator('div >> div >> span').textContent()).toBe('hi');
|
||||
});
|
||||
|
||||
it('should work with position selectors', async ({ page }) => {
|
||||
|
@ -373,3 +398,35 @@ it('should work with has=', async ({ page, server }) => {
|
|||
const error4 = await page.$(`div >> has="span!"`).catch(e => e);
|
||||
expect(error4.message).toContain('Unexpected token "!" while parsing selector "span!"');
|
||||
});
|
||||
|
||||
it('chaining should work with large DOM @smoke', async ({ page, server }) => {
|
||||
await page.evaluate(() => {
|
||||
let last = document.body;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const e = document.createElement('div');
|
||||
last.appendChild(e);
|
||||
last = e;
|
||||
}
|
||||
const target = document.createElement('span');
|
||||
target.textContent = 'Found me!';
|
||||
last.appendChild(target);
|
||||
});
|
||||
|
||||
// Naive implementation generates C(100, 9) ~= 1.9*10^12 entries.
|
||||
const selectors = [
|
||||
'div >> div >> div >> div >> div >> div >> div >> div >> span',
|
||||
'div div div div div div div div span',
|
||||
'div div >> div div >> div div >> div div >> span',
|
||||
];
|
||||
|
||||
const counts = [];
|
||||
const times = [];
|
||||
for (const selector of selectors) {
|
||||
const time = Date.now();
|
||||
counts.push(await page.$$eval(selector, els => els.length));
|
||||
times.push({ selector, time: Date.now() - time });
|
||||
}
|
||||
expect(counts).toEqual([1, 1, 1]);
|
||||
// Uncomment to see performance results.
|
||||
// console.log(times);
|
||||
});
|
||||
|
|
Загрузка…
Ссылка в новой задаче