feat(selectors): switch to the new engine (#4589)

We leave old implementation under the boolean flag,
just in case we need a quick revert.
This commit is contained in:
Dmitry Gozman 2020-12-04 06:51:18 -08:00 коммит произвёл GitHub
Родитель 7213794a65
Коммит 49a3f943b6
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
11 изменённых файлов: 269 добавлений и 110 удалений

Просмотреть файл

@ -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 {
@ -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) {

Просмотреть файл

@ -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);
@ -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}"`);
@ -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();
@ -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[]) {

Просмотреть файл

@ -14,9 +14,9 @@
* 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,
@ -24,11 +24,122 @@ export type ParsedSelector = {
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('=');
@ -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) {

Просмотреть файл

@ -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;
@ -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 {
@ -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;
}
@ -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);
@ -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);
@ -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 {
@ -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']);

Просмотреть файл

@ -36,6 +36,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
private _cache = new Map<any, { rest: any[], result: any }[]>();
constructor(extraEngines: Map<string, SelectorEngine>) {
// Note: keep predefined names in sync with Selectors class.
for (const [name, engine] of extraEngines)
this._engines.set(name, engine);
this._engines.set('not', notEngine);
@ -43,6 +44,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
this._engines.set('where', isEngine);
this._engines.set('has', hasEngine);
this._engines.set('scope', scopeEngine);
this._engines.set('light', lightEngine);
this._engines.set('text', textEngine);
this._engines.set('matches-text', matchesTextEngine);
this._engines.set('xpath', xpathEngine);
@ -321,6 +323,16 @@ const notEngine: SelectorEngine = {
},
};
const lightEngine: SelectorEngine = {
query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] {
return evaluator.query({ ...context, pierceShadow: false }, args);
},
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
return evaluator.matches(element, args, { ...context, pierceShadow: false });
}
};
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'))

Просмотреть файл

@ -39,7 +39,9 @@ export class Selectors {
'id', 'id:light',
'data-testid', 'data-testid:light',
'data-test-id', 'data-test-id:light',
'data-test', 'data-test:light'
'data-test', 'data-test:light',
// v2 engines:
'not', 'is', 'where', 'has', 'scope', 'light', 'matches-text',
]);
this._engines = new Map();
}
@ -116,11 +118,11 @@ export class Selectors {
_parseSelector(selector: string): SelectorInfo {
const parsed = parseSelector(selector);
for (const {name} of parsed.parts) {
for (const name of parsed.names) {
if (!this._builtinEngines.has(name) && !this._engines.has(name))
throw new Error(`Unknown engine "${name}" while parsing selector ${selector}`);
}
const needsMainWorld = parsed.parts.some(({name}) => {
const needsMainWorld = parsed.names.some(name => {
const custom = this._engines.get(name);
return custom ? !custom.contentScript : false;
});

Просмотреть файл

@ -21,50 +21,50 @@ const { parseCSS, serializeSelector: serialize } =
require(path.join(__dirname, '..', 'lib', 'server', 'common', 'cssParser'));
it('should parse css', async () => {
expect(serialize(parseCSS('div'))).toBe('div');
expect(serialize(parseCSS('div.class'))).toBe('div.class');
expect(serialize(parseCSS('.class'))).toBe('.class');
expect(serialize(parseCSS('#id'))).toBe('#id');
expect(serialize(parseCSS('.class#id'))).toBe('.class#id');
expect(serialize(parseCSS('div#id.class'))).toBe('div#id.class');
expect(serialize(parseCSS('*'))).toBe('*');
expect(serialize(parseCSS('*div'))).toBe('*div');
expect(serialize(parseCSS('div[attr *= foo i]'))).toBe('div[attr *= foo i]');
expect(serialize(parseCSS('div[attr~="Bar baz" ]'))).toBe('div[attr~="Bar baz" ]');
expect(serialize(parseCSS(`div [ foo = 'bar' s]`))).toBe(`div [ foo = "bar" s]`);
expect(serialize(parseCSS('div').selector)).toBe('div');
expect(serialize(parseCSS('div.class').selector)).toBe('div.class');
expect(serialize(parseCSS('.class').selector)).toBe('.class');
expect(serialize(parseCSS('#id').selector)).toBe('#id');
expect(serialize(parseCSS('.class#id').selector)).toBe('.class#id');
expect(serialize(parseCSS('div#id.class').selector)).toBe('div#id.class');
expect(serialize(parseCSS('*').selector)).toBe('*');
expect(serialize(parseCSS('*div').selector)).toBe('*div');
expect(serialize(parseCSS('div[attr *= foo i]').selector)).toBe('div[attr *= foo i]');
expect(serialize(parseCSS('div[attr~="Bar baz" ]').selector)).toBe('div[attr~="Bar baz" ]');
expect(serialize(parseCSS(`div [ foo = 'bar' s]`).selector)).toBe(`div [ foo = "bar" s]`);
expect(serialize(parseCSS(':hover'))).toBe(':hover');
expect(serialize(parseCSS('div:hover'))).toBe('div:hover');
expect(serialize(parseCSS('#id:active:hover'))).toBe('#id:active:hover');
expect(serialize(parseCSS(':dir(ltr)'))).toBe(':dir(ltr)');
expect(serialize(parseCSS('#foo-bar.cls:nth-child(3n + 10)'))).toBe('#foo-bar.cls:nth-child(3n + 10)');
expect(serialize(parseCSS(':lang(en)'))).toBe(':lang(en)');
expect(serialize(parseCSS('*:hover'))).toBe('*:hover');
expect(serialize(parseCSS(':hover').selector)).toBe(':hover');
expect(serialize(parseCSS('div:hover').selector)).toBe('div:hover');
expect(serialize(parseCSS('#id:active:hover').selector)).toBe('#id:active:hover');
expect(serialize(parseCSS(':dir(ltr)').selector)).toBe(':dir(ltr)');
expect(serialize(parseCSS('#foo-bar.cls:nth-child(3n + 10)').selector)).toBe('#foo-bar.cls:nth-child(3n + 10)');
expect(serialize(parseCSS(':lang(en)').selector)).toBe(':lang(en)');
expect(serialize(parseCSS('*:hover').selector)).toBe('*:hover');
expect(serialize(parseCSS('div span'))).toBe('div span');
expect(serialize(parseCSS('div>span'))).toBe('div > span');
expect(serialize(parseCSS('div +span'))).toBe('div + span');
expect(serialize(parseCSS('div~ span'))).toBe('div ~ span');
expect(serialize(parseCSS('div >.class #id+ span'))).toBe('div > .class #id + span');
expect(serialize(parseCSS('div>span+.class'))).toBe('div > span + .class');
expect(serialize(parseCSS('div span').selector)).toBe('div span');
expect(serialize(parseCSS('div>span').selector)).toBe('div > span');
expect(serialize(parseCSS('div +span').selector)).toBe('div + span');
expect(serialize(parseCSS('div~ span').selector)).toBe('div ~ span');
expect(serialize(parseCSS('div >.class #id+ span').selector)).toBe('div > .class #id + span');
expect(serialize(parseCSS('div>span+.class').selector)).toBe('div > span + .class');
expect(serialize(parseCSS('div:not(span)'))).toBe('div:not(span)');
expect(serialize(parseCSS(':not(span)#id'))).toBe('#id:not(span)');
expect(serialize(parseCSS('div:not(span):hover'))).toBe('div:hover:not(span)');
expect(serialize(parseCSS('div:has(span):hover'))).toBe('div:hover:has(span)');
expect(serialize(parseCSS('div:right-of(span):hover'))).toBe('div:hover:right-of(span)');
expect(serialize(parseCSS(':right-of(span):react(foobar)'))).toBe(':right-of(span):react(foobar)');
expect(serialize(parseCSS('div:is(span):hover'))).toBe('div:hover:is(span)');
expect(serialize(parseCSS('div:scope:hover'))).toBe('div:hover:scope()');
expect(serialize(parseCSS('div:sCOpe:HOVER'))).toBe('div:HOVER:scope()');
expect(serialize(parseCSS('div:NOT(span):hoVER'))).toBe('div:hoVER:not(span)');
expect(serialize(parseCSS('div:not(span)').selector)).toBe('div:not(span)');
expect(serialize(parseCSS(':not(span)#id').selector)).toBe('#id:not(span)');
expect(serialize(parseCSS('div:not(span):hover').selector)).toBe('div:hover:not(span)');
expect(serialize(parseCSS('div:has(span):hover').selector)).toBe('div:hover:has(span)');
expect(serialize(parseCSS('div:right-of(span):hover').selector)).toBe('div:hover:right-of(span)');
expect(serialize(parseCSS(':right-of(span):react(foobar)').selector)).toBe(':right-of(span):react(foobar)');
expect(serialize(parseCSS('div:is(span):hover').selector)).toBe('div:hover:is(span)');
expect(serialize(parseCSS('div:scope:hover').selector)).toBe('div:hover:scope()');
expect(serialize(parseCSS('div:sCOpe:HOVER').selector)).toBe('div:HOVER:scope()');
expect(serialize(parseCSS('div:NOT(span):hoVER').selector)).toBe('div:hoVER:not(span)');
expect(serialize(parseCSS(':text("foo")'))).toBe(':text("foo")');
expect(serialize(parseCSS(':text("*")'))).toBe(':text("*")');
expect(serialize(parseCSS(':text(*)'))).toBe(':text(*)');
expect(serialize(parseCSS(':text("foo", normalize-space)'))).toBe(':text("foo", normalize-space)');
expect(serialize(parseCSS(':index(3, div span)'))).toBe(':index(3, div span)');
expect(serialize(parseCSS(':is(foo, bar>baz.cls+:not(qux))'))).toBe(':is(foo, bar > baz.cls + :not(qux))');
expect(serialize(parseCSS(':text("foo")').selector)).toBe(':text("foo")');
expect(serialize(parseCSS(':text("*")').selector)).toBe(':text("*")');
expect(serialize(parseCSS(':text(*)').selector)).toBe(':text(*)');
expect(serialize(parseCSS(':text("foo", normalize-space)').selector)).toBe(':text("foo", normalize-space)');
expect(serialize(parseCSS(':index(3, div span)').selector)).toBe(':index(3, div span)');
expect(serialize(parseCSS(':is(foo, bar>baz.cls+:not(qux))').selector)).toBe(':is(foo, bar > baz.cls + :not(qux))');
});
it('should throw on malformed css', async () => {

Просмотреть файл

@ -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 throw for non-string selector', async ({page}) => {
const error = await page.$(null).catch(e => e);
@ -58,6 +61,8 @@ it('should auto-detect xpath selector with starting parenthesis', async ({page,
});
it('should auto-detect xpath selector starting with ..', async ({page, server}) => {
if (selectorsV2Enabled())
return; // Selectors v2 do not support this.
await page.setContent('<div><section>test</section><span></span></div>');
const span = await page.$('"test" >> ../span');
expect(await span.evaluate(e => e.nodeName)).toBe('SPAN');

Просмотреть файл

@ -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');
@ -189,9 +192,9 @@ it('should work with +', async ({page}) => {
expect(await page.$$eval(`css=#div3 + #div4 + #div5`, els => els.length)).toBe(1);
});
it('should work with spaces in :nth-child and :not', test => {
test.fixme('Our selector parser is broken');
}, async ({page, server}) => {
it('should work with spaces in :nth-child and :not', async ({page, server}) => {
if (!selectorsV2Enabled())
return; // Selectors v1 do not support this.
await page.goto(server.PREFIX + '/deep-shadow.html');
expect(await page.$$eval(`css=span:nth-child(23n +2)`, els => els.length)).toBe(1);
expect(await page.$$eval(`css=span:nth-child(23n+ 2)`, els => els.length)).toBe(1);
@ -204,9 +207,9 @@ it('should work with spaces in :nth-child and :not', test => {
expect(await page.$$eval(`css=span, section:not(span, div)`, els => els.length)).toBe(5);
});
it('should work with :is', test => {
test.skip('Needs a new selector evaluator');
}, async ({page, server}) => {
it('should work with :is', async ({page, server}) => {
if (!selectorsV2Enabled())
return; // Selectors v1 do not support this.
await page.goto(server.PREFIX + '/deep-shadow.html');
expect(await page.$$eval(`css=div:is(#root1)`, els => els.length)).toBe(1);
expect(await page.$$eval(`css=div:is(#root1, #target)`, els => els.length)).toBe(1);
@ -218,18 +221,18 @@ it('should work with :is', test => {
expect(await page.$$eval(`css=:is(div, span) > *`, els => els.length)).toBe(6);
});
it('should work with :has', test => {
test.skip('Needs a new selector evaluator');
}, async ({page, server}) => {
it('should work with :has', async ({page, server}) => {
if (!selectorsV2Enabled())
return; // Selectors v1 do not support this.
await page.goto(server.PREFIX + '/deep-shadow.html');
expect(await page.$$eval(`css=div:has(#target)`, els => els.length)).toBe(2);
expect(await page.$$eval(`css=div:has([data-testid=foo])`, els => els.length)).toBe(3);
expect(await page.$$eval(`css=div:has([attr*=value])`, els => els.length)).toBe(2);
});
it('should work with :scope', test => {
test.skip('Needs a new selector evaluator');
}, async ({page, server}) => {
it('should work with :scope', async ({page, server}) => {
if (!selectorsV2Enabled())
return; // Selectors v1 do not support this.
await page.goto(server.PREFIX + '/deep-shadow.html');
// 'is' does not change the scope, so it remains 'html'.
expect(await page.$$eval(`css=div:is(:scope#root1)`, els => els.length)).toBe(0);

Просмотреть файл

@ -69,7 +69,7 @@ it('should work in main and isolated world', async ({playwright, page}) => {
return window['__answer'];
},
queryAll(root, selector) {
return [document.body, document.documentElement, window['__answer']];
return window['__answer'] ? [window['__answer'], document.body, document.documentElement] : [];
}
});
await playwright.selectors.register('main', createDummySelector);

Просмотреть файл

@ -17,7 +17,7 @@
import { it, expect } from './fixtures';
it('query', async ({page, isWebKit}) => {
it('query', async ({page}) => {
await page.setContent(`<div>yo</div><div>ya</div><div>\nye </div>`);
expect(await page.$eval(`text=ya`, e => e.outerHTML)).toBe('<div>ya</div>');
expect(await page.$eval(`text="ya"`, e => e.outerHTML)).toBe('<div>ya</div>');
@ -59,9 +59,9 @@ it('query', async ({page, isWebKit}) => {
expect(await page.$eval(`"x"`, e => e.outerHTML)).toBe('<div>x</div>');
expect(await page.$eval(`'x'`, e => e.outerHTML)).toBe('<div>x</div>');
let error = await page.$(`"`).catch(e => e);
expect(error.message).toContain(isWebKit ? 'SyntaxError' : 'querySelector');
expect(error).toBeInstanceOf(Error);
error = await page.$(`'`).catch(e => e);
expect(error.message).toContain(isWebKit ? 'SyntaxError' : 'querySelector');
expect(error).toBeInstanceOf(Error);
await page.setContent(`<div> ' </div><div> " </div>`);
expect(await page.$eval(`text="`, e => e.outerHTML)).toBe('<div> " </div>');