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:
Родитель
7213794a65
Коммит
49a3f943b6
|
@ -14,7 +14,7 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ParsedSelector, parseSelector } from '../../server/common/selectorParser';
|
import { parseSelector } from '../../server/common/selectorParser';
|
||||||
import type InjectedScript from '../../server/injected/injectedScript';
|
import type InjectedScript from '../../server/injected/injectedScript';
|
||||||
|
|
||||||
export class ConsoleAPI {
|
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) {
|
_querySelector(selector: string): (Element | undefined) {
|
||||||
if (typeof selector !== 'string')
|
if (typeof selector !== 'string')
|
||||||
throw new Error(`Usage: playwright.query('Playwright >> selector').`);
|
throw new Error(`Usage: playwright.query('Playwright >> selector').`);
|
||||||
const parsed = parseSelector(selector);
|
const parsed = parseSelector(selector);
|
||||||
this._checkSelector(parsed);
|
return this._injectedScript.querySelector(parsed, document);
|
||||||
const elements = this._injectedScript.querySelectorAll(parsed, document);
|
|
||||||
return elements[0];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_querySelectorAll(selector: string): Element[] {
|
_querySelectorAll(selector: string): Element[] {
|
||||||
if (typeof selector !== 'string')
|
if (typeof selector !== 'string')
|
||||||
throw new Error(`Usage: playwright.$$('Playwright >> selector').`);
|
throw new Error(`Usage: playwright.$$('Playwright >> selector').`);
|
||||||
const parsed = parseSelector(selector);
|
const parsed = parseSelector(selector);
|
||||||
this._checkSelector(parsed);
|
return this._injectedScript.querySelectorAll(parsed, document);
|
||||||
const elements = this._injectedScript.querySelectorAll(parsed, document);
|
|
||||||
return elements;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_inspect(selector: string) {
|
_inspect(selector: string) {
|
||||||
|
|
|
@ -27,7 +27,7 @@ export type CSSSimpleSelector = { css?: string, functions: CSSFunction[] };
|
||||||
export type CSSComplexSelector = { simples: { selector: CSSSimpleSelector, combinator: ClauseCombinator }[] };
|
export type CSSComplexSelector = { simples: { selector: CSSSimpleSelector, combinator: ClauseCombinator }[] };
|
||||||
export type CSSComplexSelectorList = CSSComplexSelector[];
|
export type CSSComplexSelectorList = CSSComplexSelector[];
|
||||||
|
|
||||||
export function parseCSS(selector: string): CSSComplexSelectorList {
|
export function parseCSS(selector: string): { selector: CSSComplexSelectorList, names: string[] } {
|
||||||
let tokens: css.CSSTokenInterface[];
|
let tokens: css.CSSTokenInterface[];
|
||||||
try {
|
try {
|
||||||
tokens = css.tokenize(selector);
|
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}"`);
|
throw new Error(`Unsupported token "${unsupportedToken.toSource()}" while parsing selector "${selector}"`);
|
||||||
|
|
||||||
let pos = 0;
|
let pos = 0;
|
||||||
|
const names = new Set<string>();
|
||||||
|
|
||||||
function unexpected() {
|
function unexpected() {
|
||||||
return new Error(`Unexpected token "${tokens[pos].toSource()}" while parsing selector "${selector}"`);
|
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) {
|
} else if (tokens[pos] instanceof css.ColonToken) {
|
||||||
pos++;
|
pos++;
|
||||||
if (isIdent()) {
|
if (isIdent()) {
|
||||||
if (builtinCSSFilters.has(tokens[pos].value.toLowerCase()))
|
if (builtinCSSFilters.has(tokens[pos].value.toLowerCase())) {
|
||||||
rawCSSString += ':' + tokens[pos++].toSource();
|
rawCSSString += ':' + tokens[pos++].toSource();
|
||||||
else
|
} else {
|
||||||
functions.push({ name: tokens[pos++].value.toLowerCase(), args: [] });
|
const name = tokens[pos++].value.toLowerCase();
|
||||||
|
functions.push({ name, args: [] });
|
||||||
|
names.add(name);
|
||||||
|
}
|
||||||
} else if (tokens[pos] instanceof css.FunctionToken) {
|
} else if (tokens[pos] instanceof css.FunctionToken) {
|
||||||
const name = tokens[pos++].value.toLowerCase();
|
const name = tokens[pos++].value.toLowerCase();
|
||||||
if (builtinCSSFunctions.has(name))
|
if (builtinCSSFunctions.has(name)) {
|
||||||
rawCSSString += `:${name}(${consumeBuiltinFunctionArguments()})`;
|
rawCSSString += `:${name}(${consumeBuiltinFunctionArguments()})`;
|
||||||
else
|
} else {
|
||||||
functions.push({ name, args: consumeFunctionArguments() });
|
functions.push({ name, args: consumeFunctionArguments() });
|
||||||
|
names.add(name);
|
||||||
|
}
|
||||||
skipWhitespace();
|
skipWhitespace();
|
||||||
if (!isCloseParen())
|
if (!isCloseParen())
|
||||||
throw unexpected();
|
throw unexpected();
|
||||||
|
@ -210,7 +216,7 @@ export function parseCSS(selector: string): CSSComplexSelectorList {
|
||||||
throw new Error(`Error while parsing selector "${selector}"`);
|
throw new Error(`Error while parsing selector "${selector}"`);
|
||||||
if (result.some(arg => typeof arg !== 'object' || !('simples' in arg)))
|
if (result.some(arg => typeof arg !== 'object' || !('simples' in arg)))
|
||||||
throw new Error(`Error while parsing selector "${selector}"`);
|
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[]) {
|
export function serializeSelector(args: CSSFunctionArgument[]) {
|
||||||
|
|
|
@ -14,9 +14,9 @@
|
||||||
* limitations under the License.
|
* 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: {
|
parts: {
|
||||||
name: string,
|
name: string,
|
||||||
body: string,
|
body: string,
|
||||||
|
@ -24,11 +24,122 @@ export type ParsedSelector = {
|
||||||
capture?: number,
|
capture?: number,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ParsedSelector = {
|
||||||
|
v1?: ParsedSelectorV1,
|
||||||
|
v2?: CSSComplexSelectorList,
|
||||||
|
names: string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
export function selectorsV2Enabled() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function parseSelector(selector: string): ParsedSelector {
|
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 index = 0;
|
||||||
let quote: string | undefined;
|
let quote: string | undefined;
|
||||||
let start = 0;
|
let start = 0;
|
||||||
const result: ParsedSelector = { parts: [] };
|
const result: ParsedSelectorV1 = { parts: [] };
|
||||||
const append = () => {
|
const append = () => {
|
||||||
const part = selector.substring(start, index).trim();
|
const part = selector.substring(start, index).trim();
|
||||||
const eqIndex = part.indexOf('=');
|
const eqIndex = part.indexOf('=');
|
||||||
|
@ -65,6 +176,13 @@ export function parseSelector(selector: string): ParsedSelector {
|
||||||
result.capture = result.parts.length - 1;
|
result.capture = result.parts.length - 1;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!selector.includes('>>')) {
|
||||||
|
index = selector.length;
|
||||||
|
append();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
while (index < selector.length) {
|
while (index < selector.length) {
|
||||||
const c = selector[index];
|
const c = selector[index];
|
||||||
if (c === '\\' && index + 1 < selector.length) {
|
if (c === '\\' && index + 1 < selector.length) {
|
||||||
|
|
|
@ -19,8 +19,9 @@ import { createCSSEngine } from './cssSelectorEngine';
|
||||||
import { SelectorEngine, SelectorRoot } from './selectorEngine';
|
import { SelectorEngine, SelectorRoot } from './selectorEngine';
|
||||||
import { createTextSelector } from './textSelectorEngine';
|
import { createTextSelector } from './textSelectorEngine';
|
||||||
import { XPathEngine } from './xpathSelectorEngine';
|
import { XPathEngine } from './xpathSelectorEngine';
|
||||||
import { ParsedSelector, parseSelector } from '../common/selectorParser';
|
import { ParsedSelector, ParsedSelectorV1, parseSelector } from '../common/selectorParser';
|
||||||
import { FatalDOMError } from '../common/domErrors';
|
import { FatalDOMError } from '../common/domErrors';
|
||||||
|
import { SelectorEvaluatorImpl, SelectorEngine as SelectorEngineV2, QueryContext } from './selectorEvaluator';
|
||||||
|
|
||||||
type Predicate<T> = (progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol;
|
type Predicate<T> = (progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol;
|
||||||
|
|
||||||
|
@ -40,27 +41,32 @@ export type InjectedScriptPoll<T> = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export class InjectedScript {
|
export class InjectedScript {
|
||||||
readonly engines: Map<string, SelectorEngine>;
|
private _enginesV1: Map<string, SelectorEngine>;
|
||||||
|
private _evaluator: SelectorEvaluatorImpl;
|
||||||
|
|
||||||
constructor(customEngines: { name: string, engine: SelectorEngine}[]) {
|
constructor(customEngines: { name: string, engine: SelectorEngine}[]) {
|
||||||
this.engines = new Map();
|
this._enginesV1 = new Map();
|
||||||
// Note: keep predefined names in sync with Selectors class.
|
this._enginesV1.set('css', createCSSEngine(true));
|
||||||
this.engines.set('css', createCSSEngine(true));
|
this._enginesV1.set('css:light', createCSSEngine(false));
|
||||||
this.engines.set('css:light', createCSSEngine(false));
|
this._enginesV1.set('xpath', XPathEngine);
|
||||||
this.engines.set('xpath', XPathEngine);
|
this._enginesV1.set('xpath:light', XPathEngine);
|
||||||
this.engines.set('xpath:light', XPathEngine);
|
this._enginesV1.set('text', createTextSelector(true));
|
||||||
this.engines.set('text', createTextSelector(true));
|
this._enginesV1.set('text:light', createTextSelector(false));
|
||||||
this.engines.set('text:light', createTextSelector(false));
|
this._enginesV1.set('id', createAttributeEngine('id', true));
|
||||||
this.engines.set('id', createAttributeEngine('id', true));
|
this._enginesV1.set('id:light', createAttributeEngine('id', false));
|
||||||
this.engines.set('id:light', createAttributeEngine('id', false));
|
this._enginesV1.set('data-testid', createAttributeEngine('data-testid', true));
|
||||||
this.engines.set('data-testid', createAttributeEngine('data-testid', true));
|
this._enginesV1.set('data-testid:light', createAttributeEngine('data-testid', false));
|
||||||
this.engines.set('data-testid:light', createAttributeEngine('data-testid', false));
|
this._enginesV1.set('data-test-id', createAttributeEngine('data-test-id', true));
|
||||||
this.engines.set('data-test-id', createAttributeEngine('data-test-id', true));
|
this._enginesV1.set('data-test-id:light', createAttributeEngine('data-test-id', false));
|
||||||
this.engines.set('data-test-id:light', createAttributeEngine('data-test-id', false));
|
this._enginesV1.set('data-test', createAttributeEngine('data-test', true));
|
||||||
this.engines.set('data-test', createAttributeEngine('data-test', true));
|
this._enginesV1.set('data-test:light', createAttributeEngine('data-test', false));
|
||||||
this.engines.set('data-test:light', createAttributeEngine('data-test', false));
|
for (const { name, engine } of customEngines)
|
||||||
for (const {name, engine} of customEngines)
|
this._enginesV1.set(name, engine);
|
||||||
this.engines.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 {
|
parseSelector(selector: string): ParsedSelector {
|
||||||
|
@ -70,16 +76,18 @@ export class InjectedScript {
|
||||||
querySelector(selector: ParsedSelector, root: Node): Element | undefined {
|
querySelector(selector: ParsedSelector, root: Node): Element | undefined {
|
||||||
if (!(root as any)['querySelector'])
|
if (!(root as any)['querySelector'])
|
||||||
throw new Error('Node is not queryable.');
|
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];
|
const current = selector.parts[index];
|
||||||
if (index === selector.parts.length - 1)
|
if (index === selector.parts.length - 1)
|
||||||
return this.engines.get(current.name)!.query(root, current.body);
|
return this._enginesV1.get(current.name)!.query(root, current.body);
|
||||||
const all = this.engines.get(current.name)!.queryAll(root, current.body);
|
const all = this._enginesV1.get(current.name)!.queryAll(root, current.body);
|
||||||
for (const next of all) {
|
for (const next of all) {
|
||||||
const result = this._querySelectorRecursively(next, selector, index + 1);
|
const result = this._querySelectorRecursivelyV1(next, selector, index + 1);
|
||||||
if (result)
|
if (result)
|
||||||
return selector.capture === index ? next : result;
|
return selector.capture === index ? next : result;
|
||||||
}
|
}
|
||||||
|
@ -88,6 +96,12 @@ export class InjectedScript {
|
||||||
querySelectorAll(selector: ParsedSelector, root: Node): Element[] {
|
querySelectorAll(selector: ParsedSelector, root: Node): Element[] {
|
||||||
if (!(root as any)['querySelectorAll'])
|
if (!(root as any)['querySelectorAll'])
|
||||||
throw new Error('Node is not queryable.');
|
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;
|
const capture = selector.capture === undefined ? selector.parts.length - 1 : selector.capture;
|
||||||
// Query all elements up to the capture.
|
// Query all elements up to the capture.
|
||||||
const partsToQuerAll = selector.parts.slice(0, capture + 1);
|
const partsToQuerAll = selector.parts.slice(0, capture + 1);
|
||||||
|
@ -97,7 +111,7 @@ export class InjectedScript {
|
||||||
for (const { name, body } of partsToQuerAll) {
|
for (const { name, body } of partsToQuerAll) {
|
||||||
const newSet = new Set<Element>();
|
const newSet = new Set<Element>();
|
||||||
for (const prev of set) {
|
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))
|
if (newSet.has(next))
|
||||||
continue;
|
continue;
|
||||||
newSet.add(next);
|
newSet.add(next);
|
||||||
|
@ -109,7 +123,7 @@ export class InjectedScript {
|
||||||
if (!partsToCheckOne.length)
|
if (!partsToCheckOne.length)
|
||||||
return candidates;
|
return candidates;
|
||||||
const partial = { parts: partsToCheckOne };
|
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 {
|
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 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']);
|
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 }[]>();
|
private _cache = new Map<any, { rest: any[], result: any }[]>();
|
||||||
|
|
||||||
constructor(extraEngines: Map<string, SelectorEngine>) {
|
constructor(extraEngines: Map<string, SelectorEngine>) {
|
||||||
|
// Note: keep predefined names in sync with Selectors class.
|
||||||
for (const [name, engine] of extraEngines)
|
for (const [name, engine] of extraEngines)
|
||||||
this._engines.set(name, engine);
|
this._engines.set(name, engine);
|
||||||
this._engines.set('not', notEngine);
|
this._engines.set('not', notEngine);
|
||||||
|
@ -43,6 +44,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
|
||||||
this._engines.set('where', isEngine);
|
this._engines.set('where', isEngine);
|
||||||
this._engines.set('has', hasEngine);
|
this._engines.set('has', hasEngine);
|
||||||
this._engines.set('scope', scopeEngine);
|
this._engines.set('scope', scopeEngine);
|
||||||
|
this._engines.set('light', lightEngine);
|
||||||
this._engines.set('text', textEngine);
|
this._engines.set('text', textEngine);
|
||||||
this._engines.set('matches-text', matchesTextEngine);
|
this._engines.set('matches-text', matchesTextEngine);
|
||||||
this._engines.set('xpath', xpathEngine);
|
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 = {
|
const textEngine: SelectorEngine = {
|
||||||
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
|
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'))
|
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',
|
'id', 'id:light',
|
||||||
'data-testid', 'data-testid:light',
|
'data-testid', 'data-testid:light',
|
||||||
'data-test-id', 'data-test-id: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();
|
this._engines = new Map();
|
||||||
}
|
}
|
||||||
|
@ -116,11 +118,11 @@ export class Selectors {
|
||||||
|
|
||||||
_parseSelector(selector: string): SelectorInfo {
|
_parseSelector(selector: string): SelectorInfo {
|
||||||
const parsed = parseSelector(selector);
|
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))
|
if (!this._builtinEngines.has(name) && !this._engines.has(name))
|
||||||
throw new Error(`Unknown engine "${name}" while parsing selector ${selector}`);
|
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);
|
const custom = this._engines.get(name);
|
||||||
return custom ? !custom.contentScript : false;
|
return custom ? !custom.contentScript : false;
|
||||||
});
|
});
|
||||||
|
|
|
@ -21,50 +21,50 @@ const { parseCSS, serializeSelector: serialize } =
|
||||||
require(path.join(__dirname, '..', 'lib', 'server', 'common', 'cssParser'));
|
require(path.join(__dirname, '..', 'lib', 'server', 'common', 'cssParser'));
|
||||||
|
|
||||||
it('should parse css', async () => {
|
it('should parse css', async () => {
|
||||||
expect(serialize(parseCSS('div'))).toBe('div');
|
expect(serialize(parseCSS('div').selector)).toBe('div');
|
||||||
expect(serialize(parseCSS('div.class'))).toBe('div.class');
|
expect(serialize(parseCSS('div.class').selector)).toBe('div.class');
|
||||||
expect(serialize(parseCSS('.class'))).toBe('.class');
|
expect(serialize(parseCSS('.class').selector)).toBe('.class');
|
||||||
expect(serialize(parseCSS('#id'))).toBe('#id');
|
expect(serialize(parseCSS('#id').selector)).toBe('#id');
|
||||||
expect(serialize(parseCSS('.class#id'))).toBe('.class#id');
|
expect(serialize(parseCSS('.class#id').selector)).toBe('.class#id');
|
||||||
expect(serialize(parseCSS('div#id.class'))).toBe('div#id.class');
|
expect(serialize(parseCSS('div#id.class').selector)).toBe('div#id.class');
|
||||||
expect(serialize(parseCSS('*'))).toBe('*');
|
expect(serialize(parseCSS('*').selector)).toBe('*');
|
||||||
expect(serialize(parseCSS('*div'))).toBe('*div');
|
expect(serialize(parseCSS('*div').selector)).toBe('*div');
|
||||||
expect(serialize(parseCSS('div[attr *= foo i]'))).toBe('div[attr *= foo i]');
|
expect(serialize(parseCSS('div[attr *= foo i]').selector)).toBe('div[attr *= foo i]');
|
||||||
expect(serialize(parseCSS('div[attr~="Bar baz" ]'))).toBe('div[attr~="Bar baz" ]');
|
expect(serialize(parseCSS('div[attr~="Bar baz" ]').selector)).toBe('div[attr~="Bar baz" ]');
|
||||||
expect(serialize(parseCSS(`div [ foo = 'bar' s]`))).toBe(`div [ foo = "bar" s]`);
|
expect(serialize(parseCSS(`div [ foo = 'bar' s]`).selector)).toBe(`div [ foo = "bar" s]`);
|
||||||
|
|
||||||
expect(serialize(parseCSS(':hover'))).toBe(':hover');
|
expect(serialize(parseCSS(':hover').selector)).toBe(':hover');
|
||||||
expect(serialize(parseCSS('div:hover'))).toBe('div:hover');
|
expect(serialize(parseCSS('div:hover').selector)).toBe('div:hover');
|
||||||
expect(serialize(parseCSS('#id:active:hover'))).toBe('#id:active:hover');
|
expect(serialize(parseCSS('#id:active:hover').selector)).toBe('#id:active:hover');
|
||||||
expect(serialize(parseCSS(':dir(ltr)'))).toBe(':dir(ltr)');
|
expect(serialize(parseCSS(':dir(ltr)').selector)).toBe(':dir(ltr)');
|
||||||
expect(serialize(parseCSS('#foo-bar.cls:nth-child(3n + 10)'))).toBe('#foo-bar.cls:nth-child(3n + 10)');
|
expect(serialize(parseCSS('#foo-bar.cls:nth-child(3n + 10)').selector)).toBe('#foo-bar.cls:nth-child(3n + 10)');
|
||||||
expect(serialize(parseCSS(':lang(en)'))).toBe(':lang(en)');
|
expect(serialize(parseCSS(':lang(en)').selector)).toBe(':lang(en)');
|
||||||
expect(serialize(parseCSS('*:hover'))).toBe('*:hover');
|
expect(serialize(parseCSS('*:hover').selector)).toBe('*:hover');
|
||||||
|
|
||||||
expect(serialize(parseCSS('div span'))).toBe('div span');
|
expect(serialize(parseCSS('div span').selector)).toBe('div span');
|
||||||
expect(serialize(parseCSS('div>span'))).toBe('div > span');
|
expect(serialize(parseCSS('div>span').selector)).toBe('div > span');
|
||||||
expect(serialize(parseCSS('div +span'))).toBe('div + span');
|
expect(serialize(parseCSS('div +span').selector)).toBe('div + span');
|
||||||
expect(serialize(parseCSS('div~ span'))).toBe('div ~ span');
|
expect(serialize(parseCSS('div~ span').selector)).toBe('div ~ span');
|
||||||
expect(serialize(parseCSS('div >.class #id+ span'))).toBe('div > .class #id + span');
|
expect(serialize(parseCSS('div >.class #id+ span').selector)).toBe('div > .class #id + span');
|
||||||
expect(serialize(parseCSS('div>span+.class'))).toBe('div > span + .class');
|
expect(serialize(parseCSS('div>span+.class').selector)).toBe('div > span + .class');
|
||||||
|
|
||||||
expect(serialize(parseCSS('div:not(span)'))).toBe('div:not(span)');
|
expect(serialize(parseCSS('div:not(span)').selector)).toBe('div:not(span)');
|
||||||
expect(serialize(parseCSS(':not(span)#id'))).toBe('#id:not(span)');
|
expect(serialize(parseCSS(':not(span)#id').selector)).toBe('#id:not(span)');
|
||||||
expect(serialize(parseCSS('div:not(span):hover'))).toBe('div:hover:not(span)');
|
expect(serialize(parseCSS('div:not(span):hover').selector)).toBe('div:hover:not(span)');
|
||||||
expect(serialize(parseCSS('div:has(span):hover'))).toBe('div:hover:has(span)');
|
expect(serialize(parseCSS('div:has(span):hover').selector)).toBe('div:hover:has(span)');
|
||||||
expect(serialize(parseCSS('div:right-of(span):hover'))).toBe('div:hover:right-of(span)');
|
expect(serialize(parseCSS('div:right-of(span):hover').selector)).toBe('div:hover:right-of(span)');
|
||||||
expect(serialize(parseCSS(':right-of(span):react(foobar)'))).toBe(':right-of(span):react(foobar)');
|
expect(serialize(parseCSS(':right-of(span):react(foobar)').selector)).toBe(':right-of(span):react(foobar)');
|
||||||
expect(serialize(parseCSS('div:is(span):hover'))).toBe('div:hover:is(span)');
|
expect(serialize(parseCSS('div:is(span):hover').selector)).toBe('div:hover:is(span)');
|
||||||
expect(serialize(parseCSS('div:scope:hover'))).toBe('div:hover:scope()');
|
expect(serialize(parseCSS('div:scope:hover').selector)).toBe('div:hover:scope()');
|
||||||
expect(serialize(parseCSS('div:sCOpe:HOVER'))).toBe('div:HOVER:scope()');
|
expect(serialize(parseCSS('div:sCOpe:HOVER').selector)).toBe('div:HOVER:scope()');
|
||||||
expect(serialize(parseCSS('div:NOT(span):hoVER'))).toBe('div:hoVER:not(span)');
|
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("foo")').selector)).toBe(':text("foo")');
|
||||||
expect(serialize(parseCSS(':text("*")'))).toBe(':text("*")');
|
expect(serialize(parseCSS(':text("*")').selector)).toBe(':text("*")');
|
||||||
expect(serialize(parseCSS(':text(*)'))).toBe(':text(*)');
|
expect(serialize(parseCSS(':text(*)').selector)).toBe(':text(*)');
|
||||||
expect(serialize(parseCSS(':text("foo", normalize-space)'))).toBe(':text("foo", normalize-space)');
|
expect(serialize(parseCSS(':text("foo", normalize-space)').selector)).toBe(':text("foo", normalize-space)');
|
||||||
expect(serialize(parseCSS(':index(3, div span)'))).toBe(':index(3, div span)');
|
expect(serialize(parseCSS(':index(3, div span)').selector)).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(':is(foo, bar>baz.cls+:not(qux))').selector)).toBe(':is(foo, bar > baz.cls + :not(qux))');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw on malformed css', async () => {
|
it('should throw on malformed css', async () => {
|
||||||
|
|
|
@ -16,6 +16,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { it, expect } from './fixtures';
|
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}) => {
|
it('should throw for non-string selector', async ({page}) => {
|
||||||
const error = await page.$(null).catch(e => e);
|
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}) => {
|
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>');
|
await page.setContent('<div><section>test</section><span></span></div>');
|
||||||
const span = await page.$('"test" >> ../span');
|
const span = await page.$('"test" >> ../span');
|
||||||
expect(await span.evaluate(e => e.nodeName)).toBe('SPAN');
|
expect(await span.evaluate(e => e.nodeName)).toBe('SPAN');
|
||||||
|
|
|
@ -16,6 +16,9 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { it, expect } from './fixtures';
|
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}) => {
|
it('should work for open shadow roots', async ({page, server}) => {
|
||||||
await page.goto(server.PREFIX + '/deep-shadow.html');
|
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);
|
expect(await page.$$eval(`css=#div3 + #div4 + #div5`, els => els.length)).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work with spaces in :nth-child and :not', test => {
|
it('should work with spaces in :nth-child and :not', async ({page, server}) => {
|
||||||
test.fixme('Our selector parser is broken');
|
if (!selectorsV2Enabled())
|
||||||
}, async ({page, server}) => {
|
return; // Selectors v1 do not support this.
|
||||||
await page.goto(server.PREFIX + '/deep-shadow.html');
|
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);
|
||||||
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);
|
expect(await page.$$eval(`css=span, section:not(span, div)`, els => els.length)).toBe(5);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work with :is', test => {
|
it('should work with :is', async ({page, server}) => {
|
||||||
test.skip('Needs a new selector evaluator');
|
if (!selectorsV2Enabled())
|
||||||
}, async ({page, server}) => {
|
return; // Selectors v1 do not support this.
|
||||||
await page.goto(server.PREFIX + '/deep-shadow.html');
|
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)`, els => els.length)).toBe(1);
|
||||||
expect(await page.$$eval(`css=div:is(#root1, #target)`, 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);
|
expect(await page.$$eval(`css=:is(div, span) > *`, els => els.length)).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work with :has', test => {
|
it('should work with :has', async ({page, server}) => {
|
||||||
test.skip('Needs a new selector evaluator');
|
if (!selectorsV2Enabled())
|
||||||
}, async ({page, server}) => {
|
return; // Selectors v1 do not support this.
|
||||||
await page.goto(server.PREFIX + '/deep-shadow.html');
|
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(#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([data-testid=foo])`, els => els.length)).toBe(3);
|
||||||
expect(await page.$$eval(`css=div:has([attr*=value])`, els => els.length)).toBe(2);
|
expect(await page.$$eval(`css=div:has([attr*=value])`, els => els.length)).toBe(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should work with :scope', test => {
|
it('should work with :scope', async ({page, server}) => {
|
||||||
test.skip('Needs a new selector evaluator');
|
if (!selectorsV2Enabled())
|
||||||
}, async ({page, server}) => {
|
return; // Selectors v1 do not support this.
|
||||||
await page.goto(server.PREFIX + '/deep-shadow.html');
|
await page.goto(server.PREFIX + '/deep-shadow.html');
|
||||||
// 'is' does not change the scope, so it remains '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);
|
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'];
|
return window['__answer'];
|
||||||
},
|
},
|
||||||
queryAll(root, selector) {
|
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);
|
await playwright.selectors.register('main', createDummySelector);
|
||||||
|
|
|
@ -17,7 +17,7 @@
|
||||||
|
|
||||||
import { it, expect } from './fixtures';
|
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>`);
|
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>');
|
||||||
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>');
|
||||||
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);
|
let error = await page.$(`"`).catch(e => e);
|
||||||
expect(error.message).toContain(isWebKit ? 'SyntaxError' : 'querySelector');
|
expect(error).toBeInstanceOf(Error);
|
||||||
error = await page.$(`'`).catch(e => e);
|
error = await page.$(`'`).catch(e => e);
|
||||||
expect(error.message).toContain(isWebKit ? 'SyntaxError' : 'querySelector');
|
expect(error).toBeInstanceOf(Error);
|
||||||
|
|
||||||
await page.setContent(`<div> ' </div><div> " </div>`);
|
await page.setContent(`<div> ' </div><div> " </div>`);
|
||||||
expect(await page.$eval(`text="`, e => e.outerHTML)).toBe('<div> " </div>');
|
expect(await page.$eval(`text="`, e => e.outerHTML)).toBe('<div> " </div>');
|
||||||
|
|
Загрузка…
Ссылка в новой задаче