Skip to content

Commit

Permalink
chore: compute aria text consistently with the role accumulated text (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman authored Oct 18, 2024
1 parent 623a891 commit 29c84a3
Show file tree
Hide file tree
Showing 10 changed files with 1,094 additions and 124 deletions.
6 changes: 5 additions & 1 deletion packages/playwright-core/src/server/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,12 @@ export function parseAriaSnapshot(text: string): AriaTemplateNode {
return { role };
};

const normalizeWhitespace = (text: string) => {
return text.replace(/[\r\n\s\t]+/g, ' ').trim();
};

const valueOrRegex = (value: string): string | RegExp => {
return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : value;
return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : normalizeWhitespace(value);
};

const convert = (object: YamlNode | string): AriaTemplateNode | RegExp | string => {
Expand Down
62 changes: 41 additions & 21 deletions packages/playwright-core/src/server/injected/ariaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@
*/

import { escapeWithQuotes } from '@isomorphic/stringUtils';
import { beginAriaCaches, endAriaCaches, getAriaRole, getElementAccessibleName, isElementIgnoredForAria } from './roleUtils';
import { isElementVisible, isElementStyleVisibilityVisible } from './domUtils';
import { accumulatedElementText, beginAriaCaches, endAriaCaches, getAriaRole, getElementAccessibleName, getPseudoContent, isElementIgnoredForAria } from './roleUtils';
import { isElementVisible, isElementStyleVisibilityVisible, getElementComputedStyle } from './domUtils';

type AriaNode = {
role: string;
name?: string;
children?: (AriaNode | string)[];
children: (AriaNode | string)[];
};

export type AriaTemplateNode = {
Expand All @@ -38,16 +38,20 @@ export function generateAriaTree(rootElement: Element): AriaNode {

const name = role ? getElementAccessibleName(element, false) || undefined : undefined;
const isLeaf = leafRoles.has(role);
const result: AriaNode = { role, name };
if (isLeaf && !name && element.textContent)
result.children = [element.textContent];
const result: AriaNode = { role, name, children: [] };
if (isLeaf && !name) {
const text = accumulatedElementText(element);
if (text)
result.children = [text];
}
return { isLeaf, ariaNode: result };
};

const visit = (ariaNode: AriaNode, node: Node) => {
if (node.nodeType === Node.TEXT_NODE && node.nodeValue) {
ariaNode.children = ariaNode.children || [];
ariaNode.children.push(node.nodeValue);
const text = node.nodeValue;
if (text)
ariaNode.children.push(node.nodeValue || '');
return;
}

Expand All @@ -67,10 +71,8 @@ export function generateAriaTree(rootElement: Element): AriaNode {
if (visible) {
const childAriaNode = toAriaNode(element);
const isHiddenContainer = childAriaNode && hiddenContainerRoles.has(childAriaNode.ariaNode.role);
if (childAriaNode && !isHiddenContainer) {
ariaNode.children = ariaNode.children || [];
if (childAriaNode && !isHiddenContainer)
ariaNode.children.push(childAriaNode.ariaNode);
}
if (isHiddenContainer || !childAriaNode?.isLeaf)
processChildNodes(childAriaNode?.ariaNode || ariaNode, element);
} else {
Expand All @@ -79,18 +81,36 @@ export function generateAriaTree(rootElement: Element): AriaNode {
};

function processChildNodes(ariaNode: AriaNode, element: Element) {
// Process light DOM children
for (let child = element.firstChild; child; child = child.nextSibling)
visit(ariaNode, child);
// Process shadow DOM children, if any
if (element.shadowRoot) {
for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling)
// Surround every element with spaces for the sake of concatenated text nodes.
const display = getElementComputedStyle(element)?.display || 'inline';
const treatAsBlock = (display !== 'inline' || element.nodeName === 'BR') ? ' ' : '';
if (treatAsBlock)
ariaNode.children.push(treatAsBlock);

ariaNode.children.push(getPseudoContent(element, '::before'));
const assignedNodes = element.nodeName === 'SLOT' ? (element as HTMLSlotElement).assignedNodes() : [];
if (assignedNodes.length) {
for (const child of assignedNodes)
visit(ariaNode, child);
} else {
for (let child = element.firstChild; child; child = child.nextSibling) {
if (!(child as Element | Text).assignedSlot)
visit(ariaNode, child);
}
if (element.shadowRoot) {
for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling)
visit(ariaNode, child);
}
}

ariaNode.children.push(getPseudoContent(element, '::after'));

if (treatAsBlock)
ariaNode.children.push(treatAsBlock);
}

beginAriaCaches();
const ariaRoot: AriaNode = { role: '' };
const ariaRoot: AriaNode = { role: '', children: [] };
try {
visit(ariaRoot, rootElement);
} finally {
Expand Down Expand Up @@ -128,7 +148,7 @@ function normalizeStringChildren(rootA11yNode: AriaNode) {
}
}
flushChildren(buffer, normalizedChildren);
ariaNode.children = normalizedChildren.length ? normalizedChildren : undefined;
ariaNode.children = normalizedChildren.length ? normalizedChildren : [];
};
visit(rootA11yNode);
}
Expand All @@ -144,7 +164,7 @@ const leafRoles = new Set([
'textbox', 'time', 'tooltip'
]);

const normalizeWhitespaceWithin = (text: string) => text.replace(/[\s\n]+/g, ' ');
const normalizeWhitespaceWithin = (text: string) => text.replace(/[\s\t\r\n]+/g, ' ');

function matchesText(text: string | undefined, template: RegExp | string | undefined) {
if (!template)
Expand Down Expand Up @@ -233,7 +253,7 @@ export function renderAriaTree(ariaNode: AriaNode): string {
lines.push(line);
return;
}
lines.push(line + (ariaNode.children ? ':' : ''));
lines.push(line + (ariaNode.children.length ? ':' : ''));
for (const child of ariaNode.children || [])
visit(child, indent + ' ');
};
Expand Down
107 changes: 54 additions & 53 deletions packages/playwright-core/src/server/injected/roleUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ function queryInAriaOwned(element: Element, selector: string): Element[] {
return result;
}

function getPseudoContent(element: Element, pseudo: '::before' | '::after') {
export function getPseudoContent(element: Element, pseudo: '::before' | '::after') {
const cache = pseudo === '::before' ? cachePseudoContentBefore : cachePseudoContentAfter;
if (cache?.has(element))
return cache?.get(element) || '';
Expand Down Expand Up @@ -430,10 +430,6 @@ export function getElementAccessibleName(element: Element, includeHidden: boolea
accessibleName = asFlatString(getTextAlternativeInternal(element, {
includeHidden,
visitedElements: new Set(),
embeddedInDescribedBy: undefined,
embeddedInLabelledBy: undefined,
embeddedInLabel: undefined,
embeddedInNativeTextAlternative: undefined,
embeddedInTargetElement: 'self',
}));
}
Expand All @@ -458,10 +454,6 @@ export function getElementAccessibleDescription(element: Element, includeHidden:
accessibleDescription = asFlatString(describedBy.map(ref => getTextAlternativeInternal(ref, {
includeHidden,
visitedElements: new Set(),
embeddedInLabelledBy: undefined,
embeddedInLabel: undefined,
embeddedInNativeTextAlternative: undefined,
embeddedInTargetElement: 'none',
embeddedInDescribedBy: { element: ref, hidden: isElementHiddenForAria(ref) },
})).join(' '));
} else if (element.hasAttribute('aria-description')) {
Expand All @@ -480,13 +472,13 @@ export function getElementAccessibleDescription(element: Element, includeHidden:
}

type AccessibleNameOptions = {
includeHidden: boolean,
visitedElements: Set<Element>,
embeddedInDescribedBy: { element: Element, hidden: boolean } | undefined,
embeddedInLabelledBy: { element: Element, hidden: boolean } | undefined,
embeddedInLabel: { element: Element, hidden: boolean } | undefined,
embeddedInNativeTextAlternative: { element: Element, hidden: boolean } | undefined,
embeddedInTargetElement: 'none' | 'self' | 'descendant',
includeHidden?: boolean,
embeddedInDescribedBy?: { element: Element, hidden: boolean },
embeddedInLabelledBy?: { element: Element, hidden: boolean },
embeddedInLabel?: { element: Element, hidden: boolean },
embeddedInNativeTextAlternative?: { element: Element, hidden: boolean },
embeddedInTargetElement?: 'self' | 'descendant',
};

function getTextAlternativeInternal(element: Element, options: AccessibleNameOptions): string {
Expand Down Expand Up @@ -525,7 +517,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
...options,
embeddedInLabelledBy: { element: ref, hidden: isElementHiddenForAria(ref) },
embeddedInDescribedBy: undefined,
embeddedInTargetElement: 'none',
embeddedInTargetElement: undefined,
embeddedInLabel: undefined,
embeddedInNativeTextAlternative: undefined,
})).join(' ');
Expand Down Expand Up @@ -778,42 +770,7 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
!!options.embeddedInLabelledBy || !!options.embeddedInDescribedBy ||
!!options.embeddedInLabel || !!options.embeddedInNativeTextAlternative) {
options.visitedElements.add(element);
const tokens: string[] = [];
const visit = (node: Node, skipSlotted: boolean) => {
if (skipSlotted && (node as Element | Text).assignedSlot)
return;
if (node.nodeType === 1 /* Node.ELEMENT_NODE */) {
const display = getElementComputedStyle(node as Element)?.display || 'inline';
let token = getTextAlternativeInternal(node as Element, childOptions);
// SPEC DIFFERENCE.
// Spec says "append the result to the accumulated text", assuming "with space".
// However, multiple tests insist that inline elements do not add a space.
// Additionally, <br> insists on a space anyway, see "name_file-label-inline-block-elements-manual.html"
if (display !== 'inline' || node.nodeName === 'BR')
token = ' ' + token + ' ';
tokens.push(token);
} else if (node.nodeType === 3 /* Node.TEXT_NODE */) {
// step 2g.
tokens.push(node.textContent || '');
}
};
tokens.push(getPseudoContent(element, '::before'));
const assignedNodes = element.nodeName === 'SLOT' ? (element as HTMLSlotElement).assignedNodes() : [];
if (assignedNodes.length) {
for (const child of assignedNodes)
visit(child, false);
} else {
for (let child = element.firstChild; child; child = child.nextSibling)
visit(child, true);
if (element.shadowRoot) {
for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling)
visit(child, true);
}
for (const owned of getIdRefs(element, element.getAttribute('aria-owns')))
visit(owned, true);
}
tokens.push(getPseudoContent(element, '::after'));
const accessibleName = tokens.join('');
const accessibleName = innerAccumulatedElementText(element, childOptions);
// Spec says "Return the accumulated text if it is not the empty string". However, that is not really
// compatible with the real browser behavior and wpt tests, where an element with empty contents will fallback to the title.
// So we follow the spec everywhere except for the target element itself. This can probably be improved.
Expand All @@ -834,6 +791,50 @@ function getTextAlternativeInternal(element: Element, options: AccessibleNameOpt
return '';
}

function innerAccumulatedElementText(element: Element, options: AccessibleNameOptions): string {
const tokens: string[] = [];
const visit = (node: Node, skipSlotted: boolean) => {
if (skipSlotted && (node as Element | Text).assignedSlot)
return;
if (node.nodeType === 1 /* Node.ELEMENT_NODE */) {
const display = getElementComputedStyle(node as Element)?.display || 'inline';
let token = getTextAlternativeInternal(node as Element, options);
// SPEC DIFFERENCE.
// Spec says "append the result to the accumulated text", assuming "with space".
// However, multiple tests insist that inline elements do not add a space.
// Additionally, <br> insists on a space anyway, see "name_file-label-inline-block-elements-manual.html"
if (display !== 'inline' || node.nodeName === 'BR')
token = ' ' + token + ' ';
tokens.push(token);
} else if (node.nodeType === 3 /* Node.TEXT_NODE */) {
// step 2g.
tokens.push(node.textContent || '');
}
};
tokens.push(getPseudoContent(element, '::before'));
const assignedNodes = element.nodeName === 'SLOT' ? (element as HTMLSlotElement).assignedNodes() : [];
if (assignedNodes.length) {
for (const child of assignedNodes)
visit(child, false);
} else {
for (let child = element.firstChild; child; child = child.nextSibling)
visit(child, true);
if (element.shadowRoot) {
for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling)
visit(child, true);
}
for (const owned of getIdRefs(element, element.getAttribute('aria-owns')))
visit(owned, true);
}
tokens.push(getPseudoContent(element, '::after'));
return tokens.join('');
}

export function accumulatedElementText(element: Element): string {
const visitedElements = new Set<Element>();
return asFlatString(innerAccumulatedElementText(element, { visitedElements })).trim();
}

export const kAriaSelectedRoles = ['gridcell', 'option', 'row', 'tab', 'rowheader', 'columnheader', 'treeitem'];
export function getAriaSelected(element: Element): boolean {
// https://www.w3.org/TR/wai-aria-1.2/#aria-selected
Expand Down Expand Up @@ -958,7 +959,7 @@ function getAccessibleNameFromAssociatedLabels(labels: Iterable<HTMLLabelElement
embeddedInNativeTextAlternative: undefined,
embeddedInLabelledBy: undefined,
embeddedInDescribedBy: undefined,
embeddedInTargetElement: 'none',
embeddedInTargetElement: undefined,
})).filter(accessibleName => !!accessibleName).join(' ');
}

Expand Down
16 changes: 11 additions & 5 deletions packages/playwright/src/matchers/toMatchAriaSnapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,19 @@ export async function toMatchAriaSnapshot(

const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined);
const notFound = received === kNoElementsFoundError;
const escapedExpected = escapePrivateUsePoints(expected);
const escapedReceived = escapePrivateUsePoints(received);
const message = () => {
if (pass) {
if (notFound)
return messagePrefix + `Expected: not ${this.utils.printExpected(expected)}\nReceived: ${received}` + callLogText(log);
const printedReceived = printReceivedStringContainExpectedSubstring(received, received.indexOf(expected), expected.length);
return messagePrefix + `Expected: not ${this.utils.printExpected(expected)}\nReceived string: ${printedReceived}` + callLogText(log);
return messagePrefix + `Expected: not ${this.utils.printExpected(escapedExpected)}\nReceived: ${escapedReceived}` + callLogText(log);
const printedReceived = printReceivedStringContainExpectedSubstring(escapedReceived, escapedReceived.indexOf(escapedExpected), escapedExpected.length);
return messagePrefix + `Expected: not ${this.utils.printExpected(escapedExpected)}\nReceived string: ${printedReceived}` + callLogText(log);
} else {
const labelExpected = `Expected`;
if (notFound)
return messagePrefix + `${labelExpected}: ${this.utils.printExpected(expected)}\nReceived: ${received}` + callLogText(log);
return messagePrefix + this.utils.printDiffOrStringify(expected, received, labelExpected, 'Received string', false) + callLogText(log);
return messagePrefix + `${labelExpected}: ${this.utils.printExpected(escapedExpected)}\nReceived: ${escapedReceived}` + callLogText(log);
return messagePrefix + this.utils.printDiffOrStringify(escapedExpected, escapedReceived, labelExpected, 'Received string', false) + callLogText(log);
}
};

Expand All @@ -73,3 +75,7 @@ export async function toMatchAriaSnapshot(
timeout: timedOut ? timeout : undefined,
};
}

function escapePrivateUsePoints(str: string) {
return str.replace(/[\uE000-\uF8FF]/g, char => `\\u${char.charCodeAt(0).toString(16).padStart(4, '0')}`);
}
Loading

0 comments on commit 29c84a3

Please sign in to comment.