chore: experimental toMatchAriaSnapshot
This commit is contained in:
parent
6f16b6cc08
commit
a872b070a5
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
240
packages/playwright-core/src/server/injected/ariaSnapshot.ts
Normal file
240
packages/playwright-core/src/server/injected/ariaSnapshot.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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, ''),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
|
|
|||
|
|
@ -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>`;
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }>;
|
||||
}
|
||||
|
||||
|
|
|
|||
105
packages/playwright/src/matchers/toMatchAriaSnapshot.ts
Normal file
105
packages/playwright/src/matchers/toMatchAriaSnapshot.ts
Normal 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);
|
||||
42
packages/playwright/types/test.d.ts
vendored
42
packages/playwright/types/test.d.ts
vendored
|
|
@ -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"`:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
85
tests/page/to-match-aria-snapshot.spec.tsx
Normal file
85
tests/page/to-match-aria-snapshot.spec.tsx
Normal 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>
|
||||
</>);
|
||||
});
|
||||
|
|
@ -18,6 +18,6 @@
|
|||
"@web/*": ["packages/web/src/*"],
|
||||
},
|
||||
},
|
||||
"include": ["**/*.spec.js", "**/*.ts"],
|
||||
"include": ["**/*.spec.js", "**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["components/", "installation/fixture-scripts/"]
|
||||
}
|
||||
|
|
|
|||
17
utils/generate_types/overrides-test.d.ts
vendored
17
utils/generate_types/overrides-test.d.ts
vendored
|
|
@ -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 { };
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue