chore: BFS nodes, simplify querying (#7861)

This commit is contained in:
Pavel Feldman 2021-07-27 12:53:12 -07:00 committed by GitHub
parent da9b488d0d
commit 982f61d575
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 67 additions and 84 deletions

View File

@ -42,10 +42,14 @@ export type ElementStateWithoutStable = 'visible' | 'hidden' | 'enabled' | 'disa
export type ElementState = ElementStateWithoutStable | 'stable'; export type ElementState = ElementStateWithoutStable | 'stable';
export interface SelectorEngineV2 { export interface SelectorEngineV2 {
query?(root: SelectorRoot, body: any): Element | undefined;
queryAll(root: SelectorRoot, body: any): Element[]; queryAll(root: SelectorRoot, body: any): Element[];
} }
export type ElementMatch = {
element: Element;
capture: Element | undefined;
};
export class InjectedScript { export class InjectedScript {
private _engines: Map<string, SelectorEngineV2>; private _engines: Map<string, SelectorEngineV2>;
_evaluator: SelectorEvaluatorImpl; _evaluator: SelectorEvaluatorImpl;
@ -69,6 +73,8 @@ export class InjectedScript {
this._engines.set('data-test', this._createAttributeEngine('data-test', true)); this._engines.set('data-test', this._createAttributeEngine('data-test', true));
this._engines.set('data-test:light', this._createAttributeEngine('data-test', false)); this._engines.set('data-test:light', this._createAttributeEngine('data-test', false));
this._engines.set('css', this._createCSSEngine()); this._engines.set('css', this._createCSSEngine());
this._engines.set('_first', { queryAll: () => [] });
this._engines.set('_visible', { queryAll: () => [] });
for (const { name, engine } of customEngines) for (const { name, engine } of customEngines)
this._engines.set(name, engine); this._engines.set(name, engine);
@ -91,30 +97,47 @@ export class InjectedScript {
throw new Error('Node is not queryable.'); throw new Error('Node is not queryable.');
this._evaluator.begin(); this._evaluator.begin();
try { try {
return this._querySelectorRecursively(root as SelectorRoot, selector, strict, 0); const result = this._querySelectorRecursively([{ element: root as Element, capture: undefined }], selector, 0, new Map());
if (strict && result.length > 1)
throw new Error(`strict mode violation: selector resolved to ${result.length} elements.`);
return result[0]?.capture || result[0]?.element;
} finally { } finally {
this._evaluator.end(); this._evaluator.end();
} }
} }
private _querySelectorRecursively(root: SelectorRoot, selector: ParsedSelector, strict: boolean, index: number): Element | undefined { private _querySelectorRecursively(roots: ElementMatch[], selector: ParsedSelector, index: number, queryCache: Map<Element, Element[][]>): ElementMatch[] {
const current = selector.parts[index]; if (index === selector.parts.length)
if (index === selector.parts.length - 1) { return roots;
if (strict) {
const all = this._queryEngineAll(current, root); if (selector.parts[index].name === '_first')
if (all.length > 1) return roots.slice(0, 1);
throw new Error(`strict mode violation: selector resolved to ${all.length} elements.`);
return all[0]; if (selector.parts[index].name === '_visible') {
} else { const visible = Boolean(selector.parts[index].body);
return this._queryEngine(current, root); return roots.filter(match => visible === isVisible(match.element));
}
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(selector.parts[index], root.element);
queryResults[index] = all;
}
for (const element of all)
result.push({ element, capture });
} }
const all = this._queryEngineAll(current, root); return this._querySelectorRecursively(result, selector, index + 1, queryCache);
for (const next of all) {
const result = this._querySelectorRecursively(next, selector, strict, index + 1);
if (result)
return selector.capture === index ? next : result;
}
} }
querySelectorAll(selector: ParsedSelector, root: Node): Element[] { querySelectorAll(selector: ParsedSelector, root: Node): Element[] {
@ -122,42 +145,16 @@ export class InjectedScript {
throw new Error('Node is not queryable.'); throw new Error('Node is not queryable.');
this._evaluator.begin(); this._evaluator.begin();
try { try {
const capture = selector.capture === undefined ? selector.parts.length - 1 : selector.capture; const result = this._querySelectorRecursively([{ element: root as Element, capture: undefined }], selector, 0, new Map());
// Query all elements up to the capture. const set = new Set<Element>();
const partsToQueryAll = selector.parts.slice(0, capture + 1); for (const r of result)
// Check they have a descendant matching everything after the capture. set.add(r.capture || r.element);
const partsToCheckOne = selector.parts.slice(capture + 1); return [...set];
let set = new Set<SelectorRoot>([ root as SelectorRoot ]);
for (const part of partsToQueryAll) {
const newSet = new Set<Element>();
for (const prev of set) {
for (const next of this._queryEngineAll(part, prev)) {
if (newSet.has(next))
continue;
newSet.add(next);
}
}
set = newSet;
}
let result = [...set] as Element[];
if (partsToCheckOne.length) {
const partial = { parts: partsToCheckOne };
result = result.filter(e => !!this._querySelectorRecursively(e, partial, false, 0));
}
return result;
} finally { } finally {
this._evaluator.end(); this._evaluator.end();
} }
} }
private _queryEngine(part: ParsedSelectorPart, root: SelectorRoot): Element | undefined {
const engine = this._engines.get(part.name)!;
if (engine.query)
return engine.query(root, part.body);
else
return engine.queryAll(root, part.body)[0];
}
private _queryEngineAll(part: ParsedSelectorPart, root: SelectorRoot): Element[] { private _queryEngineAll(part: ParsedSelectorPart, root: SelectorRoot): Element[] {
return this._engines.get(part.name)!.queryAll(root, part.body); return this._engines.get(part.name)!.queryAll(root, part.body);
} }
@ -184,7 +181,7 @@ export class InjectedScript {
} }
private _createTextEngine(shadow: boolean): SelectorEngine { private _createTextEngine(shadow: boolean): SelectorEngine {
const queryList = (root: SelectorRoot, selector: string, single: boolean): Element[] => { const queryList = (root: SelectorRoot, selector: string): Element[] => {
const { matcher, kind } = createTextMatcher(selector); const { matcher, kind } = createTextMatcher(selector);
const result: Element[] = []; const result: Element[] = [];
let lastDidNotMatchSelf: Element | null = null; let lastDidNotMatchSelf: Element | null = null;
@ -198,26 +195,19 @@ export class InjectedScript {
lastDidNotMatchSelf = element; lastDidNotMatchSelf = element;
if (matches === 'self' || (matches === 'selfAndChildren' && kind === 'strict')) if (matches === 'self' || (matches === 'selfAndChildren' && kind === 'strict'))
result.push(element); result.push(element);
return single && result.length > 0;
}; };
if (root.nodeType === Node.ELEMENT_NODE && appendElement(root as Element)) if (root.nodeType === Node.ELEMENT_NODE)
return result; appendElement(root as Element);
const elements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: shadow }, '*'); const elements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: shadow }, '*');
for (const element of elements) { for (const element of elements)
if (appendElement(element)) appendElement(element);
return result;
}
return result; return result;
}; };
return { return {
query: (root: SelectorRoot, selector: string): Element | undefined => {
return queryList(root, selector, true)[0];
},
queryAll: (root: SelectorRoot, selector: string): Element[] => { queryAll: (root: SelectorRoot, selector: string): Element[] => {
return queryList(root, selector, false); return queryList(root, selector);
} }
}; };
} }

View File

@ -17,7 +17,5 @@
export type SelectorRoot = Element | ShadowRoot | Document; export type SelectorRoot = Element | ShadowRoot | Document;
export interface SelectorEngine { export interface SelectorEngine {
query?(root: SelectorRoot, selector: string): Element | undefined;
queryAll(root: SelectorRoot, selector: string): Element[]; queryAll(root: SelectorRoot, selector: string): Element[];
} }

View File

@ -17,19 +17,6 @@
import { SelectorEngine, SelectorRoot } from './selectorEngine'; import { SelectorEngine, SelectorRoot } from './selectorEngine';
export const XPathEngine: SelectorEngine = { export const XPathEngine: SelectorEngine = {
query(root: SelectorRoot, selector: string): Element | undefined {
if (selector.startsWith('/'))
selector = '.' + selector;
const document = root instanceof Document ? root : root.ownerDocument;
if (!document)
return;
const it = document.evaluate(selector, root, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE);
for (let node = it.iterateNext(); node; node = it.iterateNext()) {
if (node.nodeType === Node.ELEMENT_NODE)
return node as Element;
}
},
queryAll(root: SelectorRoot, selector: string): Element[] { queryAll(root: SelectorRoot, selector: string): Element[] {
if (selector.startsWith('/')) if (selector.startsWith('/'))
selector = '.' + selector; selector = '.' + selector;

View File

@ -125,13 +125,11 @@ export class Selectors {
const parsed = parseSelector(selector); const parsed = parseSelector(selector);
let needsMainWorld = false; let needsMainWorld = false;
for (const part of parsed.parts) { for (const part of parsed.parts) {
if (!Array.isArray(part)) { const custom = this._engines.get(part.name);
const custom = this._engines.get(part.name); if (!custom && !this._builtinEngines.has(part.name))
if (!custom && !this._builtinEngines.has(part.name)) throw new Error(`Unknown engine "${part.name}" while parsing selector ${selector}`);
throw new Error(`Unknown engine "${part.name}" while parsing selector ${selector}`); if (custom && !custom.contentScript)
if (custom && !custom.contentScript) needsMainWorld = true;
needsMainWorld = true;
}
} }
return { return {
parsed, parsed,

View File

@ -143,6 +143,16 @@ it('should keep dom order with comma separated list', async ({page}) => {
expect(await page.$$eval(`css=section >> *css=div,span >> css=y`, els => els.map(e => e.nodeName).join(','))).toBe('SPAN,DIV'); expect(await page.$$eval(`css=section >> *css=div,span >> css=y`, els => els.map(e => e.nodeName).join(','))).toBe('SPAN,DIV');
}); });
it('should return multiple captures for the same node', async ({page}) => {
await page.setContent(`<div><div><div><span></span></div></div></div>`);
expect(await page.$$eval(`*css=div >> span`, els => els.map(e => e.nodeName).join(','))).toBe('DIV,DIV,DIV');
});
it('should return multiple captures when going up the hierarchy', async ({page}) => {
await page.setContent(`<section>Hello<ul><li></li><li></li></ul></section>`);
expect(await page.$$eval(`*css=li >> ../.. >> text=Hello`, els => els.map(e => e.nodeName).join(','))).toBe('LI,LI');
});
it('should work with comma separated list in various positions', async ({page}) => { it('should work with comma separated list in various positions', async ({page}) => {
await page.setContent(`<section><span><div><x></x><y></y></div></span></section>`); await page.setContent(`<section><span><div><x></x><y></y></div></span></section>`);
expect(await page.$$eval(`css=span,div >> css=x,y`, els => els.map(e => e.nodeName).join(','))).toBe('X,Y'); expect(await page.$$eval(`css=span,div >> css=x,y`, els => els.map(e => e.nodeName).join(','))).toBe('X,Y');