From b753ff868625f27c4d33757b2310b58def43404d Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Wed, 11 May 2022 13:49:12 +0100 Subject: [PATCH] chore: split injected utils into proper files (#14093) --- .../src/server/injected/componentUtils.ts | 260 ------------------ .../src/server/injected/domUtils.ts | 85 ++++++ .../src/server/injected/injectedScript.ts | 11 +- .../server/injected/reactSelectorEngine.ts | 9 +- .../src/server/injected/roleSelectorEngine.ts | 18 +- .../src/server/injected/roleUtils.ts | 4 +- .../src/server/injected/selectorEvaluator.ts | 163 +---------- .../src/server/injected/selectorGenerator.ts | 4 +- .../src/server/injected/selectorUtils.ts | 129 +++++++++ .../src/server/injected/vueSelectorEngine.ts | 9 +- .../src/server/isomorphic/selectorParser.ts | 212 ++++++++++++++ tests/library/component-parser.spec.ts | 14 +- 12 files changed, 470 insertions(+), 448 deletions(-) delete mode 100644 packages/playwright-core/src/server/injected/componentUtils.ts create mode 100644 packages/playwright-core/src/server/injected/domUtils.ts create mode 100644 packages/playwright-core/src/server/injected/selectorUtils.ts diff --git a/packages/playwright-core/src/server/injected/componentUtils.ts b/packages/playwright-core/src/server/injected/componentUtils.ts deleted file mode 100644 index 3faef3ba58..0000000000 --- a/packages/playwright-core/src/server/injected/componentUtils.ts +++ /dev/null @@ -1,260 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -export type ParsedAttributeOperator = ''|'='|'*='|'|='|'^='|'$='|'~='; -export type ParsedComponentAttribute = { - name: string, - jsonPath: string[], - op: ParsedAttributeOperator, - value: any, - caseSensitive: boolean, -}; - -export type ParsedComponentSelector = { - name: string, - attributes: ParsedComponentAttribute[], -}; - -export function checkComponentAttribute(obj: any, attr: ParsedComponentAttribute) { - for (const token of attr.jsonPath) { - if (obj !== undefined && obj !== null) - obj = obj[token]; - } - return matchesAttribute(obj, attr); -} - -export function matchesAttribute(value: any, attr: ParsedComponentAttribute) { - const objValue = typeof value === 'string' && !attr.caseSensitive ? value.toUpperCase() : value; - const attrValue = typeof attr.value === 'string' && !attr.caseSensitive ? attr.value.toUpperCase() : attr.value; - - if (attr.op === '') - return !!objValue; - if (attr.op === '=') { - if (attrValue instanceof RegExp) - return typeof objValue === 'string' && !!objValue.match(attrValue); - return objValue === attrValue; - } - if (typeof objValue !== 'string' || typeof attrValue !== 'string') - return false; - if (attr.op === '*=') - return objValue.includes(attrValue); - if (attr.op === '^=') - return objValue.startsWith(attrValue); - if (attr.op === '$=') - return objValue.endsWith(attrValue); - if (attr.op === '|=') - return objValue === attrValue || objValue.startsWith(attrValue + '-'); - if (attr.op === '~=') - return objValue.split(' ').includes(attrValue); - return false; -} - -export function parseComponentSelector(selector: string, allowUnquotedStrings: boolean): ParsedComponentSelector { - let wp = 0; - let EOL = selector.length === 0; - - const next = () => selector[wp] || ''; - const eat1 = () => { - const result = next(); - ++wp; - EOL = wp >= selector.length; - return result; - }; - - const syntaxError = (stage: string|undefined) => { - if (EOL) - throw new Error(`Unexpected end of selector while parsing selector \`${selector}\``); - throw new Error(`Error while parsing selector \`${selector}\` - unexpected symbol "${next()}" at position ${wp}` + (stage ? ' during ' + stage : '')); - }; - - function skipSpaces() { - while (!EOL && /\s/.test(next())) - eat1(); - } - - function isCSSNameChar(char: string) { - // https://www.w3.org/TR/css-syntax-3/#ident-token-diagram - return (char >= '\u0080') // non-ascii - || (char >= '\u0030' && char <= '\u0039') // digit - || (char >= '\u0041' && char <= '\u005a') // uppercase letter - || (char >= '\u0061' && char <= '\u007a') // lowercase letter - || (char >= '\u0030' && char <= '\u0039') // digit - || char === '\u005f' // "_" - || char === '\u002d'; // "-" - } - - function readIdentifier() { - let result = ''; - skipSpaces(); - while (!EOL && isCSSNameChar(next())) - result += eat1(); - return result; - } - - function readQuotedString(quote: string) { - let result = eat1(); - if (result !== quote) - syntaxError('parsing quoted string'); - while (!EOL && next() !== quote) { - if (next() === '\\') - eat1(); - result += eat1(); - } - if (next() !== quote) - syntaxError('parsing quoted string'); - result += eat1(); - return result; - } - - function readRegularExpression() { - if (eat1() !== '/') - syntaxError('parsing regular expression'); - let source = ''; - let inClass = false; - // https://262.ecma-international.org/11.0/#sec-literals-regular-expression-literals - while (!EOL) { - if (next() === '\\') { - source += eat1(); - if (EOL) - syntaxError('parsing regular expressiion'); - } else if (inClass && next() === ']') { - inClass = false; - } else if (!inClass && next() === '[') { - inClass = true; - } else if (!inClass && next() === '/') { - break; - } - source += eat1(); - } - if (eat1() !== '/') - syntaxError('parsing regular expression'); - let flags = ''; - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions - while (!EOL && next().match(/[dgimsuy]/)) - flags += eat1(); - try { - return new RegExp(source, flags); - } catch (e) { - throw new Error(`Error while parsing selector \`${selector}\`: ${e.message}`); - } - } - - function readAttributeToken() { - let token = ''; - skipSpaces(); - if (next() === `'` || next() === `"`) - token = readQuotedString(next()).slice(1, -1); - else - token = readIdentifier(); - if (!token) - syntaxError('parsing property path'); - return token; - } - - function readOperator(): ParsedAttributeOperator { - skipSpaces(); - let op = ''; - if (!EOL) - op += eat1(); - if (!EOL && (op !== '=')) - op += eat1(); - if (!['=', '*=', '^=', '$=', '|=', '~='].includes(op)) - syntaxError('parsing operator'); - return (op as ParsedAttributeOperator); - } - - function readAttribute(): ParsedComponentAttribute { - // skip leading [ - eat1(); - - // read attribute name: - // foo.bar - // 'foo' . "ba zz" - const jsonPath = []; - jsonPath.push(readAttributeToken()); - skipSpaces(); - while (next() === '.') { - eat1(); - jsonPath.push(readAttributeToken()); - skipSpaces(); - } - // check property is truthy: [enabled] - if (next() === ']') { - eat1(); - return { name: jsonPath.join('.'), jsonPath, op: '', value: null, caseSensitive: false }; - } - - const operator = readOperator(); - - let value = undefined; - let caseSensitive = true; - skipSpaces(); - if (next() === '/') { - if (operator !== '=') - throw new Error(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with regular expression`); - value = readRegularExpression(); - } else if (next() === `'` || next() === `"`) { - value = readQuotedString(next()).slice(1, -1); - skipSpaces(); - if (next() === 'i' || next() === 'I') { - caseSensitive = false; - eat1(); - } else if (next() === 's' || next() === 'S') { - caseSensitive = true; - eat1(); - } - } else { - value = ''; - while (!EOL && (isCSSNameChar(next()) || next() === '+' || next() === '.')) - value += eat1(); - if (value === 'true') { - value = true; - } else if (value === 'false') { - value = false; - } else { - if (!allowUnquotedStrings) { - value = +value; - if (Number.isNaN(value)) - syntaxError('parsing attribute value'); - } - } - } - skipSpaces(); - if (next() !== ']') - syntaxError('parsing attribute value'); - - eat1(); - if (operator !== '=' && typeof value !== 'string') - throw new Error(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with non-string matching value - ${value}`); - return { name: jsonPath.join('.'), jsonPath, op: operator, value, caseSensitive }; - } - - const result: ParsedComponentSelector = { - name: '', - attributes: [], - }; - result.name = readIdentifier(); - skipSpaces(); - while (next() === '[') { - result.attributes.push(readAttribute()); - skipSpaces(); - } - if (!EOL) - syntaxError(undefined); - if (!result.name && !result.attributes.length) - throw new Error(`Error while parsing selector \`${selector}\` - selector cannot be empty`); - return result; -} diff --git a/packages/playwright-core/src/server/injected/domUtils.ts b/packages/playwright-core/src/server/injected/domUtils.ts new file mode 100644 index 0000000000..cb8a013274 --- /dev/null +++ b/packages/playwright-core/src/server/injected/domUtils.ts @@ -0,0 +1,85 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export function isInsideScope(scope: Node, element: Element | undefined): boolean { + while (element) { + if (scope.contains(element)) + return true; + element = enclosingShadowHost(element); + } + return false; +} + +export function parentElementOrShadowHost(element: Element): Element | undefined { + if (element.parentElement) + return element.parentElement; + if (!element.parentNode) + return; + if (element.parentNode.nodeType === 11 /* Node.DOCUMENT_FRAGMENT_NODE */ && (element.parentNode as ShadowRoot).host) + return (element.parentNode as ShadowRoot).host; +} + +export function enclosingShadowRootOrDocument(element: Element): Document | ShadowRoot | undefined { + let node: Node = element; + while (node.parentNode) + node = node.parentNode; + if (node.nodeType === 11 /* Node.DOCUMENT_FRAGMENT_NODE */ || node.nodeType === 9 /* Node.DOCUMENT_NODE */) + return node as Document | ShadowRoot; +} + +function enclosingShadowHost(element: Element): Element | undefined { + while (element.parentElement) + element = element.parentElement; + return parentElementOrShadowHost(element); +} + +export function closestCrossShadow(element: Element | undefined, css: string): Element | undefined { + while (element) { + const closest = element.closest(css); + if (closest) + return closest; + element = enclosingShadowHost(element); + } +} + +export function isElementVisible(element: Element): boolean { + // Note: this logic should be similar to waitForDisplayedAtStablePosition() to avoid surprises. + if (!element.ownerDocument || !element.ownerDocument.defaultView) + return true; + const style = element.ownerDocument.defaultView.getComputedStyle(element); + if (!style || style.visibility === 'hidden') + return false; + if (style.display === 'contents') { + // display:contents is not rendered itself, but its child nodes are. + for (let child = element.firstChild; child; child = child.nextSibling) { + if (child.nodeType === 1 /* Node.ELEMENT_NODE */ && isElementVisible(child as Element)) + return true; + if (child.nodeType === 3 /* Node.TEXT_NODE */ && isVisibleTextNode(child as Text)) + return true; + } + return false; + } + const rect = element.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +} + +function isVisibleTextNode(node: Text) { + // https://stackoverflow.com/questions/1461059/is-there-an-equivalent-to-getboundingclientrect-for-text-nodes + const range = document.createRange(); + range.selectNode(node); + const rect = range.getBoundingClientRect(); + return rect.width > 0 && rect.height > 0; +} diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 94e7c52c05..64442dfe29 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -21,8 +21,9 @@ import { VueEngine } from './vueSelectorEngine'; import { RoleEngine } from './roleSelectorEngine'; import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '../isomorphic/selectorParser'; import { allEngineNames, parseSelector, stringifySelector } from '../isomorphic/selectorParser'; -import type { TextMatcher } from './selectorEvaluator'; -import { SelectorEvaluatorImpl, isVisible, parentElementOrShadowHost, elementMatchesText, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorEvaluator'; +import { type TextMatcher, elementMatchesText, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher } from './selectorUtils'; +import { SelectorEvaluatorImpl } from './selectorEvaluator'; +import { isElementVisible, parentElementOrShadowHost } from './domUtils'; import type { CSSComplexSelectorList } from '../isomorphic/cssParser'; import { generateSelector } from './selectorGenerator'; import type * as channels from '../../protocol/channels'; @@ -253,7 +254,7 @@ export class InjectedScript { // TODO: replace contains() with something shadow-dom-aware? if (kind === 'lax' && lastDidNotMatchSelf && lastDidNotMatchSelf.contains(element)) return false; - const matches = elementMatchesText(this._evaluator, element, matcher); + const matches = elementMatchesText(this._evaluator._cacheText, element, matcher); if (matches === 'none') lastDidNotMatchSelf = element; if (matches === 'self' || (matches === 'selfAndChildren' && kind === 'strict')) @@ -301,7 +302,7 @@ export class InjectedScript { const queryAll = (root: SelectorRoot, body: string) => { if (root.nodeType !== 1 /* Node.ELEMENT_NODE */) return []; - return isVisible(root as Element) === Boolean(body) ? [root as Element] : []; + return isElementVisible(root as Element) === Boolean(body) ? [root as Element] : []; }; return { queryAll }; } @@ -327,7 +328,7 @@ export class InjectedScript { } isVisible(element: Element): boolean { - return isVisible(element); + return isElementVisible(element); } pollRaf(predicate: Predicate): InjectedScriptPoll { diff --git a/packages/playwright-core/src/server/injected/reactSelectorEngine.ts b/packages/playwright-core/src/server/injected/reactSelectorEngine.ts index 08698116dc..1808f71218 100644 --- a/packages/playwright-core/src/server/injected/reactSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/reactSelectorEngine.ts @@ -15,8 +15,9 @@ */ import type { SelectorEngine, SelectorRoot } from './selectorEngine'; -import { isInsideScope } from './selectorEvaluator'; -import { checkComponentAttribute, parseComponentSelector } from './componentUtils'; +import { isInsideScope } from './domUtils'; +import { matchesComponentAttribute } from './selectorUtils'; +import { parseAttributeSelector } from '../isomorphic/selectorParser'; type ComponentNode = { key?: any, @@ -176,7 +177,7 @@ function findReactRoots(root: Document | ShadowRoot, roots: ReactVNode[] = []): export const ReactEngine: SelectorEngine = { queryAll(scope: SelectorRoot, selector: string): Element[] { - const { name, attributes } = parseComponentSelector(selector, false); + const { name, attributes } = parseAttributeSelector(selector, false); const reactRoots = findReactRoots(document); const trees = reactRoots.map(reactRoot => buildComponentsTree(reactRoot)); @@ -191,7 +192,7 @@ export const ReactEngine: SelectorEngine = { if (treeNode.rootElements.some(domNode => !isInsideScope(scope, domNode))) return false; for (const attr of attributes) { - if (!checkComponentAttribute(props, attr)) + if (!matchesComponentAttribute(props, attr)) return false; } return true; diff --git a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts index 3fcd78b83e..0e306ee1f1 100644 --- a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts @@ -15,9 +15,9 @@ */ import type { SelectorEngine, SelectorRoot } from './selectorEngine'; -import type { ParsedComponentAttribute, ParsedAttributeOperator } from './componentUtils'; -import { matchesAttribute, parseComponentSelector } from './componentUtils'; +import { matchesAttributePart } from './selectorUtils'; import { getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaLevel, getAriaPressed, getAriaRole, getAriaSelected, getElementAccessibleName, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils'; +import { parseAttributeSelector, type AttributeSelectorPart, type AttributeSelectorOperator } from '../isomorphic/selectorParser'; const kSupportedAttributes = ['selected', 'checked', 'pressed', 'expanded', 'level', 'disabled', 'name', 'include-hidden']; kSupportedAttributes.sort(); @@ -27,17 +27,17 @@ function validateSupportedRole(attr: string, roles: string[], role: string) { throw new Error(`"${attr}" attribute is only supported for roles: ${roles.slice().sort().map(role => `"${role}"`).join(', ')}`); } -function validateSupportedValues(attr: ParsedComponentAttribute, values: any[]) { +function validateSupportedValues(attr: AttributeSelectorPart, values: any[]) { if (attr.op !== '' && !values.includes(attr.value)) throw new Error(`"${attr.name}" must be one of ${values.map(v => JSON.stringify(v)).join(', ')}`); } -function validateSupportedOp(attr: ParsedComponentAttribute, ops: ParsedAttributeOperator[]) { +function validateSupportedOp(attr: AttributeSelectorPart, ops: AttributeSelectorOperator[]) { if (!ops.includes(attr.op)) throw new Error(`"${attr.name}" does not support "${attr.op}" matcher`); } -function validateAttributes(attrs: ParsedComponentAttribute[], role: string) { +function validateAttributes(attrs: AttributeSelectorPart[], role: string) { for (const attr of attrs) { switch (attr.name) { case 'checked': { @@ -109,7 +109,7 @@ function validateAttributes(attrs: ParsedComponentAttribute[], role: string) { export const RoleEngine: SelectorEngine = { queryAll(scope: SelectorRoot, selector: string): Element[] { - const parsed = parseComponentSelector(selector, true); + const parsed = parseAttributeSelector(selector, true); const role = parsed.name.toLowerCase(); if (!role) throw new Error(`Role must not be empty`); @@ -121,7 +121,7 @@ export const RoleEngine: SelectorEngine = { if (getAriaRole(element) !== role) return; let includeHidden = false; // By default, hidden elements are excluded. - let nameAttr: ParsedComponentAttribute | undefined; + let nameAttr: AttributeSelectorPart | undefined; for (const attr of parsed.attributes) { if (attr.name === 'include-hidden') { includeHidden = attr.op === '' || !!attr.value; @@ -140,7 +140,7 @@ export const RoleEngine: SelectorEngine = { case 'level': actual = getAriaLevel(element); break; case 'disabled': actual = getAriaDisabled(element); break; } - if (!matchesAttribute(actual, attr)) + if (!matchesAttributePart(actual, attr)) return; } if (!includeHidden) { @@ -150,7 +150,7 @@ export const RoleEngine: SelectorEngine = { } if (nameAttr !== undefined) { const accessibleName = getElementAccessibleName(element, includeHidden, hiddenCache); - if (!matchesAttribute(accessibleName, nameAttr)) + if (!matchesAttributePart(accessibleName, nameAttr)) return; } result.push(element); diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index e913cea8f3..bbaeec6a23 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { closestCrossShadow, enclosingShadowRootOrDocument, parentElementOrShadowHost } from './selectorEvaluator'; +import { closestCrossShadow, enclosingShadowRootOrDocument, parentElementOrShadowHost } from './domUtils'; function hasExplicitAccessibleName(e: Element) { return e.hasAttribute('aria-label') || e.hasAttribute('aria-labelledby'); @@ -676,7 +676,7 @@ export function getAriaExpanded(element: Element): boolean { } export const kAriaLevelRoles = ['heading', 'listitem', 'row', 'treeitem']; -export function getAriaLevel(element: Element) { +export function getAriaLevel(element: Element): number { // https://www.w3.org/TR/wai-aria-1.2/#aria-level // https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings const native = { 'H1': 1, 'H2': 2, 'H3': 3, 'H4': 4, 'H5': 5, 'H6': 6 }[element.tagName]; diff --git a/packages/playwright-core/src/server/injected/selectorEvaluator.ts b/packages/playwright-core/src/server/injected/selectorEvaluator.ts index 087758a089..1941482757 100644 --- a/packages/playwright-core/src/server/injected/selectorEvaluator.ts +++ b/packages/playwright-core/src/server/injected/selectorEvaluator.ts @@ -16,9 +16,11 @@ import type { CSSComplexSelector, CSSSimpleSelector, CSSComplexSelectorList, CSSFunctionArgument } from '../isomorphic/cssParser'; import { customCSSNames } from '../isomorphic/selectorParser'; +import { isElementVisible, parentElementOrShadowHost } from './domUtils'; import { type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils'; +import { createLaxTextMatcher, createRegexTextMatcher, createStrictTextMatcher, elementMatchesText, elementText, shouldSkipForTextMatching, type ElementText } from './selectorUtils'; -export type QueryContext = { +type QueryContext = { scope: Element | Document; pierceShadow: boolean; // Place for more options, e.g. normalizing whitespace. @@ -373,8 +375,6 @@ const hasEngine: SelectorEngine = { return evaluator.query({ ...context, scope: element }, args).length > 0; }, - // TODO: we do not implement "relative selectors", as in "div:has(> span)" or "div:has(+ span)". - // TODO: we can implement efficient "query" by matching "args" and returning // all parents/descendants, just have to be careful with the ":scope" matching. }; @@ -423,7 +423,7 @@ const visibleEngine: SelectorEngine = { matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean { if (args.length) throw new Error(`"visible" engine expects no arguments`); - return isVisible(element); + return isElementVisible(element); } }; @@ -432,7 +432,7 @@ const textEngine: SelectorEngine = { if (args.length !== 1 || typeof args[0] !== 'string') throw new Error(`"text" engine expects a single string`); const matcher = createLaxTextMatcher(args[0]); - return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher) === 'self'; + return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) === 'self'; }, }; @@ -441,7 +441,7 @@ const textIsEngine: SelectorEngine = { if (args.length !== 1 || typeof args[0] !== 'string') throw new Error(`"text-is" engine expects a single string`); const matcher = createStrictTextMatcher(args[0]); - return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher) !== 'none'; + return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) !== 'none'; }, }; @@ -450,7 +450,7 @@ const textMatchesEngine: SelectorEngine = { if (args.length === 0 || typeof args[0] !== 'string' || args.length > 2 || (args.length === 2 && typeof args[1] !== 'string')) throw new Error(`"text-matches" engine expects a regexp body and optional regexp flags`); const matcher = createRegexTextMatcher(args[0], args.length === 2 ? args[1] : undefined); - return elementMatchesText(evaluator as SelectorEvaluatorImpl, element, matcher) === 'self'; + return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) === 'self'; }, }; @@ -461,87 +461,10 @@ const hasTextEngine: SelectorEngine = { if (shouldSkipForTextMatching(element)) return false; const matcher = createLaxTextMatcher(args[0]); - return matcher(elementText(evaluator as SelectorEvaluatorImpl, element)); + return matcher(elementText((evaluator as SelectorEvaluatorImpl)._cacheText, element)); }, }; -export function createLaxTextMatcher(text: string): TextMatcher { - text = text.trim().replace(/\s+/g, ' ').toLowerCase(); - return (elementText: ElementText) => { - const s = elementText.full.trim().replace(/\s+/g, ' ').toLowerCase(); - return s.includes(text); - }; -} - -export function createStrictTextMatcher(text: string): TextMatcher { - text = text.trim().replace(/\s+/g, ' '); - return (elementText: ElementText) => { - if (!text && !elementText.immediate.length) - return true; - return elementText.immediate.some(s => s.trim().replace(/\s+/g, ' ') === text); - }; -} - -export function createRegexTextMatcher(source: string, flags?: string): TextMatcher { - const re = new RegExp(source, flags); - return (elementText: ElementText) => { - return re.test(elementText.full); - }; -} - -function shouldSkipForTextMatching(element: Element | ShadowRoot) { - return element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || document.head && document.head.contains(element); -} - -export type ElementText = { full: string, immediate: string[] }; -export type TextMatcher = (text: ElementText) => boolean; - -export function elementText(evaluator: SelectorEvaluatorImpl, root: Element | ShadowRoot): ElementText { - let value = evaluator._cacheText.get(root); - if (value === undefined) { - value = { full: '', immediate: [] }; - if (!shouldSkipForTextMatching(root)) { - let currentImmediate = ''; - if ((root instanceof HTMLInputElement) && (root.type === 'submit' || root.type === 'button')) { - value = { full: root.value, immediate: [root.value] }; - } else { - for (let child = root.firstChild; child; child = child.nextSibling) { - if (child.nodeType === Node.TEXT_NODE) { - value.full += child.nodeValue || ''; - currentImmediate += child.nodeValue || ''; - } else { - if (currentImmediate) - value.immediate.push(currentImmediate); - currentImmediate = ''; - if (child.nodeType === Node.ELEMENT_NODE) - value.full += elementText(evaluator, child as Element).full; - } - } - if (currentImmediate) - value.immediate.push(currentImmediate); - if ((root as Element).shadowRoot) - value.full += elementText(evaluator, (root as Element).shadowRoot!).full; - } - } - evaluator._cacheText.set(root, value); - } - return value; -} - -export function elementMatchesText(evaluator: SelectorEvaluatorImpl, element: Element, matcher: TextMatcher): 'none' | 'self' | 'selfAndChildren' { - if (shouldSkipForTextMatching(element)) - return 'none'; - if (!matcher(elementText(evaluator, element))) - return 'none'; - for (let child = element.firstChild; child; child = child.nextSibling) { - if (child.nodeType === Node.ELEMENT_NODE && matcher(elementText(evaluator, child as Element))) - return 'selfAndChildren'; - } - if (element.shadowRoot && matcher(elementText(evaluator, element.shadowRoot))) - return 'selfAndChildren'; - return 'self'; -} - function createLayoutEngine(name: LayoutSelectorName): SelectorEngine { return { matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean { @@ -572,47 +495,6 @@ const nthMatchEngine: SelectorEngine = { }, }; -export function isInsideScope(scope: Node, element: Element | undefined): boolean { - while (element) { - if (scope.contains(element)) - return true; - element = enclosingShadowHost(element); - } - return false; -} - -export function parentElementOrShadowHost(element: Element): Element | undefined { - if (element.parentElement) - return element.parentElement; - if (!element.parentNode) - return; - if (element.parentNode.nodeType === 11 /* Node.DOCUMENT_FRAGMENT_NODE */ && (element.parentNode as ShadowRoot).host) - return (element.parentNode as ShadowRoot).host; -} - -export function enclosingShadowRootOrDocument(element: Element): Document | ShadowRoot | undefined { - let node: Node = element; - while (node.parentNode) - node = node.parentNode; - if (node.nodeType === 11 /* Node.DOCUMENT_FRAGMENT_NODE */ || node.nodeType === 9 /* Node.DOCUMENT_NODE */) - return node as Document | ShadowRoot; -} - -function enclosingShadowHost(element: Element): Element | undefined { - while (element.parentElement) - element = element.parentElement; - return parentElementOrShadowHost(element); -} - -export function closestCrossShadow(element: Element | undefined, css: string): Element | undefined { - while (element) { - const closest = element.closest(css); - if (closest) - return closest; - element = enclosingShadowHost(element); - } -} - function parentElementOrShadowHostInContext(element: Element, context: QueryContext): Element | undefined { if (element === context.scope) return; @@ -627,35 +509,6 @@ function previousSiblingInContext(element: Element, context: QueryContext): Elem return element.previousElementSibling || undefined; } -export function isVisible(element: Element): boolean { - // Note: this logic should be similar to waitForDisplayedAtStablePosition() to avoid surprises. - if (!element.ownerDocument || !element.ownerDocument.defaultView) - return true; - const style = element.ownerDocument.defaultView.getComputedStyle(element); - if (!style || style.visibility === 'hidden') - return false; - if (style.display === 'contents') { - // display:contents is not rendered itself, but its child nodes are. - for (let child = element.firstChild; child; child = child.nextSibling) { - if (child.nodeType === 1 /* Node.ELEMENT_NODE */ && isVisible(child as Element)) - return true; - if (child.nodeType === 3 /* Node.TEXT_NODE */ && isVisibleTextNode(child as Text)) - return true; - } - return false; - } - const rect = element.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; -} - -function isVisibleTextNode(node: Text) { - // https://stackoverflow.com/questions/1461059/is-there-an-equivalent-to-getboundingclientrect-for-text-nodes - const range = document.createRange(); - range.selectNode(node); - const rect = range.getBoundingClientRect(); - return rect.width > 0 && rect.height > 0; -} - function sortInDOMOrder(elements: Element[]): Element[] { type SortEntry = { children: Element[], taken: boolean }; diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index 93e2526213..ad6df946b9 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -15,7 +15,7 @@ */ import { type InjectedScript } from './injectedScript'; -import { elementText } from './selectorEvaluator'; +import { elementText } from './selectorUtils'; type SelectorToken = { engine: string; @@ -182,7 +182,7 @@ function buildCandidates(injectedScript: InjectedScript, element: Element): Sele function buildTextCandidates(injectedScript: InjectedScript, element: Element, allowHasText: boolean): SelectorToken[] { if (element.nodeName === 'SELECT') return []; - const text = elementText(injectedScript._evaluator, element).full.trim().replace(/\s+/g, ' ').substring(0, 80); + const text = elementText(injectedScript._evaluator._cacheText, element).full.trim().replace(/\s+/g, ' ').substring(0, 80); if (!text) return []; const candidates: SelectorToken[] = []; diff --git a/packages/playwright-core/src/server/injected/selectorUtils.ts b/packages/playwright-core/src/server/injected/selectorUtils.ts new file mode 100644 index 0000000000..accfd18156 --- /dev/null +++ b/packages/playwright-core/src/server/injected/selectorUtils.ts @@ -0,0 +1,129 @@ +/** + * Copyright (c) Microsoft Corporation. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { type AttributeSelectorPart } from '../isomorphic/selectorParser'; + +export function matchesComponentAttribute(obj: any, attr: AttributeSelectorPart) { + for (const token of attr.jsonPath) { + if (obj !== undefined && obj !== null) + obj = obj[token]; + } + return matchesAttributePart(obj, attr); +} + +export function matchesAttributePart(value: any, attr: AttributeSelectorPart) { + const objValue = typeof value === 'string' && !attr.caseSensitive ? value.toUpperCase() : value; + const attrValue = typeof attr.value === 'string' && !attr.caseSensitive ? attr.value.toUpperCase() : attr.value; + + if (attr.op === '') + return !!objValue; + if (attr.op === '=') { + if (attrValue instanceof RegExp) + return typeof objValue === 'string' && !!objValue.match(attrValue); + return objValue === attrValue; + } + if (typeof objValue !== 'string' || typeof attrValue !== 'string') + return false; + if (attr.op === '*=') + return objValue.includes(attrValue); + if (attr.op === '^=') + return objValue.startsWith(attrValue); + if (attr.op === '$=') + return objValue.endsWith(attrValue); + if (attr.op === '|=') + return objValue === attrValue || objValue.startsWith(attrValue + '-'); + if (attr.op === '~=') + return objValue.split(' ').includes(attrValue); + return false; +} + + +export function createLaxTextMatcher(text: string): TextMatcher { + text = text.trim().replace(/\s+/g, ' ').toLowerCase(); + return (elementText: ElementText) => { + const s = elementText.full.trim().replace(/\s+/g, ' ').toLowerCase(); + return s.includes(text); + }; +} + +export function createStrictTextMatcher(text: string): TextMatcher { + text = text.trim().replace(/\s+/g, ' '); + return (elementText: ElementText) => { + if (!text && !elementText.immediate.length) + return true; + return elementText.immediate.some(s => s.trim().replace(/\s+/g, ' ') === text); + }; +} + +export function createRegexTextMatcher(source: string, flags?: string): TextMatcher { + const re = new RegExp(source, flags); + return (elementText: ElementText) => { + return re.test(elementText.full); + }; +} + +export function shouldSkipForTextMatching(element: Element | ShadowRoot) { + return element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || document.head && document.head.contains(element); +} + +export type ElementText = { full: string, immediate: string[] }; +export type TextMatcher = (text: ElementText) => boolean; + +export function elementText(cache: Map, root: Element | ShadowRoot): ElementText { + let value = cache.get(root); + if (value === undefined) { + value = { full: '', immediate: [] }; + if (!shouldSkipForTextMatching(root)) { + let currentImmediate = ''; + if ((root instanceof HTMLInputElement) && (root.type === 'submit' || root.type === 'button')) { + value = { full: root.value, immediate: [root.value] }; + } else { + for (let child = root.firstChild; child; child = child.nextSibling) { + if (child.nodeType === Node.TEXT_NODE) { + value.full += child.nodeValue || ''; + currentImmediate += child.nodeValue || ''; + } else { + if (currentImmediate) + value.immediate.push(currentImmediate); + currentImmediate = ''; + if (child.nodeType === Node.ELEMENT_NODE) + value.full += elementText(cache, child as Element).full; + } + } + if (currentImmediate) + value.immediate.push(currentImmediate); + if ((root as Element).shadowRoot) + value.full += elementText(cache, (root as Element).shadowRoot!).full; + } + } + cache.set(root, value); + } + return value; +} + +export function elementMatchesText(cache: Map, element: Element, matcher: TextMatcher): 'none' | 'self' | 'selfAndChildren' { + if (shouldSkipForTextMatching(element)) + return 'none'; + if (!matcher(elementText(cache, element))) + return 'none'; + for (let child = element.firstChild; child; child = child.nextSibling) { + if (child.nodeType === Node.ELEMENT_NODE && matcher(elementText(cache, child as Element))) + return 'selfAndChildren'; + } + if (element.shadowRoot && matcher(elementText(cache, element.shadowRoot))) + return 'selfAndChildren'; + return 'self'; +} diff --git a/packages/playwright-core/src/server/injected/vueSelectorEngine.ts b/packages/playwright-core/src/server/injected/vueSelectorEngine.ts index 470c90b478..ca2fd53b9a 100644 --- a/packages/playwright-core/src/server/injected/vueSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/vueSelectorEngine.ts @@ -15,8 +15,9 @@ */ import type { SelectorEngine, SelectorRoot } from './selectorEngine'; -import { isInsideScope } from './selectorEvaluator'; -import { checkComponentAttribute, parseComponentSelector } from './componentUtils'; +import { isInsideScope } from './domUtils'; +import { matchesComponentAttribute } from './selectorUtils'; +import { parseAttributeSelector } from '../isomorphic/selectorParser'; type ComponentNode = { name: string, @@ -232,7 +233,7 @@ function findVueRoots(root: Document | ShadowRoot, roots: VueRoot[] = []): VueRo export const VueEngine: SelectorEngine = { queryAll(scope: SelectorRoot, selector: string): Element[] { - const { name, attributes } = parseComponentSelector(selector, false); + const { name, attributes } = parseAttributeSelector(selector, false); const vueRoots = findVueRoots(document); const trees = vueRoots.map(vueRoot => vueRoot.version === 3 ? buildComponentsTreeVue3(vueRoot.root) : buildComponentsTreeVue2(vueRoot.root)); const treeNodes = trees.map(tree => filterComponentsTree(tree, treeNode => { @@ -241,7 +242,7 @@ export const VueEngine: SelectorEngine = { if (treeNode.rootElements.some(rootElement => !isInsideScope(scope, rootElement))) return false; for (const attr of attributes) { - if (!checkComponentAttribute(treeNode.props, attr)) + if (!matchesComponentAttribute(treeNode.props, attr)) return false; } return true; diff --git a/packages/playwright-core/src/server/isomorphic/selectorParser.ts b/packages/playwright-core/src/server/isomorphic/selectorParser.ts index 0a62a2f3ab..bb931f867f 100644 --- a/packages/playwright-core/src/server/isomorphic/selectorParser.ts +++ b/packages/playwright-core/src/server/isomorphic/selectorParser.ts @@ -211,3 +211,215 @@ function parseSelectorString(selector: string): ParsedSelectorStrings { append(); return result; } + +export type AttributeSelectorOperator = ''|'='|'*='|'|='|'^='|'$='|'~='; +export type AttributeSelectorPart = { + name: string, + jsonPath: string[], + op: AttributeSelectorOperator, + value: any, + caseSensitive: boolean, +}; + +export type AttributeSelector = { + name: string, + attributes: AttributeSelectorPart[], +}; + + +export function parseAttributeSelector(selector: string, allowUnquotedStrings: boolean): AttributeSelector { + let wp = 0; + let EOL = selector.length === 0; + + const next = () => selector[wp] || ''; + const eat1 = () => { + const result = next(); + ++wp; + EOL = wp >= selector.length; + return result; + }; + + const syntaxError = (stage: string|undefined) => { + if (EOL) + throw new Error(`Unexpected end of selector while parsing selector \`${selector}\``); + throw new Error(`Error while parsing selector \`${selector}\` - unexpected symbol "${next()}" at position ${wp}` + (stage ? ' during ' + stage : '')); + }; + + function skipSpaces() { + while (!EOL && /\s/.test(next())) + eat1(); + } + + function isCSSNameChar(char: string) { + // https://www.w3.org/TR/css-syntax-3/#ident-token-diagram + return (char >= '\u0080') // non-ascii + || (char >= '\u0030' && char <= '\u0039') // digit + || (char >= '\u0041' && char <= '\u005a') // uppercase letter + || (char >= '\u0061' && char <= '\u007a') // lowercase letter + || (char >= '\u0030' && char <= '\u0039') // digit + || char === '\u005f' // "_" + || char === '\u002d'; // "-" + } + + function readIdentifier() { + let result = ''; + skipSpaces(); + while (!EOL && isCSSNameChar(next())) + result += eat1(); + return result; + } + + function readQuotedString(quote: string) { + let result = eat1(); + if (result !== quote) + syntaxError('parsing quoted string'); + while (!EOL && next() !== quote) { + if (next() === '\\') + eat1(); + result += eat1(); + } + if (next() !== quote) + syntaxError('parsing quoted string'); + result += eat1(); + return result; + } + + function readRegularExpression() { + if (eat1() !== '/') + syntaxError('parsing regular expression'); + let source = ''; + let inClass = false; + // https://262.ecma-international.org/11.0/#sec-literals-regular-expression-literals + while (!EOL) { + if (next() === '\\') { + source += eat1(); + if (EOL) + syntaxError('parsing regular expressiion'); + } else if (inClass && next() === ']') { + inClass = false; + } else if (!inClass && next() === '[') { + inClass = true; + } else if (!inClass && next() === '/') { + break; + } + source += eat1(); + } + if (eat1() !== '/') + syntaxError('parsing regular expression'); + let flags = ''; + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions + while (!EOL && next().match(/[dgimsuy]/)) + flags += eat1(); + try { + return new RegExp(source, flags); + } catch (e) { + throw new Error(`Error while parsing selector \`${selector}\`: ${e.message}`); + } + } + + function readAttributeToken() { + let token = ''; + skipSpaces(); + if (next() === `'` || next() === `"`) + token = readQuotedString(next()).slice(1, -1); + else + token = readIdentifier(); + if (!token) + syntaxError('parsing property path'); + return token; + } + + function readOperator(): AttributeSelectorOperator { + skipSpaces(); + let op = ''; + if (!EOL) + op += eat1(); + if (!EOL && (op !== '=')) + op += eat1(); + if (!['=', '*=', '^=', '$=', '|=', '~='].includes(op)) + syntaxError('parsing operator'); + return (op as AttributeSelectorOperator); + } + + function readAttribute(): AttributeSelectorPart { + // skip leading [ + eat1(); + + // read attribute name: + // foo.bar + // 'foo' . "ba zz" + const jsonPath = []; + jsonPath.push(readAttributeToken()); + skipSpaces(); + while (next() === '.') { + eat1(); + jsonPath.push(readAttributeToken()); + skipSpaces(); + } + // check property is truthy: [enabled] + if (next() === ']') { + eat1(); + return { name: jsonPath.join('.'), jsonPath, op: '', value: null, caseSensitive: false }; + } + + const operator = readOperator(); + + let value = undefined; + let caseSensitive = true; + skipSpaces(); + if (next() === '/') { + if (operator !== '=') + throw new Error(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with regular expression`); + value = readRegularExpression(); + } else if (next() === `'` || next() === `"`) { + value = readQuotedString(next()).slice(1, -1); + skipSpaces(); + if (next() === 'i' || next() === 'I') { + caseSensitive = false; + eat1(); + } else if (next() === 's' || next() === 'S') { + caseSensitive = true; + eat1(); + } + } else { + value = ''; + while (!EOL && (isCSSNameChar(next()) || next() === '+' || next() === '.')) + value += eat1(); + if (value === 'true') { + value = true; + } else if (value === 'false') { + value = false; + } else { + if (!allowUnquotedStrings) { + value = +value; + if (Number.isNaN(value)) + syntaxError('parsing attribute value'); + } + } + } + skipSpaces(); + if (next() !== ']') + syntaxError('parsing attribute value'); + + eat1(); + if (operator !== '=' && typeof value !== 'string') + throw new Error(`Error while parsing selector \`${selector}\` - cannot use ${operator} in attribute with non-string matching value - ${value}`); + return { name: jsonPath.join('.'), jsonPath, op: operator, value, caseSensitive }; + } + + const result: AttributeSelector = { + name: '', + attributes: [], + }; + result.name = readIdentifier(); + skipSpaces(); + while (next() === '[') { + result.attributes.push(readAttribute()); + skipSpaces(); + } + if (!EOL) + syntaxError(undefined); + if (!result.name && !result.attributes.length) + throw new Error(`Error while parsing selector \`${selector}\` - selector cannot be empty`); + return result; +} diff --git a/tests/library/component-parser.spec.ts b/tests/library/component-parser.spec.ts index 201ed2d921..82f91e91da 100644 --- a/tests/library/component-parser.spec.ts +++ b/tests/library/component-parser.spec.ts @@ -15,11 +15,11 @@ */ import { playwrightTest as it, expect } from '../config/browserTest'; -import type { ParsedComponentSelector } from '../../packages/playwright-core/src/server/injected/componentUtils'; -import { parseComponentSelector } from '../../packages/playwright-core/src/server/injected/componentUtils'; +import type { AttributeSelector } from '../../packages/playwright-core/src/server/isomorphic/selectorParser'; +import { parseAttributeSelector } from '../../packages/playwright-core/src/server/isomorphic/selectorParser'; -const parse = (selector: string) => parseComponentSelector(selector, false); -const serialize = (parsed: ParsedComponentSelector) => { +const parse = (selector: string) => parseAttributeSelector(selector, false); +const serialize = (parsed: AttributeSelector) => { return parsed.name + parsed.attributes.map(attr => { const path = attr.jsonPath.map(token => /^[a-zA-Z0-9]+$/i.test(token) ? token : JSON.stringify(token)).join('.'); if (attr.op === '') @@ -115,9 +115,9 @@ it('should parse identifiers', async () => { }); it('should parse unqouted string', async () => { - expect(serialize(parseComponentSelector('[hey=foo]', true))).toBe('[hey = "foo"]'); - expect(serialize(parseComponentSelector('[yay=and😀more]', true))).toBe('[yay = "and😀more"]'); - expect(serialize(parseComponentSelector('[yay= trims ]', true))).toBe('[yay = "trims"]'); + expect(serialize(parseAttributeSelector('[hey=foo]', true))).toBe('[hey = "foo"]'); + expect(serialize(parseAttributeSelector('[yay=and😀more]', true))).toBe('[yay = "and😀more"]'); + expect(serialize(parseAttributeSelector('[yay= trims ]', true))).toBe('[yay = "trims"]'); }); it('should throw on malformed selector', async () => {