From 04f04cdd13febb6595f7db8ab6a2f59794cff9b5 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Fri, 8 May 2026 16:17:32 +0900 Subject: [PATCH] feat(browser): show aria tree on locator element error (#10257) Co-authored-by: Codex --- docs/config/browser/locators.md | 49 +++++++ packages/browser/src/client/tester/context.ts | 20 ++- packages/vitest/src/node/cli/cli-config.ts | 1 + .../vitest/src/node/config/resolveConfig.ts | 1 + .../vitest/src/node/config/serializeConfig.ts | 1 + .../src/node/projects/resolveProjects.ts | 1 + packages/vitest/src/node/types/browser.ts | 7 + packages/vitest/src/runtime/config.ts | 1 + .../locator-error-format/basic.test.ts | 15 ++ .../locator-error-format/vitest.config.ts | 12 ++ .../specs/locator-error-format.test.ts | 131 ++++++++++++++++++ test/test-utils/index.ts | 2 +- 12 files changed, 239 insertions(+), 2 deletions(-) create mode 100644 test/browser/fixtures/locator-error-format/basic.test.ts create mode 100644 test/browser/fixtures/locator-error-format/vitest.config.ts create mode 100644 test/browser/specs/locator-error-format.test.ts diff --git a/docs/config/browser/locators.md b/docs/config/browser/locators.md index 7a5a30a41..29ecdd039 100644 --- a/docs/config/browser/locators.md +++ b/docs/config/browser/locators.md @@ -27,3 +27,52 @@ When set to `true`, [locators](/api/browser/locators) will match text exactly by const locator = page.getByText('Hello, World', { exact: true }) await locator.click() ``` + +## browser.locators.errorFormat 5.0.0 {#browser-locators-errorformat} + +- **Type:** `'html' | 'aria' | 'all'` +- **Default:** `'all'` + +Controls what Vitest prints when a locator cannot find an element. Vitest prints information for the DOM subtree where the locator search ran, or `document.body` for page-level locators. + +- `'html'` prints that DOM subtree as HTML using [`utils.prettyDOM`](/api/browser/context#prettydom). +- `'aria'` prints that DOM subtree as an [ARIA snapshot](/guide/browser/aria-snapshots), which focuses on accessible roles, names, and state. +- `'all'` prints the ARIA snapshot first, followed by the HTML output. + +```ts +import { defineConfig } from 'vitest/config' + +export default defineConfig({ + test: { + browser: { + enabled: true, + locators: { + errorFormat: 'aria', + }, + }, + }, +}) +``` + +For example, `all` displays a following error: + +```html +VitestBrowserElementError: Cannot find element with locator: getByRole('button', { name: 'Save' }) + +ARIA tree: +- main: + - heading "Settings" [level=1] + - button "Cancel" + +HTML: + +
+

+ Settings +

+ +
+ +``` diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index 0bcd5b39b..5026d4454 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -573,11 +573,29 @@ function prettyDOM( } function getElementError(selector: string | Locator, container: Element): Error { - const error = new Error(`Cannot find element with locator: ${typeof selector === 'string' ? __INTERNAL._asLocator('javascript', selector) : selector.asLocator()}\n\n${prettyDOM(container)}`) + const locator = typeof selector === 'string' ? __INTERNAL._asLocator('javascript', selector) : selector.asLocator() + const formatted = formatDOM(container) + const error = new Error(`Cannot find element with locator: ${locator}\n\n${formatted}`) error.name = 'VitestBrowserElementError' return error } +function formatDOM(container: Element): string { + const format = getBrowserState().config.browser.locators.errorFormat + if (format === 'aria') { + return `ARIA tree:\n${formatAriaTree(container)}` + } + if (format === 'all') { + return `ARIA tree:\n${formatAriaTree(container)}\n\nHTML:\n${prettyDOM(container)}` + } + return prettyDOM(container) +} + +function formatAriaTree(container: Element): string { + const { generateAriaTree, renderAriaTree } = getBrowserState().aria + return renderAriaTree(generateAriaTree(container)) +} + function configurePrettyDOM(options: StringifyOptions) { defaultOptions = options } diff --git a/packages/vitest/src/node/cli/cli-config.ts b/packages/vitest/src/node/cli/cli-config.ts index f73620591..ccd6f76cb 100644 --- a/packages/vitest/src/node/cli/cli-config.ts +++ b/packages/vitest/src/node/cli/cli-config.ts @@ -445,6 +445,7 @@ export const cliOptionsConfig: VitestCLIOptions = { exact: { description: 'Should locators match the text exactly by default (default: `false`)', }, + errorFormat: null, }, transform(val) { if (typeof val !== 'object' || val == null) { diff --git a/packages/vitest/src/node/config/resolveConfig.ts b/packages/vitest/src/node/config/resolveConfig.ts index a56296b3c..aa5ea3a0d 100644 --- a/packages/vitest/src/node/config/resolveConfig.ts +++ b/packages/vitest/src/node/config/resolveConfig.ts @@ -860,6 +860,7 @@ export function resolveConfig( resolved.browser.locators ??= {} as any resolved.browser.locators.testIdAttribute ??= 'data-testid' resolved.browser.locators.exact ??= false + resolved.browser.locators.errorFormat ??= 'all' if (typeof resolved.browser.provider === 'string') { const source = `@vitest/browser-${resolved.browser.provider}` diff --git a/packages/vitest/src/node/config/serializeConfig.ts b/packages/vitest/src/node/config/serializeConfig.ts index d81073d74..20957655d 100644 --- a/packages/vitest/src/node/config/serializeConfig.ts +++ b/packages/vitest/src/node/config/serializeConfig.ts @@ -119,6 +119,7 @@ export function serializeConfig(project: TestProject): SerializedConfig { locators: { testIdAttribute: browser.locators.testIdAttribute, exact: browser.locators.exact, + errorFormat: browser.locators.errorFormat, }, providerOptions: provider?.name === 'playwright' ? { diff --git a/packages/vitest/src/node/projects/resolveProjects.ts b/packages/vitest/src/node/projects/resolveProjects.ts index c98100d4d..0ce5691b5 100644 --- a/packages/vitest/src/node/projects/resolveProjects.ts +++ b/packages/vitest/src/node/projects/resolveProjects.ts @@ -295,6 +295,7 @@ function cloneConfig(project: TestProject, { browser, ...config }: BrowserInstan ? { testIdAttribute: locators.testIdAttribute ?? currentConfig.locators.testIdAttribute, exact: locators.exact ?? currentConfig.locators.exact, + errorFormat: locators.errorFormat ?? currentConfig.locators.errorFormat, } : project.config.browser.locators, viewport: viewport ?? currentConfig.viewport, diff --git a/packages/vitest/src/node/types/browser.ts b/packages/vitest/src/node/types/browser.ts index b364d390b..7d1503d26 100644 --- a/packages/vitest/src/node/types/browser.ts +++ b/packages/vitest/src/node/types/browser.ts @@ -240,6 +240,12 @@ export interface BrowserConfigOptions { * @default false */ exact?: boolean + /** + * Format used for locator "Cannot find element" error details. + * + * @default 'all' + */ + errorFormat?: 'html' | 'aria' | 'all' } /** @@ -447,6 +453,7 @@ export interface ResolvedBrowserOptions extends BrowserConfigOptions { locators: { testIdAttribute: string exact: boolean + errorFormat: 'html' | 'aria' | 'all' } trace: { mode: BrowserTraceViewMode diff --git a/packages/vitest/src/runtime/config.ts b/packages/vitest/src/runtime/config.ts index 325ee6533..4d140e1e6 100644 --- a/packages/vitest/src/runtime/config.ts +++ b/packages/vitest/src/runtime/config.ts @@ -109,6 +109,7 @@ export interface SerializedConfig { locators: { testIdAttribute: string exact: boolean + errorFormat: 'html' | 'aria' | 'all' } screenshotFailures: boolean providerOptions: { diff --git a/test/browser/fixtures/locator-error-format/basic.test.ts b/test/browser/fixtures/locator-error-format/basic.test.ts new file mode 100644 index 000000000..537d739bb --- /dev/null +++ b/test/browser/fixtures/locator-error-format/basic.test.ts @@ -0,0 +1,15 @@ +import { expect, test } from 'vitest' +import { page } from 'vitest/browser' + +test('not found', async () => { + document.body.innerHTML = ` +
+

Settings

+ +
+ ` + // TODO: surfacing element eror via expect.element is racy since + // new timeout behavior https://github.com/vitest-dev/vitest/pull/10233 + // await expect.element(page.getByRole('button', { name: 'Save' }), { timeout: 200 }).toBeVisible() + await page.getByRole('button', { name: 'Save' }).findElement({ timeout: 200 }) +}) diff --git a/test/browser/fixtures/locator-error-format/vitest.config.ts b/test/browser/fixtures/locator-error-format/vitest.config.ts new file mode 100644 index 000000000..dc4ad642c --- /dev/null +++ b/test/browser/fixtures/locator-error-format/vitest.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vitest/config' +import { instances, provider } from '../../settings' + +export default defineConfig({ + test: { + browser: { + enabled: true, + provider, + instances, + }, + }, +}) diff --git a/test/browser/specs/locator-error-format.test.ts b/test/browser/specs/locator-error-format.test.ts new file mode 100644 index 000000000..0fdc75c65 --- /dev/null +++ b/test/browser/specs/locator-error-format.test.ts @@ -0,0 +1,131 @@ +import { expect, test } from 'vitest' +import { instances, runBrowserTests } from './utils' + +test('locator error format aria', async () => { + const result = await runBrowserTests({ + root: './fixtures/locator-error-format', + browser: { + locators: { + errorFormat: 'aria', + }, + }, + }) + + const trees = result.errorTree({ project: true }) + for (const { browser } of instances) { + const tree = trees[browser] + expect.soft(tree, browser).toMatchInlineSnapshot(` + { + "basic.test.ts": { + "not found": [ + "Cannot find element with locator: getByRole('button', { name: 'Save' }) + + ARIA tree: + - main: + - heading "Settings" [level=1] + - button "Cancel"", + ], + }, + } + `) + } +}) + +test('locator error format html', async () => { + const result = await runBrowserTests({ + root: './fixtures/locator-error-format', + browser: { + locators: { + errorFormat: 'html', + }, + }, + }) + + const trees = result.errorTree({ project: true }) + for (const { browser } of instances) { + const tree = trees[browser] + expect.soft(tree, browser).toMatchInlineSnapshot(` + { + "basic.test.ts": { + "not found": [ + "Cannot find element with locator: getByRole('button', { name: 'Save' }) + + + + +
+ + +

+ Settings +

+ + + + + +
+ + + ", + ], + }, + } + `) + } +}) + +test('locator error format all', async () => { + const result = await runBrowserTests({ + root: './fixtures/locator-error-format', + browser: { + locators: { + // default + // errorFormat: 'all', + }, + }, + }) + + const trees = result.errorTree({ project: true }) + for (const { browser } of instances) { + const tree = trees[browser] + expect.soft(tree, browser).toMatchInlineSnapshot(` + { + "basic.test.ts": { + "not found": [ + "Cannot find element with locator: getByRole('button', { name: 'Save' }) + + ARIA tree: + - main: + - heading "Settings" [level=1] + - button "Cancel" + + HTML: + + + +
+ + +

+ Settings +

+ + + + + +
+ + + ", + ], + }, + } + `) + } +}) diff --git a/test/test-utils/index.ts b/test/test-utils/index.ts index efc0f5eae..9be8365ae 100644 --- a/test/test-utils/index.ts +++ b/test/test-utils/index.ts @@ -580,7 +580,7 @@ export function buildErrorTree(testModules: TestModule[], options?: BuildErrorTr const root = testModules[0]?.project.config.root function mapError(e: { message: string; diff?: string; stacks?: ParsedStack[] }) { - let message = e.message + let message = stripVTControlCharacters(e.message) if (options?.diff && e.diff) { message = [message, stripVTControlCharacters(e.diff)].join('\n') } -- 2.51.2