Skip to content

Commit

Permalink
feat(selectors): switch to the new engine (#4589)
Browse files Browse the repository at this point in the history
We leave old implementation under the boolean flag,
just in case we need a quick revert.
  • Loading branch information
dgozman authored Dec 4, 2020
1 parent 7213794 commit 49a3f94
Show file tree
Hide file tree
Showing 11 changed files with 269 additions and 110 deletions.
17 changes: 3 additions & 14 deletions src/debug/injected/consoleApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* limitations under the License.
*/

import { ParsedSelector, parseSelector } from '../../server/common/selectorParser';
import { parseSelector } from '../../server/common/selectorParser';
import type InjectedScript from '../../server/injected/injectedScript';

export class ConsoleAPI {
Expand All @@ -29,29 +29,18 @@ export class ConsoleAPI {
};
}

private _checkSelector(parsed: ParsedSelector) {
for (const {name} of parsed.parts) {
if (!this._injectedScript.engines.has(name))
throw new Error(`Unknown engine "${name}"`);
}
}

_querySelector(selector: string): (Element | undefined) {
if (typeof selector !== 'string')
throw new Error(`Usage: playwright.query('Playwright >> selector').`);
const parsed = parseSelector(selector);
this._checkSelector(parsed);
const elements = this._injectedScript.querySelectorAll(parsed, document);
return elements[0];
return this._injectedScript.querySelector(parsed, document);
}

_querySelectorAll(selector: string): Element[] {
if (typeof selector !== 'string')
throw new Error(`Usage: playwright.$$('Playwright >> selector').`);
const parsed = parseSelector(selector);
this._checkSelector(parsed);
const elements = this._injectedScript.querySelectorAll(parsed, document);
return elements;
return this._injectedScript.querySelectorAll(parsed, document);
}

_inspect(selector: string) {
Expand Down
20 changes: 13 additions & 7 deletions src/server/common/cssParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type CSSSimpleSelector = { css?: string, functions: CSSFunction[] };
export type CSSComplexSelector = { simples: { selector: CSSSimpleSelector, combinator: ClauseCombinator }[] };
export type CSSComplexSelectorList = CSSComplexSelector[];

export function parseCSS(selector: string): CSSComplexSelectorList {
export function parseCSS(selector: string): { selector: CSSComplexSelectorList, names: string[] } {
let tokens: css.CSSTokenInterface[];
try {
tokens = css.tokenize(selector);
Expand Down Expand Up @@ -62,6 +62,7 @@ export function parseCSS(selector: string): CSSComplexSelectorList {
throw new Error(`Unsupported token "${unsupportedToken.toSource()}" while parsing selector "${selector}"`);

let pos = 0;
const names = new Set<string>();

function unexpected() {
return new Error(`Unexpected token "${tokens[pos].toSource()}" while parsing selector "${selector}"`);
Expand Down Expand Up @@ -163,16 +164,21 @@ export function parseCSS(selector: string): CSSComplexSelectorList {
} else if (tokens[pos] instanceof css.ColonToken) {
pos++;
if (isIdent()) {
if (builtinCSSFilters.has(tokens[pos].value.toLowerCase()))
if (builtinCSSFilters.has(tokens[pos].value.toLowerCase())) {
rawCSSString += ':' + tokens[pos++].toSource();
else
functions.push({ name: tokens[pos++].value.toLowerCase(), args: [] });
} else {
const name = tokens[pos++].value.toLowerCase();
functions.push({ name, args: [] });
names.add(name);
}
} else if (tokens[pos] instanceof css.FunctionToken) {
const name = tokens[pos++].value.toLowerCase();
if (builtinCSSFunctions.has(name))
if (builtinCSSFunctions.has(name)) {
rawCSSString += `:${name}(${consumeBuiltinFunctionArguments()})`;
else
} else {
functions.push({ name, args: consumeFunctionArguments() });
names.add(name);
}
skipWhitespace();
if (!isCloseParen())
throw unexpected();
Expand Down Expand Up @@ -210,7 +216,7 @@ export function parseCSS(selector: string): CSSComplexSelectorList {
throw new Error(`Error while parsing selector "${selector}"`);
if (result.some(arg => typeof arg !== 'object' || !('simples' in arg)))
throw new Error(`Error while parsing selector "${selector}"`);
return result as CSSComplexSelector[];
return { selector: result as CSSComplexSelector[], names: Array.from(names) };
}

export function serializeSelector(args: CSSFunctionArgument[]) {
Expand Down
124 changes: 121 additions & 3 deletions src/server/common/selectorParser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,132 @@
* limitations under the License.
*/

// This file can't have dependencies, it is a part of the utility script.
import { CSSComplexSelector, CSSComplexSelectorList, CSSFunctionArgument, CSSSimpleSelector, parseCSS } from './cssParser';

export type ParsedSelector = {
export type ParsedSelectorV1 = {
parts: {
name: string,
body: string,
}[],
capture?: number,
};

export type ParsedSelector = {
v1?: ParsedSelectorV1,
v2?: CSSComplexSelectorList,
names: string[],
};

export function selectorsV2Enabled() {
return true;
}

export function parseSelector(selector: string): ParsedSelector {
const v1 = parseSelectorV1(selector);
const names = new Set<string>(v1.parts.map(part => part.name));

if (!selectorsV2Enabled()) {
return {
v1,
names: Array.from(names),
};
}

const chain = (from: number, to: number): CSSComplexSelector => {
let result: CSSComplexSelector = { simples: [] };
for (const part of v1.parts.slice(from, to)) {
let name = part.name;
let wrapInLight = false;
if (['css:light', 'xpath:light', 'text:light', 'id:light', 'data-testid:light', 'data-test-id:light', 'data-test:light'].includes(name)) {
wrapInLight = true;
name = name.substring(0, name.indexOf(':'));
}
let simple: CSSSimpleSelector;
if (name === 'css') {
const parsed = parseCSS(part.body);
parsed.names.forEach(name => names.add(name));
simple = callWith('is', parsed.selector);
} else if (name === 'text') {
simple = textSelectorToSimple(part.body);
} else {
simple = callWith(name, [part.body]);
}
if (wrapInLight)
simple = callWith('light', [simpleToComplex(simple)]);
if (name === 'text') {
const copy = result.simples.map(one => {
return { selector: copySimple(one.selector), combinator: one.combinator };
});
copy.push({ selector: simple, combinator: '' });
if (!result.simples.length)
result.simples.push({ selector: callWith('scope', []), combinator: '' });
const last = result.simples[result.simples.length - 1];
last.selector.functions.push({ name: 'is', args: [simpleToComplex(simple)] });
result = simpleToComplex(callWith('is', [{ simples: copy }, result]));
} else {
result.simples.push({ selector: simple, combinator: '' });
}
}
return result;
};

const capture = v1.capture === undefined ? v1.parts.length - 1 : v1.capture;
const result = chain(0, capture + 1);
if (capture + 1 < v1.parts.length) {
const has = chain(capture + 1, v1.parts.length);
const last = result.simples[result.simples.length - 1];
last.selector.functions.push({ name: 'has', args: [has] });
}
return { v2: [result], names: Array.from(names) };
}

function callWith(name: string, args: CSSFunctionArgument[]): CSSSimpleSelector {
return { functions: [{ name, args }] };
}

function simpleToComplex(simple: CSSSimpleSelector): CSSComplexSelector {
return { simples: [{ selector: simple, combinator: '' }]};
}

function copySimple(simple: CSSSimpleSelector): CSSSimpleSelector {
return { css: simple.css, functions: simple.functions.slice() };
}

function textSelectorToSimple(selector: string): CSSSimpleSelector {
function unescape(s: string): string {
if (!s.includes('\\'))
return s;
const r: string[] = [];
let i = 0;
while (i < s.length) {
if (s[i] === '\\' && i + 1 < s.length)
i++;
r.push(s[i++]);
}
return r.join('');
}

let functionName = 'text';
let args: string[];
if (selector.length > 1 && selector[0] === '"' && selector[selector.length - 1] === '"') {
args = [unescape(selector.substring(1, selector.length - 1))];
} else if (selector.length > 1 && selector[0] === "'" && selector[selector.length - 1] === "'") {
args = [unescape(selector.substring(1, selector.length - 1))];
} else if (selector[0] === '/' && selector.lastIndexOf('/') > 0) {
functionName = 'matches-text';
const lastSlash = selector.lastIndexOf('/');
args = [selector.substring(1, lastSlash), selector.substring(lastSlash + 1)];
} else {
args = [selector, 'sgi'];
}
return callWith(functionName, args);
}

function parseSelectorV1(selector: string): ParsedSelectorV1 {
let index = 0;
let quote: string | undefined;
let start = 0;
const result: ParsedSelector = { parts: [] };
const result: ParsedSelectorV1 = { parts: [] };
const append = () => {
const part = selector.substring(start, index).trim();
const eqIndex = part.indexOf('=');
Expand Down Expand Up @@ -65,6 +176,13 @@ export function parseSelector(selector: string): ParsedSelector {
result.capture = result.parts.length - 1;
}
};

if (!selector.includes('>>')) {
index = selector.length;
append();
return result;
}

while (index < selector.length) {
const c = selector[index];
if (c === '\\' && index + 1 < selector.length) {
Expand Down
78 changes: 51 additions & 27 deletions src/server/injected/injectedScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,9 @@ import { createCSSEngine } from './cssSelectorEngine';
import { SelectorEngine, SelectorRoot } from './selectorEngine';
import { createTextSelector } from './textSelectorEngine';
import { XPathEngine } from './xpathSelectorEngine';
import { ParsedSelector, parseSelector } from '../common/selectorParser';
import { ParsedSelector, ParsedSelectorV1, parseSelector } from '../common/selectorParser';
import { FatalDOMError } from '../common/domErrors';
import { SelectorEvaluatorImpl, SelectorEngine as SelectorEngineV2, QueryContext } from './selectorEvaluator';

type Predicate<T> = (progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol;

Expand All @@ -40,27 +41,32 @@ export type InjectedScriptPoll<T> = {
};

export class InjectedScript {
readonly engines: Map<string, SelectorEngine>;
private _enginesV1: Map<string, SelectorEngine>;
private _evaluator: SelectorEvaluatorImpl;

constructor(customEngines: { name: string, engine: SelectorEngine}[]) {
this.engines = new Map();
// Note: keep predefined names in sync with Selectors class.
this.engines.set('css', createCSSEngine(true));
this.engines.set('css:light', createCSSEngine(false));
this.engines.set('xpath', XPathEngine);
this.engines.set('xpath:light', XPathEngine);
this.engines.set('text', createTextSelector(true));
this.engines.set('text:light', createTextSelector(false));
this.engines.set('id', createAttributeEngine('id', true));
this.engines.set('id:light', createAttributeEngine('id', false));
this.engines.set('data-testid', createAttributeEngine('data-testid', true));
this.engines.set('data-testid:light', createAttributeEngine('data-testid', false));
this.engines.set('data-test-id', createAttributeEngine('data-test-id', true));
this.engines.set('data-test-id:light', createAttributeEngine('data-test-id', false));
this.engines.set('data-test', createAttributeEngine('data-test', true));
this.engines.set('data-test:light', createAttributeEngine('data-test', false));
for (const {name, engine} of customEngines)
this.engines.set(name, engine);
this._enginesV1 = new Map();
this._enginesV1.set('css', createCSSEngine(true));
this._enginesV1.set('css:light', createCSSEngine(false));
this._enginesV1.set('xpath', XPathEngine);
this._enginesV1.set('xpath:light', XPathEngine);
this._enginesV1.set('text', createTextSelector(true));
this._enginesV1.set('text:light', createTextSelector(false));
this._enginesV1.set('id', createAttributeEngine('id', true));
this._enginesV1.set('id:light', createAttributeEngine('id', false));
this._enginesV1.set('data-testid', createAttributeEngine('data-testid', true));
this._enginesV1.set('data-testid:light', createAttributeEngine('data-testid', false));
this._enginesV1.set('data-test-id', createAttributeEngine('data-test-id', true));
this._enginesV1.set('data-test-id:light', createAttributeEngine('data-test-id', false));
this._enginesV1.set('data-test', createAttributeEngine('data-test', true));
this._enginesV1.set('data-test:light', createAttributeEngine('data-test', false));
for (const { name, engine } of customEngines)
this._enginesV1.set(name, engine);

const wrapped = new Map<string, SelectorEngineV2>();
for (const { name, engine } of customEngines)
wrapped.set(name, wrapV2(name, engine));
this._evaluator = new SelectorEvaluatorImpl(wrapped);
}

parseSelector(selector: string): ParsedSelector {
Expand All @@ -70,16 +76,18 @@ export class InjectedScript {
querySelector(selector: ParsedSelector, root: Node): Element | undefined {
if (!(root as any)['querySelector'])
throw new Error('Node is not queryable.');
return this._querySelectorRecursively(root as SelectorRoot, selector, 0);
if (selector.v1)
return this._querySelectorRecursivelyV1(root as SelectorRoot, selector.v1, 0);
return this._evaluator.evaluate({ scope: root as Document | Element, pierceShadow: true }, selector.v2!)[0];
}

private _querySelectorRecursively(root: SelectorRoot, selector: ParsedSelector, index: number): Element | undefined {
private _querySelectorRecursivelyV1(root: SelectorRoot, selector: ParsedSelectorV1, index: number): Element | undefined {
const current = selector.parts[index];
if (index === selector.parts.length - 1)
return this.engines.get(current.name)!.query(root, current.body);
const all = this.engines.get(current.name)!.queryAll(root, current.body);
return this._enginesV1.get(current.name)!.query(root, current.body);
const all = this._enginesV1.get(current.name)!.queryAll(root, current.body);
for (const next of all) {
const result = this._querySelectorRecursively(next, selector, index + 1);
const result = this._querySelectorRecursivelyV1(next, selector, index + 1);
if (result)
return selector.capture === index ? next : result;
}
Expand All @@ -88,6 +96,12 @@ export class InjectedScript {
querySelectorAll(selector: ParsedSelector, root: Node): Element[] {
if (!(root as any)['querySelectorAll'])
throw new Error('Node is not queryable.');
if (selector.v1)
return this._querySelectorAllV1(selector.v1, root as SelectorRoot);
return this._evaluator.evaluate({ scope: root as Document | Element, pierceShadow: true }, selector.v2!);
}

private _querySelectorAllV1(selector: ParsedSelectorV1, root: SelectorRoot): Element[] {
const capture = selector.capture === undefined ? selector.parts.length - 1 : selector.capture;
// Query all elements up to the capture.
const partsToQuerAll = selector.parts.slice(0, capture + 1);
Expand All @@ -97,7 +111,7 @@ export class InjectedScript {
for (const { name, body } of partsToQuerAll) {
const newSet = new Set<Element>();
for (const prev of set) {
for (const next of this.engines.get(name)!.queryAll(prev, body)) {
for (const next of this._enginesV1.get(name)!.queryAll(prev, body)) {
if (newSet.has(next))
continue;
newSet.add(next);
Expand All @@ -109,7 +123,7 @@ export class InjectedScript {
if (!partsToCheckOne.length)
return candidates;
const partial = { parts: partsToCheckOne };
return candidates.filter(e => !!this._querySelectorRecursively(e, partial, 0));
return candidates.filter(e => !!this._querySelectorRecursivelyV1(e, partial, 0));
}

extend(source: string, params: any): any {
Expand Down Expand Up @@ -662,6 +676,16 @@ export class InjectedScript {
}
}

function wrapV2(name: string, engine: SelectorEngine): SelectorEngineV2 {
return {
query(context: QueryContext, args: string[]): Element[] {
if (args.length !== 1 || typeof args[0] !== 'string')
throw new Error(`engine "${name}" expects a single string`);
return engine.queryAll(context.scope, args[0]);
}
};
}

const autoClosingTags = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
const booleanAttributes = new Set(['checked', 'selected', 'disabled', 'readonly', 'multiple']);

Expand Down
Loading

0 comments on commit 49a3f94

Please sign in to comment.