diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index b2621fc72e..c83bc5bd64 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -22,7 +22,7 @@ import { createRoleEngine } from './roleSelectorEngine'; import { parseAttributeSelector } from '../isomorphic/selectorParser'; import type { NestedSelectorBody, ParsedSelector, ParsedSelectorPart } from '../isomorphic/selectorParser'; import { allEngineNames, parseSelector, stringifySelector } from '../isomorphic/selectorParser'; -import { type TextMatcher, elementMatchesText, createRegexTextMatcher, createStrictTextMatcher, createLaxTextMatcher, elementText, createStrictFullTextMatcher } from './selectorUtils'; +import { type TextMatcher, elementMatchesText, elementText, type ElementText } from './selectorUtils'; import { SelectorEvaluatorImpl } from './selectorEvaluator'; import { enclosingShadowRootOrDocument, isElementVisible, parentElementOrShadowHost } from './domUtils'; import type { CSSComplexSelectorList } from '../isomorphic/cssParser'; @@ -33,6 +33,7 @@ import { getAriaCheckedStrict, getAriaDisabled, getAriaRole, getElementAccessibl import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils'; import { asLocator } from '../isomorphic/locatorGenerators'; import type { Language } from '../isomorphic/locatorGenerators'; +import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils'; type Predicate = (progress: InjectedScriptProgress) => T | symbol; @@ -64,16 +65,12 @@ export type InjectedScriptPoll = { export type ElementStateWithoutStable = 'visible' | 'hidden' | 'enabled' | 'disabled' | 'editable' | 'checked' | 'unchecked'; export type ElementState = ElementStateWithoutStable | 'stable'; -export interface SelectorEngineV2 { - queryAll(root: SelectorRoot, body: any): Element[]; -} - export type HitTargetInterceptionResult = { stop: () => 'done' | { hitTargetDescription: string }; }; export class InjectedScript { - private _engines: Map; + private _engines: Map; _evaluator: SelectorEvaluatorImpl; private _stableRafCount: number; private _browserName: string; @@ -247,17 +244,16 @@ export class InjectedScript { }; } - private _createCSSEngine(): SelectorEngineV2 { - const evaluator = this._evaluator; + private _createCSSEngine(): SelectorEngine { return { - queryAll(root: SelectorRoot, body: any) { - return evaluator.query({ scope: root as Document | Element, pierceShadow: true }, body); + queryAll: (root: SelectorRoot, body: any) => { + return this._evaluator.query({ scope: root as Document | Element, pierceShadow: true }, body); } }; } private _createTextEngine(shadow: boolean, internal: boolean): SelectorEngine { - const queryList = (root: SelectorRoot, selector: string): Element[] => { + const queryAll = (root: SelectorRoot, selector: string): Element[] => { const { matcher, kind } = createTextMatcher(selector, internal); const result: Element[] = []; let lastDidNotMatchSelf: Element | null = null; @@ -280,22 +276,16 @@ export class InjectedScript { appendElement(element); return result; }; - - return { - queryAll: (root: SelectorRoot, selector: string): Element[] => { - return queryList(root, selector); - } - }; + return { queryAll }; } private _createInternalHasTextEngine(): SelectorEngine { - const evaluator = this._evaluator; return { queryAll: (root: SelectorRoot, selector: string): Element[] => { if (root.nodeType !== 1 /* Node.ELEMENT_NODE */) return []; const element = root as Element; - const text = elementText(evaluator._cacheText, element); + const text = elementText(this._evaluator._cacheText, element); const { matcher } = createTextMatcher(selector, true); return matcher(text) ? [element] : []; } @@ -303,7 +293,6 @@ export class InjectedScript { } private _createInternalLabelEngine(): SelectorEngine { - const evaluator = this._evaluator; return { queryAll: (root: SelectorRoot, selector: string): Element[] => { const { matcher } = createTextMatcher(selector, true); @@ -311,7 +300,7 @@ export class InjectedScript { const labels = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: true }, 'label') as HTMLLabelElement[]; for (const label of labels) { const control = label.control; - if (control && matcher(elementText(evaluator._cacheText, label))) + if (control && matcher(elementText(this._evaluator._cacheText, label))) result.push(control); } return result; @@ -320,7 +309,7 @@ export class InjectedScript { } private _createNamedAttributeEngine(): SelectorEngine { - const queryList = (root: SelectorRoot, selector: string): Element[] => { + const queryAll = (root: SelectorRoot, selector: string): Element[] => { const parsed = parseAttributeSelector(selector, true); if (parsed.name || parsed.attributes.length !== 1) throw new Error('Malformed attribute selector: ' + selector); @@ -336,15 +325,10 @@ export class InjectedScript { const elements = this._evaluator._queryCSS({ scope: root as Document | Element, pierceShadow: true }, `[${name}]`); return elements.filter(e => matcher(e.getAttribute(name)!)); }; - - return { - queryAll: (root: SelectorRoot, selector: string): Element[] => { - return queryList(root, selector); - } - }; + return { queryAll }; } - private _createControlEngine(): SelectorEngineV2 { + private _createControlEngine(): SelectorEngine { return { queryAll(root: SelectorRoot, body: any) { if (body === 'enter-frame') @@ -363,7 +347,7 @@ export class InjectedScript { }; } - private _createHasEngine(): SelectorEngineV2 { + private _createHasEngine(): SelectorEngine { const queryAll = (root: SelectorRoot, body: NestedSelectorBody) => { if (root.nodeType !== 1 /* Node.ELEMENT_NODE */) return []; @@ -373,7 +357,7 @@ export class InjectedScript { return { queryAll }; } - private _createVisibleEngine(): SelectorEngineV2 { + private _createVisibleEngine(): SelectorEngine { const queryAll = (root: SelectorRoot, body: string) => { if (root.nodeType !== 1 /* Node.ELEMENT_NODE */) return []; @@ -1369,8 +1353,8 @@ function cssUnquote(s: string): string { function createTextMatcher(selector: string, internal: boolean): { matcher: TextMatcher, kind: 'regex' | 'strict' | 'lax' } { if (selector[0] === '/' && selector.lastIndexOf('/') > 0) { const lastSlash = selector.lastIndexOf('/'); - const matcher: TextMatcher = createRegexTextMatcher(selector.substring(1, lastSlash), selector.substring(lastSlash + 1)); - return { matcher, kind: 'regex' }; + const re = new RegExp(selector.substring(1, lastSlash), selector.substring(lastSlash + 1)); + return { matcher: (elementText: ElementText) => re.test(elementText.full), kind: 'regex' }; } const unquote = internal ? JSON.parse.bind(JSON) : cssUnquote; let strict = false; @@ -1387,9 +1371,20 @@ function createTextMatcher(selector: string, internal: boolean): { matcher: Text selector = unquote(selector); strict = true; } - if (strict) - return { matcher: internal ? createStrictFullTextMatcher(selector) : createStrictTextMatcher(selector), kind: 'strict' }; - return { matcher: createLaxTextMatcher(selector), kind: 'lax' }; + selector = normalizeWhiteSpace(selector); + if (strict) { + if (internal) + return { kind: 'strict', matcher: (elementText: ElementText) => normalizeWhiteSpace(elementText.full) === selector }; + + const strictTextNodeMatcher = (elementText: ElementText) => { + if (!selector && !elementText.immediate.length) + return true; + return elementText.immediate.some(s => normalizeWhiteSpace(s) === selector); + }; + return { matcher: strictTextNodeMatcher, kind: 'strict' }; + } + selector = selector.toLowerCase(); + return { kind: 'lax', matcher: (elementText: ElementText) => normalizeWhiteSpace(elementText.full).toLowerCase().includes(selector) }; } class ExpectedTextMatcher { @@ -1430,7 +1425,7 @@ class ExpectedTextMatcher { if (!s) return s; if (this._normalizeWhiteSpace) - s = s.trim().replace(/\u200b/g, '').replace(/\s+/g, ' '); + s = normalizeWhiteSpace(s); if (this._ignoreCase) s = s.toLocaleLowerCase(); return s; diff --git a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts index a3fdb5c3ba..eb879e0a9d 100644 --- a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts @@ -18,6 +18,7 @@ import type { SelectorEngine, SelectorRoot } from './selectorEngine'; 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'; +import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils'; const kSupportedAttributes = ['selected', 'checked', 'pressed', 'expanded', 'level', 'disabled', 'name', 'include-hidden']; kSupportedAttributes.sort(); @@ -155,9 +156,9 @@ export function createRoleEngine(internal: boolean): SelectorEngine { } if (nameAttr !== undefined) { // Always normalize whitespace in the accessible name. - const accessibleName = getElementAccessibleName(element, includeHidden, hiddenCache).trim().replace(/\s+/g, ' '); + const accessibleName = normalizeWhiteSpace(getElementAccessibleName(element, includeHidden, hiddenCache)); if (typeof nameAttr.value === 'string') - nameAttr.value = nameAttr.value.trim().replace(/\s+/g, ' '); + nameAttr.value = normalizeWhiteSpace(nameAttr.value); // internal:role assumes that [name="foo"i] also means substring. if (internal && !nameAttr.caseSensitive && nameAttr.op === '=') nameAttr.op = '*='; diff --git a/packages/playwright-core/src/server/injected/selectorEngine.ts b/packages/playwright-core/src/server/injected/selectorEngine.ts index 0fdf64a265..5a24ec3e75 100644 --- a/packages/playwright-core/src/server/injected/selectorEngine.ts +++ b/packages/playwright-core/src/server/injected/selectorEngine.ts @@ -17,5 +17,5 @@ export type SelectorRoot = Element | ShadowRoot | Document; export interface SelectorEngine { - queryAll(root: SelectorRoot, selector: string): Element[]; + queryAll(root: SelectorRoot, selector: string | any): Element[]; } diff --git a/packages/playwright-core/src/server/injected/selectorEvaluator.ts b/packages/playwright-core/src/server/injected/selectorEvaluator.ts index abe0566249..b778931356 100644 --- a/packages/playwright-core/src/server/injected/selectorEvaluator.ts +++ b/packages/playwright-core/src/server/injected/selectorEvaluator.ts @@ -18,7 +18,8 @@ import type { CSSComplexSelector, CSSSimpleSelector, CSSComplexSelectorList, CSS 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'; +import { elementMatchesText, elementText, shouldSkipForTextMatching, type ElementText } from './selectorUtils'; +import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils'; type QueryContext = { scope: Element | Document; @@ -431,7 +432,8 @@ const textEngine: SelectorEngine = { matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean { if (args.length !== 1 || typeof args[0] !== 'string') throw new Error(`"text" engine expects a single string`); - const matcher = createLaxTextMatcher(args[0]); + const text = normalizeWhiteSpace(args[0]).toLowerCase(); + const matcher = (elementText: ElementText) => normalizeWhiteSpace(elementText.full).toLowerCase().includes(text); return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) === 'self'; }, }; @@ -440,7 +442,12 @@ const textIsEngine: SelectorEngine = { matches(element: Element, args: (string | number | Selector)[], context: QueryContext, evaluator: SelectorEvaluator): boolean { if (args.length !== 1 || typeof args[0] !== 'string') throw new Error(`"text-is" engine expects a single string`); - const matcher = createStrictTextMatcher(args[0]); + const text = normalizeWhiteSpace(args[0]); + const matcher = (elementText: ElementText) => { + if (!text && !elementText.immediate.length) + return true; + return elementText.immediate.some(s => normalizeWhiteSpace(s) === text); + }; return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) !== 'none'; }, }; @@ -449,7 +456,8 @@ const textMatchesEngine: 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-matches" engine expects a regexp body and optional regexp flags`); - const matcher = createRegexTextMatcher(args[0], args.length === 2 ? args[1] : undefined); + const re = new RegExp(args[0], args.length === 2 ? args[1] : undefined); + const matcher = (elementText: ElementText) => re.test(elementText.full); return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) === 'self'; }, }; @@ -460,7 +468,8 @@ const hasTextEngine: SelectorEngine = { throw new Error(`"has-text" engine expects a single string`); if (shouldSkipForTextMatching(element)) return false; - const matcher = createLaxTextMatcher(args[0]); + const text = normalizeWhiteSpace(args[0]).toLowerCase(); + const matcher = (elementText: ElementText) => normalizeWhiteSpace(elementText.full).toLowerCase().includes(text); return matcher(elementText((evaluator as SelectorEvaluatorImpl)._cacheText, element)); }, }; diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index 3cf5e7e51f..2451a99514 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { cssEscape, escapeForAttributeSelector, escapeForTextSelector } from '../../utils/isomorphic/stringUtils'; +import { cssEscape, escapeForAttributeSelector, escapeForTextSelector, normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils'; import { type InjectedScript } from './injectedScript'; import { getAriaRole, getElementAccessibleName } from './roleUtils'; import { elementText } from './selectorUtils'; @@ -216,7 +216,7 @@ function buildCandidates(injectedScript: InjectedScript, element: Element, testI function buildTextCandidates(injectedScript: InjectedScript, element: Element, isTargetNode: boolean, accessibleNameCache: Map): SelectorToken[][] { if (element.nodeName === 'SELECT') return []; - const text = elementText(injectedScript._evaluator._cacheText, element).full.trim().replace(/\s+/g, ' ').substring(0, 80); + const text = normalizeWhiteSpace(elementText(injectedScript._evaluator._cacheText, element).full).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 index 073e900fae..edfce2a34a 100644 --- a/packages/playwright-core/src/server/injected/selectorUtils.ts +++ b/packages/playwright-core/src/server/injected/selectorUtils.ts @@ -50,38 +50,6 @@ export function matchesAttributePart(value: any, attr: AttributeSelectorPart) { 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 createStrictFullTextMatcher(text: string): TextMatcher { - text = text.trim().replace(/\s+/g, ' '); - return (elementText: ElementText) => { - return elementText.full.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 === 'NOSCRIPT' || element.nodeName === 'STYLE' || document.head && document.head.contains(element); } diff --git a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts index e612e2d1ef..91fb7b82b2 100644 --- a/packages/playwright-core/src/utils/isomorphic/stringUtils.ts +++ b/packages/playwright-core/src/utils/isomorphic/stringUtils.ts @@ -63,6 +63,10 @@ function cssEscapeOne(s: string, i: number): string { return '\\' + s.charAt(i); } +export function normalizeWhiteSpace(text: string): string { + return text.replace(/\u200b/g, '').trim().replace(/\s+/g, ' '); +} + export function escapeForTextSelector(text: string | RegExp, exact: boolean): string { if (typeof text !== 'string') return String(text);