Skip to content

Commit

Permalink
chore: experimental toMatchAriaSnapshot (#33014)
Browse files Browse the repository at this point in the history
  • Loading branch information
pavelfeldman authored Oct 14, 2024
1 parent 6cfcbe0 commit a38ff6e
Show file tree
Hide file tree
Showing 22 changed files with 716 additions and 149 deletions.
27 changes: 27 additions & 0 deletions docs/src/api/class-locatorassertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -2103,3 +2103,30 @@ Expected options currently selected.
### option: LocatorAssertions.toHaveValues.timeout = %%-csharp-java-python-assertions-timeout-%%
* since: v1.23


## async method: LocatorAssertions.toMatchAriaSnapshot
* since: v1.49
* langs: js

Asserts that the target element matches the given accessibility snapshot.

**Usage**

```js
import { role as x } from '@playwright/test';
// ...
await page.goto('https://demo.playwright.dev/todomvc/');
await expect(page.locator('body')).toMatchAriaSnapshot(`
- heading "todos"
- textbox "What needs to be done?"
`);
```

### param: LocatorAssertions.toMatchAriaSnapshot.expected
* since: v1.49
* langs: js
- `expected` <string>

### option: LocatorAssertions.toMatchAriaSnapshot.timeout = %%-js-assertions-timeout-%%
* since: v1.49
* langs: js
11 changes: 7 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,6 @@
"vite": "^5.4.6",
"ws": "^8.17.1",
"xml2js": "^0.5.0",
"yaml": "^2.2.2"
"yaml": "^2.5.1"
}
}
281 changes: 281 additions & 0 deletions packages/playwright-core/src/server/injected/ariaSnapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

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

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

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

export function generateAriaTree(rootElement: Element): AriaNode {
const toAriaNode = (element: Element): { ariaNode: AriaNode, isLeaf: boolean } | null => {
const role = getAriaRole(element);
if (!role)
return null;

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];
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);
return;
}

if (node.nodeType !== Node.ELEMENT_NODE)
return;

const element = node as Element;
if (isElementIgnoredForAria(element))
return;

const visible = isElementVisible(element);
const hasVisibleChildren = element.checkVisibility({
opacityProperty: true,
visibilityProperty: true,
contentVisibilityAuto: true
});

if (!hasVisibleChildren)
return;

if (visible) {
const childAriaNode = toAriaNode(element);
const isHiddenContainer = childAriaNode && hiddenContainerRoles.has(childAriaNode.ariaNode.role);
if (childAriaNode && !isHiddenContainer) {
ariaNode.children = ariaNode.children || [];
ariaNode.children.push(childAriaNode.ariaNode);
}
if (isHiddenContainer || !childAriaNode?.isLeaf)
processChildNodes(childAriaNode?.ariaNode || ariaNode, element);
} else {
processChildNodes(ariaNode, element);
}
};

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)
visit(ariaNode, child);
}
}

beginAriaCaches();
const result = toAriaNode(rootElement);
const ariaRoot = result?.ariaNode || { role: '' };
try {
visit(ariaRoot, rootElement);
} finally {
endAriaCaches();
}

normalizeStringChildren(ariaRoot);
return ariaRoot;
}

export function renderedAriaTree(rootElement: Element): string {
return renderAriaTree(generateAriaTree(rootElement));
}

function normalizeStringChildren(rootA11yNode: AriaNode) {
const flushChildren = (buffer: string[], normalizedChildren: (AriaNode | string)[]) => {
if (!buffer.length)
return;
const text = normalizeWhitespaceWithin(buffer.join('')).trim();
if (text)
normalizedChildren.push(text);
buffer.length = 0;
};

const visit = (ariaNode: AriaNode) => {
const normalizedChildren: (AriaNode | string)[] = [];
const buffer: string[] = [];
for (const child of ariaNode.children || []) {
if (typeof child === 'string') {
buffer.push(child);
} else {
flushChildren(buffer, normalizedChildren);
visit(child);
normalizedChildren.push(child);
}
}
flushChildren(buffer, normalizedChildren);
ariaNode.children = normalizedChildren.length ? normalizedChildren : undefined;
};
visit(rootA11yNode);
}

const hiddenContainerRoles = new Set(['none', 'presentation']);

const leafRoles = new Set([
'alert', 'blockquote', 'button', 'caption', 'checkbox', 'code', 'columnheader',
'definition', 'deletion', 'emphasis', 'generic', 'heading', 'img', 'insertion',
'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'meter', 'option',
'progressbar', 'radio', 'rowheader', 'scrollbar', 'searchbox', 'separator',
'slider', 'spinbutton', 'strong', 'subscript', 'superscript', 'switch', 'tab', 'term',
'textbox', 'time', 'tooltip'
]);

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

function matchesText(text: string | undefined, template: RegExp | string | undefined) {
if (!template)
return true;
if (!text)
return false;
if (typeof template === 'string')
return text === template;
return !!text.match(template);
}

export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: string } {
const root = generateAriaTree(rootElement);
const matches = nodeMatches(root, template);
return { matches, received: renderAriaTree(root) };
}

function matchesNode(node: AriaNode | string, template: AriaTemplateNode | RegExp | string, depth: number): boolean {
if (typeof node === 'string' && (typeof template === 'string' || template instanceof RegExp))
return matchesText(node, template);

if (typeof node === 'object' && typeof template === 'object' && !(template instanceof RegExp)) {
if (template.role && template.role !== node.role)
return false;
if (!matchesText(node.name, template.name))
return false;
if (!containsList(node.children || [], template.children || [], depth))
return false;
return true;
}
return false;
}

function containsList(children: (AriaNode | string)[], template: (AriaTemplateNode | RegExp | string)[], depth: number): boolean {
if (template.length > children.length)
return false;
const cc = children.slice();
const tt = template.slice();
for (const t of tt) {
let c = cc.shift();
while (c) {
if (matchesNode(c, t, depth + 1))
break;
c = cc.shift();
}
if (!c)
return false;
}
return true;
}

function nodeMatches(root: AriaNode, template: AriaTemplateNode): boolean {
const results: (AriaNode | string)[] = [];
const visit = (node: AriaNode | string): boolean => {
if (matchesNode(node, template, 0)) {
results.push(node);
return true;
}
if (typeof node === 'string')
return false;
for (const child of node.children || []) {
if (visit(child))
return true;
}
return false;
};
visit(root);
return !!results.length;
}

export function renderAriaTree(ariaNode: AriaNode): string {
const lines: string[] = [];
const visit = (ariaNode: AriaNode, indent: string) => {
let line = `${indent}- ${ariaNode.role}`;
if (ariaNode.name)
line += ` ${escapeWithQuotes(ariaNode.name, '"')}`;
const noChild = !ariaNode.name && !ariaNode.children?.length;
const oneChild = !ariaNode.name && ariaNode.children?.length === 1 && typeof ariaNode.children[0] === 'string';
if (noChild || oneChild) {
if (oneChild)
line += ': ' + escapeYamlString(ariaNode.children?.[0] as string);
lines.push(line);
return;
}
lines.push(line + (ariaNode.children ? ':' : ''));
for (const child of ariaNode.children || []) {
if (typeof child === 'string')
lines.push(indent + ' - text: ' + escapeYamlString(child));
else
visit(child, indent + ' ');
}
};
visit(ariaNode, '');
return lines.join('\n');
}

function escapeYamlString(str: string) {
if (str === '')
return '""';

const needQuotes = (
// Starts or ends with whitespace
/^\s|\s$/.test(str) ||
// Contains control characters
/[\x00-\x1f]/.test(str) ||
// Contains special YAML characters that could cause parsing issues
/[\[\]{}&*!,|>%@`]/.test(str) ||
// Contains a colon followed by a space (could be interpreted as a key-value pair)
/:\s/.test(str) ||
// Is a YAML boolean or null value
/^(?:y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|null|Null|NULL|~)$/.test(str) ||
// Could be interpreted as a number
/^[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?$/.test(str) ||
// Contains a newline character
/\n/.test(str) ||
// Starts with a special character
/^[\-?:,>|%@"`]/.test(str)
);

if (needQuotes) {
return `"${str
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')}"`;
}

return str;
}
2 changes: 2 additions & 0 deletions packages/playwright-core/src/server/injected/consoleApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { escapeForTextSelector } from '../../utils/isomorphic/stringUtils';
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
import type { Language } from '../../utils/isomorphic/locatorGenerators';
import type { InjectedScript } from './injectedScript';
import { renderedAriaTree } from './ariaSnapshot';

const selectorSymbol = Symbol('selector');

Expand Down Expand Up @@ -85,6 +86,7 @@ class ConsoleAPI {
inspect: (selector: string) => this._inspect(selector),
selector: (element: Element) => this._selector(element),
generateLocator: (element: Element, language?: Language) => this._generateLocator(element, language),
ariaSnapshot: (element?: Element) => renderedAriaTree(element || this._injectedScript.document.body),
resume: () => this._resume(),
...new Locator(injectedScript, ''),
};
Expand Down
Loading

0 comments on commit a38ff6e

Please sign in to comment.