From 64bf1bc1072a48e0def6785c4885a58497452fc5 Mon Sep 17 00:00:00 2001 From: Pavel Feldman Date: Fri, 18 Oct 2024 20:18:18 -0700 Subject: [PATCH] chore: support basic aria attributes (#33182) --- .../src/server/ariaSnapshot.ts | 120 +++++++++------- .../src/server/injected/ariaSnapshot.ts | 134 +++++++++++++----- .../src/server/injected/roleUtils.ts | 62 ++++---- tests/page/page-aria-snapshot.spec.ts | 15 +- tests/page/to-match-aria-snapshot.spec.ts | 18 ++- 5 files changed, 228 insertions(+), 121 deletions(-) diff --git a/packages/playwright-core/src/server/ariaSnapshot.ts b/packages/playwright-core/src/server/ariaSnapshot.ts index e450e5b15df67..47ed4066b76b3 100644 --- a/packages/playwright-core/src/server/ariaSnapshot.ts +++ b/packages/playwright-core/src/server/ariaSnapshot.ts @@ -16,63 +16,87 @@ import type { AriaTemplateNode } from './injected/ariaSnapshot'; import { yaml } from '../utilsBundle'; +import type { AriaRole } from '@injected/roleUtils'; export function parseAriaSnapshot(text: string): AriaTemplateNode { - type YamlNode = Record | string>; + const fragment = yaml.parse(text) as any[]; + const result: AriaTemplateNode = { role: 'fragment' }; + populateNode(result, fragment); + return result; +} - const parseKey = (key: string): AriaTemplateNode => { - if (!key) - return { role: '' }; +function populateNode(node: AriaTemplateNode, container: any[]) { + for (const object of container) { + if (typeof object === 'string') { + const { role, name } = parseKey(object); + node.children = node.children || []; + node.children.push({ role, name }); + 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 match = key.match(/^([a-z]+)(?:\s+(?:"([^"]*)"|\/([^\/]*)\/))?$/); + const { role, name } = parseKey(key); + const value = object[key]; + node.children = node.children || []; - if (!match) - throw new Error(`Invalid key ${key}`); + if (role === 'text') { + node.children.push(valueOrRegex(value)); + continue; + } - const role = match[1]; - if (role && role !== 'text' && !allRoles.includes(role)) - throw new Error(`Invalid role ${role}`); + if (typeof value === 'string') { + node.children.push({ role, name, children: [valueOrRegex(value)] }); + continue; + } - if (match[2]) - return { role, name: match[2] }; - if (match[3]) - return { role, name: new RegExp(match[3]) }; - return { role }; - }; + const childNode = { role, name }; + node.children.push(childNode); + populateNode(childNode, value); + } + } +} - const normalizeWhitespace = (text: string) => { - return text.replace(/[\r\n\s\t]+/g, ' ').trim(); - }; +function parseKey(key: string) { + const match = key.match(/^([a-z]+)(?:\s+(?:"([^"]*)"|\/([^\/]*)\/))?$/); + if (!match) + throw new Error(`Invalid key ${key}`); - const valueOrRegex = (value: string): string | RegExp => { - return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : normalizeWhitespace(value); - }; + 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 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; +function normalizeWhitespace(text: string) { + return text.replace(/[\r\n\s\t]+/g, ' ').trim(); } -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' -]; +function valueOrRegex(value: string): string | RegExp { + return value.startsWith('/') && value.endsWith('/') ? new RegExp(value.slice(1, -1)) : normalizeWhitespace(value); +} diff --git a/packages/playwright-core/src/server/injected/ariaSnapshot.ts b/packages/playwright-core/src/server/injected/ariaSnapshot.ts index 907006ce0a1f8..16d8dc60709bc 100644 --- a/packages/playwright-core/src/server/injected/ariaSnapshot.ts +++ b/packages/playwright-core/src/server/injected/ariaSnapshot.ts @@ -15,38 +15,32 @@ */ import { escapeWithQuotes } from '@isomorphic/stringUtils'; -import { accumulatedElementText, beginAriaCaches, endAriaCaches, getAriaRole, getElementAccessibleName, getPseudoContent, isElementIgnoredForAria } from './roleUtils'; +import * as roleUtils from './roleUtils'; import { isElementVisible, isElementStyleVisibilityVisible, getElementComputedStyle } from './domUtils'; +import type { AriaRole } from './roleUtils'; + +type AriaProps = { + checked?: boolean | 'mixed'; + disabled?: boolean; + expanded?: boolean | 'none', + level?: number, + pressed?: boolean | 'mixed'; + selected?: boolean; +}; -type AriaNode = { - role: string; - name?: string; +type AriaNode = AriaProps & { + role: AriaRole | 'fragment' | 'text'; + name: string; children: (AriaNode | string)[]; }; -export type AriaTemplateNode = { - role: string; +export type AriaTemplateNode = AriaProps & { + role: AriaRole | 'fragment' | 'text'; 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, children: [] }; - if (isLeaf && !name) { - const text = accumulatedElementText(element); - if (text) - result.children = [text]; - } - return { isLeaf, ariaNode: result }; - }; - const visit = (ariaNode: AriaNode, node: Node) => { if (node.nodeType === Node.TEXT_NODE && node.nodeValue) { const text = node.nodeValue; @@ -59,7 +53,7 @@ export function generateAriaTree(rootElement: Element): AriaNode { return; const element = node as Element; - if (isElementIgnoredForAria(element)) + if (roleUtils.isElementIgnoredForAria(element)) return; const visible = isElementVisible(element); @@ -87,7 +81,7 @@ export function generateAriaTree(rootElement: Element): AriaNode { if (treatAsBlock) ariaNode.children.push(treatAsBlock); - ariaNode.children.push(getPseudoContent(element, '::before')); + ariaNode.children.push(roleUtils.getPseudoContent(element, '::before')); const assignedNodes = element.nodeName === 'SLOT' ? (element as HTMLSlotElement).assignedNodes() : []; if (assignedNodes.length) { for (const child of assignedNodes) @@ -103,24 +97,59 @@ export function generateAriaTree(rootElement: Element): AriaNode { } } - ariaNode.children.push(getPseudoContent(element, '::after')); + ariaNode.children.push(roleUtils.getPseudoContent(element, '::after')); if (treatAsBlock) ariaNode.children.push(treatAsBlock); } - beginAriaCaches(); - const ariaRoot: AriaNode = { role: '', children: [] }; + roleUtils.beginAriaCaches(); + const ariaRoot: AriaNode = { role: 'fragment', name: '', children: [] }; try { visit(ariaRoot, rootElement); } finally { - endAriaCaches(); + roleUtils.endAriaCaches(); } normalizeStringChildren(ariaRoot); return ariaRoot; } +function toAriaNode(element: Element): { ariaNode: AriaNode, isLeaf: boolean } | null { + const role = roleUtils.getAriaRole(element); + if (!role) + return null; + + const name = roleUtils.getElementAccessibleName(element, false) || ''; + const isLeaf = leafRoles.has(role); + const result: AriaNode = { role, name, children: [] }; + if (isLeaf && !name) { + const text = roleUtils.accumulatedElementText(element); + if (text) + result.children = [text]; + } + + if (roleUtils.kAriaCheckedRoles.includes(role)) + result.checked = roleUtils.getAriaChecked(element); + + if (roleUtils.kAriaDisabledRoles.includes(role)) + result.disabled = roleUtils.getAriaDisabled(element); + + if (roleUtils.kAriaExpandedRoles.includes(role)) + result.expanded = roleUtils.getAriaExpanded(element); + + if (roleUtils.kAriaLevelRoles.includes(role)) + result.level = roleUtils.getAriaLevel(element); + + if (roleUtils.kAriaPressedRoles.includes(role)) + result.pressed = roleUtils.getAriaPressed(element); + + if (roleUtils.kAriaSelectedRoles.includes(role)) + result.selected = roleUtils.getAriaSelected(element); + + return { isLeaf, ariaNode: result }; +} + export function renderedAriaTree(rootElement: Element): string { return renderAriaTree(generateAriaTree(rootElement)); } @@ -155,7 +184,7 @@ function normalizeStringChildren(rootA11yNode: AriaNode) { const hiddenContainerRoles = new Set(['none', 'presentation']); -const leafRoles = new Set([ +const leafRoles = new Set([ 'alert', 'blockquote', 'button', 'caption', 'checkbox', 'code', 'columnheader', 'definition', 'deletion', 'emphasis', 'generic', 'heading', 'img', 'insertion', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'meter', 'option', @@ -178,7 +207,7 @@ function matchesText(text: string | undefined, template: RegExp | string | undef export function matchesAriaTree(rootElement: Element, template: AriaTemplateNode): { matches: boolean, received: string } { const root = generateAriaTree(rootElement); - const matches = nodeMatches(root, template); + const matches = matchesNodeDeep(root, template); return { matches, received: renderAriaTree(root) }; } @@ -187,7 +216,19 @@ function matchesNode(node: AriaNode | string, template: AriaTemplateNode | RegEx return matchesText(node, template); if (typeof node === 'object' && typeof template === 'object' && !(template instanceof RegExp)) { - if (template.role && template.role !== node.role) + if (template.role !== 'fragment' && template.role !== node.role) + return false; + if (template.checked !== undefined && template.checked !== node.checked) + return false; + if (template.disabled !== undefined && template.disabled !== node.disabled) + return false; + if (template.expanded !== undefined && template.expanded !== node.expanded) + return false; + if (template.level !== undefined && template.level !== node.level) + return false; + if (template.pressed !== undefined && template.pressed !== node.pressed) + return false; + if (template.selected !== undefined && template.selected !== node.selected) return false; if (!matchesText(node.name, template.name)) return false; @@ -216,7 +257,7 @@ function containsList(children: (AriaNode | string)[], template: (AriaTemplateNo return true; } -function nodeMatches(root: AriaNode, template: AriaTemplateNode): boolean { +function matchesNodeDeep(root: AriaNode, template: AriaTemplateNode): boolean { const results: (AriaNode | string)[] = []; const visit = (node: AriaNode | string): boolean => { if (matchesNode(node, template, 0)) { @@ -245,19 +286,36 @@ export function renderAriaTree(ariaNode: AriaNode): 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) + 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 (stringValue) { + if (ariaNode.children.length) line += ': ' + escapeYamlString(ariaNode.children?.[0] as string); lines.push(line); return; } - lines.push(line + (ariaNode.children.length ? ':' : '')); + + 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 + ' '); }; - if (ariaNode.role === '') { + + if (ariaNode.role === 'fragment') { // Render fragment. for (const child of ariaNode.children || []) visit(child, ''); diff --git a/packages/playwright-core/src/server/injected/roleUtils.ts b/packages/playwright-core/src/server/injected/roleUtils.ts index d085a8e36d4de..9c5712311ef41 100644 --- a/packages/playwright-core/src/server/injected/roleUtils.ts +++ b/packages/playwright-core/src/server/injected/roleUtils.ts @@ -82,7 +82,7 @@ function isNativelyFocusable(element: Element) { // https://w3c.github.io/html-aam/#html-element-role-mappings // https://www.w3.org/TR/html-aria/#docconformance -const kImplicitRoleByTagName: { [tagName: string]: (e: Element) => string | null } = { +const kImplicitRoleByTagName: { [tagName: string]: (e: Element) => AriaRole | null } = { 'A': (e: Element) => { return e.hasAttribute('href') ? 'link' : null; }, @@ -127,17 +127,8 @@ const kImplicitRoleByTagName: { [tagName: string]: (e: Element) => string | null return (list && elementSafeTagName(list) === 'DATALIST') ? 'combobox' : 'textbox'; } if (type === 'hidden') - return ''; - return { - 'button': 'button', - 'checkbox': 'checkbox', - 'image': 'button', - 'number': 'spinbutton', - 'radio': 'radio', - 'range': 'slider', - 'reset': 'button', - 'submit': 'button', - }[type] || 'textbox'; + return null; + return inputTypeToRole[type] || 'textbox'; }, 'INS': () => 'insertion', 'LI': () => 'listitem', @@ -200,7 +191,7 @@ const kPresentationInheritanceParents: { [tagName: string]: string[] } = { 'TR': ['THEAD', 'TBODY', 'TFOOT', 'TABLE'], }; -function getImplicitAriaRole(element: Element): string | null { +function getImplicitAriaRole(element: Element): AriaRole | null { const implicitRole = kImplicitRoleByTagName[elementSafeTagName(element)]?.(element) || ''; if (!implicitRole) return null; @@ -221,23 +212,29 @@ function getImplicitAriaRole(element: Element): string | null { } // https://www.w3.org/TR/wai-aria-1.2/#role_definitions -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' -]; // https://www.w3.org/TR/wai-aria-1.2/#abstract_roles -const abstractRoles = ['command', 'composite', 'input', 'landmark', 'range', 'roletype', 'section', 'sectionhead', 'select', 'structure', 'widget', 'window']; -const validRoles = allRoles.filter(role => !abstractRoles.includes(role)); +// type AbstractRoles = 'command' | 'composite' | 'input' | 'landmark' | 'range' | 'roletype' | 'section' | 'sectionhead' | 'select' | 'structure' | 'widget' | 'window'; + +export type AriaRole = 'alert' | 'alertdialog' | 'application' | 'article' | 'banner' | 'blockquote' | 'button' | 'caption' | 'cell' | 'checkbox' | 'code' | 'columnheader' | 'combobox' | + 'complementary' | 'contentinfo' | 'definition' | 'deletion' | 'dialog' | 'directory' | 'document' | 'emphasis' | 'feed' | 'figure' | 'form' | 'generic' | 'grid' | + 'gridcell' | 'group' | 'heading' | 'img' | 'insertion' | 'link' | 'list' | 'listbox' | 'listitem' | 'log' | 'main' | 'mark' | 'marquee' | 'math' | 'meter' | 'menu' | + 'menubar' | 'menuitem' | 'menuitemcheckbox' | 'menuitemradio' | 'navigation' | 'none' | 'note' | 'option' | 'paragraph' | 'presentation' | 'progressbar' | 'radio' | 'radiogroup' | + 'region' | 'row' | 'rowgroup' | 'rowheader' | 'scrollbar' | 'search' | 'searchbox' | 'separator' | 'slider' | + 'spinbutton' | 'status' | 'strong' | 'subscript' | 'superscript' | 'switch' | 'tab' | 'table' | 'tablist' | 'tabpanel' | 'term' | 'textbox' | 'time' | 'timer' | + 'toolbar' | 'tooltip' | 'tree' | 'treegrid' | 'treeitem'; + +const validRoles: AriaRole[] = ['alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button', 'caption', 'cell', 'checkbox', 'code', 'columnheader', 'combobox', + 'complementary', 'contentinfo', 'definition', 'deletion', 'dialog', 'directory', 'document', 'emphasis', 'feed', 'figure', 'form', 'generic', 'grid', + 'gridcell', 'group', 'heading', 'img', 'insertion', 'link', 'list', 'listbox', 'listitem', 'log', 'main', 'mark', 'marquee', 'math', 'meter', 'menu', + 'menubar', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'navigation', 'none', 'note', 'option', 'paragraph', 'presentation', 'progressbar', 'radio', 'radiogroup', + 'region', 'row', 'rowgroup', 'rowheader', 'scrollbar', 'search', 'searchbox', 'separator', 'slider', + 'spinbutton', 'status', 'strong', 'subscript', 'superscript', 'switch', 'tab', 'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer', + 'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem']; -function getExplicitAriaRole(element: Element): string | null { +function getExplicitAriaRole(element: Element): AriaRole | null { // https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles const roles = (element.getAttribute('role') || '').split(' ').map(role => role.trim()); - return roles.find(role => validRoles.includes(role)) || null; + return roles.find(role => validRoles.includes(role as any)) as AriaRole || null; } function hasPresentationConflictResolution(element: Element, role: string | null) { @@ -245,7 +242,7 @@ function hasPresentationConflictResolution(element: Element, role: string | null return hasGlobalAriaAttribute(element, role) || isFocusable(element); } -export function getAriaRole(element: Element): string | null { +export function getAriaRole(element: Element): AriaRole | null { const explicitRole = getExplicitAriaRole(element); if (!explicitRole) return getImplicitAriaRole(element); @@ -994,3 +991,14 @@ export function endAriaCaches() { cachePseudoContentAfter = undefined; } } + +const inputTypeToRole: Record = { + 'button': 'button', + 'checkbox': 'checkbox', + 'image': 'button', + 'number': 'spinbutton', + 'radio': 'radio', + 'range': 'slider', + 'reset': 'button', + 'submit': 'button', +}; diff --git a/tests/page/page-aria-snapshot.spec.ts b/tests/page/page-aria-snapshot.spec.ts index 878f48439edaf..cf71611320c17 100644 --- a/tests/page/page-aria-snapshot.spec.ts +++ b/tests/page/page-aria-snapshot.spec.ts @@ -40,7 +40,8 @@ async function checkAndMatchSnapshot(locator: Locator, snapshot: string) { it('should snapshot', async ({ page }) => { await page.setContent(`

title

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

title 2

`); await checkAndMatchSnapshot(page.locator('body'), ` - - heading "title" - - heading "title 2" + - heading "title": + - level: 1 + - heading "title 2": + - level: 1 `); }); @@ -91,7 +94,8 @@ it('should allow text nodes', async ({ page }) => { `); await checkAndMatchSnapshot(page.locator('body'), ` - - heading "Microsoft" + - heading "Microsoft": + - level: 1 - text: Open source projects and samples from Microsoft `); }); @@ -144,7 +148,8 @@ it('should snapshot integration', async ({ page }) => { `); await checkAndMatchSnapshot(page.locator('body'), ` - - heading "Microsoft" + - 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 3de7b6a9d4457..d4be35fb8a366 100644 --- a/tests/page/to-match-aria-snapshot.spec.ts +++ b/tests/page/to-match-aria-snapshot.spec.ts @@ -107,6 +107,17 @@ test('details visibility', async ({ page }) => { `); }); +test('checked state', async ({ page }) => { + await page.setContent(` + + `); + + await expect(page.locator('body')).toMatchAriaSnapshot(` + - checkbox: + - checked: true + `); +}); + test('integration test', async ({ page }) => { await page.setContent(`

Microsoft

@@ -182,11 +193,12 @@ test('expected formatter', async ({ page }) => { expect(stripAnsi(error.message)).toContain(` Locator: locator('body') - Expected - 2 -+ Received string + 3 ++ Received string + 4 - - heading "todos" -+ - banner: -+ - heading "todos" - - textbox "Wrong text" ++ - banner: ++ - heading "todos": ++ - level: 1 + - textbox "What needs to be done?"`); });