chore: experimental toMatchAriaSnapshot

This commit is contained in:
Pavel Feldman 2024-10-04 14:45:11 -07:00
parent 6f16b6cc08
commit a872b070a5
15 changed files with 535 additions and 140 deletions

View file

@ -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
<x.link>real TodoMVC app.</x.link>
<x.heading>todos</x.heading>
<x.textbox>What needs to be done?</x.textbox>
</>);
```
### param: LocatorAssertions.toMatchAriaSnapshot.expected
* since: v1.49
* langs: js
- `expected` <[JSX.Element]>
### option: LocatorAssertions.toMatchAriaSnapshot.timeout = %%-js-assertions-timeout-%%
* since: v1.49
* langs: js

View file

@ -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",

View file

@ -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}<x.${ariaNode.role}`;
if (ariaNode.name)
line += ` name="${injectedScript.utils.escapeHTMLAttribute(ariaNode.name)}"`;
line += '>';
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 += `</x.${ariaNode.role}>`;
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}</x.${ariaNode.role}>`);
};
visit(ariaNode, '');
return lines.join('\n');
}

View file

@ -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, ''),
};

View file

@ -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<channels.FrameExpectParams, 'expectedValue'> & { 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']);

View file

@ -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<string, Element>;
};
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<string, Element>();
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 `<button id="${id}">${escapedTextContent}</button>`;
case 'link': return `<a id="${id}">${escapedTextContent}</a>`;
case 'textbox': return `<input id="${id}" title="${escapedTextContent}" value="${escapedValue}"></input>`;
}
return `<div role=${role} id="${id}">${escapedTextContent}</div>`;
}

View file

@ -724,3 +724,4 @@ export const test = _baseTest.extend<TestFixtures, WorkerFixtures>(playwrightFix
export { defineConfig } from './common/configLoader';
export { mergeTests } from './common/testType';
export { mergeExpects } from './matchers/expect';
export { roleFactory as role } from './matchers/toMatchAriaSnapshot';

View file

@ -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,
};

View file

@ -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<FrameExpectOptions, 'expectedValue'> & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>;
}

View file

@ -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<MatcherResult<string | RegExp, string>> {
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);

View file

@ -6694,11 +6694,26 @@ type MergedExpect<List> = Expect<MergedExpectMatchers<List>>;
*/
export function mergeExpects<List extends any[]>(...expects: List): MergedExpect<List>;
/**
* 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<Role, React.FunctionComponent<React.PropsWithChildren<{ name?: string }>>> & {
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<void>;
/**
* 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
* <x.link>real TodoMVC app.</x.link>
* <x.heading>todos</x.heading>
* <x.textbox>What needs to be done?</x.textbox>
* </>);
* ```
*
* @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<void>;
/**
* Makes the assertion check for the opposite condition. For example, this code tests that the Locator doesn't contain
* text `"error"`:

View file

@ -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<PageTestFixtures & ServerFixtures & TestModeTestFixtures, PageWorkerFixtures & PlatformWorkerFixtures & TestModeWorkerFixtures & TestModeWorkerOptions & ServerWorkerOptions> = browserTest;

View file

@ -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(`<h1>title</h1>`);
await expect(page.locator('body')).toMatchAriaSnapshot(<>
<x.heading>title</x.heading>
</>);
});
test('should match in list', async ({ page }) => {
await page.setContent(`
<h1>title</h1>
<h1>title 2</h1>
`);
await expect(page.locator('body')).toMatchAriaSnapshot(<>
<x.heading>title</x.heading>
</>);
});
test('should match list with accessible name', async ({ page }) => {
await page.setContent(`
<ul aria-label="my list">
<li>one</li>
<li>two</li>
</ul>
`);
await expect(page.locator('body')).toMatchAriaSnapshot(<>
<x.list name='my list'>
<x.listitem>one</x.listitem>
<x.listitem>two</x.listitem>
</x.list>
</>);
});
test('should match deep item', async ({ page }) => {
await page.setContent(`
<div>
<h1>title</h1>
<h1>title 2</h1>
</div>
`);
await expect(page.locator('body')).toMatchAriaSnapshot(<>
<x.heading>title</x.heading>
</>);
});
test('should match complex', async ({ page }) => {
await page.setContent(`
<ul>
<li>
<a href='about:blank'>link</a>
</li>
</ul>
`);
await expect(page.locator('body')).toMatchAriaSnapshot(<>
<x.list>
<x.listitem>
<x.link>link</x.link>
</x.listitem>
</x.list>
</>);
});
test('should match regex', async ({ page }) => {
await page.setContent(`<h1>Issues 12</h1>`);
await expect(page.locator('body')).toMatchAriaSnapshot(<>
<x.heading>Issues {x.match(/\d+/)}</x.heading>
</>);
});

View file

@ -18,6 +18,6 @@
"@web/*": ["packages/web/src/*"],
},
},
"include": ["**/*.spec.js", "**/*.ts"],
"include": ["**/*.spec.js", "**/*.ts", "**/*.tsx"],
"exclude": ["components/", "installation/fixture-scripts/"]
}

View file

@ -471,6 +471,21 @@ type MergedExpect<List> = Expect<MergedExpectMatchers<List>>;
*/
export function mergeExpects<List extends any[]>(...expects: List): MergedExpect<List>;
/**
* 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<Role, React.FunctionComponent<React.PropsWithChildren<{ name?: string }>>> & {
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 { };