mirror of
				https://github.com/microsoft/playwright.git
				synced 2025-06-26 21:40:17 +00:00 
			
		
		
		
	feat(selectors): implement builtin selectors in new evaluator (#4579)
This commit is contained in:
		
							parent
							
								
									3121de403b
								
							
						
					
					
						commit
						016925cd16
					
				| @ -17,8 +17,9 @@ | |||||||
| import { CSSComplexSelector, CSSSimpleSelector, CSSComplexSelectorList, CSSFunctionArgument } from '../common/cssParser'; | import { CSSComplexSelector, CSSSimpleSelector, CSSComplexSelectorList, CSSFunctionArgument } from '../common/cssParser'; | ||||||
| 
 | 
 | ||||||
| export type QueryContext = { | export type QueryContext = { | ||||||
|   scope: Element | ShadowRoot | Document; |   scope: Element | Document; | ||||||
|   // Place for more options, e.g. normalizing whitespace or piercing shadow.
 |   pierceShadow: boolean; | ||||||
|  |   // Place for more options, e.g. normalizing whitespace.
 | ||||||
| }; | }; | ||||||
| export type Selector = any; // Opaque selector type.
 | export type Selector = any; // Opaque selector type.
 | ||||||
| export interface SelectorEvaluator { | export interface SelectorEvaluator { | ||||||
| @ -42,6 +43,11 @@ 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('text', textEngine); | ||||||
|  |     this._engines.set('matches-text', matchesTextEngine); | ||||||
|  |     this._engines.set('xpath', xpathEngine); | ||||||
|  |     for (const attr of ['id', 'data-testid', 'data-test-id', 'data-test']) | ||||||
|  |       this._engines.set(attr, createAttributeEngine(attr)); | ||||||
|     // TODO: host
 |     // TODO: host
 | ||||||
|     // TODO: host-context?
 |     // TODO: host-context?
 | ||||||
|   } |   } | ||||||
| @ -154,19 +160,19 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { | |||||||
|         return true; |         return true; | ||||||
|       const { selector: simple, combinator } = complex.simples[index]; |       const { selector: simple, combinator } = complex.simples[index]; | ||||||
|       if (combinator === '>') { |       if (combinator === '>') { | ||||||
|         const parent = parentElementOrShadowHostInScope(element, context.scope); |         const parent = parentElementOrShadowHostInContext(element, context); | ||||||
|         if (!parent || !this._matchesSimple(parent, simple, context)) |         if (!parent || !this._matchesSimple(parent, simple, context)) | ||||||
|           return false; |           return false; | ||||||
|         return this._matchesParents(parent, complex, index - 1, context); |         return this._matchesParents(parent, complex, index - 1, context); | ||||||
|       } |       } | ||||||
|       if (combinator === '+') { |       if (combinator === '+') { | ||||||
|         const previousSibling = element === context.scope ? null : element.previousElementSibling; |         const previousSibling = previousSiblingInContext(element, context); | ||||||
|         if (!previousSibling || !this._matchesSimple(previousSibling, simple, context)) |         if (!previousSibling || !this._matchesSimple(previousSibling, simple, context)) | ||||||
|           return false; |           return false; | ||||||
|         return this._matchesParents(previousSibling, complex, index - 1, context); |         return this._matchesParents(previousSibling, complex, index - 1, context); | ||||||
|       } |       } | ||||||
|       if (combinator === '') { |       if (combinator === '') { | ||||||
|         let parent = parentElementOrShadowHostInScope(element, context.scope); |         let parent = parentElementOrShadowHostInContext(element, context); | ||||||
|         while (parent) { |         while (parent) { | ||||||
|           if (this._matchesSimple(parent, simple, context)) { |           if (this._matchesSimple(parent, simple, context)) { | ||||||
|             if (this._matchesParents(parent, complex, index - 1, context)) |             if (this._matchesParents(parent, complex, index - 1, context)) | ||||||
| @ -174,12 +180,12 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { | |||||||
|             if (complex.simples[index - 1].combinator === '') |             if (complex.simples[index - 1].combinator === '') | ||||||
|               break; |               break; | ||||||
|           } |           } | ||||||
|           parent = parentElementOrShadowHostInScope(parent, context.scope); |           parent = parentElementOrShadowHostInContext(parent, context); | ||||||
|         } |         } | ||||||
|         return false; |         return false; | ||||||
|       } |       } | ||||||
|       if (combinator === '~') { |       if (combinator === '~') { | ||||||
|         let previousSibling = element === context.scope ? null : element.previousElementSibling; |         let previousSibling = previousSiblingInContext(element, context); | ||||||
|         while (previousSibling) { |         while (previousSibling) { | ||||||
|           if (this._matchesSimple(previousSibling, simple, context)) { |           if (this._matchesSimple(previousSibling, simple, context)) { | ||||||
|             if (this._matchesParents(previousSibling, complex, index - 1, context)) |             if (this._matchesParents(previousSibling, complex, index - 1, context)) | ||||||
| @ -187,7 +193,7 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { | |||||||
|             if (complex.simples[index - 1].combinator === '~') |             if (complex.simples[index - 1].combinator === '~') | ||||||
|               break; |               break; | ||||||
|           } |           } | ||||||
|           previousSibling = previousSibling === context.scope ? null : previousSibling.previousElementSibling; |           previousSibling = previousSiblingInContext(previousSibling, context); | ||||||
|         } |         } | ||||||
|         return false; |         return false; | ||||||
|       } |       } | ||||||
| @ -212,13 +218,13 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { | |||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private _callMatches(engine: SelectorEngine, element: Element, args: CSSFunctionArgument[], context: QueryContext): boolean { |   private _callMatches(engine: SelectorEngine, element: Element, args: CSSFunctionArgument[], context: QueryContext): boolean { | ||||||
|     return this._cached<boolean>(element, ['_callMatches', engine, args, context.scope], () => { |     return this._cached<boolean>(element, ['_callMatches', engine, args, context.scope, context.pierceShadow], () => { | ||||||
|       return engine.matches!(element, args, context, this); |       return engine.matches!(element, args, context, this); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private _callQuery(engine: SelectorEngine, args: CSSFunctionArgument[], context: QueryContext): Element[] { |   private _callQuery(engine: SelectorEngine, args: CSSFunctionArgument[], context: QueryContext): Element[] { | ||||||
|     return this._cached<Element[]>(args, ['_callQuery', engine, context.scope], () => { |     return this._cached<Element[]>(args, ['_callQuery', engine, context.scope, context.pierceShadow], () => { | ||||||
|       return engine.query!(context, args, this); |       return engine.query!(context, args, this); | ||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
| @ -229,11 +235,13 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { | |||||||
|     }); |     }); | ||||||
|   } |   } | ||||||
| 
 | 
 | ||||||
|   private _queryCSS(context: QueryContext, css: string): Element[] { |   _queryCSS(context: QueryContext, css: string): Element[] { | ||||||
|     return this._cached<Element[]>(css, ['_queryCSS', context], () => { |     return this._cached<Element[]>(css, ['_queryCSS', context], () => { | ||||||
|       const result: Element[] = []; |       const result: Element[] = []; | ||||||
|       function query(root: Element | ShadowRoot | Document) { |       function query(root: Element | ShadowRoot | Document) { | ||||||
|         result.push(...root.querySelectorAll(css)); |         result.push(...root.querySelectorAll(css)); | ||||||
|  |         if (!context.pierceShadow) | ||||||
|  |           return; | ||||||
|         if ((root as Element).shadowRoot) |         if ((root as Element).shadowRoot) | ||||||
|           query((root as Element).shadowRoot!); |           query((root as Element).shadowRoot!); | ||||||
|         for (const element of root.querySelectorAll('*')) { |         for (const element of root.querySelectorAll('*')) { | ||||||
| @ -255,13 +263,13 @@ export class SelectorEvaluatorImpl implements SelectorEvaluator { | |||||||
| } | } | ||||||
| 
 | 
 | ||||||
| const isEngine: SelectorEngine = { | const isEngine: SelectorEngine = { | ||||||
|   matches(element: Element, args: any[], context: QueryContext, evaluator: SelectorEvaluator): boolean { |   matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean { | ||||||
|     if (args.length === 0) |     if (args.length === 0) | ||||||
|       throw new Error(`"is" engine expects non-empty selector list`); |       throw new Error(`"is" engine expects non-empty selector list`); | ||||||
|     return args.some(selector => evaluator.matches(element, selector, context)); |     return args.some(selector => evaluator.matches(element, selector, context)); | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|   query(context: QueryContext, args: any[], evaluator: SelectorEvaluator): Element[] { |   query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] { | ||||||
|     if (args.length === 0) |     if (args.length === 0) | ||||||
|       throw new Error(`"is" engine expects non-empty selector list`); |       throw new Error(`"is" engine expects non-empty selector list`); | ||||||
|     const elements: Element[] = []; |     const elements: Element[] = []; | ||||||
| @ -273,7 +281,7 @@ const isEngine: SelectorEngine = { | |||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const hasEngine: SelectorEngine = { | const hasEngine: SelectorEngine = { | ||||||
|   matches(element: Element, args: any[], context: QueryContext, evaluator: SelectorEvaluator): boolean { |   matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean { | ||||||
|     if (args.length === 0) |     if (args.length === 0) | ||||||
|       throw new Error(`"has" engine expects non-empty selector list`); |       throw new Error(`"has" engine expects non-empty selector list`); | ||||||
|     return evaluator.query({ ...context, scope: element }, args).length > 0; |     return evaluator.query({ ...context, scope: element }, args).length > 0; | ||||||
| @ -284,7 +292,7 @@ const hasEngine: SelectorEngine = { | |||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const scopeEngine: SelectorEngine = { | const scopeEngine: SelectorEngine = { | ||||||
|   matches(element: Element, args: any[], context: QueryContext, evaluator: SelectorEvaluator): boolean { |   matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean { | ||||||
|     if (args.length !== 0) |     if (args.length !== 0) | ||||||
|       throw new Error(`"scope" engine expects no arguments`); |       throw new Error(`"scope" engine expects no arguments`); | ||||||
|     if (context.scope.nodeType === 9 /* Node.DOCUMENT_NODE */) |     if (context.scope.nodeType === 9 /* Node.DOCUMENT_NODE */) | ||||||
| @ -292,7 +300,7 @@ const scopeEngine: SelectorEngine = { | |||||||
|     return element === context.scope; |     return element === context.scope; | ||||||
|   }, |   }, | ||||||
| 
 | 
 | ||||||
|   query(context: QueryContext, args: any[], evaluator: SelectorEvaluator): Element[] { |   query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] { | ||||||
|     if (args.length !== 0) |     if (args.length !== 0) | ||||||
|       throw new Error(`"scope" engine expects no arguments`); |       throw new Error(`"scope" engine expects no arguments`); | ||||||
|     if (context.scope.nodeType === 9 /* Node.DOCUMENT_NODE */) { |     if (context.scope.nodeType === 9 /* Node.DOCUMENT_NODE */) { | ||||||
| @ -306,13 +314,102 @@ const scopeEngine: SelectorEngine = { | |||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
| const notEngine: SelectorEngine = { | const notEngine: SelectorEngine = { | ||||||
|   matches(element: Element, args: any[], context: QueryContext, evaluator: SelectorEvaluator): boolean { |   matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean { | ||||||
|     if (args.length === 0) |     if (args.length === 0) | ||||||
|       throw new Error(`"not" engine expects non-empty selector list`); |       throw new Error(`"not" engine expects non-empty selector list`); | ||||||
|     return !evaluator.matches(element, args, context); |     return !evaluator.matches(element, args, context); | ||||||
|   }, |   }, | ||||||
| }; | }; | ||||||
| 
 | 
 | ||||||
|  | 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')) | ||||||
|  |       throw new Error(`"text" engine expects a string and an optional flags string`); | ||||||
|  |     const text = args[0]; | ||||||
|  |     const flags = args.length === 2 ? args[1] : ''; | ||||||
|  |     const matcher = textMatcher(text, flags); | ||||||
|  |     return elementMatchesText(element, context, matcher); | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | const matchesTextEngine: 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')) | ||||||
|  |       throw new Error(`"matches-text" engine expects a regexp body and optional regexp flags`); | ||||||
|  |     const re = new RegExp(args[0], args.length === 2 ? args[1] : undefined); | ||||||
|  |     return elementMatchesText(element, context, s => re.test(s)); | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | function textMatcher(text: string, flags: string): (s: string) => boolean { | ||||||
|  |   const normalizeSpace = flags.includes('s'); | ||||||
|  |   const lowerCase = flags.includes('i'); | ||||||
|  |   const substring = flags.includes('g'); | ||||||
|  |   if (normalizeSpace) | ||||||
|  |     text = text.trim().replace(/\s+/g, ' '); | ||||||
|  |   if (lowerCase) | ||||||
|  |     text = text.toLowerCase(); | ||||||
|  |   return (s: string) => { | ||||||
|  |     if (normalizeSpace) | ||||||
|  |       s = s.trim().replace(/\s+/g, ' '); | ||||||
|  |     if (lowerCase) | ||||||
|  |       s = s.toLowerCase(); | ||||||
|  |     return substring ? s.includes(text) : s === text; | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function elementMatchesText(element: Element, context: QueryContext, matcher: (s: string) => boolean) { | ||||||
|  |   if (element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || document.head && document.head.contains(element)) | ||||||
|  |     return false; | ||||||
|  |   if ((element instanceof HTMLInputElement) && (element.type === 'submit' || element.type === 'button') && matcher(element.value)) | ||||||
|  |     return true; | ||||||
|  |   let lastText = ''; | ||||||
|  |   for (let child = element.firstChild; child; child = child.nextSibling) { | ||||||
|  |     if (child.nodeType === 3 /* Node.TEXT_NODE */) { | ||||||
|  |       lastText += child.nodeValue; | ||||||
|  |     } else { | ||||||
|  |       if (lastText && matcher(lastText)) | ||||||
|  |         return true; | ||||||
|  |       lastText = ''; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |   return !!lastText && matcher(lastText); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | const xpathEngine: SelectorEngine = { | ||||||
|  |   query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] { | ||||||
|  |     if (args.length !== 1 || typeof args[0] !== 'string') | ||||||
|  |       throw new Error(`"xpath" engine expects a single string`); | ||||||
|  |     const document = context.scope.nodeType === 9 /* Node.DOCUMENT_NODE */ ? context.scope as Document : context.scope.ownerDocument; | ||||||
|  |     if (!document) | ||||||
|  |       return []; | ||||||
|  |     const result: Element[] = []; | ||||||
|  |     const it = document.evaluate(args[0], context.scope, null, XPathResult.ORDERED_NODE_ITERATOR_TYPE); | ||||||
|  |     for (let node = it.iterateNext(); node; node = it.iterateNext()) { | ||||||
|  |       if (node.nodeType === 1 /* Node.ELEMENT_NODE */) | ||||||
|  |         result.push(node as Element); | ||||||
|  |     } | ||||||
|  |     return result; | ||||||
|  |   }, | ||||||
|  | }; | ||||||
|  | 
 | ||||||
|  | function createAttributeEngine(attr: string): SelectorEngine { | ||||||
|  |   return { | ||||||
|  |     matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean { | ||||||
|  |       if (args.length === 0 || typeof args[0] !== 'string') | ||||||
|  |         throw new Error(`"${attr}" engine expects a single string`); | ||||||
|  |       return element.getAttribute(attr) === args[0]; | ||||||
|  |     }, | ||||||
|  | 
 | ||||||
|  |     query(context: QueryContext, args: (string | number | Selector)[], evaluator: SelectorEvaluator): Element[] { | ||||||
|  |       if (args.length !== 1 || typeof args[0] !== 'string') | ||||||
|  |         throw new Error(`"${attr}" engine expects a single string`); | ||||||
|  |       const css = `[${attr}=${CSS.escape(args[0])}]`; | ||||||
|  |       return (evaluator as SelectorEvaluatorImpl)._queryCSS(context, css); | ||||||
|  |     }, | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  | 
 | ||||||
| function parentElementOrShadowHost(element: Element): Element | undefined { | function parentElementOrShadowHost(element: Element): Element | undefined { | ||||||
|   if (element.parentElement) |   if (element.parentElement) | ||||||
|     return element.parentElement; |     return element.parentElement; | ||||||
| @ -322,8 +419,18 @@ function parentElementOrShadowHost(element: Element): Element | undefined { | |||||||
|     return (element.parentNode as ShadowRoot).host; |     return (element.parentNode as ShadowRoot).host; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function parentElementOrShadowHostInScope(element: Element, scope: Element | ShadowRoot | Document): Element | undefined { | function parentElementOrShadowHostInContext(element: Element, context: QueryContext): Element | undefined { | ||||||
|   return element === scope ? undefined : parentElementOrShadowHost(element); |   if (element === context.scope) | ||||||
|  |     return; | ||||||
|  |   if (!context.pierceShadow) | ||||||
|  |     return element.parentElement || undefined; | ||||||
|  |   return parentElementOrShadowHost(element); | ||||||
|  | } | ||||||
|  | 
 | ||||||
|  | function previousSiblingInContext(element: Element, context: QueryContext): Element | undefined { | ||||||
|  |   if (element === context.scope) | ||||||
|  |     return; | ||||||
|  |   return element.previousElementSibling || undefined; | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| function sortInDOMOrder(elements: Element[]): Element[] { | function sortInDOMOrder(elements: Element[]): Element[] { | ||||||
|  | |||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 Dmitry Gozman
						Dmitry Gozman