diff --git a/src/server/common/selectorParser.ts b/src/server/common/selectorParser.ts index c2cda46821..d25b78bc00 100644 --- a/src/server/common/selectorParser.ts +++ b/src/server/common/selectorParser.ts @@ -14,144 +14,51 @@ * limitations under the License. */ -import { CSSComplexSelector, CSSComplexSelectorList, CSSFunctionArgument, CSSSimpleSelector, parseCSS } from './cssParser'; +import { CSSComplexSelectorList, parseCSS } from './cssParser'; -export type ParsedSelectorV1 = { - parts: { - name: string, - body: string, - }[], - capture?: number, -}; +export type ParsedSelectorPart = { + name: string, + body: string, +} | CSSComplexSelectorList; export type ParsedSelector = { - v1?: ParsedSelectorV1, - v2?: CSSComplexSelectorList, - names: string[], + parts: ParsedSelectorPart[], + capture?: number, }; export function selectorsV2Enabled() { return true; } -export function selectorsV2EngineNames() { - return ['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text-matches', 'text-is']; -} +const customCSSNames = new Set(['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text', 'text-matches', 'text-is']); -export function parseSelector(selector: string, customNames: Set): ParsedSelector { - const v1 = parseSelectorV1(selector); - const names = new Set(); - for (const { name } of v1.parts) { - names.add(name); - if (!customNames.has(name)) - throw new Error(`Unknown engine "${name}" while parsing selector ${selector}`); - } +export function parseSelector(selector: string): ParsedSelector { + const result = parseSelectorV1(selector); - if (!selectorsV2Enabled()) { - return { - v1, - names: Array.from(names), - }; - } - - const chain = (from: number, to: number, turnFirstTextIntoScope: boolean): CSSComplexSelector => { - const 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(':')); + if (selectorsV2Enabled()) { + result.parts = result.parts.map(part => { + if (Array.isArray(part)) + return part; + if (part.name === 'css' || part.name === 'css:light') { + if (part.name === 'css:light') + part.body = ':light(' + part.body + ')'; + const parsedCSS = parseCSS(part.body, customCSSNames); + return parsedCSS.selector; } - if (name === 'css') { - const parsed = parseCSS(part.body, customNames); - parsed.names.forEach(name => names.add(name)); - if (wrapInLight || parsed.selector.length > 1) { - let simple = callWith('is', parsed.selector); - if (wrapInLight) - simple = callWith('light', [simpleToComplex(simple)]); - result.simples.push({ selector: simple, combinator: '' }); - } else { - result.simples.push(...parsed.selector[0].simples); - } - } else if (name === 'text') { - let simple = textSelectorToSimple(part.body); - if (turnFirstTextIntoScope) - simple.functions.push({ name: 'is', args: [ simpleToComplex(callWith('scope', [])), simpleToComplex({ css: '*', functions: [] }) ]}); - if (result.simples.length) - result.simples[result.simples.length - 1].combinator = '>='; - if (wrapInLight) - simple = callWith('light', [simpleToComplex(simple)]); - result.simples.push({ selector: simple, combinator: '' }); - } else { - let simple = callWith(name, [part.body]); - if (wrapInLight) - simple = callWith('light', [simpleToComplex(simple)]); - result.simples.push({ selector: simple, combinator: '' }); - } - if (name !== 'text') - turnFirstTextIntoScope = false; - } - return result; + return part; + }); + } + return { + parts: result.parts, + capture: result.capture, }; - - const capture = v1.capture === undefined ? v1.parts.length - 1 : v1.capture; - const result = chain(0, capture + 1, false); - if (capture + 1 < v1.parts.length) { - const has = chain(capture + 1, v1.parts.length, true); - 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 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(''); - } - - function escapeRegExp(s: string) { - return s.replace(/[.*+\?^${}()|[\]\\]/g, '\\$&').replace(/-/g, '\\x2d'); - } - - let functionName = 'text-matches'; - let args: string[]; - if (selector.length > 1 && selector[0] === '"' && selector[selector.length - 1] === '"') { - args = ['^' + escapeRegExp(unescape(selector.substring(1, selector.length - 1))) + '$']; - } else if (selector.length > 1 && selector[0] === "'" && selector[selector.length - 1] === "'") { - args = ['^' + escapeRegExp(unescape(selector.substring(1, selector.length - 1))) + '$']; - } else if (selector[0] === '/' && selector.lastIndexOf('/') > 0) { - const lastSlash = selector.lastIndexOf('/'); - args = [selector.substring(1, lastSlash), selector.substring(lastSlash + 1)]; - } else { - functionName = 'text'; - args = [selector]; - } - return callWith(functionName, args); -} - -function parseSelectorV1(selector: string): ParsedSelectorV1 { +function parseSelectorV1(selector: string): ParsedSelector { let index = 0; let quote: string | undefined; let start = 0; - const result: ParsedSelectorV1 = { parts: [] }; + const result: ParsedSelector = { parts: [] }; const append = () => { const part = selector.substring(start, index).trim(); const eqIndex = part.indexOf('='); diff --git a/src/server/injected/injectedScript.ts b/src/server/injected/injectedScript.ts index 5d099bad92..d956dab1ee 100644 --- a/src/server/injected/injectedScript.ts +++ b/src/server/injected/injectedScript.ts @@ -15,13 +15,13 @@ */ import { createAttributeEngine } from './attributeSelectorEngine'; -import { createCSSEngine } from './cssSelectorEngine'; import { SelectorEngine, SelectorRoot } from './selectorEngine'; import { createTextSelector } from './textSelectorEngine'; import { XPathEngine } from './xpathSelectorEngine'; -import { ParsedSelector, ParsedSelectorV1, parseSelector, selectorsV2Enabled, selectorsV2EngineNames } from '../common/selectorParser'; +import { ParsedSelector, ParsedSelectorPart, parseSelector } from '../common/selectorParser'; import { FatalDOMError } from '../common/domErrors'; -import { SelectorEvaluatorImpl, SelectorEngine as SelectorEngineV2, QueryContext, isVisible, parentElementOrShadowHost } from './selectorEvaluator'; +import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost } from './selectorEvaluator'; +import { createCSSEngine } from './cssSelectorEngine'; type Predicate = (progress: InjectedScriptProgress, continuePolling: symbol) => T | symbol; @@ -43,7 +43,6 @@ export type InjectedScriptPoll = { export class InjectedScript { private _enginesV1: Map; private _evaluator: SelectorEvaluatorImpl; - private _engineNames: Set; constructor(customEngines: { name: string, engine: SelectorEngine}[]) { this._enginesV1 = new Map(); @@ -64,37 +63,32 @@ export class InjectedScript { 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); - - this._engineNames = new Set(this._enginesV1.keys()); - if (selectorsV2Enabled()) { - for (const name of selectorsV2EngineNames()) - this._engineNames.add(name); - } + // No custom engines in V2 for now. + this._evaluator = new SelectorEvaluatorImpl(new Map()); } parseSelector(selector: string): ParsedSelector { - return parseSelector(selector, this._engineNames); + const result = parseSelector(selector); + for (const part of result.parts) { + if (!Array.isArray(part) && !this._enginesV1.has(part.name)) + throw new Error(`Unknown engine "${part.name}" while parsing selector ${selector}`); + } + return result; } querySelector(selector: ParsedSelector, root: Node): Element | undefined { if (!(root as any)['querySelector']) throw new Error('Node is not queryable.'); - 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]; + return this._querySelectorRecursively(root as SelectorRoot, selector, 0); } - private _querySelectorRecursivelyV1(root: SelectorRoot, selector: ParsedSelectorV1, index: number): Element | undefined { + private _querySelectorRecursively(root: SelectorRoot, selector: ParsedSelector, index: number): Element | undefined { const current = selector.parts[index]; if (index === selector.parts.length - 1) - return this._enginesV1.get(current.name)!.query(root, current.body); - const all = this._enginesV1.get(current.name)!.queryAll(root, current.body); + return this._queryEngine(current, root); + const all = this._queryEngineAll(current, root); for (const next of all) { - const result = this._querySelectorRecursivelyV1(next, selector, index + 1); + const result = this._querySelectorRecursively(next, selector, index + 1); if (result) return selector.capture === index ? next : result; } @@ -103,22 +97,16 @@ 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); + const partsToQueryAll = selector.parts.slice(0, capture + 1); // Check they have a descendant matching everything after the capture. const partsToCheckOne = selector.parts.slice(capture + 1); let set = new Set([ root as SelectorRoot ]); - for (const { name, body } of partsToQuerAll) { + for (const part of partsToQueryAll) { const newSet = new Set(); for (const prev of set) { - for (const next of this._enginesV1.get(name)!.queryAll(prev, body)) { + for (const next of this._queryEngineAll(part, prev)) { if (newSet.has(next)) continue; newSet.add(next); @@ -130,7 +118,19 @@ export class InjectedScript { if (!partsToCheckOne.length) return candidates; const partial = { parts: partsToCheckOne }; - return candidates.filter(e => !!this._querySelectorRecursivelyV1(e, partial, 0)); + return candidates.filter(e => !!this._querySelectorRecursively(e, partial, 0)); + } + + private _queryEngine(part: ParsedSelectorPart, root: SelectorRoot): Element | undefined { + if (Array.isArray(part)) + return this._evaluator.evaluate({ scope: root as Document | Element, pierceShadow: true }, part)[0]; + return this._enginesV1.get(part.name)!.query(root, part.body); + } + + private _queryEngineAll(part: ParsedSelectorPart, root: SelectorRoot): Element[] { + if (Array.isArray(part)) + return this._evaluator.evaluate({ scope: root as Document | Element, pierceShadow: true }, part); + return this._enginesV1.get(part.name)!.queryAll(root, part.body); } extend(source: string, params: any): any { @@ -667,16 +667,6 @@ 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/selectors.ts b/src/server/selectors.ts index 57b7565908..df105e0a04 100644 --- a/src/server/selectors.ts +++ b/src/server/selectors.ts @@ -18,7 +18,7 @@ import * as dom from './dom'; import * as frames from './frames'; import * as js from './javascript'; import * as types from './types'; -import { ParsedSelector, parseSelector, selectorsV2Enabled, selectorsV2EngineNames } from './common/selectorParser'; +import { ParsedSelector, parseSelector } from './common/selectorParser'; export type SelectorInfo = { parsed: ParsedSelector, @@ -29,7 +29,6 @@ export type SelectorInfo = { export class Selectors { readonly _builtinEngines: Set; readonly _engines: Map; - readonly _engineNames: Set; constructor() { // Note: keep in sync with SelectorEvaluator class. @@ -42,12 +41,7 @@ export class Selectors { 'data-test-id', 'data-test-id:light', 'data-test', 'data-test:light', ]); - if (selectorsV2Enabled()) { - for (const name of selectorsV2EngineNames()) - this._builtinEngines.add(name); - } this._engines = new Map(); - this._engineNames = new Set(this._builtinEngines); } async register(name: string, source: string, contentScript: boolean = false): Promise { @@ -59,7 +53,6 @@ export class Selectors { if (this._engines.has(name)) throw new Error(`"${name}" selector engine has been already registered`); this._engines.set(name, { source, contentScript }); - this._engineNames.add(name); } async _query(frame: frames.Frame, selector: string, scope?: dom.ElementHandle): Promise | null> { @@ -122,11 +115,17 @@ export class Selectors { } _parseSelector(selector: string): SelectorInfo { - const parsed = parseSelector(selector, this._engineNames); - const needsMainWorld = parsed.names.some(name => { - const custom = this._engines.get(name); - return custom ? !custom.contentScript : false; - }); + const parsed = parseSelector(selector); + let needsMainWorld = false; + for (const part of parsed.parts) { + if (!Array.isArray(part)) { + const custom = this._engines.get(part.name); + if (!custom && !this._builtinEngines.has(part.name)) + throw new Error(`Unknown engine "${part.name}" while parsing selector ${selector}`); + if (custom && !custom.contentScript) + needsMainWorld = true; + } + } return { parsed, selector, diff --git a/test/selectors-css.spec.ts b/test/selectors-css.spec.ts index 8150e74669..2f7f9cb375 100644 --- a/test/selectors-css.spec.ts +++ b/test/selectors-css.spec.ts @@ -334,6 +334,7 @@ it('should work with spaces in :nth-child and :not', async ({page, server}) => { expect(await page.$$eval(`css=div > :not(span)`, els => els.length)).toBe(2); expect(await page.$$eval(`css=body :not(span, div)`, els => els.length)).toBe(1); expect(await page.$$eval(`css=span, section:not(span, div)`, els => els.length)).toBe(5); + expect(await page.$$eval(`span:nth-child(23n+ 2) >> xpath=.`, els => els.length)).toBe(1); }); it('should work with :is', async ({page, server}) => { diff --git a/test/selectors-misc.spec.ts b/test/selectors-misc.spec.ts index 0d05f93535..7620839e1a 100644 --- a/test/selectors-misc.spec.ts +++ b/test/selectors-misc.spec.ts @@ -153,3 +153,8 @@ it('should work with proximity selectors', test => { expect(await page.$$eval('div:near(#id7)', els => els.map(e => e.id).join(','))).toBe('id0,id3,id4,id5,id6'); expect(await page.$$eval('div:near(#id0)', els => els.map(e => e.id).join(','))).toBe('id1,id2,id3,id4,id5,id7,id8,id9'); }); + +it('should escape the scope with >>', async ({ page }) => { + await page.setContent(`
`); + expect(await page.$eval(`label >> xpath=.. >> input`, e => e.id)).toBe('myinput'); +});