From 368941457a82da112aa8610107e25f4bde94339a Mon Sep 17 00:00:00 2001 From: Dmitry Gozman Date: Fri, 31 Jul 2026 10:07:58 +0100 Subject: [PATCH] feat(codegen): do not use contenteditable text in generated fill selectors (#42059) --- packages/injected/src/injectedScript.ts | 2 +- packages/injected/src/recorder/recorder.ts | 11 +- packages/injected/src/roleSelectorEngine.ts | 2 +- packages/injected/src/roleUtils.ts | 52 ++++++--- packages/injected/src/selectorGenerator.ts | 102 ++++++------------ packages/injected/src/selectorUtils.ts | 9 +- tests/library/inspector/cli-codegen-1.spec.ts | 20 ++++ tests/library/role-utils.spec.ts | 2 +- tests/library/selector-generator.spec.ts | 72 +++++++------ 9 files changed, 147 insertions(+), 125 deletions(-) diff --git a/packages/injected/src/injectedScript.ts b/packages/injected/src/injectedScript.ts index 415f9cd58f786..42094de5185fb 100644 --- a/packages/injected/src/injectedScript.ts +++ b/packages/injected/src/injectedScript.ts @@ -1620,7 +1620,7 @@ export class InjectedScript { } else if (expression === 'to.have.accessible.name') { received = getElementAccessibleNameText(element, false /* includeHidden */); } else if (expression === 'to.have.accessible.description') { - received = getElementAccessibleDescription(element, false /* includeHidden */); + received = getElementAccessibleDescription(element, false /* includeHidden */).text; } else if (expression === 'to.have.accessible.error.message') { received = getElementAccessibleErrorMessage(element); } else if (expression === 'to.have.role') { diff --git a/packages/injected/src/recorder/recorder.ts b/packages/injected/src/recorder/recorder.ts index 26547100b2d14..d06c3cc9dd440 100644 --- a/packages/injected/src/recorder/recorder.ts +++ b/packages/injected/src/recorder/recorder.ts @@ -123,7 +123,7 @@ class InspectTool implements RecorderTool { let model: HighlightModel | null = null; if (this._hoveredElement) { - const generated = this._recorder.injectedScript.generateSelector(this._hoveredElement, { testIdAttributeName: this._recorder.state.testIdAttributeName, multiple: false }); + const generated = this._recorder.injectedScript.generateSelector(this._hoveredElement, { testIdAttributeName: this._recorder.state.testIdAttributeName }); model = { selector: generated.selector, elements: generated.elements, @@ -403,9 +403,12 @@ class RecordActionTool implements RecorderTool { return; } + // By the time the input event arrives, the contenteditable already contains the new text. + // Generate a selector that does not depend on that text, so that it works before the fill. + const selector = target.isContentEditable ? this._selectorForElement(target, { noText: true }) : this._activeSelectorForEvent(event); this._recordAction({ name: 'fill', - selector: this._activeSelectorForEvent(event), + selector, text: target.isContentEditable ? target.innerText : (target as HTMLInputElement).value, }); } @@ -561,8 +564,8 @@ class RecordActionTool implements RecorderTool { consumeEvent(event); } - private _selectorForElement(element: HTMLElement): string { - return this._recorder.injectedScript.generateSelector(element, { testIdAttributeName: this._recorder.state.testIdAttributeName }).selector; + private _selectorForElement(element: HTMLElement, options?: { noText?: boolean }): string { + return this._recorder.injectedScript.generateSelector(element, { ...options, testIdAttributeName: this._recorder.state.testIdAttributeName }).selector; } private _modelForElement(element: HTMLElement): HighlightModelWithSelector | null { diff --git a/packages/injected/src/roleSelectorEngine.ts b/packages/injected/src/roleSelectorEngine.ts index ad3c3a5ac06fc..fe83ca1dce717 100644 --- a/packages/injected/src/roleSelectorEngine.ts +++ b/packages/injected/src/roleSelectorEngine.ts @@ -175,7 +175,7 @@ function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: bo } if (options.description !== undefined) { // Always normalize whitespace in the accessible description. - const accessibleDescription = normalizeWhiteSpace(getElementAccessibleDescription(element, !!options.includeHidden)); + const accessibleDescription = normalizeWhiteSpace(getElementAccessibleDescription(element, !!options.includeHidden).text); if (typeof options.description === 'string') options.description = normalizeWhiteSpace(options.description); // internal:role assumes that [description="foo"i] also means substring. diff --git a/packages/injected/src/roleUtils.ts b/packages/injected/src/roleUtils.ts index a0e7f43339a2b..a05f4d5e62df5 100644 --- a/packages/injected/src/roleUtils.ts +++ b/packages/injected/src/roleUtils.ts @@ -510,26 +510,32 @@ function allowsNameFromContent(role: string, targetDescendant: boolean) { return alwaysAllowsNameFromContent || descendantAllowsNameFromContent; } -function computeAccessibleNameComposite(element: Element, includeHidden: boolean, collectElements: boolean): CompositeString { +export type AccessibleName = CompositeString & { + derivedFromContent: boolean, +}; + +function computeAccessibleNameComposite(element: Element, includeHidden: boolean, collectElements: boolean): AccessibleName { // https://w3c.github.io/accname/#computation-steps // step 1. // https://w3c.github.io/aria/#namefromprohibited const elementProhibitsNaming = ['caption', 'code', 'definition', 'deletion', 'emphasis', 'generic', 'insertion', 'mark', 'paragraph', 'presentation', 'strong', 'subscript', 'suggestion', 'superscript', 'term', 'time'].includes(getAriaRole(element) || ''); if (elementProhibitsNaming) - return emptyCompositeString(); + return { ...emptyCompositeString(), derivedFromContent: false }; // step 2. + const outDerivedFromContent = { value: false }; const result = getTextAlternativeInternal(element, { includeHidden, collectElements, + outDerivedFromContent, visitedElements: new Set(), embeddedInTargetElement: 'self', }); - return { text: asFlatString(result.text), elements: result.elements }; + return { text: asFlatString(result.text), elements: result.elements, derivedFromContent: outDerivedFromContent.value }; } -export function getElementAccessibleName(element: Element, includeHidden: boolean): CompositeString { +export function getElementAccessibleName(element: Element, includeHidden: boolean): AccessibleName { const cache = (includeHidden ? cacheAccessibleNameHidden : cacheAccessibleName); let accessibleName = cache?.get(element); if (accessibleName === undefined) { @@ -553,31 +559,37 @@ export function getElementAccessibleNameText(element: Element, includeHidden: bo return text; } -export function getElementAccessibleDescription(element: Element, includeHidden: boolean): string { +export type AccessibleDescription = { + text: string, + derivedFromContent: boolean, +}; + +export function getElementAccessibleDescription(element: Element, includeHidden: boolean): AccessibleDescription { const cache = (includeHidden ? cacheAccessibleDescriptionHidden : cacheAccessibleDescription); let accessibleDescription = cache?.get(element); if (accessibleDescription === undefined) { // https://w3c.github.io/accname/#mapping_additional_nd_description // https://www.w3.org/TR/html-aam-1.0/#accdesc-computation - accessibleDescription = ''; + accessibleDescription = { text: '', derivedFromContent: false }; if (element.hasAttribute('aria-describedby')) { // precedence 1 const describedBy = getIdRefs(element, element.getAttribute('aria-describedby')); - accessibleDescription = asFlatString(describedBy.map(ref => getTextAlternativeInternal(ref, { + accessibleDescription.text = asFlatString(describedBy.map(ref => getTextAlternativeInternal(ref, { includeHidden, visitedElements: new Set(), embeddedInDescribedBy: { element: ref, hidden: isElementHiddenForAria(ref) }, }).text).join(' ')); + accessibleDescription.derivedFromContent = describedBy.some(ref => ref === element || element.contains(ref)); } else if (element.hasAttribute('aria-description')) { // precedence 2 - accessibleDescription = asFlatString(element.getAttribute('aria-description') || ''); + accessibleDescription.text = asFlatString(element.getAttribute('aria-description') || ''); } else { // TODO: handle precedence 3 - html-aam-specific cases like table>caption. // https://www.w3.org/TR/html-aam-1.0/#accdesc-computation // precedence 4 - accessibleDescription = asFlatString(element.getAttribute('title') || ''); + accessibleDescription.text = asFlatString(element.getAttribute('title') || ''); } cache?.set(element, accessibleDescription); @@ -658,6 +670,9 @@ type AccessibleNameOptions = { visitedElements: Set, collectElements?: boolean, includeHidden?: boolean, + // Set to true during the computation when the name is derived from the content of the target + // element, e.g. inner text or aria-labelledby pointing inside the element. + outDerivedFromContent?: { value: boolean }, embeddedInDescribedBy?: { element: Element, hidden: boolean }, embeddedInLabelledBy?: { element: Element, hidden: boolean }, embeddedInLabel?: { element: Element, hidden: boolean }, @@ -665,6 +680,10 @@ type AccessibleNameOptions = { embeddedInTargetElement?: 'self' | 'descendant', }; +function insideTargetElement(options: AccessibleNameOptions) { + return options.embeddedInTargetElement === 'self' || options.embeddedInTargetElement === 'descendant'; +} + function getTextAlternativeInternal(element: Element, options: AccessibleNameOptions): CompositeString { if (options.visitedElements.has(element)) return emptyCompositeString(); @@ -705,8 +724,11 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt embeddedInLabel: undefined, embeddedInNativeTextAlternative: undefined, })), ' ', options.collectElements); - if (accessibleName.text) + if (accessibleName.text) { + if (options.outDerivedFromContent && insideTargetElement(options) && (labelledBy || []).some(ref => ref === element || element.contains(ref))) + options.outDerivedFromContent.value = true; return accessibleName; + } } const role = getAriaRole(element) || ''; @@ -972,6 +994,8 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt // So we follow the spec everywhere except for the target element itself. This can probably be improved. const maybeTrimmedAccessibleName = options.embeddedInTargetElement === 'self' ? trimFlatString(accessibleName.text) : accessibleName.text; if (maybeTrimmedAccessibleName) { + if (options.outDerivedFromContent && insideTargetElement(options) && trimFlatString(accessibleName.text)) + options.outDerivedFromContent.value = true; // This element owns the accumulated content - record it alongside the descendants it was computed from. accessibleName.elements?.add(element); return accessibleName; @@ -1242,12 +1266,12 @@ export function receivesPointerEvents(element: Element): boolean { return result; } -let cacheAccessibleName: Map | undefined; -let cacheAccessibleNameHidden: Map | undefined; +let cacheAccessibleName: Map | undefined; +let cacheAccessibleNameHidden: Map | undefined; let cacheAccessibleNameText: Map | undefined; let cacheAccessibleNameTextHidden: Map | undefined; -let cacheAccessibleDescription: Map | undefined; -let cacheAccessibleDescriptionHidden: Map | undefined; +let cacheAccessibleDescription: Map | undefined; +let cacheAccessibleDescriptionHidden: Map | undefined; let cacheAccessibleErrorMessage: Map | undefined; let cacheIsHidden: Map | undefined; let cachePseudoContent: Map | undefined; diff --git a/packages/injected/src/selectorGenerator.ts b/packages/injected/src/selectorGenerator.ts index ace5636aa239e..fd6f85f9b582b 100644 --- a/packages/injected/src/selectorGenerator.ts +++ b/packages/injected/src/selectorGenerator.ts @@ -18,7 +18,7 @@ import { splitTestIdAttributeNames } from '@isomorphic/locatorUtils'; import { escapeForAttributeSelector, escapeForTextSelector, escapeRegExp, quoteCSSAttributeValue } from '@isomorphic/stringUtils'; import { beginDOMCaches, closestCrossShadow, endDOMCaches, isElementVisible, isInsideScope, parentElementOrShadowHost } from './domUtils'; -import { beginAriaCaches, endAriaCaches, getAriaRole, getElementAccessibleDescription, getElementAccessibleNameText } from './roleUtils'; +import { beginAriaCaches, endAriaCaches, getAriaRole, getElementAccessibleDescription, getElementAccessibleName } from './roleUtils'; import { elementText, getElementLabels } from './selectorUtils'; import type { InjectedScript } from './injectedScript'; @@ -72,18 +72,18 @@ export type GenerateSelectorOptions = { omitInternalEngines?: boolean; root?: Element | Document; forTextExpect?: boolean; - multiple?: boolean; + noText?: boolean; // Do not use text of the target element in the generated selector. }; -export function generateSelector(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): { selector: string, selectors: string[], elements: Element[] } { +export function generateSelector(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): { selector: string, elements: Element[] } { injectedScript._evaluator.begin(); const cache: Cache = { allowText: new Map(), disallowText: new Map() }; beginAriaCaches(); beginDOMCaches(); try { - let selectors: string[] = []; + let targetTokens: SelectorToken[]; if (options.forTextExpect) { - let targetTokens = cssFallback(injectedScript, targetElement.ownerDocument.documentElement, options); + targetTokens = cssFallback(injectedScript, targetElement.ownerDocument.documentElement, options); for (let element: Element | undefined = targetElement; element; element = parentElementOrShadowHost(element)) { const tokens = generateSelectorFor(cache, injectedScript, element, { ...options, noText: true }); if (!tokens) @@ -94,7 +94,6 @@ export function generateSelector(injectedScript: InjectedScript, targetElement: break; } } - selectors = [joinTokens(targetTokens)]; } else { // Note: this matches InjectedScript.retarget(). if (!targetElement.matches('input,textarea,select') && !(targetElement as any).isContentEditable) { @@ -102,38 +101,12 @@ export function generateSelector(injectedScript: InjectedScript, targetElement: if (interactiveParent && isElementVisible(interactiveParent)) targetElement = interactiveParent; } - if (options.multiple) { - const withText = generateSelectorFor(cache, injectedScript, targetElement, options); - const withoutText = generateSelectorFor(cache, injectedScript, targetElement, { ...options, noText: true }); - let tokens = [withText, withoutText]; - - // Clear cache to re-generate without css id. - cache.allowText.clear(); - cache.disallowText.clear(); - - if (withText && hasCSSIdToken(withText)) - tokens.push(generateSelectorFor(cache, injectedScript, targetElement, { ...options, noCSSId: true })); - if (withoutText && hasCSSIdToken(withoutText)) - tokens.push(generateSelectorFor(cache, injectedScript, targetElement, { ...options, noText: true, noCSSId: true })); - - tokens = tokens.filter(Boolean); - if (!tokens.length) { - const css = cssFallback(injectedScript, targetElement, options); - tokens.push(css); - if (hasCSSIdToken(css)) - tokens.push(cssFallback(injectedScript, targetElement, { ...options, noCSSId: true })); - } - selectors = [...new Set(tokens.map(t => joinTokens(t!)))]; - } else { - const targetTokens = generateSelectorFor(cache, injectedScript, targetElement, options) || cssFallback(injectedScript, targetElement, options); - selectors = [joinTokens(targetTokens)]; - } + targetTokens = generateSelectorFor(cache, injectedScript, targetElement, options) || cssFallback(injectedScript, targetElement, options); } - const selector = selectors[0]; + const selector = joinTokens(targetTokens); const parsedSelector = injectedScript.parseSelector(selector); return { selector, - selectors, elements: injectedScript.querySelectorAll(parsedSelector, options.root ?? targetElement.ownerDocument) }; } finally { @@ -143,7 +116,7 @@ export function generateSelector(injectedScript: InjectedScript, targetElement: } } -type InternalOptions = GenerateSelectorOptions & { noText?: boolean, noCSSId?: boolean, isRecursive?: boolean }; +type InternalOptions = GenerateSelectorOptions & { isRecursive?: boolean }; function generateSelectorFor(cache: Cache, injectedScript: InjectedScript, targetElement: Element, options: InternalOptions): SelectorToken[] | null { if (options.root && !isInsideScope(options.root, targetElement)) @@ -161,10 +134,8 @@ function generateSelectorFor(cache: Cache, injectedScript: InjectedScript, targe }; const candidates: { candidate: SelectorToken[], isTextCandidate: boolean }[] = []; - if (!options.noText) { - for (const candidate of buildTextCandidates(injectedScript, targetElement, !options.isRecursive)) - candidates.push({ candidate, isTextCandidate: true }); - } + for (const candidate of buildTextCandidates(injectedScript, targetElement, !options.isRecursive, options)) + candidates.push({ candidate, isTextCandidate: true }); for (const token of buildNoTextCandidates(injectedScript, targetElement, options)) { if (options.omitInternalEngines && token.engine.startsWith('internal:')) continue; @@ -241,11 +212,9 @@ function buildNoTextCandidates(injectedScript: InjectedScript, element: Element, candidates.push({ engine: 'css', selector: `[${attr}=${quoteCSSAttributeValue(element.getAttribute(attr)!)}]`, score: kOtherTestIdScore }); } - if (!options.noCSSId) { - const idAttr = element.getAttribute('id'); - if (idAttr && !isGuidLike(idAttr)) - candidates.push({ engine: 'css', selector: makeSelectorForId(idAttr), score: kCSSIdScore }); - } + const idAttr = element.getAttribute('id'); + if (idAttr && !isGuidLike(idAttr)) + candidates.push({ engine: 'css', selector: makeSelectorForId(idAttr), score: kCSSIdScore }); candidates.push({ engine: 'css', selector: escapeNodeName(element), score: kCSSTagNameScore }); } @@ -281,7 +250,7 @@ function buildNoTextCandidates(injectedScript: InjectedScript, element: Element, } } - const labels = getElementLabels(injectedScript._evaluator._cacheText, element); + const labels = getElementLabels(injectedScript._evaluator._cacheText, element, { skipRefsInsideElement: options.noText }); for (const label of labels) { const labelText = label.normalized; candidates.push({ engine: 'internal:label', selector: escapeForTextSelector(labelText, true), score: kLabelScoreExact }); @@ -308,26 +277,28 @@ function buildNoTextCandidates(injectedScript: InjectedScript, element: Element, return candidates; } -function buildTextCandidates(injectedScript: InjectedScript, element: Element, isTargetNode: boolean): SelectorToken[][] { +function buildTextCandidates(injectedScript: InjectedScript, element: Element, isTargetNode: boolean, options: InternalOptions): SelectorToken[][] { if (element.nodeName === 'SELECT') return []; const candidates: SelectorToken[][] = []; - const title = element.getAttribute('title'); - if (title) { - candidates.push([{ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(title, true)}]`, score: kTitleScoreExact }]); - for (const alternative of suitableTextAlternatives(title)) - candidates.push([{ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(alternative.text, false)}]`, score: kTitleScore - alternative.scoreBonus }]); - } + if (!options.noText) { + const title = element.getAttribute('title'); + if (title) { + candidates.push([{ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(title, true)}]`, score: kTitleScoreExact }]); + for (const alternative of suitableTextAlternatives(title)) + candidates.push([{ engine: 'internal:attr', selector: `[title=${escapeForAttributeSelector(alternative.text, false)}]`, score: kTitleScore - alternative.scoreBonus }]); + } - const alt = element.getAttribute('alt'); - if (alt && ['APPLET', 'AREA', 'IMG', 'INPUT'].includes(element.nodeName)) { - candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(alt, true)}]`, score: kAltTextScoreExact }]); - for (const alternative of suitableTextAlternatives(alt)) - candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(alternative.text, false)}]`, score: kAltTextScore - alternative.scoreBonus }]); + const alt = element.getAttribute('alt'); + if (alt && ['APPLET', 'AREA', 'IMG', 'INPUT'].includes(element.nodeName)) { + candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(alt, true)}]`, score: kAltTextScoreExact }]); + for (const alternative of suitableTextAlternatives(alt)) + candidates.push([{ engine: 'internal:attr', selector: `[alt=${escapeForAttributeSelector(alternative.text, false)}]`, score: kAltTextScore - alternative.scoreBonus }]); + } } - const text = elementText(injectedScript._evaluator._cacheText, element).normalized; + const text = options.noText ? '' : elementText(injectedScript._evaluator._cacheText, element).normalized; const textAlternatives = text ? suitableTextAlternatives(text) : []; if (text) { if (isTargetNode) { @@ -348,14 +319,16 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i const ariaRole = getAriaRole(element); if (ariaRole && !['none', 'presentation'].includes(ariaRole)) { - const ariaName = getElementAccessibleNameText(element, false); + const accessibleName = getElementAccessibleName(element, false); + const ariaName = options.noText && accessibleName.derivedFromContent ? '' : accessibleName.text; + const accessibleDescription = getElementAccessibleDescription(element, false); + const ariaDescription = options.noText && accessibleDescription.derivedFromContent ? '' : accessibleDescription.text; // \p{Co} means "Private Use" characters - these are often used for icon fonts and make for bad locators. if (ariaName && !ariaName.match(/^\p{Co}+$/u)) { const roleToken = { engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: kRoleWithNameScoreExact }; candidates.push([roleToken]); for (const alternative of suitableTextAlternatives(ariaName)) candidates.push([{ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(alternative.text, false)}]`, score: kRoleWithNameScore - alternative.scoreBonus }]); - const ariaDescription = getElementAccessibleDescription(element, false); if (ariaDescription) { candidates.push([{ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}][description=${escapeForAttributeSelector(ariaDescription, true)}]`, score: kRoleWithNameScoreExact + 1 }]); for (const alternative of suitableTextAlternatives(ariaName)) @@ -363,12 +336,11 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i } } else { const roleToken = { engine: 'internal:role', selector: `${ariaRole}`, score: kRoleWithoutNameScore }; - const ariaDescription = getElementAccessibleDescription(element, false); if (ariaDescription) candidates.push([{ engine: 'internal:role', selector: `${ariaRole}[description=${escapeForAttributeSelector(ariaDescription, true)}]`, score: kRoleWithoutNameScore + 1 }]); for (const alternative of textAlternatives) candidates.push([roleToken, { engine: 'internal:has-text', selector: escapeForTextSelector(alternative.text, false), score: kTextScore - alternative.scoreBonus }]); - if (isTargetNode && text.length <= 80) { + if (!options.noText && isTargetNode && text.length <= 80) { // Do not use regex for parent elements (for performance). const re = new RegExp('^' + escapeRegExp(text) + '$'); candidates.push([roleToken, { engine: 'internal:has-text', selector: escapeForTextSelector(re, false), score: kTextScoreRegex }]); @@ -384,10 +356,6 @@ function makeSelectorForId(id: string) { return /^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(id) ? '#' + id : `[id=${quoteCSSAttributeValue(id)}]`; } -function hasCSSIdToken(tokens: SelectorToken[]) { - return tokens.some(token => token.engine === 'css' && (token.selector.startsWith('#') || token.selector.startsWith('[id="'))); -} - function cssFallback(injectedScript: InjectedScript, targetElement: Element, options: InternalOptions): SelectorToken[] { const root: Node = options.root ?? targetElement.ownerDocument; const tokens: string[] = []; @@ -416,7 +384,7 @@ function cssFallback(injectedScript: InjectedScript, targetElement: Element, opt let bestTokenForLevel: string = ''; // Element ID is the strongest signal, use it. - if (element.id && !options.noCSSId) { + if (element.id) { const token = makeSelectorForId(element.id); const selector = uniqueCSSSelector(token); if (selector) diff --git a/packages/injected/src/selectorUtils.ts b/packages/injected/src/selectorUtils.ts index 2a02df62ff9f6..89ef0a1c90277 100644 --- a/packages/injected/src/selectorUtils.ts +++ b/packages/injected/src/selectorUtils.ts @@ -112,10 +112,13 @@ export function elementMatchesText(cache: Map return 'self'; } -export function getElementLabels(textCache: Map, element: Element): ElementText[] { - const labels = getAriaLabelledByElements(element); - if (labels) +export function getElementLabels(textCache: Map, element: Element, options?: { skipRefsInsideElement?: boolean }): ElementText[] { + let labels = getAriaLabelledByElements(element); + if (labels) { + if (options?.skipRefsInsideElement) + labels = labels.filter(label => label !== element && !element.contains(label)); return labels.map(label => elementText(textCache, label)); + } const ariaLabel = element.getAttribute('aria-label'); if (ariaLabel !== null && !!ariaLabel.trim()) return [{ full: ariaLabel, normalized: normalizeWhiteSpace(ariaLabel), immediate: [ariaLabel] }]; diff --git a/tests/library/inspector/cli-codegen-1.spec.ts b/tests/library/inspector/cli-codegen-1.spec.ts index d228c7b7d7552..9f42cf44febfe 100644 --- a/tests/library/inspector/cli-codegen-1.spec.ts +++ b/tests/library/inspector/cli-codegen-1.spec.ts @@ -429,6 +429,26 @@ await page.Locator("#input").FillAsync(\"てすと\");`); expect(message.text()).toBe('John Doe'); }); + test('should fill [contentEditable] with initial text', async ({ openRecorder }) => { + const { page, recorder } = await openRecorder(); + + await recorder.setContentAndWait(` +

Initial text

+

More text

+ `); + + const [message, sources] = await Promise.all([ + page.waitForEvent('console', msg => msg.type() !== 'error'), + recorder.waitForOutput('JavaScript', 'fill'), + page.fill('section[aria-label=First] h1', 'John Doe') + ]); + // The selector should not be derived from the text that is being changed by the fill, + // but can use the accessible name of the parent element. + expect(sources.get('JavaScript')!.text).toContain(` + await page.getByRole('region', { name: 'First' }).getByRole('heading').fill('John Doe');`); + expect(message.text()).toBe('John Doe'); + }); + test('should press', async ({ openRecorder }) => { const { page, recorder } = await openRecorder(); diff --git a/tests/library/role-utils.spec.ts b/tests/library/role-utils.spec.ts index 81be2a9f163c7..4edeb18d7b3f8 100644 --- a/tests/library/role-utils.spec.ts +++ b/tests/library/role-utils.spec.ts @@ -85,7 +85,7 @@ for (let range = 0; range <= ranges.length; range++) { if (!element) throw new Error(`Unable to resolve "${step.selector}"`); const injected = (window as any).__injectedScript; - const received = step.property === 'name' ? injected.utils.getElementAccessibleNameText(element) : injected.utils.getElementAccessibleDescription(element); + const received = step.property === 'name' ? injected.utils.getElementAccessibleNameText(element) : injected.utils.getElementAccessibleDescription(element).text; result.push({ selector: step.selector, expected: step.value, received }); } return result; diff --git a/tests/library/selector-generator.spec.ts b/tests/library/selector-generator.spec.ts index a8a233940c9a8..f81171f15d01d 100644 --- a/tests/library/selector-generator.spec.ts +++ b/tests/library/selector-generator.spec.ts @@ -28,8 +28,8 @@ async function generate(pageOrFrame: Page | Frame, target: string, expected?: st }, expected); } -async function generateMultiple(pageOrFrame: Page | Frame, target: string): Promise { - return pageOrFrame.$eval(target, e => (window as any).__injectedScript.generateSelector(e, { multiple: true, testIdAttributeName: 'data-testid' }).selectors); +async function generateNoText(pageOrFrame: Page | Frame, target: string): Promise { + return pageOrFrame.$eval(target, e => (window as any).__injectedScript.generateSelector(e, { noText: true, testIdAttributeName: 'data-testid' }).selector); } it.describe('selector generator', () => { @@ -670,44 +670,51 @@ it.describe('selector generator', () => { }); }); - it('should generate multiple: noText in role', async ({ page }) => { - await page.setContent(` - - `); - expect(await generateMultiple(page, 'button')).toEqual([`internal:role=button[name="Click me"i]`, `internal:role=button`]); + it('should generate noText: no text engine', async ({ page }) => { + await page.setContent(`
Some text
`); + expect(await generateNoText(page, 'div')).toBe(`div`); }); - it('should generate multiple: noText in text', async ({ page }) => { - await page.setContent(` -
Some div
- `); - expect(await generateMultiple(page, 'div')).toEqual([`internal:text="Some div"i`, `div`]); + it('should generate noText: no name from content', async ({ page }) => { + await page.setContent(``); + expect(await generateNoText(page, 'button')).toBe(`internal:role=button`); + }); + + it('should generate noText: name from aria-label', async ({ page }) => { + await page.setContent(``); + expect(await generateNoText(page, 'button')).toBe(`internal:role=button[name="Send message"i]`); + }); + + it('should generate noText: name from external aria-labelledby', async ({ page }) => { + await page.setContent(`Editor
Text
`); + expect(await generateNoText(page, 'div[role=textbox]')).toBe(`internal:role=textbox[name="Editor"i]`); }); - it('should generate multiple: noId', async ({ page }) => { + it('should generate noText: ignore aria-labelledby pointing inside the element', async ({ page }) => { + await page.setContent(`

Title

Text

`); + expect(await generateNoText(page, 'div[role=textbox]')).toBe(`internal:role=textbox`); + }); + + it('should generate noText: contenteditable heading', async ({ page }) => { + await page.setContent(`

Page title

`); + expect(await generateNoText(page, 'h1')).toBe(`internal:role=heading`); + }); + + it('should generate noText: description from external aria-describedby', async ({ page }) => { await page.setContent(` -
-
+ FirstSecond +
foo
+
bar
`); - expect(await generateMultiple(page, '#second button')).toEqual([ - `#second >> internal:role=button[name="Click me"i]`, - `#second >> internal:role=button`, - `internal:role=button[name="Click me"i] >> nth=1`, - `internal:role=button >> nth=1`, - ]); + expect(await generateNoText(page, 'div[aria-describedby=desc1]')).toBe(`internal:role=textbox[name="Editor"i][description="First"i]`); }); - it('should generate multiple: noId noText', async ({ page }) => { + it('should generate noText: ignore aria-describedby pointing inside the element', async ({ page }) => { await page.setContent(` -
Some span
-
Some span
+

First

+
Second
`); - expect(await generateMultiple(page, '#second span')).toEqual([ - `#second >> internal:text="Some span"i`, - `#second span`, - `internal:text="Some span"i >> nth=1`, - `span >> nth=1`, - ]); + expect(await generateNoText(page, 'div[aria-describedby=child]')).toBe(`internal:role=textbox[name="Editor"i] >> nth=0`); }); it('should prefer role with hasText to css with hasText', async ({ page }) => { @@ -723,10 +730,7 @@ it.describe('selector generator', () => { `); - expect(await generateMultiple(page, 'input')).toEqual([ - `internal:role=listitem >> internal:has-text=\"buy flowers\"i >> internal:label=\"Toggle Todo\"i`, - `internal:label=\"Toggle Todo\"i >> nth=0`, - ]); + expect(await generate(page, 'input')).toBe(`internal:role=listitem >> internal:has-text=\"buy flowers\"i >> internal:label=\"Toggle Todo\"i`); }); it('should not use icon fonts aria name', async ({ page }) => {