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

title

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

title

+

title 2

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

title

+

title 2

+
+ `); + await expect(page.locator('body')).toMatchAriaSnapshot(<> + title + ); +}); + +test('should match complex', async ({ page }) => { + await page.setContent(` + + `); + await expect(page.locator('body')).toMatchAriaSnapshot(<> + + + link + + + ); +}); + +test('should match regex', async ({ page }) => { + await page.setContent(`

Issues 12

`); + await expect(page.locator('body')).toMatchAriaSnapshot(<> + Issues {x.match(/\d+/)} + ); +}); diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 1a1e3d7527..e9f56c19b9 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -18,6 +18,6 @@ "@web/*": ["packages/web/src/*"], }, }, - "include": ["**/*.spec.js", "**/*.ts"], + "include": ["**/*.spec.js", "**/*.ts", "**/*.tsx"], "exclude": ["components/", "installation/fixture-scripts/"] } diff --git a/utils/generate_types/overrides-test.d.ts b/utils/generate_types/overrides-test.d.ts index be1fa7ee37..1f587da5af 100644 --- a/utils/generate_types/overrides-test.d.ts +++ b/utils/generate_types/overrides-test.d.ts @@ -471,6 +471,21 @@ type MergedExpect = Expect>; */ export function mergeExpects(...expects: List): MergedExpect; +/** + * Aria snapshots. + */ +type Role = + 'alert' | 'alertdialog' | 'application' | 'article' | 'banner' | 'blockquote' | 'button' | 'caption' | 'cell' | 'checkbox' | 'code' | 'columnheader' | 'combobox' | 'command' | + 'complementary' | 'composite' | 'contentinfo' | 'definition' | 'deletion' | 'dialog' | 'directory' | 'document' | 'emphasis' | 'feed' | 'figure' | 'form' | 'generic' | 'grid' | + 'gridcell' | 'group' | 'heading' | 'img' | 'input' | 'insertion' | 'landmark' | 'link' | 'list' | 'listbox' | 'listitem' | 'log' | 'main' | 'marquee' | 'math' | 'meter' | 'menu' | + 'menubar' | 'menuitem' | 'menuitemcheckbox' | 'menuitemradio' | 'navigation' | 'none' | 'note' | 'option' | 'paragraph' | 'presentation' | 'progressbar' | 'radio' | 'radiogroup' | + 'range' | 'region' | 'roletype' | 'row' | 'rowgroup' | 'rowheader' | 'scrollbar' | 'search' | 'searchbox' | 'section' | 'sectionhead' | 'select' | 'separator' | 'slider' | + 'spinbutton' | 'status' | 'strong' | 'structure' | 'subscript' | 'superscript' | 'switch' | 'tab' | 'table' | 'tablist' | 'tabpanel' | 'term' | 'textbox' | 'time' | 'timer' | + 'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem' | 'widget' | 'window'; + +export const role: Record>> & { + match: (regex: RegExp) => JSX.Element; +}; + // This is required to not export everything by default. See https://github.com/Microsoft/TypeScript/issues/19545#issuecomment-340490459 export { }; -