chore: experimental toMatchAriaSnapshot (#33014)

This commit is contained in:
Pavel Feldman 2024-10-14 14:07:19 -07:00 коммит произвёл GitHub
Родитель 6cfcbe0d6d
Коммит a38ff6e0d8
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
22 изменённых файлов: 716 добавлений и 149 удалений

Просмотреть файл

@ -2103,3 +2103,30 @@ Expected options currently selected.
### option: LocatorAssertions.toHaveValues.timeout = %%-csharp-java-python-assertions-timeout-%% ### option: LocatorAssertions.toHaveValues.timeout = %%-csharp-java-python-assertions-timeout-%%
* since: v1.23 * since: v1.23
## async method: LocatorAssertions.toMatchAriaSnapshot
* since: v1.49
* langs: js
Asserts that the target element matches the given accessibility snapshot.
**Usage**
```js
import { role as x } from '@playwright/test';
// ...
await page.goto('https://demo.playwright.dev/todomvc/');
await expect(page.locator('body')).toMatchAriaSnapshot(`
- heading "todos"
- textbox "What needs to be done?"
`);
```
### param: LocatorAssertions.toMatchAriaSnapshot.expected
* since: v1.49
* langs: js
- `expected` <string>
### option: LocatorAssertions.toMatchAriaSnapshot.timeout = %%-js-assertions-timeout-%%
* since: v1.49
* langs: js

11
package-lock.json сгенерированный
Просмотреть файл

@ -64,7 +64,7 @@
"vite": "^5.4.6", "vite": "^5.4.6",
"ws": "^8.17.1", "ws": "^8.17.1",
"xml2js": "^0.5.0", "xml2js": "^0.5.0",
"yaml": "^2.2.2" "yaml": "^2.5.1"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=18"
@ -7852,10 +7852,13 @@
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="
}, },
"node_modules/yaml": { "node_modules/yaml": {
"version": "2.3.4", "version": "2.5.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz",
"integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==",
"dev": true, "dev": true,
"bin": {
"yaml": "bin.mjs"
},
"engines": { "engines": {
"node": ">= 14" "node": ">= 14"
} }

Просмотреть файл

@ -103,6 +103,6 @@
"vite": "^5.4.6", "vite": "^5.4.6",
"ws": "^8.17.1", "ws": "^8.17.1",
"xml2js": "^0.5.0", "xml2js": "^0.5.0",
"yaml": "^2.2.2" "yaml": "^2.5.1"
} }
} }

Просмотреть файл

@ -0,0 +1,281 @@
/**
* Copyright (c) Microsoft Corporation.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { escapeWithQuotes } from '@isomorphic/stringUtils';
import { beginAriaCaches, endAriaCaches, getAriaRole, getElementAccessibleName, isElementIgnoredForAria } from './roleUtils';
import { isElementVisible } from './domUtils';
type AriaNode = {
role: string;
name?: string;
children?: (AriaNode | string)[];
};
export type AriaTemplateNode = {
role: string;
name?: RegExp | string;
children?: (AriaTemplateNode | string | RegExp)[];
};
export function generateAriaTree(rootElement: Element): AriaNode {
const toAriaNode = (element: Element): { ariaNode: AriaNode, isLeaf: boolean } | null => {
const role = getAriaRole(element);
if (!role)
return null;
const name = role ? getElementAccessibleName(element, false) || undefined : undefined;
const isLeaf = leafRoles.has(role);
const result: AriaNode = { role, name };
if (isLeaf && !name && element.textContent)
result.children = [element.textContent];
return { isLeaf, ariaNode: result };
};
const visit = (ariaNode: AriaNode, node: Node) => {
if (node.nodeType === Node.TEXT_NODE && node.nodeValue) {
ariaNode.children = ariaNode.children || [];
ariaNode.children.push(node.nodeValue);
return;
}
if (node.nodeType !== Node.ELEMENT_NODE)
return;
const element = node as Element;
if (isElementIgnoredForAria(element))
return;
const visible = isElementVisible(element);
const hasVisibleChildren = element.checkVisibility({
opacityProperty: true,
visibilityProperty: true,
contentVisibilityAuto: true
});
if (!hasVisibleChildren)
return;
if (visible) {
const childAriaNode = toAriaNode(element);
const isHiddenContainer = childAriaNode && hiddenContainerRoles.has(childAriaNode.ariaNode.role);
if (childAriaNode && !isHiddenContainer) {
ariaNode.children = ariaNode.children || [];
ariaNode.children.push(childAriaNode.ariaNode);
}
if (isHiddenContainer || !childAriaNode?.isLeaf)
processChildNodes(childAriaNode?.ariaNode || ariaNode, element);
} else {
processChildNodes(ariaNode, element);
}
};
function processChildNodes(ariaNode: AriaNode, element: Element) {
// Process light DOM children
for (let child = element.firstChild; child; child = child.nextSibling)
visit(ariaNode, child);
// Process shadow DOM children, if any
if (element.shadowRoot) {
for (let child = element.shadowRoot.firstChild; child; child = child.nextSibling)
visit(ariaNode, child);
}
}
beginAriaCaches();
const result = toAriaNode(rootElement);
const ariaRoot = result?.ariaNode || { role: '' };
try {
visit(ariaRoot, rootElement);
} finally {
endAriaCaches();
}
normalizeStringChildren(ariaRoot);
return ariaRoot;
}
export function renderedAriaTree(rootElement: Element): string {
return renderAriaTree(generateAriaTree(rootElement));
}
function normalizeStringChildren(rootA11yNode: AriaNode) {
const flushChildren = (buffer: string[], normalizedChildren: (AriaNode | string)[]) => {
if (!buffer.length)
return;
const text = normalizeWhitespaceWithin(buffer.join('')).trim();
if (text)
normalizedChildren.push(text);
buffer.length = 0;
};
const visit = (ariaNode: AriaNode) => {
const normalizedChildren: (AriaNode | string)[] = [];
const buffer: string[] = [];
for (const child of ariaNode.children || []) {
if (typeof child === 'string') {
buffer.push(child);
} else {
flushChildren(buffer, normalizedChildren);
visit(child);
normalizedChildren.push(child);
}
}
flushChildren(buffer, normalizedChildren);
ariaNode.children = normalizedChildren.length ? normalizedChildren : undefined;
};
visit(rootA11yNode);
}
const hiddenContainerRoles = new Set(['none', 'presentation']);
const leafRoles = new Set([
'alert', 'blockquote', 'button', 'caption', 'checkbox', 'code', 'columnheader',
'definition', 'deletion', 'emphasis', 'generic', 'heading', 'img', 'insertion',
'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'meter', 'option',
'progressbar', 'radio', 'rowheader', 'scrollbar', 'searchbox', 'separator',
'slider', 'spinbutton', 'strong', 'subscript', 'superscript', 'switch', 'tab', 'term',
'textbox', 'time', 'tooltip'
]);
const normalizeWhitespaceWithin = (text: string) => text.replace(/[\s\n]+/g, ' ');
function matchesText(text: string | undefined, template: RegExp | string | undefined) {
if (!template)
return true;
if (!text)
return false;
if (typeof template === 'string')
return text === template;
return !!text.match(template);
}
export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: string } {
const root = generateAriaTree(rootElement);
const matches = nodeMatches(root, template);
return { matches, received: renderAriaTree(root) };
}
function matchesNode(node: AriaNode | string, template: AriaTemplateNode | RegExp | string, depth: number): boolean {
if (typeof node === 'string' && (typeof template === 'string' || template instanceof RegExp))
return matchesText(node, template);
if (typeof node === 'object' && typeof template === 'object' && !(template instanceof RegExp)) {
if (template.role && template.role !== node.role)
return false;
if (!matchesText(node.name, template.name))
return false;
if (!containsList(node.children || [], template.children || [], depth))
return false;
return true;
}
return false;
}
function containsList(children: (AriaNode | string)[], template: (AriaTemplateNode | RegExp | string)[], depth: number): boolean {
if (template.length > children.length)
return false;
const cc = children.slice();
const tt = template.slice();
for (const t of tt) {
let c = cc.shift();
while (c) {
if (matchesNode(c, t, depth + 1))
break;
c = cc.shift();
}
if (!c)
return false;
}
return true;
}
function nodeMatches(root: AriaNode, template: AriaTemplateNode): boolean {
const results: (AriaNode | string)[] = [];
const visit = (node: AriaNode | string): boolean => {
if (matchesNode(node, template, 0)) {
results.push(node);
return true;
}
if (typeof node === 'string')
return false;
for (const child of node.children || []) {
if (visit(child))
return true;
}
return false;
};
visit(root);
return !!results.length;
}
export function renderAriaTree(ariaNode: AriaNode): string {
const lines: string[] = [];
const visit = (ariaNode: AriaNode, indent: string) => {
let line = `${indent}- ${ariaNode.role}`;
if (ariaNode.name)
line += ` ${escapeWithQuotes(ariaNode.name, '"')}`;
const noChild = !ariaNode.name && !ariaNode.children?.length;
const oneChild = !ariaNode.name && ariaNode.children?.length === 1 && typeof ariaNode.children[0] === 'string';
if (noChild || oneChild) {
if (oneChild)
line += ': ' + escapeYamlString(ariaNode.children?.[0] as string);
lines.push(line);
return;
}
lines.push(line + (ariaNode.children ? ':' : ''));
for (const child of ariaNode.children || []) {
if (typeof child === 'string')
lines.push(indent + ' - text: ' + escapeYamlString(child));
else
visit(child, indent + ' ');
}
};
visit(ariaNode, '');
return lines.join('\n');
}
function escapeYamlString(str: string) {
if (str === '')
return '""';
const needQuotes = (
// Starts or ends with whitespace
/^\s|\s$/.test(str) ||
// Contains control characters
/[\x00-\x1f]/.test(str) ||
// Contains special YAML characters that could cause parsing issues
/[\[\]{}&*!,|>%@`]/.test(str) ||
// Contains a colon followed by a space (could be interpreted as a key-value pair)
/:\s/.test(str) ||
// Is a YAML boolean or null value
/^(?:y|Y|yes|Yes|YES|n|N|no|No|NO|true|True|TRUE|false|False|FALSE|null|Null|NULL|~)$/.test(str) ||
// Could be interpreted as a number
/^[-+]?[0-9]*\.?[0-9]+([eE][-+]?[0-9]+)?$/.test(str) ||
// Contains a newline character
/\n/.test(str) ||
// Starts with a special character
/^[\-?:,>|%@"`]/.test(str)
);
if (needQuotes) {
return `"${str
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"')
.replace(/\n/g, '\\n')
.replace(/\r/g, '\\r')}"`;
}
return str;
}

Просмотреть файл

@ -20,6 +20,7 @@ import { escapeForTextSelector } from '../../utils/isomorphic/stringUtils';
import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import { asLocator } from '../../utils/isomorphic/locatorGenerators';
import type { Language } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators';
import type { InjectedScript } from './injectedScript'; import type { InjectedScript } from './injectedScript';
import { renderedAriaTree } from './ariaSnapshot';
const selectorSymbol = Symbol('selector'); const selectorSymbol = Symbol('selector');
@ -85,6 +86,7 @@ class ConsoleAPI {
inspect: (selector: string) => this._inspect(selector), inspect: (selector: string) => this._inspect(selector),
selector: (element: Element) => this._selector(element), selector: (element: Element) => this._selector(element),
generateLocator: (element: Element, language?: Language) => this._generateLocator(element, language), generateLocator: (element: Element, language?: Language) => this._generateLocator(element, language),
ariaSnapshot: (element?: Element) => renderedAriaTree(element || this._injectedScript.document.body),
resume: () => this._resume(), resume: () => this._resume(),
...new Locator(injectedScript, ''), ...new Locator(injectedScript, ''),
}; };

Просмотреть файл

@ -29,13 +29,12 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser';
import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator'; import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator';
import type * as channels from '@protocol/channels'; import type * as channels from '@protocol/channels';
import { Highlight } from './highlight'; import { Highlight } from './highlight';
import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, beginAriaCaches, endAriaCaches } from './roleUtils'; import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription } from './roleUtils';
import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils'; import { kLayoutSelectorNames, type LayoutSelectorName, layoutSelectorScore } from './layoutSelectorUtils';
import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import { asLocator } from '../../utils/isomorphic/locatorGenerators';
import type { Language } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators';
import { cacheNormalizedWhitespaces, escapeHTML, escapeHTMLAttribute, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils'; import { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils';
import { selectorForSimpleDomNodeId, generateSimpleDomNode } from './simpleDom'; import { matchesAriaTree } from './ariaSnapshot';
import type { SimpleDomNode } from './simpleDom';
export type FrameExpectParams = Omit<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any }; export type FrameExpectParams = Omit<channels.FrameExpectParams, 'expectedValue'> & { expectedValue?: any };
@ -75,12 +74,8 @@ export class InjectedScript {
// module-level globals will be duplicated, which leads to subtle bugs. // module-level globals will be duplicated, which leads to subtle bugs.
readonly utils = { readonly utils = {
asLocator, asLocator,
beginAriaCaches,
cacheNormalizedWhitespaces, cacheNormalizedWhitespaces,
elementText, elementText,
endAriaCaches,
escapeHTML,
escapeHTMLAttribute,
getAriaRole, getAriaRole,
getElementAccessibleDescription, getElementAccessibleDescription,
getElementAccessibleName, getElementAccessibleName,
@ -1255,6 +1250,11 @@ export class InjectedScript {
} }
} }
{
if (expression === 'to.match.aria')
return matchesAriaTree(element, options.expectedValue);
}
{ {
// Single text value. // Single text value.
let received: string | undefined; let received: string | undefined;
@ -1332,17 +1332,6 @@ export class InjectedScript {
} }
throw this.createStacklessError('Unknown expect matcher: ' + expression); 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']); const autoClosingTags = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']);

Просмотреть файл

@ -261,7 +261,7 @@ function getAriaBoolean(attr: string | null) {
return attr === null ? undefined : attr.toLowerCase() === 'true'; return attr === null ? undefined : attr.toLowerCase() === 'true';
} }
function isElementIgnoredForAria(element: Element) { export function isElementIgnoredForAria(element: Element) {
return ['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(elementSafeTagName(element)); return ['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(elementSafeTagName(element));
} }

Просмотреть файл

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

Просмотреть файл

@ -155,6 +155,7 @@ This project incorporates components from the projects listed below. The origina
- undici-types@6.19.8 (https://github.com/nodejs/undici) - undici-types@6.19.8 (https://github.com/nodejs/undici)
- update-browserslist-db@1.0.13 (https://github.com/browserslist/update-db) - update-browserslist-db@1.0.13 (https://github.com/browserslist/update-db)
- yallist@3.1.1 (https://github.com/isaacs/yallist) - yallist@3.1.1 (https://github.com/isaacs/yallist)
- yaml@2.5.1 (https://github.com/eemeli/yaml)
%% @ampproject/remapping@2.2.1 NOTICES AND INFORMATION BEGIN HERE %% @ampproject/remapping@2.2.1 NOTICES AND INFORMATION BEGIN HERE
========================================= =========================================
@ -4397,8 +4398,26 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
========================================= =========================================
END OF yallist@3.1.1 AND INFORMATION END OF yallist@3.1.1 AND INFORMATION
%% yaml@2.5.1 NOTICES AND INFORMATION BEGIN HERE
=========================================
Copyright Eemeli Aro <eemeli@gmail.com>
Permission to use, copy, modify, and/or distribute this software for any purpose
with or without fee is hereby granted, provided that the above copyright notice
and this permission notice appear in all copies.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
THIS SOFTWARE.
=========================================
END OF yaml@2.5.1 AND INFORMATION
SUMMARY BEGIN HERE SUMMARY BEGIN HERE
========================================= =========================================
Total Packages: 151 Total Packages: 152
========================================= =========================================
END OF SUMMARY END OF SUMMARY

Просмотреть файл

@ -40,6 +40,7 @@ export const matcherUtils = {
}; };
export { export {
EXPECTED_COLOR,
INVERTED_COLOR, INVERTED_COLOR,
RECEIVED_COLOR, RECEIVED_COLOR,
printReceived, printReceived,

19
packages/playwright/bundles/utils/package-lock.json сгенерированный
Просмотреть файл

@ -13,7 +13,8 @@
"json5": "2.2.3", "json5": "2.2.3",
"pirates": "4.0.4", "pirates": "4.0.4",
"source-map-support": "0.5.21", "source-map-support": "0.5.21",
"stoppable": "1.1.0" "stoppable": "1.1.0",
"yaml": "^2.5.1"
}, },
"devDependencies": { "devDependencies": {
"@types/source-map-support": "^0.5.4", "@types/source-map-support": "^0.5.4",
@ -280,6 +281,17 @@
"engines": { "engines": {
"node": ">=8.0" "node": ">=8.0"
} }
},
"node_modules/yaml": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz",
"integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14"
}
} }
}, },
"dependencies": { "dependencies": {
@ -464,6 +476,11 @@
"requires": { "requires": {
"is-number": "^7.0.0" "is-number": "^7.0.0"
} }
},
"yaml": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz",
"integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q=="
} }
} }
} }

Просмотреть файл

@ -14,7 +14,8 @@
"json5": "2.2.3", "json5": "2.2.3",
"pirates": "4.0.4", "pirates": "4.0.4",
"source-map-support": "0.5.21", "source-map-support": "0.5.21",
"stoppable": "1.1.0" "stoppable": "1.1.0",
"yaml": "^2.5.1"
}, },
"devDependencies": { "devDependencies": {
"@types/source-map-support": "^0.5.4", "@types/source-map-support": "^0.5.4",

Просмотреть файл

@ -31,3 +31,6 @@ export const enquirer = enquirerLibrary;
import chokidarLibrary from 'chokidar'; import chokidarLibrary from 'chokidar';
export const chokidar = chokidarLibrary; export const chokidar = chokidarLibrary;
import yamlLibrary from 'yaml';
export const yaml = yamlLibrary;

Просмотреть файл

@ -1,4 +1,5 @@
[*] [*]
../common/ ../common/
../util.ts ../util.ts
../utilsBundle.ts
../worker/testInfo.ts ../worker/testInfo.ts

Просмотреть файл

@ -62,6 +62,7 @@ import {
import { zones } from 'playwright-core/lib/utils'; import { zones } from 'playwright-core/lib/utils';
import { TestInfoImpl } from '../worker/testInfo'; import { TestInfoImpl } from '../worker/testInfo';
import { ExpectError, isExpectError } from './matcherHint'; import { ExpectError, isExpectError } from './matcherHint';
import { toMatchAriaSnapshot } from './toMatchAriaSnapshot';
// #region // #region
// Mirrored from https://github.com/facebook/jest/blob/f13abff8df9a0e1148baf3584bcde6d1b479edc7/packages/expect/src/print.ts // Mirrored from https://github.com/facebook/jest/blob/f13abff8df9a0e1148baf3584bcde6d1b479edc7/packages/expect/src/print.ts
@ -236,6 +237,7 @@ const customAsyncMatchers = {
toHaveValue, toHaveValue,
toHaveValues, toHaveValues,
toHaveScreenshot, toHaveScreenshot,
toMatchAriaSnapshot,
toPass, toPass,
}; };

Просмотреть файл

@ -27,7 +27,7 @@ import { TestInfoImpl } from '../worker/testInfo';
import type { ExpectMatcherState } from '../../types/test'; import type { ExpectMatcherState } from '../../types/test';
import { takeFirst } from '../common/config'; 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 }>; _expect(expression: string, options: Omit<FrameExpectOptions, 'expectedValue'> & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>;
} }

Просмотреть файл

@ -0,0 +1,134 @@
/**
* 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 { kNoElementsFoundError, matcherHint, type MatcherResult } from './matcherHint';
import type { AriaTemplateNode } from 'playwright-core/lib/server/injected/ariaSnapshot';
import { yaml } from '../utilsBundle';
import { colors } from 'playwright-core/lib/utilsBundle';
import { EXPECTED_COLOR } from '../common/expectBundle';
import { callLogText } from '../util';
import { printReceivedStringContainExpectedSubstring } from './expect';
export async function toMatchAriaSnapshot(
this: ExpectMatcherState,
receiver: LocatorEx,
expected: string,
options: { timeout?: number, matchSubstring?: boolean } = {},
): Promise<MatcherResult<string | RegExp, string>> {
const matcherName = 'toMatchAriaSnapshot';
const matcherOptions = {
isNot: this.isNot,
promise: this.promise,
};
if (typeof expected !== 'string') {
throw new Error([
matcherHint(this, receiver, matcherName, receiver, expected, matcherOptions),
`${colors.bold('Matcher error')}: ${EXPECTED_COLOR('expected',)} value must be a string`,
this.utils.printWithType('Expected', expected, this.utils.printExpected)
].join('\n\n'));
}
const ariaTree = toAriaTree(expected) as AriaTemplateNode;
const timeout = options.timeout ?? this.timeout;
const { matches: pass, received, log, timedOut } = await receiver._expect('to.match.aria', { expectedValue: ariaTree, isNot: this.isNot, timeout });
const messagePrefix = matcherHint(this, receiver, matcherName, 'locator', undefined, matcherOptions, timedOut ? timeout : undefined);
const notFound = received === kNoElementsFoundError;
const message = () => {
if (pass) {
if (notFound)
return messagePrefix + `Expected: not ${this.utils.printExpected(expected)}\nReceived: ${received}` + callLogText(log);
const printedReceived = printReceivedStringContainExpectedSubstring(received, received.indexOf(expected), expected.length);
return messagePrefix + `Expected: not ${this.utils.printExpected(expected)}\nReceived string: ${printedReceived}` + callLogText(log);
} else {
const labelExpected = `Expected`;
if (notFound)
return messagePrefix + `${labelExpected}: ${this.utils.printExpected(expected)}\nReceived: ${received}` + callLogText(log);
return messagePrefix + this.utils.printDiffOrStringify(expected, received, labelExpected, 'Received string', false) + callLogText(log);
}
};
return {
name: matcherName,
expected,
message,
pass,
actual: received,
log,
timeout: timedOut ? timeout : undefined,
};
}
function parseKey(key: string): AriaTemplateNode {
if (!key)
return { role: '' };
const match = key.match(/^([a-z]+)(?:\s+(?:"([^"]*)"|\/([^\/]*)\/))?$/);
if (!match)
throw new Error(`Invalid key ${key}`);
const role = match[1];
if (role && role !== 'text' && !allRoles.includes(role))
throw new Error(`Invalid role ${role}`);
if (match[2])
return { role, name: match[2] };
if (match[3])
return { role, name: new RegExp(match[3]) };
return { role };
}
function valueOrRegex(value: string): string | RegExp {
return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : value;
}
type YamlNode = Record<string, Array<YamlNode> | string>;
function toAriaTree(text: string): AriaTemplateNode {
const convert = (object: YamlNode | string): AriaTemplateNode | RegExp | string => {
const key = typeof object === 'string' ? object : Object.keys(object)[0];
const value = typeof object === 'string' ? undefined : object[key];
const parsed = parseKey(key);
if (parsed.role === 'text') {
if (typeof value !== 'string')
throw new Error(`Generic role must have a text value`);
return valueOrRegex(value as string);
}
if (Array.isArray(value))
parsed.children = value.map(convert);
else if (value)
parsed.children = [valueOrRegex(value)];
return parsed;
};
const fragment = yaml.parse(text) as YamlNode[];
return convert({ '': fragment }) as AriaTemplateNode;
}
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'
];

Просмотреть файл

@ -20,3 +20,4 @@ export const sourceMapSupport: typeof import('../bundles/utils/node_modules/@typ
export const stoppable: typeof import('../bundles/utils/node_modules/@types/stoppable') = require('./utilsBundleImpl').stoppable; export const stoppable: typeof import('../bundles/utils/node_modules/@types/stoppable') = require('./utilsBundleImpl').stoppable;
export const enquirer: typeof import('../bundles/utils/node_modules/enquirer') = require('./utilsBundleImpl').enquirer; export const enquirer: typeof import('../bundles/utils/node_modules/enquirer') = require('./utilsBundleImpl').enquirer;
export const chokidar: typeof import('../bundles/utils/node_modules/chokidar') = require('./utilsBundleImpl').chokidar; export const chokidar: typeof import('../bundles/utils/node_modules/chokidar') = require('./utilsBundleImpl').chokidar;
export const yaml: typeof import('../bundles/utils/node_modules/yaml') = require('./utilsBundleImpl').yaml;

25
packages/playwright/types/test.d.ts поставляемый
Просмотреть файл

@ -7638,6 +7638,31 @@ interface LocatorAssertions {
timeout?: number; timeout?: number;
}): Promise<void>; }): Promise<void>;
/**
* Asserts that the target element matches the given accessibility snapshot.
*
* **Usage**
*
* ```js
* import { role as x } from '@playwright/test';
* // ...
* await page.goto('https://demo.playwright.dev/todomvc/');
* await expect(page.locator('body')).toMatchAriaSnapshot(`
* - heading "todos"
* - textbox "What needs to be done?"
* `);
* ```
*
* @param expected
* @param options
*/
toMatchAriaSnapshot(expected: string, 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 * Makes the assertion check for the opposite condition. For example, this code tests that the Locator doesn't contain
* text `"error"`: * text `"error"`:

Просмотреть файл

@ -107,6 +107,7 @@ it('expected properties on playwright object', async ({ page }) => {
'inspect', 'inspect',
'selector', 'selector',
'generateLocator', 'generateLocator',
'ariaSnapshot',
'resume', 'resume',
'locator', 'locator',
'getByTestId', 'getByTestId',

Просмотреть файл

@ -0,0 +1,179 @@
/**
* 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 { stripAnsi } from 'tests/config/utils';
import { test, expect } from './pageTest';
test('should match', async ({ page }) => {
await page.setContent(`<h1>title</h1>`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- heading "title"
`);
});
test('should match in list', async ({ page }) => {
await page.setContent(`
<h1>title</h1>
<h1>title 2</h1>
`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- heading "title"
`);
});
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(`
- list "my list":
- listitem: one
- listitem: two
`);
});
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(`
- heading "title"
`);
});
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(`
- list:
- listitem:
- link "link"
`);
});
test('should match regex', async ({ page }) => {
await page.setContent(`<h1>Issues 12</h1>`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- heading /Issues \\d+/
`);
});
test('should allow text nodes', async ({ page }) => {
await page.setContent(`
<h1>Microsoft</h1>
<div>Open source projects and samples from Microsoft</div>
`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- heading "Microsoft"
- text: Open source projects and samples from Microsoft
`);
});
test('integration test', async ({ page, browserName }) => {
test.fixme(browserName === 'webkit');
await page.setContent(`
<h1>Microsoft</h1>
<div>Open source projects and samples from Microsoft</div>
<ul>
<li>
<details>
<summary>
Verified
</summary>
<div>
<div>
<p>
We've verified that the organization <strong>microsoft</strong> controls the domain:
</p>
<ul>
<li class="mb-1">
<strong>opensource.microsoft.com</strong>
</li>
</ul>
<div>
<a href="about: blank">Learn more about verified organizations</a>
</div>
</div>
</div>
</details>
</li>
<li>
<a href="about:blank">
<summary title="Label: GitHub Sponsor">Sponsor</summary>
</a>
</li>
</ul>`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- heading "Microsoft"
- text: Open source projects and samples from Microsoft
- list:
- listitem:
- group: Verified
- listitem:
- link "Sponsor"
`);
});
test('integration test 2', async ({ page }) => {
await page.setContent(`
<div>
<header>
<h1>todos</h1>
<input placeholder="What needs to be done?">
</header>
</div>`);
await expect(page.locator('body')).toMatchAriaSnapshot(`
- heading "todos"
- textbox "What needs to be done?"
`);
});
test('expected formatter', async ({ page }) => {
await page.setContent(`
<div>
<header>
<h1>todos</h1>
<input placeholder="What needs to be done?">
</header>
</div>`);
const error = await expect(page.locator('body')).toMatchAriaSnapshot(`
- heading "todos"
- textbox "Wrong text"
`, { timeout: 1 }).catch(e => e);
expect(stripAnsi(error.message)).toContain(`- Expected - 3
+ Received string + 3
-
+ - :
+ - banner:
- heading "todos"
- - textbox "Wrong text"
-
+ - textbox "What needs to be done?"`);
});

Просмотреть файл

@ -152,6 +152,7 @@ class JSLintingService extends LintingService {
'notice/notice': 'off', 'notice/notice': 'off',
'@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': 'off',
'max-len': ['error', { code: 100 }], 'max-len': ['error', { code: 100 }],
'react/react-in-jsx-scope': 'off',
}, },
} }
}); });