Skip to content

Commit 016925c

Browse files
authored
feat(selectors): implement builtin selectors in new evaluator (microsoft#4579)
1 parent 3121de4 commit 016925c

File tree

1 file changed

+126
-19
lines changed

1 file changed

+126
-19
lines changed

src/server/injected/selectorEvaluator.ts

Lines changed: 126 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,9 @@
1717
import { CSSComplexSelector, CSSSimpleSelector, CSSComplexSelectorList, CSSFunctionArgument } from '../common/cssParser';
1818

1919
export type QueryContext = {
20-
scope: Element | ShadowRoot | Document;
21-
// Place for more options, e.g. normalizing whitespace or piercing shadow.
20+
scope: Element | Document;
21+
pierceShadow: boolean;
22+
// Place for more options, e.g. normalizing whitespace.
2223
};
2324
export type Selector = any; // Opaque selector type.
2425
export interface SelectorEvaluator {
@@ -42,6 +43,11 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
4243
this._engines.set('where', isEngine);
4344
this._engines.set('has', hasEngine);
4445
this._engines.set('scope', scopeEngine);
46+
this._engines.set('text', textEngine);
47+
this._engines.set('matches-text', matchesTextEngine);
48+
this._engines.set('xpath', xpathEngine);
49+
for (const attr of ['id', 'data-testid', 'data-test-id', 'data-test'])
50+
this._engines.set(attr, createAttributeEngine(attr));
4551
// TODO: host
4652
// TODO: host-context?
4753
}
@@ -154,40 +160,40 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
154160
return true;
155161
const { selector: simple, combinator } = complex.simples[index];
156162
if (combinator === '>') {
157-
const parent = parentElementOrShadowHostInScope(element, context.scope);
163+
const parent = parentElementOrShadowHostInContext(element, context);
158164
if (!parent || !this._matchesSimple(parent, simple, context))
159165
return false;
160166
return this._matchesParents(parent, complex, index - 1, context);
161167
}
162168
if (combinator === '+') {
163-
const previousSibling = element === context.scope ? null : element.previousElementSibling;
169+
const previousSibling = previousSiblingInContext(element, context);
164170
if (!previousSibling || !this._matchesSimple(previousSibling, simple, context))
165171
return false;
166172
return this._matchesParents(previousSibling, complex, index - 1, context);
167173
}
168174
if (combinator === '') {
169-
let parent = parentElementOrShadowHostInScope(element, context.scope);
175+
let parent = parentElementOrShadowHostInContext(element, context);
170176
while (parent) {
171177
if (this._matchesSimple(parent, simple, context)) {
172178
if (this._matchesParents(parent, complex, index - 1, context))
173179
return true;
174180
if (complex.simples[index - 1].combinator === '')
175181
break;
176182
}
177-
parent = parentElementOrShadowHostInScope(parent, context.scope);
183+
parent = parentElementOrShadowHostInContext(parent, context);
178184
}
179185
return false;
180186
}
181187
if (combinator === '~') {
182-
let previousSibling = element === context.scope ? null : element.previousElementSibling;
188+
let previousSibling = previousSiblingInContext(element, context);
183189
while (previousSibling) {
184190
if (this._matchesSimple(previousSibling, simple, context)) {
185191
if (this._matchesParents(previousSibling, complex, index - 1, context))
186192
return true;
187193
if (complex.simples[index - 1].combinator === '~')
188194
break;
189195
}
190-
previousSibling = previousSibling === context.scope ? null : previousSibling.previousElementSibling;
196+
previousSibling = previousSiblingInContext(previousSibling, context);
191197
}
192198
return false;
193199
}
@@ -212,13 +218,13 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
212218
}
213219

214220
private _callMatches(engine: SelectorEngine, element: Element, args: CSSFunctionArgument[], context: QueryContext): boolean {
215-
return this._cached<boolean>(element, ['_callMatches', engine, args, context.scope], () => {
221+
return this._cached<boolean>(element, ['_callMatches', engine, args, context.scope, context.pierceShadow], () => {
216222
return engine.matches!(element, args, context, this);
217223
});
218224
}
219225

220226
private _callQuery(engine: SelectorEngine, args: CSSFunctionArgument[], context: QueryContext): Element[] {
221-
return this._cached<Element[]>(args, ['_callQuery', engine, context.scope], () => {
227+
return this._cached<Element[]>(args, ['_callQuery', engine, context.scope, context.pierceShadow], () => {
222228
return engine.query!(context, args, this);
223229
});
224230
}
@@ -229,11 +235,13 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
229235
});
230236
}
231237

232-
private _queryCSS(context: QueryContext, css: string): Element[] {
238+
_queryCSS(context: QueryContext, css: string): Element[] {
233239
return this._cached<Element[]>(css, ['_queryCSS', context], () => {
234240
const result: Element[] = [];
235241
function query(root: Element | ShadowRoot | Document) {
236242
result.push(...root.querySelectorAll(css));
243+
if (!context.pierceShadow)
244+
return;
237245
if ((root as Element).shadowRoot)
238246
query((root as Element).shadowRoot!);
239247
for (const element of root.querySelectorAll('*')) {
@@ -255,13 +263,13 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator {
255263
}
256264

257265
const isEngine: SelectorEngine = {
258-
matches(element: Element, args: any[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
266+
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
259267
if (args.length === 0)
260268
throw new Error(`"is" engine expects non-empty selector list`);
261269
return args.some(selector => evaluator.matches(element, selector, context));
262270
},
263271

264-
query(context: QueryContext, args: any[], evaluator: SelectorEvaluator): Element[] {
272+
query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] {
265273
if (args.length === 0)
266274
throw new Error(`"is" engine expects non-empty selector list`);
267275
const elements: Element[] = [];
@@ -273,7 +281,7 @@ const isEngine: SelectorEngine = {
273281
};
274282

275283
const hasEngine: SelectorEngine = {
276-
matches(element: Element, args: any[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
284+
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
277285
if (args.length === 0)
278286
throw new Error(`"has" engine expects non-empty selector list`);
279287
return evaluator.query({ ...context, scope: element }, args).length > 0;
@@ -284,15 +292,15 @@ const hasEngine: SelectorEngine = {
284292
};
285293

286294
const scopeEngine: SelectorEngine = {
287-
matches(element: Element, args: any[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
295+
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
288296
if (args.length !== 0)
289297
throw new Error(`"scope" engine expects no arguments`);
290298
if (context.scope.nodeType === 9 /* Node.DOCUMENT_NODE */)
291299
return element === (context.scope as Document).documentElement;
292300
return element === context.scope;
293301
},
294302

295-
query(context: QueryContext, args: any[], evaluator: SelectorEvaluator): Element[] {
303+
query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] {
296304
if (args.length !== 0)
297305
throw new Error(`"scope" engine expects no arguments`);
298306
if (context.scope.nodeType === 9 /* Node.DOCUMENT_NODE */) {
@@ -306,13 +314,102 @@ const scopeEngine: SelectorEngine = {
306314
};
307315

308316
const notEngine: SelectorEngine = {
309-
matches(element: Element, args: any[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
317+
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
310318
if (args.length === 0)
311319
throw new Error(`"not" engine expects non-empty selector list`);
312320
return !evaluator.matches(element, args, context);
313321
},
314322
};
315323

324+
const textEngine: SelectorEngine = {
325+
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
326+
if (args.length === 0 || typeof args[0] !== 'string' || args.length > 2 || (args.length === 2 && typeof args[1] !== 'string'))
327+
throw new Error(`"text" engine expects a string and an optional flags string`);
328+
const text = args[0];
329+
const flags = args.length === 2 ? args[1] : '';
330+
const matcher = textMatcher(text, flags);
331+
return elementMatchesText(element, context, matcher);
332+
},
333+
};
334+
335+
const matchesTextEngine: SelectorEngine = {
336+
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
337+
if (args.length === 0 || typeof args[0] !== 'string' || args.length > 2 || (args.length === 2 && typeof args[1] !== 'string'))
338+
throw new Error(`"matches-text" engine expects a regexp body and optional regexp flags`);
339+
const re = new RegExp(args[0], args.length === 2 ? args[1] : undefined);
340+
return elementMatchesText(element, context, s => re.test(s));
341+
},
342+
};
343+
344+
function textMatcher(text: string, flags: string): (s: string) => boolean {
345+
const normalizeSpace = flags.includes('s');
346+
const lowerCase = flags.includes('i');
347+
const substring = flags.includes('g');
348+
if (normalizeSpace)
349+
text = text.trim().replace(/\s+/g, ' ');
350+
if (lowerCase)
351+
text = text.toLowerCase();
352+
return (s: string) => {
353+
if (normalizeSpace)
354+
s = s.trim().replace(/\s+/g, ' ');
355+
if (lowerCase)
356+
s = s.toLowerCase();
357+
return substring ? s.includes(text) : s === text;
358+
};
359+
}
360+
361+
function elementMatchesText(element: Element, context: QueryContext, matcher: (s: string) => boolean) {
362+
if (element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || document.head && document.head.contains(element))
363+
return false;
364+
if ((element instanceof HTMLInputElement) && (element.type === 'submit' || element.type === 'button') && matcher(element.value))
365+
return true;
366+
let lastText = '';
367+
for (let child = element.firstChild; child; child = child.nextSibling) {
368+
if (child.nodeType === 3 /* Node.TEXT_NODE */) {
369+
lastText += child.nodeValue;
370+
} else {
371+
if (lastText && matcher(lastText))
372+
return true;
373+
lastText = '';
374+
}
375+
}
376+
return !!lastText && matcher(lastText);
377+
}
378+
379+
const xpathEngine: SelectorEngine = {
380+
query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] {
381+
if (args.length !== 1 || typeof args[0] !== 'string')
382+
throw new Error(`"xpath" engine expects a single string`);
383+
const document = context.scope.nodeType === 9 /* Node.DOCUMENT_NODE */ ? context.scope as Document : context.scope.ownerDocument;
384+
if (!document)
385+
return [];
386+
const result: Element[] = [];
387+
const it = document.evaluate(args[0], context.scope, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE);
388+
for (let node = it.iterateNext(); node; node = it.iterateNext()) {
389+
if (node.nodeType === 1 /* Node.ELEMENT_NODE */)
390+
result.push(node as Element);
391+
}
392+
return result;
393+
},
394+
};
395+
396+
function createAttributeEngine(attr: string): SelectorEngine {
397+
return {
398+
matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean {
399+
if (args.length === 0 || typeof args[0] !== 'string')
400+
throw new Error(`"${attr}" engine expects a single string`);
401+
return element.getAttribute(attr) === args[0];
402+
},
403+
404+
query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] {
405+
if (args.length !== 1 || typeof args[0] !== 'string')
406+
throw new Error(`"${attr}" engine expects a single string`);
407+
const css = `[${attr}=${CSS.escape(args[0])}]`;
408+
return (evaluator as SelectorEvaluatorImpl)._queryCSS(context, css);
409+
},
410+
};
411+
}
412+
316413
function parentElementOrShadowHost(element: Element): Element | undefined {
317414
if (element.parentElement)
318415
return element.parentElement;
@@ -322,8 +419,18 @@ function parentElementOrShadowHost(element: Element): Element | undefined {
322419
return (element.parentNode as ShadowRoot).host;
323420
}
324421

325-
function parentElementOrShadowHostInScope(element: Element, scope: Element | ShadowRoot | Document): Element | undefined {
326-
return element === scope ? undefined : parentElementOrShadowHost(element);
422+
function parentElementOrShadowHostInContext(element: Element, context: QueryContext): Element | undefined {
423+
if (element === context.scope)
424+
return;
425+
if (!context.pierceShadow)
426+
return element.parentElement || undefined;
427+
return parentElementOrShadowHost(element);
428+
}
429+
430+
function previousSiblingInContext(element: Element, context: QueryContext): Element | undefined {
431+
if (element === context.scope)
432+
return;
433+
return element.previousElementSibling || undefined;
327434
}
328435

329436
function sortInDOMOrder(elements: Element[]): Element[] {

0 commit comments

Comments
 (0)