Skip to content

Commit 64c8639

Browse files
authored
cherrypick(release-1.7): fix(selector): bring back v1 query logic (#4791)
PR #4754 SHA: 5a1c9f1
1 parent 29568e8 commit 64c8639

File tree

5 files changed

+77
-175
lines changed

5 files changed

+77
-175
lines changed

src/server/common/selectorParser.ts

Lines changed: 27 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -14,144 +14,51 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { CSSComplexSelector, CSSComplexSelectorList, CSSFunctionArgument, CSSSimpleSelector, parseCSS } from './cssParser';
17+
import { CSSComplexSelectorList, parseCSS } from './cssParser';
1818

19-
export type ParsedSelectorV1 = {
20-
parts: {
21-
name: string,
22-
body: string,
23-
}[],
24-
capture?: number,
25-
};
19+
export type ParsedSelectorPart = {
20+
name: string,
21+
body: string,
22+
} | CSSComplexSelectorList;
2623

2724
export type ParsedSelector = {
28-
v1?: ParsedSelectorV1,
29-
v2?: CSSComplexSelectorList,
30-
names: string[],
25+
parts: ParsedSelectorPart[],
26+
capture?: number,
3127
};
3228

3329
export function selectorsV2Enabled() {
3430
return true;
3531
}
3632

37-
export function selectorsV2EngineNames() {
38-
return ['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text-matches', 'text-is'];
39-
}
33+
const customCSSNames = new Set(['not', 'is', 'where', 'has', 'scope', 'light', 'visible', 'text', 'text-matches', 'text-is']);
4034

41-
export function parseSelector(selector: string, customNames: Set<string>): ParsedSelector {
42-
const v1 = parseSelectorV1(selector);
43-
const names = new Set<string>();
44-
for (const { name } of v1.parts) {
45-
names.add(name);
46-
if (!customNames.has(name))
47-
throw new Error(`Unknown engine "${name}" while parsing selector ${selector}`);
48-
}
49-
50-
if (!selectorsV2Enabled()) {
51-
return {
52-
v1,
53-
names: Array.from(names),
54-
};
55-
}
35+
export function parseSelector(selector: string): ParsedSelector {
36+
const result = parseSelectorV1(selector);
5637

57-
const chain = (from: number, to: number, turnFirstTextIntoScope: boolean): CSSComplexSelector => {
58-
const result: CSSComplexSelector = { simples: [] };
59-
for (const part of v1.parts.slice(from, to)) {
60-
let name = part.name;
61-
let wrapInLight = false;
62-
if (['css:light', 'xpath:light', 'text:light', 'id:light', 'data-testid:light', 'data-test-id:light', 'data-test:light'].includes(name)) {
63-
wrapInLight = true;
64-
name = name.substring(0, name.indexOf(':'));
38+
if (selectorsV2Enabled()) {
39+
result.parts = result.parts.map(part => {
40+
if (Array.isArray(part))
41+
return part;
42+
if (part.name === 'css' || part.name === 'css:light') {
43+
if (part.name === 'css:light')
44+
part.body = ':light(' + part.body + ')';
45+
const parsedCSS = parseCSS(part.body, customCSSNames);
46+
return parsedCSS.selector;
6547
}
66-
if (name === 'css') {
67-
const parsed = parseCSS(part.body, customNames);
68-
parsed.names.forEach(name => names.add(name));
69-
if (wrapInLight || parsed.selector.length > 1) {
70-
let simple = callWith('is', parsed.selector);
71-
if (wrapInLight)
72-
simple = callWith('light', [simpleToComplex(simple)]);
73-
result.simples.push({ selector: simple, combinator: '' });
74-
} else {
75-
result.simples.push(...parsed.selector[0].simples);
76-
}
77-
} else if (name === 'text') {
78-
let simple = textSelectorToSimple(part.body);
79-
if (turnFirstTextIntoScope)
80-
simple.functions.push({ name: 'is', args: [ simpleToComplex(callWith('scope', [])), simpleToComplex({ css: '*', functions: [] }) ]});
81-
if (result.simples.length)
82-
result.simples[result.simples.length - 1].combinator = '>=';
83-
if (wrapInLight)
84-
simple = callWith('light', [simpleToComplex(simple)]);
85-
result.simples.push({ selector: simple, combinator: '' });
86-
} else {
87-
let simple = callWith(name, [part.body]);
88-
if (wrapInLight)
89-
simple = callWith('light', [simpleToComplex(simple)]);
90-
result.simples.push({ selector: simple, combinator: '' });
91-
}
92-
if (name !== 'text')
93-
turnFirstTextIntoScope = false;
94-
}
95-
return result;
96-
};
97-
98-
const capture = v1.capture === undefined ? v1.parts.length - 1 : v1.capture;
99-
const result = chain(0, capture + 1, false);
100-
if (capture + 1 < v1.parts.length) {
101-
const has = chain(capture + 1, v1.parts.length, true);
102-
const last = result.simples[result.simples.length - 1];
103-
last.selector.functions.push({ name: 'has', args: [has] });
48+
return part;
49+
});
10450
}
105-
return { v2: [result], names: Array.from(names) };
106-
}
107-
108-
function callWith(name: string, args: CSSFunctionArgument[]): CSSSimpleSelector {
109-
return { functions: [{ name, args }] };
110-
}
111-
112-
function simpleToComplex(simple: CSSSimpleSelector): CSSComplexSelector {
113-
return { simples: [{ selector: simple, combinator: '' }]};
114-
}
115-
116-
function textSelectorToSimple(selector: string): CSSSimpleSelector {
117-
function unescape(s: string): string {
118-
if (!s.includes('\\'))
119-
return s;
120-
const r: string[] = [];
121-
let i = 0;
122-
while (i < s.length) {
123-
if (s[i] === '\\' && i + 1 < s.length)
124-
i++;
125-
r.push(s[i++]);
126-
}
127-
return r.join('');
128-
}
129-
130-
function escapeRegExp(s: string) {
131-
return s.replace(/[.*+\?^${}()|[\]\\]/g, '\\$&').replace(/-/g, '\\x2d');
132-
}
133-
134-
let functionName = 'text-matches';
135-
let args: string[];
136-
if (selector.length > 1 && selector[0] === '"' && selector[selector.length - 1] === '"') {
137-
args = ['^' + escapeRegExp(unescape(selector.substring(1, selector.length - 1))) + '$'];
138-
} else if (selector.length > 1 && selector[0] === "'" && selector[selector.length - 1] === "'") {
139-
args = ['^' + escapeRegExp(unescape(selector.substring(1, selector.length - 1))) + '$'];
140-
} else if (selector[0] === '/' && selector.lastIndexOf('/') > 0) {
141-
const lastSlash = selector.lastIndexOf('/');
142-
args = [selector.substring(1, lastSlash), selector.substring(lastSlash + 1)];
143-
} else {
144-
functionName = 'text';
145-
args = [selector];
146-
}
147-
return callWith(functionName, args);
51+
return {
52+
parts: result.parts,
53+
capture: result.capture,
54+
};
14855
}
14956

150-
function parseSelectorV1(selector: string): ParsedSelectorV1 {
57+
function parseSelectorV1(selector: string): ParsedSelector {
15158
let index = 0;
15259
let quote: string | undefined;
15360
let start = 0;
154-
const result: ParsedSelectorV1 = { parts: [] };
61+
const result: ParsedSelector = { parts: [] };
15562
const append = () => {
15663
const part = selector.substring(start, index).trim();
15764
const eqIndex = part.indexOf('=');

src/server/injected/injectedScript.ts

Lines changed: 32 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,13 @@
1515
*/
1616

1717
import { createAttributeEngine } from './attributeSelectorEngine';
18-
import { createCSSEngine } from './cssSelectorEngine';
1918
import { SelectorEngine, SelectorRoot } from './selectorEngine';
2019
import { createTextSelector } from './textSelectorEngine';
2120
import { XPathEngine } from './xpathSelectorEngine';
22-
import { ParsedSelector, ParsedSelectorV1, parseSelector, selectorsV2Enabled, selectorsV2EngineNames } from '../common/selectorParser';
21+
import { ParsedSelector, ParsedSelectorPart, parseSelector } from '../common/selectorParser';
2322
import { FatalDOMError } from '../common/domErrors';
24-
import { SelectorEvaluatorImpl, SelectorEngine as SelectorEngineV2, QueryContext, isVisible, parentElementOrShadowHost } from './selectorEvaluator';
23+
import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost } from './selectorEvaluator';
24+
import { createCSSEngine } from './cssSelectorEngine';
2525

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

@@ -43,7 +43,6 @@ export type InjectedScriptPoll<T> = {
4343
export class InjectedScript {
4444
private _enginesV1: Map<string, SelectorEngine>;
4545
private _evaluator: SelectorEvaluatorImpl;
46-
private _engineNames: Set<string>;
4746

4847
constructor(customEngines: { name: string, engine: SelectorEngine}[]) {
4948
this._enginesV1 = new Map();
@@ -64,37 +63,32 @@ export class InjectedScript {
6463
for (const { name, engine } of customEngines)
6564
this._enginesV1.set(name, engine);
6665

67-
const wrapped = new Map<string, SelectorEngineV2>();
68-
for (const { name, engine } of customEngines)
69-
wrapped.set(name, wrapV2(name, engine));
70-
this._evaluator = new SelectorEvaluatorImpl(wrapped);
71-
72-
this._engineNames = new Set(this._enginesV1.keys());
73-
if (selectorsV2Enabled()) {
74-
for (const name of selectorsV2EngineNames())
75-
this._engineNames.add(name);
76-
}
66+
// No custom engines in V2 for now.
67+
this._evaluator = new SelectorEvaluatorImpl(new Map());
7768
}
7869

7970
parseSelector(selector: string): ParsedSelector {
80-
return parseSelector(selector, this._engineNames);
71+
const result = parseSelector(selector);
72+
for (const part of result.parts) {
73+
if (!Array.isArray(part) && !this._enginesV1.has(part.name))
74+
throw new Error(`Unknown engine "${part.name}" while parsing selector ${selector}`);
75+
}
76+
return result;
8177
}
8278

8379
querySelector(selector: ParsedSelector, root: Node): Element | undefined {
8480
if (!(root as any)['querySelector'])
8581
throw new Error('Node is not queryable.');
86-
if (selector.v1)
87-
return this._querySelectorRecursivelyV1(root as SelectorRoot, selector.v1, 0);
88-
return this._evaluator.evaluate({ scope: root as Document | Element, pierceShadow: true }, selector.v2!)[0];
82+
return this._querySelectorRecursively(root as SelectorRoot, selector, 0);
8983
}
9084

91-
private _querySelectorRecursivelyV1(root: SelectorRoot, selector: ParsedSelectorV1, index: number): Element | undefined {
85+
private _querySelectorRecursively(root: SelectorRoot, selector: ParsedSelector, index: number): Element | undefined {
9286
const current = selector.parts[index];
9387
if (index === selector.parts.length - 1)
94-
return this._enginesV1.get(current.name)!.query(root, current.body);
95-
const all = this._enginesV1.get(current.name)!.queryAll(root, current.body);
88+
return this._queryEngine(current, root);
89+
const all = this._queryEngineAll(current, root);
9690
for (const next of all) {
97-
const result = this._querySelectorRecursivelyV1(next, selector, index + 1);
91+
const result = this._querySelectorRecursively(next, selector, index + 1);
9892
if (result)
9993
return selector.capture === index ? next : result;
10094
}
@@ -103,22 +97,16 @@ export class InjectedScript {
10397
querySelectorAll(selector: ParsedSelector, root: Node): Element[] {
10498
if (!(root as any)['querySelectorAll'])
10599
throw new Error('Node is not queryable.');
106-
if (selector.v1)
107-
return this._querySelectorAllV1(selector.v1, root as SelectorRoot);
108-
return this._evaluator.evaluate({ scope: root as Document | Element, pierceShadow: true }, selector.v2!);
109-
}
110-
111-
private _querySelectorAllV1(selector: ParsedSelectorV1, root: SelectorRoot): Element[] {
112100
const capture = selector.capture === undefined ? selector.parts.length - 1 : selector.capture;
113101
// Query all elements up to the capture.
114-
const partsToQuerAll = selector.parts.slice(0, capture + 1);
102+
const partsToQueryAll = selector.parts.slice(0, capture + 1);
115103
// Check they have a descendant matching everything after the capture.
116104
const partsToCheckOne = selector.parts.slice(capture + 1);
117105
let set = new Set<SelectorRoot>([ root as SelectorRoot ]);
118-
for (const { name, body } of partsToQuerAll) {
106+
for (const part of partsToQueryAll) {
119107
const newSet = new Set<Element>();
120108
for (const prev of set) {
121-
for (const next of this._enginesV1.get(name)!.queryAll(prev, body)) {
109+
for (const next of this._queryEngineAll(part, prev)) {
122110
if (newSet.has(next))
123111
continue;
124112
newSet.add(next);
@@ -130,7 +118,19 @@ export class InjectedScript {
130118
if (!partsToCheckOne.length)
131119
return candidates;
132120
const partial = { parts: partsToCheckOne };
133-
return candidates.filter(e => !!this._querySelectorRecursivelyV1(e, partial, 0));
121+
return candidates.filter(e => !!this._querySelectorRecursively(e, partial, 0));
122+
}
123+
124+
private _queryEngine(part: ParsedSelectorPart, root: SelectorRoot): Element | undefined {
125+
if (Array.isArray(part))
126+
return this._evaluator.evaluate({ scope: root as Document | Element, pierceShadow: true }, part)[0];
127+
return this._enginesV1.get(part.name)!.query(root, part.body);
128+
}
129+
130+
private _queryEngineAll(part: ParsedSelectorPart, root: SelectorRoot): Element[] {
131+
if (Array.isArray(part))
132+
return this._evaluator.evaluate({ scope: root as Document | Element, pierceShadow: true }, part);
133+
return this._enginesV1.get(part.name)!.queryAll(root, part.body);
134134
}
135135

136136
extend(source: string, params: any): any {
@@ -667,16 +667,6 @@ export class InjectedScript {
667667
}
668668
}
669669

670-
function wrapV2(name: string, engine: SelectorEngine): SelectorEngineV2 {
671-
return {
672-
query(context: QueryContext, args: string[]): Element[] {
673-
if (args.length !== 1 || typeof args[0] !== 'string')
674-
throw new Error(`engine "${name}" expects a single string`);
675-
return engine.queryAll(context.scope, args[0]);
676-
}
677-
};
678-
}
679-
680670
const autoClosingTags = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
681671
const booleanAttributes = new Set(['checked', 'selected', 'disabled', 'readonly', 'multiple']);
682672

src/server/selectors.ts

Lines changed: 12 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import * as dom from './dom';
1818
import * as frames from './frames';
1919
import * as js from './javascript';
2020
import * as types from './types';
21-
import { ParsedSelector, parseSelector, selectorsV2Enabled, selectorsV2EngineNames } from './common/selectorParser';
21+
import { ParsedSelector, parseSelector } from './common/selectorParser';
2222

2323
export type SelectorInfo = {
2424
parsed: ParsedSelector,
@@ -29,7 +29,6 @@ export type SelectorInfo = {
2929
export class Selectors {
3030
readonly _builtinEngines: Set<string>;
3131
readonly _engines: Map<string, { source: string, contentScript: boolean }>;
32-
readonly _engineNames: Set<string>;
3332

3433
constructor() {
3534
// Note: keep in sync with SelectorEvaluator class.
@@ -42,12 +41,7 @@ export class Selectors {
4241
'data-test-id', 'data-test-id:light',
4342
'data-test', 'data-test:light',
4443
]);
45-
if (selectorsV2Enabled()) {
46-
for (const name of selectorsV2EngineNames())
47-
this._builtinEngines.add(name);
48-
}
4944
this._engines = new Map();
50-
this._engineNames = new Set(this._builtinEngines);
5145
}
5246

5347
async register(name: string, source: string, contentScript: boolean = false): Promise<void> {
@@ -59,7 +53,6 @@ export class Selectors {
5953
if (this._engines.has(name))
6054
throw new Error(`"${name}" selector engine has been already registered`);
6155
this._engines.set(name, { source, contentScript });
62-
this._engineNames.add(name);
6356
}
6457

6558
async _query(frame: frames.Frame, selector: string, scope?: dom.ElementHandle): Promise<dom.ElementHandle<Element> | null> {
@@ -122,11 +115,17 @@ export class Selectors {
122115
}
123116

124117
_parseSelector(selector: string): SelectorInfo {
125-
const parsed = parseSelector(selector, this._engineNames);
126-
const needsMainWorld = parsed.names.some(name => {
127-
const custom = this._engines.get(name);
128-
return custom ? !custom.contentScript : false;
129-
});
118+
const parsed = parseSelector(selector);
119+
let needsMainWorld = false;
120+
for (const part of parsed.parts) {
121+
if (!Array.isArray(part)) {
122+
const custom = this._engines.get(part.name);
123+
if (!custom && !this._builtinEngines.has(part.name))
124+
throw new Error(`Unknown engine "${part.name}" while parsing selector ${selector}`);
125+
if (custom && !custom.contentScript)
126+
needsMainWorld = true;
127+
}
128+
}
130129
return {
131130
parsed,
132131
selector,

test/selectors-css.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@ it('should work with spaces in :nth-child and :not', async ({page, server}) => {
334334
expect(await page.$$eval(`css=div > :not(span)`, els => els.length)).toBe(2);
335335
expect(await page.$$eval(`css=body :not(span, div)`, els => els.length)).toBe(1);
336336
expect(await page.$$eval(`css=span, section:not(span, div)`, els => els.length)).toBe(5);
337+
expect(await page.$$eval(`span:nth-child(23n+ 2) >> xpath=.`, els => els.length)).toBe(1);
337338
});
338339

339340
it('should work with :is', async ({page, server}) => {

test/selectors-misc.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,3 +153,8 @@ it('should work with proximity selectors', test => {
153153
expect(await page.$$eval('div:near(#id7)', els => els.map(e => e.id).join(','))).toBe('id0,id3,id4,id5,id6');
154154
expect(await page.$$eval('div:near(#id0)', els => els.map(e => e.id).join(','))).toBe('id1,id2,id3,id4,id5,id7,id8,id9');
155155
});
156+
157+
it('should escape the scope with >>', async ({ page }) => {
158+
await page.setContent(`<div><label>Test</label><input id='myinput'></div>`);
159+
expect(await page.$eval(`label >> xpath=.. >> input`, e => e.id)).toBe('myinput');
160+
});

0 commit comments

Comments
 (0)