diff --git a/src/debug/injected/consoleApi.ts b/src/debug/injected/consoleApi.ts index e04ab610e0..abad64a101 100644 --- a/src/debug/injected/consoleApi.ts +++ b/src/debug/injected/consoleApi.ts @@ -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) { diff --git a/src/server/common/cssParser.ts b/src/server/common/cssParser.ts index 46bb579121..3bcd14738c 100644 --- a/src/server/common/cssParser.ts +++ b/src/server/common/cssParser.ts @@ -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(); 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[]) { diff --git a/src/server/common/selectorParser.ts b/src/server/common/selectorParser.ts index e227b6afca..a12dd6c304 100644 --- a/src/server/common/selectorParser.ts +++ b/src/server/common/selectorParser.ts @@ -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(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) { diff --git a/src/server/injected/injectedScript.ts b/src/server/injected/injectedScript.ts index ba45417daf..f8e8fe7b18 100644 --- a/src/server/injected/injectedScript.ts +++ b/src/server/injected/injectedScript.ts @@ -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 = (progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol; @@ -40,27 +41,32 @@ export type InjectedScriptPoll = { }; export class InjectedScript { - readonly engines: Map; + private _enginesV1: Map; + 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(); + 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(); 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']); diff --git a/src/server/injected/selectorEvaluator.ts b/src/server/injected/selectorEvaluator.ts index ab6da1fb8f..e964de4fd6 100644 --- a/src/server/injected/selectorEvaluator.ts +++ b/src/server/injected/selectorEvaluator.ts @@ -36,6 +36,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { private _cache = new Map(); constructor(extraEngines: Map) { + // 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')) diff --git a/src/server/selectors.ts b/src/server/selectors.ts index d700560e71..7018b52d00 100644 --- a/src/server/selectors.ts +++ b/src/server/selectors.ts @@ -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; }); diff --git a/test/css-parser.spec.ts b/test/css-parser.spec.ts index 504a1ea662..b9d59c40fc 100644 --- a/test/css-parser.spec.ts +++ b/test/css-parser.spec.ts @@ -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 () => { diff --git a/test/queryselector.spec.ts b/test/queryselector.spec.ts index a5fb19035c..45cc9a5b9c 100644 --- a/test/queryselector.spec.ts +++ b/test/queryselector.spec.ts @@ -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('
test
'); const span = await page.$('"test" >> ../span'); expect(await span.evaluate(e => e.nodeName)).toBe('SPAN'); diff --git a/test/selectors-css.spec.ts b/test/selectors-css.spec.ts index a9cee405a2..e400d6b4e6 100644 --- a/test/selectors-css.spec.ts +++ b/test/selectors-css.spec.ts @@ -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); diff --git a/test/selectors-register.spec.ts b/test/selectors-register.spec.ts index 86625cc71f..168ae483e3 100644 --- a/test/selectors-register.spec.ts +++ b/test/selectors-register.spec.ts @@ -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); diff --git a/test/selectors-text.spec.ts b/test/selectors-text.spec.ts index 64a5c33954..ac56028df3 100644 --- a/test/selectors-text.spec.ts +++ b/test/selectors-text.spec.ts @@ -17,7 +17,7 @@ import { it, expect } from './fixtures'; -it('query', async ({page, isWebKit}) => { +it('query', async ({page}) => { await page.setContent(`
yo
ya
\nye
`); expect(await page.$eval(`text=ya`, e => e.outerHTML)).toBe('
ya
'); expect(await page.$eval(`text="ya"`, e => e.outerHTML)).toBe('
ya
'); @@ -59,9 +59,9 @@ it('query', async ({page, isWebKit}) => { expect(await page.$eval(`"x"`, e => e.outerHTML)).toBe('
x
'); expect(await page.$eval(`'x'`, e => e.outerHTML)).toBe('
x
'); 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(`
'
"
`); expect(await page.$eval(`text="`, e => e.outerHTML)).toBe('
"
');