diff --git a/docs/src/api/class-locatorassertions.md b/docs/src/api/class-locatorassertions.md index bfff2b07ad..46f8f610e0 100644 --- a/docs/src/api/class-locatorassertions.md +++ b/docs/src/api/class-locatorassertions.md @@ -2103,3 +2103,30 @@ Expected options currently selected. ### option: LocatorAssertions.toHaveValues.timeout = %%-csharp-java-python-assertions-timeout-%% * since: v1.23 + +## async method: LocatorAssertions.toMatchAriaSnapshot +* since: v1.49 +* langs: js + +Asserts that the target element matches the given accessibility snapshot. + +**Usage** + +```js +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` + +### option: LocatorAssertions.toMatchAriaSnapshot.timeout = %%-js-assertions-timeout-%% +* since: v1.49 +* langs: js diff --git a/package-lock.json b/package-lock.json index 71c0dad10c..57046ec2f8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,7 +64,7 @@ "vite": "^5.4.6", "ws": "^8.17.1", "xml2js": "^0.5.0", - "yaml": "^2.2.2" + "yaml": "^2.5.1" }, "engines": { "node": ">=18" @@ -7852,10 +7852,13 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, "node_modules/yaml": { - "version": "2.3.4", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.4.tgz", - "integrity": "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA==", + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.5.1.tgz", + "integrity": "sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==", "dev": true, + "bin": { + "yaml": "bin.mjs" + }, "engines": { "node": ">= 14" } diff --git a/package.json b/package.json index 1f2ce943c9..0e14d12e3e 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,6 @@ "vite": "^5.4.6", "ws": "^8.17.1", "xml2js": "^0.5.0", - "yaml": "^2.2.2" + "yaml": "^2.5.1" } } diff --git a/packages/playwright-core/src/server/injected/ariaSnapshot.ts b/packages/playwright-core/src/server/injected/ariaSnapshot.ts new file mode 100644 index 0000000000..b573569f8f --- /dev/null +++ b/packages/playwright-core/src/server/injected/ariaSnapshot.ts @@ -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; +} diff --git a/packages/playwright-core/src/server/injected/consoleApi.ts b/packages/playwright-core/src/server/injected/consoleApi.ts index f0231e9551..526694745b 100644 --- a/packages/playwright-core/src/server/injected/consoleApi.ts +++ b/packages/playwright-core/src/server/injected/consoleApi.ts @@ -20,6 +20,7 @@ import { escapeForTextSelector } from '../../utils/isomorphic/stringUtils'; import { asLocator } from '../../utils/isomorphic/locatorGenerators'; import type { Language } from '../../utils/isomorphic/locatorGenerators'; import type { InjectedScript } from './injectedScript'; +import { renderedAriaTree } from './ariaSnapshot'; const selectorSymbol = Symbol('selector'); @@ -85,6 +86,7 @@ class ConsoleAPI { inspect: (selector: string) => this._inspect(selector), selector: (element: Element) => this._selector(element), generateLocator: (element: Element, language?: Language) => this._generateLocator(element, language), + ariaSnapshot: (element?: Element) => renderedAriaTree(element || this._injectedScript.document.body), resume: () => this._resume(), ...new Locator(injectedScript, ''), }; diff --git a/packages/playwright-core/src/server/injected/injectedScript.ts b/packages/playwright-core/src/server/injected/injectedScript.ts index 53cf647cfb..0f3308fe7c 100644 --- a/packages/playwright-core/src/server/injected/injectedScript.ts +++ b/packages/playwright-core/src/server/injected/injectedScript.ts @@ -29,13 +29,12 @@ import type { CSSComplexSelectorList } from '../../utils/isomorphic/cssParser'; import { generateSelector, type GenerateSelectorOptions } from './selectorGenerator'; import type * as channels from '@protocol/channels'; import { Highlight } from './highlight'; -import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription, beginAriaCaches, endAriaCaches } from './roleUtils'; +import { getChecked, getAriaDisabled, getAriaRole, getElementAccessibleName, getElementAccessibleDescription } 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 { cacheNormalizedWhitespaces, normalizeWhiteSpace, trimStringWithEllipsis } from '../../utils/isomorphic/stringUtils'; +import { matchesAriaTree } from './ariaSnapshot'; export type FrameExpectParams = Omit & { expectedValue?: any }; @@ -75,12 +74,8 @@ export class InjectedScript { // module-level globals will be duplicated, which leads to subtle bugs. readonly utils = { asLocator, - beginAriaCaches, cacheNormalizedWhitespaces, elementText, - endAriaCaches, - escapeHTML, - escapeHTMLAttribute, getAriaRole, getElementAccessibleDescription, getElementAccessibleName, @@ -1255,6 +1250,11 @@ export class InjectedScript { } } + { + if (expression === 'to.match.aria') + return matchesAriaTree(element, options.expectedValue); + } + { // Single text value. let received: string | undefined; @@ -1332,17 +1332,6 @@ export class InjectedScript { } throw this.createStacklessError('Unknown expect matcher: ' + expression); } - - generateSimpleDomNode(selector: string): SimpleDomNode | undefined { - const element = this.querySelector(this.parseSelector(selector), this.document.documentElement, true); - if (!element) - return; - return generateSimpleDomNode(this, element); - } - - selectorForSimpleDomNodeId(nodeId: string) { - return selectorForSimpleDomNodeId(this, nodeId); - } } const autoClosingTags = new Set(['AREA', 'BASE', 'BR', 'COL', 'COMMAND', 'EMBED', 'HR', 'IMG', 'INPUT', 'KEYGEN', 'LINK', 'MENUITEM', 'META', 'PARAM', 'SOURCE', 'TRACK', 'WBR']); diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index 498fc189aa..6e05c39901 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -261,7 +261,7 @@ function getAriaBoolean(attr: string | null) { return attr === null ? undefined : attr.toLowerCase() === 'true'; } -function isElementIgnoredForAria(element: Element) { +export function isElementIgnoredForAria(element: Element) { return ['STYLE', 'SCRIPT', 'NOSCRIPT', 'TEMPLATE'].includes(elementSafeTagName(element)); } diff --git a/packages/playwright-core/src/server/injected/simpleDom.ts b/packages/playwright-core/src/server/injected/simpleDom.ts deleted file mode 100644 index c31862cd6c..0000000000 --- a/packages/playwright-core/src/server/injected/simpleDom.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Copyright (c) Microsoft Corporation. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import type { InjectedScript } from './injectedScript'; - -const leafRoles = new Set([ - 'button', - 'checkbox', - 'combobox', - 'link', - 'textbox', -]); - -export type SimpleDom = { - markup: string; - elements: Map; -}; - -export type SimpleDomNode = { - dom: SimpleDom; - id: string; - tag: string; -}; - -let lastDom: SimpleDom | undefined; - -export function generateSimpleDom(injectedScript: InjectedScript): SimpleDom { - return generate(injectedScript).dom; -} - -export function generateSimpleDomNode(injectedScript: InjectedScript, target: Element): SimpleDomNode { - return generate(injectedScript, target).node!; -} - -export function selectorForSimpleDomNodeId(injectedScript: InjectedScript, id: string): string { - const element = lastDom?.elements.get(id); - if (!element) - throw new Error(`Internal error: element with id "${id}" not found`); - return injectedScript.generateSelectorSimple(element); -} - -function generate(injectedScript: InjectedScript, target?: Element): { dom: SimpleDom, node?: SimpleDomNode } { - const normalizeWhitespace = (text: string) => text.replace(/[\s\n]+/g, match => match.includes('\n') ? '\n' : ' '); - const tokens: string[] = []; - const elements = new Map(); - let lastId = 0; - let resultTarget: { tag: string, id: string } | undefined; - const visit = (node: Node) => { - if (node.nodeType === Node.TEXT_NODE) { - tokens.push(node.nodeValue!); - return; - } - - if (node.nodeType === Node.ELEMENT_NODE) { - const element = node as Element; - if (element.nodeName === 'SCRIPT' || element.nodeName === 'STYLE' || element.nodeName === 'NOSCRIPT') - return; - if (injectedScript.utils.isElementVisible(element)) { - const role = injectedScript.utils.getAriaRole(element) as string; - if (role && leafRoles.has(role)) { - let value: string | undefined; - if (element.nodeName === 'INPUT' || element.nodeName === 'TEXTAREA') - value = (element as HTMLInputElement | HTMLTextAreaElement).value; - const name = injectedScript.utils.getElementAccessibleName(element, false); - const structuralId = String(++lastId); - elements.set(structuralId, element); - tokens.push(renderTag(injectedScript, role, name, structuralId, { value })); - if (element === target) { - const tagNoValue = renderTag(injectedScript, role, name, structuralId); - resultTarget = { tag: tagNoValue, id: structuralId }; - } - return; - } - } - for (let child = element.firstChild; child; child = child.nextSibling) - visit(child); - } - }; - injectedScript.utils.beginAriaCaches(); - try { - visit(injectedScript.document.body); - } finally { - injectedScript.utils.endAriaCaches(); - } - const dom = { - markup: normalizeWhitespace(tokens.join(' ')), - elements - }; - - if (target && !resultTarget) - throw new Error('Target element is not in the simple DOM'); - - lastDom = dom; - - return { dom, node: resultTarget ? { dom, ...resultTarget } : undefined }; -} - -function renderTag(injectedScript: InjectedScript, role: string, name: string, id: string, params?: { value?: string }): string { - const escapedTextContent = injectedScript.utils.escapeHTML(name); - const escapedValue = injectedScript.utils.escapeHTMLAttribute(params?.value || ''); - switch (role) { - case 'button': return ``; - case 'link': return `${escapedTextContent}`; - case 'textbox': return ``; - } - return `
${escapedTextContent}
`; -} diff --git a/packages/playwright/ThirdPartyNotices.txt b/packages/playwright/ThirdPartyNotices.txt index f2bb64d661..46c2f60cd2 100644 --- a/packages/playwright/ThirdPartyNotices.txt +++ b/packages/playwright/ThirdPartyNotices.txt @@ -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) - update-browserslist-db@1.0.13 (https://github.com/browserslist/update-db) - 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 ========================================= @@ -4397,8 +4398,26 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ========================================= END OF yallist@3.1.1 AND INFORMATION +%% yaml@2.5.1 NOTICES AND INFORMATION BEGIN HERE +========================================= +Copyright Eemeli Aro + +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 ========================================= -Total Packages: 151 +Total Packages: 152 ========================================= END OF SUMMARY \ No newline at end of file diff --git a/packages/playwright/bundles/expect/src/expectBundleImpl.ts b/packages/playwright/bundles/expect/src/expectBundleImpl.ts index dbfd169353..875b48e614 100644 --- a/packages/playwright/bundles/expect/src/expectBundleImpl.ts +++ b/packages/playwright/bundles/expect/src/expectBundleImpl.ts @@ -40,6 +40,7 @@ export const matcherUtils = { }; export { + EXPECTED_COLOR, INVERTED_COLOR, RECEIVED_COLOR, printReceived, diff --git a/packages/playwright/bundles/utils/package-lock.json b/packages/playwright/bundles/utils/package-lock.json index fcf9f972fe..90df9a258b 100644 --- a/packages/playwright/bundles/utils/package-lock.json +++ b/packages/playwright/bundles/utils/package-lock.json @@ -13,7 +13,8 @@ "json5": "2.2.3", "pirates": "4.0.4", "source-map-support": "0.5.21", - "stoppable": "1.1.0" + "stoppable": "1.1.0", + "yaml": "^2.5.1" }, "devDependencies": { "@types/source-map-support": "^0.5.4", @@ -280,6 +281,17 @@ "engines": { "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": { @@ -464,6 +476,11 @@ "requires": { "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==" } } } diff --git a/packages/playwright/bundles/utils/package.json b/packages/playwright/bundles/utils/package.json index 69477909c5..dc807c0d95 100644 --- a/packages/playwright/bundles/utils/package.json +++ b/packages/playwright/bundles/utils/package.json @@ -14,7 +14,8 @@ "json5": "2.2.3", "pirates": "4.0.4", "source-map-support": "0.5.21", - "stoppable": "1.1.0" + "stoppable": "1.1.0", + "yaml": "^2.5.1" }, "devDependencies": { "@types/source-map-support": "^0.5.4", diff --git a/packages/playwright/bundles/utils/src/utilsBundleImpl.ts b/packages/playwright/bundles/utils/src/utilsBundleImpl.ts index 7c29c301a8..76cf961ab7 100644 --- a/packages/playwright/bundles/utils/src/utilsBundleImpl.ts +++ b/packages/playwright/bundles/utils/src/utilsBundleImpl.ts @@ -31,3 +31,6 @@ export const enquirer = enquirerLibrary; import chokidarLibrary from 'chokidar'; export const chokidar = chokidarLibrary; + +import yamlLibrary from 'yaml'; +export const yaml = yamlLibrary; diff --git a/packages/playwright/src/matchers/DEPS.list b/packages/playwright/src/matchers/DEPS.list index 59b704628d..de39c6b545 100644 --- a/packages/playwright/src/matchers/DEPS.list +++ b/packages/playwright/src/matchers/DEPS.list @@ -1,4 +1,5 @@ [*] ../common/ ../util.ts +../utilsBundle.ts ../worker/testInfo.ts diff --git a/packages/playwright/src/matchers/expect.ts b/packages/playwright/src/matchers/expect.ts index 16300607d9..0d276d4101 100644 --- a/packages/playwright/src/matchers/expect.ts +++ b/packages/playwright/src/matchers/expect.ts @@ -62,6 +62,7 @@ import { import { zones } from 'playwright-core/lib/utils'; import { TestInfoImpl } from '../worker/testInfo'; import { ExpectError, isExpectError } from './matcherHint'; +import { toMatchAriaSnapshot } from './toMatchAriaSnapshot'; // #region // Mirrored from https://github.com/facebook/jest/blob/f13abff8df9a0e1148baf3584bcde6d1b479edc7/packages/expect/src/print.ts @@ -236,6 +237,7 @@ const customAsyncMatchers = { toHaveValue, toHaveValues, toHaveScreenshot, + toMatchAriaSnapshot, toPass, }; diff --git a/packages/playwright/src/matchers/matchers.ts b/packages/playwright/src/matchers/matchers.ts index 3ca9180ae2..c0319e371f 100644 --- a/packages/playwright/src/matchers/matchers.ts +++ b/packages/playwright/src/matchers/matchers.ts @@ -27,7 +27,7 @@ import { TestInfoImpl } from '../worker/testInfo'; import type { ExpectMatcherState } from '../../types/test'; import { takeFirst } from '../common/config'; -interface LocatorEx extends Locator { +export interface LocatorEx extends Locator { _expect(expression: string, options: Omit & { expectedValue?: any }): Promise<{ matches: boolean, received?: any, log?: string[], timedOut?: boolean }>; } diff --git a/packages/playwright/src/matchers/toMatchAriaSnapshot.ts b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts new file mode 100644 index 0000000000..949e44af6f --- /dev/null +++ b/packages/playwright/src/matchers/toMatchAriaSnapshot.ts @@ -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> { + 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>; + +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' +]; diff --git a/packages/playwright/src/utilsBundle.ts b/packages/playwright/src/utilsBundle.ts index 072e16bb03..5ded7993b7 100644 --- a/packages/playwright/src/utilsBundle.ts +++ b/packages/playwright/src/utilsBundle.ts @@ -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 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 yaml: typeof import('../bundles/utils/node_modules/yaml') = require('./utilsBundleImpl').yaml; diff --git a/packages/playwright/types/test.d.ts b/packages/playwright/types/test.d.ts index a1128c519e..3f1179e34f 100644 --- a/packages/playwright/types/test.d.ts +++ b/packages/playwright/types/test.d.ts @@ -7638,6 +7638,31 @@ interface LocatorAssertions { timeout?: number; }): Promise; + /** + * 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; + /** * Makes the assertion check for the opposite condition. For example, this code tests that the Locator doesn't contain * text `"error"`: diff --git a/tests/library/inspector/console-api.spec.ts b/tests/library/inspector/console-api.spec.ts index 51d2262bf8..0305f5c5e3 100644 --- a/tests/library/inspector/console-api.spec.ts +++ b/tests/library/inspector/console-api.spec.ts @@ -107,6 +107,7 @@ it('expected properties on playwright object', async ({ page }) => { 'inspect', 'selector', 'generateLocator', + 'ariaSnapshot', 'resume', 'locator', 'getByTestId', diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts new file mode 100644 index 0000000000..d1e0d7b91e --- /dev/null +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -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(`

title

`); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading "title" + `); +}); + +test('should match in list', async ({ page }) => { + await page.setContent(` +

title

+

title 2

+ `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading "title" + `); +}); + +test('should match list with accessible name', async ({ page }) => { + await page.setContent(` +
    +
  • one
  • +
  • two
  • +
+ `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - list "my list": + - listitem: one + - listitem: two + `); +}); + +test('should match deep item', async ({ page }) => { + await page.setContent(` +
+

title

+

title 2

+
+ `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading "title" + `); +}); + +test('should match complex', async ({ page }) => { + await page.setContent(` + + `); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - list: + - listitem: + - link "link" + `); +}); + +test('should match regex', async ({ page }) => { + await page.setContent(`

Issues 12

`); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading /Issues \\d+/ + `); +}); + +test('should allow text nodes', async ({ page }) => { + await page.setContent(` +

Microsoft

+
Open source projects and samples from Microsoft
+ `); + + 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(` +

Microsoft

+
Open source projects and samples from Microsoft
+ `); + + 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(` +
+
+

todos

+ +
+
`); + await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading "todos" + - textbox "What needs to be done?" + `); +}); + +test('expected formatter', async ({ page }) => { + await page.setContent(` +
+
+

todos

+ +
+
`); + 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?"`); +}); diff --git a/utils/doclint/linting-code-snippets/cli.js b/utils/doclint/linting-code-snippets/cli.js index 146646a1f0..5d3200aa9e 100644 --- a/utils/doclint/linting-code-snippets/cli.js +++ b/utils/doclint/linting-code-snippets/cli.js @@ -152,6 +152,7 @@ class JSLintingService extends LintingService { 'notice/notice': 'off', '@typescript-eslint/no-unused-vars': 'off', 'max-len': ['error', { code: 100 }], + 'react/react-in-jsx-scope': 'off', }, } });