Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: compute aria text consistently with the role accumulated text #33157

Merged
merged 1 commit into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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