diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md
index 1ff5e52119..3a44a75e64 100644
--- a/docs/src/api/class-locatorassertions.md
+++ b/docs/src/api/class-locatorassertions.md
@@ -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
+await page.goto('https://demo.playwright.dev/todomvc/');
+await expect(page.locator('body')).toMatchAriaSnapshot(<>
+ This is just a demo of TodoMVC for testing, not the
+ real TodoMVC app.
+ todos
+ What needs to be done?
+>);
+```
+
+### param: LocatorAssertions.toMatchAriaSnapshot.expected
+* since: v1.49
+* langs: js
+- `expected` <[JSX.Element]>
+
+### option: LocatorAssertions.toMatchAriaSnapshot.timeout = %%-js-assertions-timeout-%%
+* since: v1.49
+* langs: js
diff --git a/package.json b/package.json
index 1f2ce943c9..41fd868330 100644
--- a/package.json
+++ b/package.json
@@ -41,7 +41,7 @@
"clean": "node utils/build/clean.js",
"build": "node utils/build/build.js",
"watch": "node utils/build/build.js --watch --lint",
- "test-types": "node utils/generate_types/ && tsc -p utils/generate_types/test/tsconfig.json && tsc -p ./tests/",
+ "test-types": "node utils/generate_types/ && tsc -p utils/generate_types/test/tsconfig.json && tsc --jsx preserve -p ./tests/",
"roll": "node utils/roll_browser.js",
"check-deps": "node utils/check_deps.js",
"build-android-driver": "./utils/build_android_driver.sh",
diff --git a/packages/playwright-core/src/server/injected/ariaSnapshot.ts b/packages/playwright-core/src/server/injected/ariaSnapshot.ts
new file mode 100644
index 0000000000..6057a39492
--- /dev/null
+++ b/packages/playwright-core/src/server/injected/ariaSnapshot.ts
@@ -0,0 +1,240 @@
+/**
+ * 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 type { InjectedScript } from './injectedScript';
+
+type AriaNode = {
+ role: string;
+ name?: string;
+ children?: (AriaNode | string)[];
+};
+
+export type AriaTemplateString = {
+ kind: 'string';
+ chunks: (RegExp | string)[];
+};
+
+export type AriaTemplateNode = {
+ kind: 'node';
+ role: string;
+ name?: string;
+ children?: (AriaTemplateNode | AriaTemplateString)[];
+};
+
+export function generateAriaTree(injectedScript: InjectedScript, rootElement?: Element): AriaNode {
+ const toAriaNode = (element: Element): { ariaNode: AriaNode, isLeaf: boolean } | null => {
+ const role = injectedScript.utils.getAriaRole(element);
+ if (!role)
+ return null;
+
+ const name = role ? injectedScript.utils.getElementAccessibleName(element, false) || undefined : undefined;
+ const isLeaf = leafRoles.has(role);
+ const result: AriaNode = { role };
+
+ if (isLeaf)
+ result.children = [name || element.textContent || ''];
+ else
+ result.name = name;
+ 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 (element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || element.nodeName === 'NOSCRIPT')
+ return;
+
+ const isElementVisible = injectedScript.utils.isElementVisible(element);
+ const hasVisibleChildren = element.checkVisibility({ opacityProperty: true, visibilityProperty: true, contentVisibilityAuto: true });
+
+ if (!hasVisibleChildren)
+ return;
+
+ if (isElementVisible) {
+ const childAriaNode = toAriaNode(element);
+ if (childAriaNode) {
+ ariaNode.children = ariaNode.children || [];
+ ariaNode.children.push(childAriaNode.ariaNode);
+ }
+ if (!childAriaNode?.isLeaf) {
+ for (let child = element.firstChild; child; child = child.nextSibling)
+ visit(childAriaNode?.ariaNode || ariaNode, child);
+ }
+ } else {
+ for (let child = element.firstChild; child; child = child.nextSibling)
+ visit(ariaNode, child);
+ }
+ };
+
+ injectedScript.utils.beginAriaCaches();
+ rootElement = rootElement || injectedScript.document.body;
+ const result = toAriaNode(rootElement);
+ const ariaRoot = result?.ariaNode || { role: '' };
+ try {
+ visit(ariaRoot, rootElement);
+ } finally {
+ injectedScript.utils.endAriaCaches();
+ }
+
+ normalizeStringChildren(ariaRoot);
+ return ariaRoot;
+}
+
+export function renderedAriaTree(injectedScript: InjectedScript, rootElement?: Element): string {
+ return renderAriaTree(injectedScript, generateAriaTree(injectedScript, rootElement));
+}
+
+function normalizeStringChildren(rootA11yNode: AriaNode) {
+ const flushChildren = (buffer: string[], normalizedChildren: (AriaNode | string)[]) => {
+ if (!buffer.length)
+ return;
+ const text = normalizeWhitespace(buffer.join(''));
+ if (text.trim())
+ normalizedChildren.push(text.trim());
+ 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 leafRoles = new Set([
+ 'alert', 'blockquote', 'button', 'caption', 'checkbox', 'code', 'columnheader',
+ 'definition', 'deletion', 'emphasis', 'generic', 'heading', 'img', 'insertion',
+ 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'meter', 'none', 'option',
+ 'presentation', 'progressbar', 'radio', 'rowheader', 'scrollbar', 'searchbox', 'separator',
+ 'slider', 'spinbutton', 'strong', 'subscript', 'superscript', 'switch', 'tab', 'term',
+ 'textbox', 'time', 'tooltip'
+]);
+
+const normalizeWhitespace = (text: string) => text.replace(/[\s\n]+/g, ' ');
+
+function escapeRegex(string: string) {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
+
+export function matchesAriaTree(injectedScript: InjectedScript, rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: string } {
+ const root = generateAriaTree(injectedScript, rootElement);
+ const matches = nodeMatches(root, template);
+ return { matches, received: renderAriaTree(injectedScript, root) };
+}
+
+function matchesNode(node: AriaNode | string, template: AriaTemplateNode | AriaTemplateString, depth: number): boolean {
+ if (typeof node === 'string' && template.kind === 'string') {
+ const pattern = template.chunks.map(s => typeof s === 'string' ? escapeRegex(normalizeWhitespace(s)) : s.source);
+ return !!node.match(new RegExp(pattern.join('')));
+ }
+
+ if (typeof node === 'object' && template.kind === 'node') {
+ if (template.role && template.role !== node.role)
+ return false;
+ if (template.role && template.name !== node.name)
+ return false;
+ if (!containsList(node.children || [], template.children || [], depth))
+ return false;
+ return true;
+ }
+ return false;
+}
+
+function containsList(children: (AriaNode | string)[], template: (AriaTemplateNode | AriaTemplateString)[], depth: number): boolean {
+ if (template.length > children.length)
+ return false;
+ const cc = children.slice();
+ const tt = template.slice();
+ for (let t = tt.shift(); t; t = tt.shift()) {
+ let c = cc.shift();
+ while (c) {
+ if (matchesNode(c, t, depth + 1))
+ break;
+ c = cc.shift();
+ }
+ if (!c)
+ return false;
+ }
+ return !tt.length;
+}
+
+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(injectedScript: InjectedScript, ariaNode: AriaNode): string {
+ const lines: string[] = [];
+ const visit = (ariaNode: AriaNode, indent: string) => {
+ let line = `${indent}';
+
+ 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 += injectedScript.utils.escapeHTML(ariaNode.children?.[0] as string);
+ line += ``;
+ lines.push(line);
+ return;
+ }
+ lines.push(line);
+ for (const child of ariaNode.children || []) {
+ if (typeof child === 'string')
+ lines.push(indent + ' ' + injectedScript.utils.escapeHTML(child));
+ else
+ visit(child, indent + ' ');
+ }
+ lines.push(`${indent}`);
+ };
+ visit(ariaNode, '');
+ return lines.join('\n');
+}
diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts
index f0231e9551..03eea73122 100644
--- a/packages/playwright-core/src/server/injected/consoleApi.ts
+++ b/packages/playwright-core/src/server/injected/consoleApi.ts
@@ -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');
@@ -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(this._injectedScript, element),
resume: () => this._resume(),
...new Locator(injectedScript, ''),
};
diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts
index 69fe959f81..1f52ae92eb 100644
--- a/packages/playwright-core/src/server/injected/injectedScript.ts
+++ b/packages/playwright-core/src/server/injected/injectedScript.ts
@@ -29,13 +29,12 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser';
import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator';
import type * as channels from '@protocol/channels';
import { Highlight } from './highlight';
-import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, beginAriaCaches, endAriaCaches } from './roleUtils';
+import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, beginAriaCaches, endAriaCaches, getAriaLevel, getAriaChecked } from './roleUtils';
import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
import { asLocator } from '../../utils/isomorphic/locatorGenerators';
import type { Language } from '../../utils/isomorphic/locatorGenerators';
import { cacheNormalizedWhitespaces, escapeHTML, escapeHTMLAttribute, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils';
-import { selectorForSimpleDomNodeId, generateSimpleDomNode } from './simpleDom';
-import type { SimpleDomNode } from './simpleDom';
+import { generateAriaTree, matchesAriaTree, renderAriaTree } from './ariaSnapshot';
export type FrameExpectParams = Omit & { expectedValue?: any };
@@ -80,12 +79,17 @@ export class InjectedScript {
endAriaCaches,
escapeHTML,
escapeHTMLAttribute,
+ generateAriaTree,
getAriaRole,
+ getAriaLevel,
+ getAriaChecked,
getElementAccessibleDescription,
getElementAccessibleName,
isElementVisible,
isInsideScope,
normalizeWhiteSpace,
+ autoClosingTags,
+ renderAriaTree,
};
// eslint-disable-next-line no-restricted-globals
@@ -1235,6 +1239,11 @@ export class InjectedScript {
}
}
+ {
+ if (expression === 'to.match.aria')
+ return matchesAriaTree(this, element, options.expectedValue);
+ }
+
{
// Single text value.
let received: string | undefined;
@@ -1312,17 +1321,6 @@ export class InjectedScript {
}
throw this.createStacklessError('Unknown expect matcher: ' + expression);
}
-
- generateSimpleDomNode(selector: string): SimpleDomNode | undefined {
- const element = this.querySelector(this.parseSelector(selector), this.document.documentElement, true);
- if (!element)
- return;
- return generateSimpleDomNode(this, element);
- }
-
- selectorForSimpleDomNodeId(nodeId: string) {
- return selectorForSimpleDomNodeId(this, nodeId);
- }
}
const autoClosingTags = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);
diff --git a/packages/playwright-core/src/server/injected/simpleDom.ts b/packages/playwright-core/src/server/injected/simpleDom.ts
deleted file mode 100644
index c31862cd6c..0000000000
--- a/packages/playwright-core/src/server/injected/simpleDom.ts
+++ /dev/null
@@ -1,120 +0,0 @@
-/**
- * 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 type { InjectedScript } from './injectedScript';
-
-const leafRoles = new Set([
- 'button',
- 'checkbox',
- 'combobox',
- 'link',
- 'textbox',
-]);
-
-export type SimpleDom = {
- markup: string;
- elements: Map;
-};
-
-export type SimpleDomNode = {
- dom: SimpleDom;
- id: string;
- tag: string;
-};
-
-let lastDom: SimpleDom | undefined;
-
-export function generateSimpleDom(injectedScript: InjectedScript): SimpleDom {
- return generate(injectedScript).dom;
-}
-
-export function generateSimpleDomNode(injectedScript: InjectedScript, target: Element): SimpleDomNode {
- return generate(injectedScript, target).node!;
-}
-
-export function selectorForSimpleDomNodeId(injectedScript: InjectedScript, id: string): string {
- const element = lastDom?.elements.get(id);
- if (!element)
- throw new Error(`Internal error: element with id "${id}" not found`);
- return injectedScript.generateSelectorSimple(element);
-}
-
-function generate(injectedScript: InjectedScript, target?: Element): { dom: SimpleDom, node?: SimpleDomNode } {
- const normalizeWhitespace = (text: string) => text.replace(/[\s\n]+/g, match => match.includes('\n') ? '\n' : ' ');
- const tokens: string[] = [];
- const elements = new Map();
- let lastId = 0;
- let resultTarget: { tag: string, id: string } | undefined;
- const visit = (node: Node) => {
- if (node.nodeType === Node.TEXT_NODE) {
- tokens.push(node.nodeValue!);
- return;
- }
-
- if (node.nodeType === Node.ELEMENT_NODE) {
- const element = node as Element;
- if (element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || element.nodeName === 'NOSCRIPT')
- return;
- if (injectedScript.utils.isElementVisible(element)) {
- const role = injectedScript.utils.getAriaRole(element) as string;
- if (role && leafRoles.has(role)) {
- let value: string | undefined;
- if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA')
- value = (element as HTMLInputElement | HTMLTextAreaElement).value;
- const name = injectedScript.utils.getElementAccessibleName(element, false);
- const structuralId = String(++lastId);
- elements.set(structuralId, element);
- tokens.push(renderTag(injectedScript, role, name, structuralId, { value }));
- if (element === target) {
- const tagNoValue = renderTag(injectedScript, role, name, structuralId);
- resultTarget = { tag: tagNoValue, id: structuralId };
- }
- return;
- }
- }
- for (let child = element.firstChild; child; child = child.nextSibling)
- visit(child);
- }
- };
- injectedScript.utils.beginAriaCaches();
- try {
- visit(injectedScript.document.body);
- } finally {
- injectedScript.utils.endAriaCaches();
- }
- const dom = {
- markup: normalizeWhitespace(tokens.join(' ')),
- elements
- };
-
- if (target && !resultTarget)
- throw new Error('Target element is not in the simple DOM');
-
- lastDom = dom;
-
- return { dom, node: resultTarget ? { dom, ...resultTarget } : undefined };
-}
-
-function renderTag(injectedScript: InjectedScript, role: string, name: string, id: string, params?: { value?: string }): string {
- const escapedTextContent = injectedScript.utils.escapeHTML(name);
- const escapedValue = injectedScript.utils.escapeHTMLAttribute(params?.value || '');
- switch (role) {
- case 'button': return ``;
- case 'link': return `${escapedTextContent}`;
- case 'textbox': return ``;
- }
- return `${escapedTextContent}
`;
-}
diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts
index add1661502..d7e100d182 100644
--- a/packages/playwright/src/index.ts
+++ b/packages/playwright/src/index.ts
@@ -724,3 +724,4 @@ export const test = _baseTest.extend(playwrightFix
export { defineConfig } from './common/configLoader';
export { mergeTests } from './common/testType';
export { mergeExpects } from './matchers/expect';
+export { roleFactory as role } from './matchers/toMatchAriaSnapshot';
diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts
index 16300607d9..0d276d4101 100644
--- a/packages/playwright/src/matchers/expect.ts
+++ b/packages/playwright/src/matchers/expect.ts
@@ -62,6 +62,7 @@ import {
import { zones } from 'playwright-core/lib/utils';
import { TestInfoImpl } from '../worker/testInfo';
import { ExpectError, isExpectError } from './matcherHint';
+import { toMatchAriaSnapshot } from './toMatchAriaSnapshot';
// #region
// Mirrored from https://github.com/facebook/jest/blob/f13abff8df9a0e1148baf3584bcde6d1b479edc7/packages/expect/src/print.ts
@@ -236,6 +237,7 @@ const customAsyncMatchers = {
toHaveValue,
toHaveValues,
toHaveScreenshot,
+ toMatchAriaSnapshot,
toPass,
};
diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts
index 3ca9180ae2..c0319e371f 100644
--- a/packages/playwright/src/matchers/matchers.ts
+++ b/packages/playwright/src/matchers/matchers.ts
@@ -27,7 +27,7 @@ import { TestInfoImpl } from '../worker/testInfo';
import type { ExpectMatcherState } from '../../types/test';
import { takeFirst } from '../common/config';
-interface LocatorEx extends Locator {
+export interface LocatorEx extends Locator {
_expect(expression: string, options: Omit & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>;
}
diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts
new file mode 100644
index 0000000000..1aeaae3798
--- /dev/null
+++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts
@@ -0,0 +1,105 @@
+/**
+ * Copyright Microsoft Corporation. All rights reserved.
+ *
+ * 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 type { LocatorEx } from './matchers';
+import type { ExpectMatcherState } from '../../types/test';
+import type { MatcherResult } from './matcherHint';
+import type { AriaTemplateNode, AriaTemplateString } from 'playwright-core/lib/server/injected/ariaSnapshot';
+
+export async function toMatchAriaSnapshot(
+ this: ExpectMatcherState,
+ locator: LocatorEx,
+ expected: JSX.Element,
+ options: { timeout?: number, matchSubstring?: boolean } = {},
+): Promise> {
+ const timeout = options.timeout ?? this.timeout;
+ const ariaTree = jsxToAriaTree(expected) as AriaTemplateNode;
+ normalizeStringChildren(ariaTree);
+ const result = await locator._expect('to.match.aria', { expectedValue: ariaTree, isNot: this.isNot, timeout });
+ return {
+ name: 'toMatchAriaSnapshot',
+ expected: JSON.stringify(ariaTree, null, 2),
+ message: () => result.received,
+ pass: result.matches,
+ actual: result.received,
+ log: result.log,
+ timeout: result.timedOut ? timeout : undefined,
+ };
+}
+
+function jsxToAriaTree(element: JSX.Element | string): AriaTemplateNode | AriaTemplateString {
+ if (typeof element === 'string')
+ return { kind: 'string', chunks: [element] };
+ const children = element.props.children || [];
+ const role = typeof element.type === 'function' ? element.type().role : '' as string;
+ if (role === 'regex')
+ return { kind: 'string', chunks: [element.props.regex] };
+
+ const name = element.props.name || undefined;
+ const ariaNode: AriaTemplateNode = { kind: 'node', role, name };
+ if (Array.isArray(children)) {
+ ariaNode.children = [];
+ for (const child of children)
+ ariaNode.children.push(jsxToAriaTree(child));
+ } else {
+ ariaNode.children = [jsxToAriaTree(children)];
+ }
+ return ariaNode;
+}
+
+function normalizeStringChildren(rootA11yNode: AriaTemplateNode) {
+ const flushChildren = (buffer: AriaTemplateString, normalizedChildren: (AriaTemplateNode | AriaTemplateString)[]) => {
+ if (!buffer.chunks.length)
+ return;
+ normalizedChildren.push({ kind: 'string', chunks: buffer.chunks.slice() });
+ buffer.chunks.length = 0;
+ };
+
+ const visit = (ariaNode: AriaTemplateNode) => {
+ const normalizedChildren: (AriaTemplateNode | AriaTemplateString)[] = [];
+ const buffer: AriaTemplateString = { kind: 'string', chunks: [] };
+ for (const child of ariaNode.children || []) {
+ if (child.kind === 'string') {
+ buffer.chunks.push(...child.chunks);
+ } else {
+ flushChildren(buffer, normalizedChildren);
+ visit(child);
+ normalizedChildren.push(child);
+ }
+ }
+ flushChildren(buffer, normalizedChildren);
+ ariaNode.children = normalizedChildren.length ? normalizedChildren : undefined;
+ };
+ visit(rootA11yNode);
+}
+
+const allRoles = [
+ 'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button', 'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', 'command',
+ 'complementary', 'composite', 'contentinfo', 'definition', 'deletion', 'dialog', 'directory', 'document', 'emphasis', 'feed', 'figure', 'form', 'generic', 'grid',
+ 'gridcell', 'group', 'heading', 'img', 'input', 'insertion', 'landmark', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 'marquee', 'math', 'meter', 'menu',
+ 'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'navigation', 'none', 'note', 'option', 'paragraph', 'presentation', 'progressbar', 'radio', 'radiogroup',
+ 'range', 'region', 'roletype', 'row', 'rowgroup', 'rowheader', 'scrollbar', 'search', 'searchbox', 'section', 'sectionhead', 'select', 'separator', 'slider',
+ 'spinbutton', 'status', 'strong', 'structure', 'subscript', 'superscript', 'switch', 'tab', 'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer',
+ 'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem', 'widget', 'window'
+];
+
+export const roleFactory: any = {
+ match: (regex: RegExp) => ({ type: () => ({ role: 'regex' }), props: { regex } })
+};
+
+for (const r of allRoles)
+ roleFactory[r] = () => ({ role: r } as any);
diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts
index 400d7cbcf3..44741466d8 100644
--- a/packages/playwright/types/test.d.ts
+++ b/packages/playwright/types/test.d.ts
@@ -6694,11 +6694,26 @@ type MergedExpect = Expect>;
*/
export function mergeExpects(...expects: List): MergedExpect;
+/**
+ * Aria snapshots.
+ */
+type Role =
+ 'alert' | 'alertdialog' | 'application' | 'article' | 'banner' | 'blockquote' | 'button' | 'caption' | 'cell' | 'checkbox' | 'code' | 'columnheader' | 'combobox' | 'command' |
+ 'complementary' | 'composite' | 'contentinfo' | 'definition' | 'deletion' | 'dialog' | 'directory' | 'document' | 'emphasis' | 'feed' | 'figure' | 'form' | 'generic' | 'grid' |
+ 'gridcell' | 'group' | 'heading' | 'img' | 'input' | 'insertion' | 'landmark' | 'link' | 'list' | 'listbox' | 'listitem' | 'log' | 'main' | 'marquee' | 'math' | 'meter' | 'menu' |
+ 'menubar' | 'menuitem' | 'menuitemcheckbox' | 'menuitemradio' | 'navigation' | 'none' | 'note' | 'option' | 'paragraph' | 'presentation' | 'progressbar' | 'radio' | 'radiogroup' |
+ 'range' | 'region' | 'roletype' | 'row' | 'rowgroup' | 'rowheader' | 'scrollbar' | 'search' | 'searchbox' | 'section' | 'sectionhead' | 'select' | 'separator' | 'slider' |
+ 'spinbutton' | 'status' | 'strong' | 'structure' | 'subscript' | 'superscript' | 'switch' | 'tab' | 'table' | 'tablist' | 'tabpanel' | 'term' | 'textbox' | 'time' | 'timer' |
+ 'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem' | 'widget' | 'window';
+
+export const role: Record>> & {
+ match: (regex: RegExp) => JSX.Element;
+};
+
// This is required to not export everything by default. See https://github.com/Microsoft/TypeScript/issues/19545#issuecomment-340490459
export { };
-
/**
* The [APIResponseAssertions](https://playwright.dev/docs/api/class-apiresponseassertions) class provides assertion
* methods that can be used to make assertions about the
@@ -7618,6 +7633,31 @@ interface LocatorAssertions {
timeout?: number;
}): Promise;
+ /**
+ * Asserts that the target element matches the given accessibility snapshot.
+ *
+ * **Usage**
+ *
+ * ```js
+ * await page.goto('https://demo.playwright.dev/todomvc/');
+ * await expect(page.locator('body')).toMatchAriaSnapshot(<>
+ * This is just a demo of TodoMVC for testing, not the
+ * real TodoMVC app.
+ * todos
+ * What needs to be done?
+ * >);
+ * ```
+ *
+ * @param expected
+ * @param options
+ */
+ toMatchAriaSnapshot(expected: JSX.Element, options?: {
+ /**
+ * Time to retry the assertion for in milliseconds. Defaults to `timeout` in `TestConfig.expect`.
+ */
+ timeout?: number;
+ }): Promise;
+
/**
* Makes the assertion check for the opposite condition. For example, this code tests that the Locator doesn't contain
* text `"error"`:
diff --git a/tests/page/pageTest.ts b/tests/page/pageTest.ts
index 533d901037..c1469b69bc 100644
--- a/tests/page/pageTest.ts
+++ b/tests/page/pageTest.ts
@@ -23,7 +23,7 @@ import { electronTest } from '../electron/electronTest';
import { webView2Test } from '../webview2/webView2Test';
import type { PageTestFixtures, PageWorkerFixtures } from './pageTestApi';
import type { ServerFixtures, ServerWorkerOptions } from '../config/serverFixtures';
-export { expect } from '@playwright/test';
+export { expect, role } from '@playwright/test';
let impl: TestType = browserTest;
diff --git a/tests/page/to-match-aria-snapshot.spec.tsx b/tests/page/to-match-aria-snapshot.spec.tsx
new file mode 100644
index 0000000000..dfef229577
--- /dev/null
+++ b/tests/page/to-match-aria-snapshot.spec.tsx
@@ -0,0 +1,85 @@
+/**
+ * Copyright (c) Microsoft Corporation. All rights reserved.
+ *
+ * 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 { test, expect, role as x } from './pageTest';
+
+test('should match', async ({ page }) => {
+ await page.setContent(`title
`);
+ await expect(page.locator('body')).toMatchAriaSnapshot(<>
+ title
+ >);
+});
+
+test('should match in list', async ({ page }) => {
+ await page.setContent(`
+ title
+ title 2
+ `);
+ await expect(page.locator('body')).toMatchAriaSnapshot(<>
+ title
+ >);
+});
+
+test('should match list with accessible name', async ({ page }) => {
+ await page.setContent(`
+
+ `);
+ await expect(page.locator('body')).toMatchAriaSnapshot(<>
+
+ one
+ two
+
+ >);
+});
+
+test('should match deep item', async ({ page }) => {
+ await page.setContent(`
+
+
title
+ title 2
+
+ `);
+ await expect(page.locator('body')).toMatchAriaSnapshot(<>
+ title
+ >);
+});
+
+test('should match complex', async ({ page }) => {
+ await page.setContent(`
+
+ `);
+ await expect(page.locator('body')).toMatchAriaSnapshot(<>
+
+
+ link
+
+
+ >);
+});
+
+test('should match regex', async ({ page }) => {
+ await page.setContent(`Issues 12
`);
+ await expect(page.locator('body')).toMatchAriaSnapshot(<>
+ Issues {x.match(/\d+/)}
+ >);
+});
diff --git a/tests/tsconfig.json b/tests/tsconfig.json
index 1a1e3d7527..e9f56c19b9 100644
--- a/tests/tsconfig.json
+++ b/tests/tsconfig.json
@@ -18,6 +18,6 @@
"@web/*": ["packages/web/src/*"],
},
},
- "include": ["**/*.spec.js", "**/*.ts"],
+ "include": ["**/*.spec.js", "**/*.ts", "**/*.tsx"],
"exclude": ["components/", "installation/fixture-scripts/"]
}
diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts
index be1fa7ee37..1f587da5af 100644
--- a/utils/generate_types/overrides-test.d.ts
+++ b/utils/generate_types/overrides-test.d.ts
@@ -471,6 +471,21 @@ type MergedExpect = Expect>;
*/
export function mergeExpects(...expects: List): MergedExpect;
+/**
+ * Aria snapshots.
+ */
+type Role =
+ 'alert' | 'alertdialog' | 'application' | 'article' | 'banner' | 'blockquote' | 'button' | 'caption' | 'cell' | 'checkbox' | 'code' | 'columnheader' | 'combobox' | 'command' |
+ 'complementary' | 'composite' | 'contentinfo' | 'definition' | 'deletion' | 'dialog' | 'directory' | 'document' | 'emphasis' | 'feed' | 'figure' | 'form' | 'generic' | 'grid' |
+ 'gridcell' | 'group' | 'heading' | 'img' | 'input' | 'insertion' | 'landmark' | 'link' | 'list' | 'listbox' | 'listitem' | 'log' | 'main' | 'marquee' | 'math' | 'meter' | 'menu' |
+ 'menubar' | 'menuitem' | 'menuitemcheckbox' | 'menuitemradio' | 'navigation' | 'none' | 'note' | 'option' | 'paragraph' | 'presentation' | 'progressbar' | 'radio' | 'radiogroup' |
+ 'range' | 'region' | 'roletype' | 'row' | 'rowgroup' | 'rowheader' | 'scrollbar' | 'search' | 'searchbox' | 'section' | 'sectionhead' | 'select' | 'separator' | 'slider' |
+ 'spinbutton' | 'status' | 'strong' | 'structure' | 'subscript' | 'superscript' | 'switch' | 'tab' | 'table' | 'tablist' | 'tabpanel' | 'term' | 'textbox' | 'time' | 'timer' |
+ 'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem' | 'widget' | 'window';
+
+export const role: Record>> & {
+ match: (regex: RegExp) => JSX.Element;
+};
+
// This is required to not export everything by default. See https://github.com/Microsoft/TypeScript/issues/19545#issuecomment-340490459
export { };
-