mirror of
https://github.com/microsoft/playwright.git
synced 2024-11-24 06:49:04 +03:00
chore: add aria attribute tests (#33184)
This commit is contained in:
parent
64bf1bc107
commit
97d26e8166
@ -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) {
|
||||
|
@ -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 + ' ');
|
||||
};
|
||||
|
@ -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'];
|
||||
|
@ -40,8 +40,7 @@ async function checkAndMatchSnapshot(locator: Locator, snapshot: string) {
|
||||
it('should snapshot', async ({ page }) => {
|
||||
await page.setContent(`<h1>title</h1>`);
|
||||
await checkAndMatchSnapshot(page.locator('body'), `
|
||||
- heading "title":
|
||||
- level: 1
|
||||
- heading "title" [level=1]
|
||||
`);
|
||||
});
|
||||
|
||||
@ -51,10 +50,8 @@ it('should snapshot list', async ({ page }) => {
|
||||
<h1>title 2</h1>
|
||||
`);
|
||||
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 }) => {
|
||||
</ul>`);
|
||||
|
||||
await checkAndMatchSnapshot(page.locator('body'), `
|
||||
- heading "Microsoft":
|
||||
- level: 1
|
||||
- heading "Microsoft" [level=1]
|
||||
- text: Open source projects and samples from Microsoft
|
||||
- list:
|
||||
- listitem:
|
||||
|
@ -107,15 +107,219 @@ test('details visibility', async ({ page }) => {
|
||||
`);
|
||||
});
|
||||
|
||||
test('checked state', async ({ page }) => {
|
||||
test('checked attribute', async ({ page }) => {
|
||||
await page.setContent(`
|
||||
<input type='checkbox' checked />
|
||||
`);
|
||||
|
||||
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(`
|
||||
<button disabled>Click me</button>
|
||||
`);
|
||||
|
||||
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(`
|
||||
<button aria-expanded="true">Toggle</button>
|
||||
`);
|
||||
|
||||
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(`
|
||||
<h2>Section Title</h2>
|
||||
`);
|
||||
|
||||
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(`
|
||||
<button aria-pressed="true">Like</button>
|
||||
`);
|
||||
|
||||
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(`
|
||||
<button aria-pressed="mixed">Like</button>
|
||||
`);
|
||||
|
||||
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(`
|
||||
<table>
|
||||
<tr aria-selected="true">
|
||||
<td>Row</td>
|
||||
</tr>
|
||||
</table>
|
||||
`);
|
||||
|
||||
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?"`);
|
||||
});
|
||||
|
@ -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%)');
|
||||
});
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user