diff --git a/packages/playwright-core/src/server/injected/domUtils.ts b/packages/playwright-core/src/server/injected/domUtils.ts index b00dd91dfd..cf888248c4 100644 --- a/packages/playwright-core/src/server/injected/domUtils.ts +++ b/packages/playwright-core/src/server/injected/domUtils.ts @@ -46,6 +46,7 @@ function enclosingShadowHost(element: Element): Element | undefined { return parentElementOrShadowHost(element); } +// Assumption: if scope is provided, element must be inside scope's subtree. export function closestCrossShadow(element: Element | undefined, css: string, scope?: Document | Element): Element | undefined { while (element) { const closest = element.closest(css); diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index aa7e2bc1cf..41a4846c42 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -1341,8 +1341,7 @@ export class InjectedScript { } getElementAccessibleName(element: Element, includeHidden?: boolean): string { - const hiddenCache = new Map(); - return getElementAccessibleName(element, !!includeHidden, hiddenCache); + return getElementAccessibleName(element, !!includeHidden); } getAriaRole(element: Element) { diff --git a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts index d82a819cd5..b647f81b8e 100644 --- a/packages/playwright-core/src/server/injected/roleSelectorEngine.ts +++ b/packages/playwright-core/src/server/injected/roleSelectorEngine.ts @@ -16,7 +16,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 { beginAriaCaches, endAriaCaches, getAriaChecked, getAriaDisabled, getAriaExpanded, getAriaLevel, getAriaPressed, getAriaRole, getAriaSelected, getElementAccessibleName, isElementHiddenForAria, kAriaCheckedRoles, kAriaExpandedRoles, kAriaLevelRoles, kAriaPressedRoles, kAriaSelectedRoles } from './roleUtils'; import { parseAttributeSelector, type AttributeSelectorPart, type AttributeSelectorOperator } from '../../utils/isomorphic/selectorParser'; import { normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils'; @@ -125,7 +125,6 @@ function validateAttributes(attrs: AttributeSelectorPart[], role: string): RoleE } function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: boolean): Element[] { - const hiddenCache = new Map(); const result: Element[] = []; const match = (element: Element) => { if (getAriaRole(element) !== options.role) @@ -143,13 +142,13 @@ function queryRole(scope: SelectorRoot, options: RoleEngineOptions, internal: bo if (options.disabled !== undefined && getAriaDisabled(element) !== options.disabled) return; if (!options.includeHidden) { - const isHidden = isElementHiddenForAria(element, hiddenCache); + const isHidden = isElementHiddenForAria(element); if (isHidden) return; } if (options.name !== undefined) { // Always normalize whitespace in the accessible name. - const accessibleName = normalizeWhiteSpace(getElementAccessibleName(element, !!options.includeHidden, hiddenCache)); + const accessibleName = normalizeWhiteSpace(getElementAccessibleName(element, !!options.includeHidden)); if (typeof options.name === 'string') options.name = normalizeWhiteSpace(options.name); // internal:role assumes that [name="foo"i] also means substring. @@ -185,7 +184,12 @@ export function createRoleEngine(internal: boolean): SelectorEngine { if (!role) throw new Error(`Role must not be empty`); const options = validateAttributes(parsed.attributes, role); - return queryRole(scope, options, internal); + beginAriaCaches(); + try { + return queryRole(scope, options, internal); + } finally { + endAriaCaches(); + } } }; } diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index ad38aeab20..16c3a71f8b 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -235,7 +235,7 @@ function getAriaBoolean(attr: string | null) { // Not implemented: // `Any descendants of elements that have the characteristic "Children Presentational: True"` // https://www.w3.org/TR/wai-aria-1.2/#aria-hidden -export function isElementHiddenForAria(element: Element, cache: Map): boolean { +export function isElementHiddenForAria(element: Element): boolean { if (['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(element.tagName)) return true; const style = getElementComputedStyle(element); @@ -243,7 +243,7 @@ export function isElementHiddenForAria(element: Element, cache: Map): boolean { - if (!cache.has(element)) { - let hidden = false; +function belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element: Element): boolean { + let hidden = cacheIsHidden?.get(element); + if (hidden === undefined) { + hidden = false; // When parent has a shadow root, all light dom children must be assigned to a slot, // otherwise they are not rendered and considered hidden for aria. @@ -278,11 +279,11 @@ function belongsToDisplayNoneOrAriaHiddenOrNonSlotted(element: Element, cache: M if (!hidden) { const parent = parentElementOrShadowHost(element); if (parent) - hidden = belongsToDisplayNoneOrAriaHiddenOrNonSlotted(parent, cache); + hidden = belongsToDisplayNoneOrAriaHiddenOrNonSlotted(parent); } - cache.set(element, hidden); + cacheIsHidden?.set(element, hidden); } - return cache.get(element)!; + return hidden; } function getIdRefs(element: Element, ref: string | null): Element[] { @@ -325,14 +326,14 @@ function queryInAriaOwned(element: Element, selector: string): Element[] { function getPseudoContent(pseudoStyle: CSSStyleDeclaration | undefined) { if (!pseudoStyle) return ''; - const content = pseudoStyle.getPropertyValue('content'); + const content = pseudoStyle.content; if ((content[0] === '\'' && content[content.length - 1] === '\'') || (content[0] === '"' && content[content.length - 1] === '"')) { const unquoted = content.substring(1, content.length - 1); // SPEC DIFFERENCE. // Spec says "CSS textual content, without a space", but we account for display // to pass "name_file-label-inline-block-styles-manual.html" - const display = pseudoStyle.getPropertyValue('display') || 'inline'; + const display = pseudoStyle.display || 'inline'; if (display !== 'inline') return ' ' + unquoted + ' '; return unquoted; @@ -360,31 +361,37 @@ function allowsNameFromContent(role: string, targetDescendant: boolean) { return alwaysAllowsNameFromContent || descendantAllowsNameFromContent; } -export function getElementAccessibleName(element: Element, includeHidden: boolean, hiddenCache: Map): string { - // https://w3c.github.io/accname/#computation-steps +export function getElementAccessibleName(element: Element, includeHidden: boolean): string { + const cache = (includeHidden ? cacheAccessibleNameHidden : cacheAccessibleName); + let accessibleName = cache?.get(element); - // 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 ''; + if (accessibleName === undefined) { + // https://w3c.github.io/accname/#computation-steps + accessibleName = ''; - // step 2. - const accessibleName = normalizeAccessbileName(getElementAccessibleNameInternal(element, { - includeHidden, - hiddenCache, - visitedElements: new Set(), - embeddedInLabelledBy: 'none', - embeddedInLabel: 'none', - embeddedInTextAlternativeElement: false, - embeddedInTargetElement: 'self', - })); + // 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) { + // step 2. + accessibleName = normalizeAccessbileName(getElementAccessibleNameInternal(element, { + includeHidden, + visitedElements: new Set(), + embeddedInLabelledBy: 'none', + embeddedInLabel: 'none', + embeddedInTextAlternativeElement: false, + embeddedInTargetElement: 'self', + })); + } + + cache?.set(element, accessibleName); + } return accessibleName; } type AccessibleNameOptions = { includeHidden: boolean, - hiddenCache: Map, visitedElements: Set, embeddedInLabelledBy: 'none' | 'self' | 'descendant', embeddedInLabel: 'none' | 'self' | 'descendant', @@ -404,7 +411,7 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN }; // step 2a. - if (!options.includeHidden && options.embeddedInLabelledBy !== 'self' && isElementHiddenForAria(element, options.hiddenCache)) { + if (!options.includeHidden && options.embeddedInLabelledBy !== 'self' && isElementHiddenForAria(element)) { options.visitedElements.add(element); return ''; } @@ -668,7 +675,7 @@ function getElementAccessibleNameInternal(element: Element, options: AccessibleN if (skipSlotted && (node as Element | Text).assignedSlot) return; if (node.nodeType === 1 /* Node.ELEMENT_NODE */) { - const display = getElementComputedStyle(node as Element)?.getPropertyValue('display') || 'inline'; + const display = getElementComputedStyle(node as Element)?.display || 'inline'; let token = getElementAccessibleNameInternal(node as Element, childOptions); // SPEC DIFFERENCE. // Spec says "append the result to the accumulated text", assuming "with space". @@ -828,3 +835,23 @@ function hasExplicitAriaDisabled(element: Element | undefined): boolean { // aria-disabled works across shadow boundaries. return hasExplicitAriaDisabled(parentElementOrShadowHost(element)); } + +let cacheAccessibleName: Map | undefined; +let cacheAccessibleNameHidden: Map | undefined; +let cacheIsHidden: Map | undefined; +let cachesCounter = 0; + +export function beginAriaCaches() { + ++cachesCounter; + cacheAccessibleName ??= new Map(); + cacheAccessibleNameHidden ??= new Map(); + cacheIsHidden ??= new Map(); +} + +export function endAriaCaches() { + if (!--cachesCounter) { + cacheAccessibleName = undefined; + cacheAccessibleNameHidden = undefined; + cacheIsHidden = undefined; + } +} diff --git a/packages/playwright-core/src/server/injected/selectorGenerator.ts b/packages/playwright-core/src/server/injected/selectorGenerator.ts index 8b42742c92..a2917faf44 100644 --- a/packages/playwright-core/src/server/injected/selectorGenerator.ts +++ b/packages/playwright-core/src/server/injected/selectorGenerator.ts @@ -17,7 +17,7 @@ import { cssEscape, escapeForAttributeSelector, escapeForTextSelector, normalizeWhiteSpace } from '../../utils/isomorphic/stringUtils'; import { closestCrossShadow, isInsideScope, parentElementOrShadowHost } from './domUtils'; import type { InjectedScript } from './injectedScript'; -import { getAriaRole, getElementAccessibleName } from './roleUtils'; +import { getAriaRole, getElementAccessibleName, beginAriaCaches, endAriaCaches } from './roleUtils'; import { elementText } from './selectorUtils'; type SelectorToken = { @@ -28,8 +28,6 @@ type SelectorToken = { const cacheAllowText = new Map(); const cacheDisallowText = new Map(); -const cacheAccesibleName = new Map(); -const cacheAccesibleNameHidden = new Map(); const kTextScoreRange = 10; const kExactPenalty = kTextScoreRange / 2; @@ -70,6 +68,7 @@ export type GenerateSelectorOptions = { export function generateSelector(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): { selector: string, elements: Element[] } { injectedScript._evaluator.begin(); + beginAriaCaches(); try { targetElement = closestCrossShadow(targetElement, 'button,select,input,[role=button],[role=checkbox],[role=radio],a,[role=link]', options.root) || targetElement; const targetTokens = generateSelectorFor(injectedScript, targetElement, options); @@ -82,8 +81,7 @@ export function generateSelector(injectedScript: InjectedScript, targetElement: } finally { cacheAllowText.clear(); cacheDisallowText.clear(); - cacheAccesibleName.clear(); - cacheAccesibleNameHidden.clear(); + endAriaCaches(); injectedScript._evaluator.end(); } } @@ -274,7 +272,7 @@ function buildTextCandidates(injectedScript: InjectedScript, element: Element, i const ariaRole = getAriaRole(element); if (ariaRole && !['none', 'presentation'].includes(ariaRole)) { - const ariaName = getAccessibleName(element); + const ariaName = getElementAccessibleName(element, false); if (ariaName) { candidates.push([{ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, false)}]`, score: kRoleWithNameScore }]); candidates.push([{ engine: 'internal:role', selector: `${ariaRole}[name=${escapeForAttributeSelector(ariaName, true)}]`, score: kRoleWithNameScoreExact }]); @@ -289,12 +287,6 @@ function makeSelectorForId(id: string) { return /^[a-zA-Z][a-zA-Z0-9\-\_]+$/.test(id) ? '#' + id : `[id="${cssEscape(id)}"]`; } -function getAccessibleName(element: Element) { - if (!cacheAccesibleName.has(element)) - cacheAccesibleName.set(element, getElementAccessibleName(element, false, cacheAccesibleNameHidden)); - return cacheAccesibleName.get(element)!; -} - function cssFallback(injectedScript: InjectedScript, targetElement: Element, options: GenerateSelectorOptions): SelectorToken[] { const root: Node = options.root ?? targetElement.ownerDocument; const tokens: string[] = [];