diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 3cdce8fc41877..2efb23a081a55 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -1385,7 +1385,7 @@ function createTextMatcher(selector: string, internal: boolean): { matcher: Text selector = normalizeWhiteSpace(selector); if (strict) { if (internal) - return { kind: 'strict', matcher: (elementText: ElementText) => normalizeWhiteSpace(elementText.full) === selector }; + return { kind: 'strict', matcher: (elementText: ElementText) => elementText.normalized === selector }; const strictTextNodeMatcher = (elementText: ElementText) => { if (!selector && !elementText.immediate.length) @@ -1395,7 +1395,7 @@ function createTextMatcher(selector: string, internal: boolean): { matcher: Text return { matcher: strictTextNodeMatcher, kind: 'strict' }; } selector = selector.toLowerCase(); - return { kind: 'lax', matcher: (elementText: ElementText) => normalizeWhiteSpace(elementText.full).toLowerCase().includes(selector) }; + return { kind: 'lax', matcher: (elementText: ElementText) => elementText.normalized.toLowerCase().includes(selector) }; } class ExpectedTextMatcher { diff --git a/packages/playwright-core/src/server/injected/recorder.ts b/packages/playwright-core/src/server/injected/recorder.ts index f88bddd528596..b4cf40b5d5eb5 100644 --- a/packages/playwright-core/src/server/injected/recorder.ts +++ b/packages/playwright-core/src/server/injected/recorder.ts @@ -585,7 +585,7 @@ class TextAssertionTool implements RecorderTool { name: 'assertText', selector: this._hoverHighlight.selector, signals: [], - text: normalizeWhiteSpace(elementText(this._textCache, target).full), + text: elementText(this._textCache, target).normalized, substring: true, }; } @@ -653,7 +653,7 @@ class TextAssertionTool implements RecorderTool { if (!target) return; action.text = newValue; - const targetText = normalizeWhiteSpace(elementText(this._textCache, target).full); + const targetText = elementText(this._textCache, target).normalized; const matches = newValue && targetText.includes(newValue); textElement.classList.toggle('does-not-match', !matches); }; diff --git a/packages/playwright-core/src/server/injected/selectorEvaluator.ts b/packages/playwright-core/src/server/injected/selectorEvaluator.ts index 43abbcd8b4ff9..d2f481526fd95 100644 --- a/packages/playwright-core/src/server/injected/selectorEvaluator.ts +++ b/packages/playwright-core/src/server/injected/selectorEvaluator.ts @@ -450,7 +450,7 @@ const textEngine: SelectorEngine = { if (args.length !== 1 || typeof args[0] !== 'string') throw new Error(`"text" engine expects a single string`); const text = normalizeWhiteSpace(args[0]).toLowerCase(); - const matcher = (elementText: ElementText) => normalizeWhiteSpace(elementText.full).toLowerCase().includes(text); + const matcher = (elementText: ElementText) => elementText.normalized.toLowerCase().includes(text); return elementMatchesText((evaluator as SelectorEvaluatorImpl)._cacheText, element, matcher) === 'self'; }, }; @@ -486,7 +486,7 @@ const hasTextEngine: SelectorEngine = { if (shouldSkipForTextMatching(element)) return false; const text = normalizeWhiteSpace(args[0]).toLowerCase(); - const matcher = (elementText: ElementText) => normalizeWhiteSpace(elementText.full).toLowerCase().includes(text); + const matcher = (elementText: ElementText) => elementText.normalized.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 edeb2b5b8ea9d..eccef270f6212 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, normalizeWhiteSpace, quoteCSSAttributeValue } from '../../utils/isomorphic/stringUtils'; +import { cssEscape, escapeForAttributeSelector, escapeForTextSelector, quoteCSSAttributeValue } from '../../utils/isomorphic/stringUtils'; import { closestCrossShadow, isInsideScope, parentElementOrShadowHost } from './domUtils'; import type { InjectedScript } from './injectedScript'; import { getAriaRole, getElementAccessibleName, beginAriaCaches, endAriaCaches } from './roleUtils'; @@ -237,7 +237,7 @@ function buildNoTextCandidates(injectedScript: InjectedScript, element: Element, const labels = getElementLabels(injectedScript._evaluator._cacheText, element); for (const label of labels) { - const labelText = label.full.trim(); + const labelText = label.normalized; candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(labelText, true), score: kLabelScoreExact }); for (const alternative of suitableTextAlternatives(labelText)) candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(alternative.text, false), score: kLabelScore - alternative.scoreBouns }); @@ -281,7 +281,7 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(alternative.text, false)}]`, score: kAltTextScore - alternative.scoreBouns }]); } - const text = normalizeWhiteSpace(elementText(injectedScript._evaluator._cacheText, element).full); + const text = elementText(injectedScript._evaluator._cacheText, element).normalized; if (text) { const alternatives = suitableTextAlternatives(text); if (isTargetNode) { diff --git a/packages/playwright-core/src/server/injected/selectorUtils.ts b/packages/playwright-core/src/server/injected/selectorUtils.ts index 45e27451ef3d0..746e12af9d177 100644 --- a/packages/playwright-core/src/server/injected/selectorUtils.ts +++ b/packages/playwright-core/src/server/injected/selectorUtils.ts @@ -15,6 +15,7 @@ */ import type { AttributeSelectorPart } from '../../utils/isomorphic/selectorParser'; +import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils'; import { getAriaLabelledByElements } from './roleUtils'; export function matchesComponentAttribute(obj: any, attr: AttributeSelectorPart) { @@ -56,17 +57,17 @@ export function shouldSkipForTextMatching(element: Element | ShadowRoot) { return element.nodeName === 'SCRIPT' || element.nodeName === 'NOSCRIPT' || element.nodeName === 'STYLE' || document.head && document.head.contains(element); } -export type ElementText = { full: string, immediate: string[] }; +export type ElementText = { full: string, normalized: 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: [] }; + value = { full: '', normalized: '', immediate: [] }; if (!shouldSkipForTextMatching(root)) { let currentImmediate = ''; if ((root instanceof HTMLInputElement) && (root.type === 'submit' || root.type === 'button')) { - value = { full: root.value, immediate: [root.value] }; + value = { full: root.value, normalized: normalizeWhiteSpace(root.value), immediate: [root.value] }; } else { for (let child = root.firstChild; child; child = child.nextSibling) { if (child.nodeType === Node.TEXT_NODE) { @@ -84,6 +85,8 @@ export function elementText(cache: Map, root: value.immediate.push(currentImmediate); if ((root as Element).shadowRoot) value.full += elementText(cache, (root as Element).shadowRoot!).full; + if (value.full) + value.normalized = normalizeWhiteSpace(value.full); } } cache.set(root, value); @@ -111,7 +114,7 @@ export function getElementLabels(textCache: Map elementText(textCache, label)); const ariaLabel = element.getAttribute('aria-label'); if (ariaLabel !== null && !!ariaLabel.trim()) - return [{ full: ariaLabel, immediate: [ariaLabel] }]; + return [{ full: ariaLabel, normalized: normalizeWhiteSpace(ariaLabel), immediate: [ariaLabel] }]; // https://html.spec.whatwg.org/multipage/forms.html#category-label const isNonHiddenInput = element.nodeName === 'INPUT' && (element as HTMLInputElement).type !== 'hidden';