From 97d26e8166b0519756344afbfc33277dbfd530af Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Sat, 19 Oct 2024 14:23:08 -0700 Subject: [PATCH] chore: add aria attribute tests (#33184) --- .../src/server/ariaSnapshot.ts | 118 ++++++---- .../src/server/injected/ariaSnapshot.ts | 38 ++-- .../src/server/injected/roleUtils.ts | 6 +- tests/page/page-aria-snapshot.spec.ts | 15 +- tests/page/to-match-aria-snapshot.spec.ts | 215 +++++++++++++++++- .../playwright-test/ui-mode-test-run.spec.ts | 17 ++ 6 files changed, 331 insertions(+), 78 deletions(-) diff --git a/packages/playwright-core/src/server/ariaSnapshot.ts b/packages/playwright-core/src/server/ariaSnapshot.ts index 47ed4066b7..03b5a05e92 100644 --- a/packages/playwright-core/src/server/ariaSnapshot.ts +++ b/packages/playwright-core/src/server/ariaSnapshot.ts @@ -17,6 +17,7 @@ import type { AriaTemplateNode } from './injected/ariaSnapshot'; import { yaml } from '../utilsBundle'; import type { AriaRole } from '@injected/roleUtils'; +import { assert } from '../utils'; export function parseAriaSnapshot(text: string): AriaTemplateNode { const fragment = yaml.parse(text) as any[]; @@ -28,69 +29,106 @@ export function parseAriaSnapshot(text: string): AriaTemplateNode { function populateNode(node: AriaTemplateNode, container: any[]) { for (const object of container) { if (typeof object === 'string') { - const { role, name } = parseKey(object); + const childNode = parseKey(object); node.children = node.children || []; - node.children.push({ role, name }); + node.children.push(childNode); continue; } - for (const key of Object.keys(object)) { - if (key === 'checked') { - node.checked = object[key]; - continue; - } - if (key === 'disabled') { - node.disabled = object[key]; - continue; - } - if (key === 'expanded') { - node.expanded = object[key]; - continue; - } - if (key === 'level') { - node.level = object[key]; - continue; - } - if (key === 'pressed') { - node.pressed = object[key]; - continue; - } - if (key === 'selected') { - node.selected = object[key]; - continue; - } - const { role, name } = parseKey(key); + for (const key of Object.keys(object)) { + const childNode = parseKey(key); const value = object[key]; node.children = node.children || []; - if (role === 'text') { + if (childNode.role === 'text') { node.children.push(valueOrRegex(value)); continue; } if (typeof value === 'string') { - node.children.push({ role, name, children: [valueOrRegex(value)] }); + node.children.push({ ...childNode, children: [valueOrRegex(value)] }); continue; } - const childNode = { role, name }; node.children.push(childNode); populateNode(childNode, value); } } } -function parseKey(key: string) { - const match = key.match(/^([a-z]+)(?:\s+(?:"([^"]*)"|\/([^\/]*)\/))?$/); - if (!match) +function applyAttribute(node: AriaTemplateNode, key: string, value: string) { + if (key === 'checked') { + assert(value === 'true' || value === 'false' || value === 'mixed', 'Value of "disabled" attribute must be a boolean or "mixed"'); + node.checked = value === 'true' ? true : value === 'false' ? false : 'mixed'; + return; + } + if (key === 'disabled') { + assert(value === 'true' || value === 'false', 'Value of "disabled" attribute must be a boolean'); + node.disabled = value === 'true'; + return; + } + if (key === 'expanded') { + assert(value === 'true' || value === 'false', 'Value of "expanded" attribute must be a boolean'); + node.expanded = value === 'true'; + return; + } + if (key === 'level') { + assert(!isNaN(Number(value)), 'Value of "level" attribute must be a number'); + node.level = Number(value); + return; + } + if (key === 'pressed') { + assert(value === 'true' || value === 'false' || value === 'mixed', 'Value of "pressed" attribute must be a boolean or "mixed"'); + node.pressed = value === 'true' ? true : value === 'false' ? false : 'mixed'; + return; + } + if (key === 'selected') { + assert(value === 'true' || value === 'false', 'Value of "selected" attribute must be a boolean'); + node.selected = value === 'true'; + return; + } + throw new Error(`Unsupported attribute [${key}] `); +} + +function parseKey(key: string): AriaTemplateNode { + const tokenRegex = /\s*([a-z]+|"(?:[^"]*)"|\/(?:[^\/]*)\/|\[.*?\])/g; + let match; + const tokens = []; + while ((match = tokenRegex.exec(key)) !== null) + tokens.push(match[1]); + + if (tokens.length === 0) throw new Error(`Invalid key ${key}`); - const role = match[1] as AriaRole | 'text'; - if (match[2]) - return { role, name: match[2] }; - if (match[3]) - return { role, name: new RegExp(match[3]) }; - return { role }; + const role = tokens[0] as AriaRole | 'text'; + + let name: string | RegExp = ''; + let index = 1; + if (tokens.length > 1 && (tokens[1].startsWith('"') || tokens[1].startsWith('/'))) { + const nameToken = tokens[1]; + if (nameToken.startsWith('"')) { + name = nameToken.slice(1, -1); + } else { + const pattern = nameToken.slice(1, -1); + name = new RegExp(pattern); + } + index = 2; + } + + const result: AriaTemplateNode = { role, name }; + for (; index < tokens.length; index++) { + const attrToken = tokens[index]; + if (attrToken.startsWith('[') && attrToken.endsWith(']')) { + const attrContent = attrToken.slice(1, -1).trim(); + const [attrName, attrValue] = attrContent.split('=', 2); + const value = attrValue !== undefined ? attrValue.trim() : 'true'; + applyAttribute(result, attrName, value); + } else { + throw new Error(`Invalid attribute token ${attrToken} in key ${key}`); + } + } + + return result; } function normalizeWhitespace(text: string) { diff --git a/packages/playwright-core/src/server/injected/ariaSnapshot.ts b/packages/playwright-core/src/server/injected/ariaSnapshot.ts index 16d8dc6070..56044a2027 100644 --- a/packages/playwright-core/src/server/injected/ariaSnapshot.ts +++ b/packages/playwright-core/src/server/injected/ariaSnapshot.ts @@ -22,8 +22,8 @@ import type { AriaRole } from './roleUtils'; type AriaProps = { checked?: boolean | 'mixed'; disabled?: boolean; - expanded?: boolean | 'none', - level?: number, + expanded?: boolean; + level?: number; pressed?: boolean | 'mixed'; selected?: boolean; }; @@ -286,13 +286,23 @@ export function renderAriaTree(ariaNode: AriaNode): string { let line = `${indent}- ${ariaNode.role}`; if (ariaNode.name) line += ` ${escapeWithQuotes(ariaNode.name, '"')}`; - const stringValue = !ariaNode.checked - && !ariaNode.disabled - && (!ariaNode.expanded || ariaNode.expanded === 'none') - && !ariaNode.level - && !ariaNode.pressed - && !ariaNode.selected - && (!ariaNode.children.length || (ariaNode.children?.length === 1 && typeof ariaNode.children[0] === 'string')); + + if (ariaNode.checked === 'mixed') + line += ` [checked=mixed]`; + if (ariaNode.checked === true) + line += ` [checked]`; + if (ariaNode.disabled) + line += ` [disabled]`; + if (ariaNode.expanded) + line += ` [expanded]`; + if (ariaNode.level) + line += ` [level=${ariaNode.level}]`; + if (ariaNode.pressed === 'mixed') + line += ` [pressed=mixed]`; + if (ariaNode.pressed === true) + line += ` [pressed]`; + + const stringValue = !ariaNode.children.length || (ariaNode.children?.length === 1 && typeof ariaNode.children[0] === 'string'); if (stringValue) { if (ariaNode.children.length) line += ': ' + escapeYamlString(ariaNode.children?.[0] as string); @@ -301,16 +311,6 @@ export function renderAriaTree(ariaNode: AriaNode): string { } lines.push(line + ':'); - if (ariaNode.checked) - lines.push(`${indent} - checked: ${ariaNode.checked}`); - if (ariaNode.disabled) - lines.push(`${indent} - disabled: ${ariaNode.disabled}`); - if (ariaNode.expanded && ariaNode.expanded !== 'none') - lines.push(`${indent} - expanded: ${ariaNode.expanded}`); - if (ariaNode.level) - lines.push(`${indent} - level: ${ariaNode.level}`); - if (ariaNode.pressed) - lines.push(`${indent} - pressed: ${ariaNode.pressed}`); for (const child of ariaNode.children || []) visit(child, indent + ' '); }; diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index 9c5712311e..df52329b1e 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -881,7 +881,7 @@ export function getAriaPressed(element: Element): boolean | 'mixed' { } export const kAriaExpandedRoles = ['application', 'button', 'checkbox', 'combobox', 'gridcell', 'link', 'listbox', 'menuitem', 'row', 'rowheader', 'tab', 'treeitem', 'columnheader', 'menuitemcheckbox', 'menuitemradio', 'rowheader', 'switch']; -export function getAriaExpanded(element: Element): boolean | 'none' { +export function getAriaExpanded(element: Element): boolean | undefined { // https://www.w3.org/TR/wai-aria-1.2/#aria-expanded // https://www.w3.org/TR/html-aam-1.0/#html-attribute-state-and-property-mappings if (elementSafeTagName(element) === 'DETAILS') @@ -889,12 +889,12 @@ export function getAriaExpanded(element: Element): boolean | 'none' { if (kAriaExpandedRoles.includes(getAriaRole(element) || '')) { const expanded = element.getAttribute('aria-expanded'); if (expanded === null) - return 'none'; + return undefined; if (expanded === 'true') return true; return false; } - return 'none'; + return undefined; } export const kAriaLevelRoles = ['heading', 'listitem', 'row', 'treeitem']; diff --git a/tests/page/page-aria-snapshot.spec.ts b/tests/page/page-aria-snapshot.spec.ts index cf71611320..88306a1e43 100644 --- a/tests/page/page-aria-snapshot.spec.ts +++ b/tests/page/page-aria-snapshot.spec.ts @@ -40,8 +40,7 @@ async function checkAndMatchSnapshot(locator: Locator, snapshot: string) { it('should snapshot', async ({ page }) => { await page.setContent(`

title

`); await checkAndMatchSnapshot(page.locator('body'), ` - - heading "title": - - level: 1 + - heading "title" [level=1] `); }); @@ -51,10 +50,8 @@ it('should snapshot list', async ({ page }) => {

title 2

`); await checkAndMatchSnapshot(page.locator('body'), ` - - heading "title": - - level: 1 - - heading "title 2": - - level: 1 + - heading "title" [level=1] + - heading "title 2" [level=1] `); }); @@ -94,8 +91,7 @@ it('should allow text nodes', async ({ page }) => { `); await checkAndMatchSnapshot(page.locator('body'), ` - - heading "Microsoft": - - level: 1 + - heading "Microsoft" [level=1] - text: Open source projects and samples from Microsoft `); }); @@ -148,8 +144,7 @@ it('should snapshot integration', async ({ page }) => { `); await checkAndMatchSnapshot(page.locator('body'), ` - - heading "Microsoft": - - level: 1 + - heading "Microsoft" [level=1] - text: Open source projects and samples from Microsoft - list: - listitem: diff --git a/tests/page/to-match-aria-snapshot.spec.ts b/tests/page/to-match-aria-snapshot.spec.ts index d4be35fb8a..1335be5a59 100644 --- a/tests/page/to-match-aria-snapshot.spec.ts +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -107,15 +107,219 @@ test('details visibility', async ({ page }) => { `); }); -test('checked state', async ({ page }) => { +test('checked attribute', async ({ page }) => { await page.setContent(` `); await expect(page.locator('body')).toMatchAriaSnapshot(` - - checkbox: - - checked: true + - checkbox `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - checkbox [checked] + `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - checkbox [checked=true] + `); + + { + const e = await expect(page.locator('body')).toMatchAriaSnapshot(` + - checkbox [checked=false] + `, { timeout: 1000 }).catch(e => e); + expect(stripAnsi(e.message)).toContain('Timed out 1000ms waiting for expect'); + } + + { + const e = await expect(page.locator('body')).toMatchAriaSnapshot(` + - checkbox [checked=mixed] + `, { timeout: 1000 }).catch(e => e); + expect(stripAnsi(e.message)).toContain('Timed out 1000ms waiting for expect'); + } + + { + const e = await expect(page.locator('body')).toMatchAriaSnapshot(` + - checkbox [checked=5] + `, { timeout: 1000 }).catch(e => e); + expect(stripAnsi(e.message)).toContain(' attribute must be a boolean or "mixed"'); + } +}); + +test('disabled attribute', async ({ page }) => { + await page.setContent(` + + `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - button + `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - button [disabled] + `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - button [disabled=true] + `); + + { + const e = await expect(page.locator('body')).toMatchAriaSnapshot(` + - button [disabled=false] + `, { timeout: 1000 }).catch(e => e); + expect(stripAnsi(e.message)).toContain('Timed out 1000ms waiting for expect'); + } + + { + const e = await expect(page.locator('body')).toMatchAriaSnapshot(` + - button [disabled=invalid] + `, { timeout: 1000 }).catch(e => e); + expect(stripAnsi(e.message)).toContain(' attribute must be a boolean'); + } +}); + +test('expanded attribute', async ({ page }) => { + await page.setContent(` + + `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - button + `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - button [expanded] + `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - button [expanded=true] + `); + + { + const e = await expect(page.locator('body')).toMatchAriaSnapshot(` + - button [expanded=false] + `, { timeout: 1000 }).catch(e => e); + expect(stripAnsi(e.message)).toContain('Timed out 1000ms waiting for expect'); + } + + { + const e = await expect(page.locator('body')).toMatchAriaSnapshot(` + - button [expanded=invalid] + `, { timeout: 1000 }).catch(e => e); + expect(stripAnsi(e.message)).toContain(' attribute must be a boolean'); + } +}); + +test('level attribute', async ({ page }) => { + await page.setContent(` +

Section Title

+ `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading + `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading [level=2] + `); + + { + const e = await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading [level=3] + `, { timeout: 1000 }).catch(e => e); + expect(stripAnsi(e.message)).toContain('Timed out 1000ms waiting for expect'); + } + + { + const e = await expect(page.locator('body')).toMatchAriaSnapshot(` + - heading [level=two] + `, { timeout: 1000 }).catch(e => e); + expect(stripAnsi(e.message)).toContain(' attribute must be a number'); + } +}); + +test('pressed attribute', async ({ page }) => { + await page.setContent(` + + `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - button + `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - button [pressed] + `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - button [pressed=true] + `); + + { + const e = await expect(page.locator('body')).toMatchAriaSnapshot(` + - button [pressed=false] + `, { timeout: 1000 }).catch(e => e); + expect(stripAnsi(e.message)).toContain('Timed out 1000ms waiting for expect'); + } + + // Test for 'mixed' state + await page.setContent(` + + `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - button [pressed=mixed] + `); + + { + const e = await expect(page.locator('body')).toMatchAriaSnapshot(` + - button [pressed=true] + `, { timeout: 1000 }).catch(e => e); + expect(stripAnsi(e.message)).toContain('Timed out 1000ms waiting for expect'); + } + + { + const e = await expect(page.locator('body')).toMatchAriaSnapshot(` + - button [pressed=5] + `, { timeout: 1000 }).catch(e => e); + expect(stripAnsi(e.message)).toContain(' attribute must be a boolean or "mixed"'); + } +}); + +test('selected attribute', async ({ page }) => { + await page.setContent(` + + + + +
Row
+ `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - row + `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - row [selected] + `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - row [selected=true] + `); + + { + const e = await expect(page.locator('body')).toMatchAriaSnapshot(` + - row [selected=false] + `, { timeout: 1000 }).catch(e => e); + expect(stripAnsi(e.message)).toContain('Timed out 1000ms waiting for expect'); + } + + { + const e = await expect(page.locator('body')).toMatchAriaSnapshot(` + - row [selected=invalid] + `, { timeout: 1000 }).catch(e => e); + expect(stripAnsi(e.message)).toContain(' attribute must be a boolean'); + } }); test('integration test', async ({ page }) => { @@ -193,12 +397,11 @@ test('expected formatter', async ({ page }) => { expect(stripAnsi(error.message)).toContain(` Locator: locator('body') - Expected - 2 -+ Received string + 4 ++ Received string + 3 - - heading "todos" - - textbox "Wrong text" + - banner: -+ - heading "todos": -+ - level: 1 ++ - heading "todos" [level=1] + - textbox "What needs to be done?"`); }); diff --git a/tests/playwright-test/ui-mode-test-run.spec.ts b/tests/playwright-test/ui-mode-test-run.spec.ts index 1120cede6b..f60a6dde86 100644 --- a/tests/playwright-test/ui-mode-test-run.spec.ts +++ b/tests/playwright-test/ui-mode-test-run.spec.ts @@ -61,6 +61,23 @@ test('should run visible', async ({ runUITest }) => { ⊘ skipped `); + // await expect(page.getByTestId('test-tree')).toMatchAriaSnapshot(` + // - tree: + // - treeitem "a.test.ts" [expanded]: + // - treeitem "passes" + // - treeitem "fails" [selected]: + // - button "Run" + // - button "Show source" + // - button "Watch" + // - treeitem "suite" + // - treeitem "b.test.ts" [expanded]: + // - treeitem "passes" + // - treeitem "fails" + // - treeitem "c.test.ts" [expanded]: + // - treeitem "passes" + // - treeitem "skipped" + // `); + await expect(page.getByTestId('status-line')).toHaveText('4/8 passed (50%)'); });