Skip to content

feat(experimental): support aria snapshot#9668

Open
hi-ogawa wants to merge 199 commits intovitest-dev:mainfrom
hi-ogawa:feat-aria-snapshot
Open

feat(experimental): support aria snapshot#9668
hi-ogawa wants to merge 199 commits intovitest-dev:mainfrom
hi-ogawa:feat-aria-snapshot

Conversation

@hi-ogawa
Copy link
Copy Markdown
Collaborator

@hi-ogawa hi-ogawa commented Feb 15, 2026

Description

Adds toMatchAriaSnapshot() and toMatchAriaInlineSnapshot() matchers for asserting DOM accessibility trees, inspired by Playwright's aria snapshot feature. Core logic of aria tree generation, parsing, matching are entirely implemented on ivya vitest-dev/ivya#8 as Aria related runtime utility lives there.

Under the hood, this PR also introduces domain snapshot API in Vitest core to allow extending comparison mechanism beyond exact string equality. The simple example is added as a test case and also documented to illustrate the idea. However, I'd treat this API being "experimental" since I haven't too deeply thought through the API shape beyond what I minimally needed for implementing aria snapshot.

API

// file
expect(document.querySelector('nav')).toMatchAriaSnapshot()

// inline
expect(document.body).toMatchAriaInlineSnapshot(`
  - paragraph: Original
  - button "1234": Pattern
`)

// with expect.element
await expect.element(page.getByRole('navigation')).toMatchAriaInlineSnapshot(`
  - button: Save
  - button: Cancel
`)

// with expect.poll (technically works but not documented specifically)
await expect.poll(() => document.body).toMatchAriaInlineSnapshot(`
  - heading "Dashboard" [level=1]
`)

Example

Given HTML like

<p>Original</p>
<button aria-label="1234">Pattern</button>
<p>Extra</p>

Initially generated snapshot looks like:

- paragraph: Original
- button "1234": Pattern
- paragraph: Extra

Snapshot can be manually edited to include regex pattern or remove some part and snapshot assertion continues to pass:

- paragraph: Original
- button /\d+/: Pattern

Now when actual HTML changes to:

<p>Changes</p>        👈 (before: "Original", after: "Changed")
<button aria-label="1234">Pattern</button>
<p>Extra</p>

Snapshot would now fail, but the error diff normalizes partially matching part:

- - paragraph: Original
+ - paragraph: Changed
  - button /\d+/: Pattern

When forcing the snapshot update via --update, newly generated snapshot would reflect the same error diff, which means manually edited part is preserved:

- paragraph: Changed
- button /\d+/: Pattern

TODO

Questions

  • Should poll snapshot with --update wait for stable snapshot?
    • TODO: new heuristics and new option? employ similar strategy as toMatchScreenshot?
      Yes, it should. We can simplify pool more and wait until stable on "new" (initial snaphsot) and "all" (--update) cases
  • How to pull in yaml? (fixed by feat: add minimal YAML parser for aria snapshot templates ivya#13)
    • Aria snapshot requires yaml parser and it's now bundled through @vitest/browser/dist/expect-element.js. The package yaml is huge around 100kb. This caused expect-element.js to go from 23kb to 140kb. Should we try code split and lazily load on runtime?
    • domain snapshot itself is synchronous. lazy load requires making toMatchAriaSnapshot specifically asynchronous, which is technically possible. Or we can make explicit opt-in/out flag to lazy load early during runtime.
    • Or we (AI) can hand-roll simplified yaml parser (note that yaml serializer is already hand-rolled partly by playwright).
      feat: add minimal YAML parser for aria snapshot templates ivya#13

Follow-up considered


TODO

  • design and poc
    • file snapshot
    • inline snapshot
  • aria snapshot adapter
  • support poll + snapshot via domain model
    • file snapshot
    • inline snapshot
    • expect.element + aria snapshot becomes a special case
      • works except (current) webkit by the same reason as defineHelper. should be fixed on next playwright update, which has better async stack.
  • wire up feat: add aria snapshot utilities ivya#8
  • review ai slop
    • aria implementation
    • tests
    • domain snapshot match
    • poll snapshot
    • reduce bloat API
    • credit playwright
  • migrate internal to use snaphsot domain (later)
  • docs
  • PR summary

Please don't delete this checklist! Before submitting the PR, please make sure you do the following:

  • It's really useful if your PR references an issue where it is discussed ahead of time. If the feature is substantial or introduces breaking changes without a discussion, PR might be closed.
  • Ideally, include a test that fails without this PR but passes with it.
  • Please, don't make changes to pnpm-lock.yaml unless you introduce a new test example.
  • Please check Allow edits by maintainers to make review process faster. Note that this option is not available for repositories that are owned by Github organizations.

Tests

  • Run the tests with pnpm test:ci.

Documentation

  • If you introduce new functionality, document it. You can run documentation with pnpm run docs command.

Changesets

  • Changes in changelog are generated from PR name. Please, make sure that it explains your changes in an understandable manner. Please, prefix changeset messages with feat:, fix:, perf:, docs:, or chore:.

@netlify
Copy link
Copy Markdown

netlify bot commented Feb 15, 2026

Deploy Preview for vitest-dev ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit d5c1a28
🔍 Latest deploy log https://app.netlify.com/projects/vitest-dev/deploys/69cce9bc5a1abf0007794baf
😎 Deploy Preview https://deploy-preview-9668--vitest-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Copy link
Copy Markdown
Member

@AriPerkkio AriPerkkio left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First review for docs only:

Comment on lines +395 to +399
expect(user).toMatchDomainInlineSnapshot(`
name=Alice
score=/\\d+/
`, 'kv')
})
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the equivalent of current user flow of generating the first snapshot?

// User writes
expect(user).toMatchInlineSnapshot(); // note, no args

// Snapshot generates into
expect(user).toMatchInlineSnapshot(`
  {
    "name": ...
    ...
  }
`);

Do they pass empty string there?

expect(user).toMatchDomainInlineSnapshot("", "kv");

Or does that run into comparison error "" does not match 'name=...?

Copy link
Copy Markdown
Collaborator Author

@hi-ogawa hi-ogawa Mar 22, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is one of unpolished parts of toMatchDomainInlineSnapshot. It currently requires toMatchDomainInlineSnapshot("", "kv") as an initial state to work. Of course, toMatchAriaInlineSnapshot doesn't have this limitation, so I forgot about this.

It should be fixable but it's independent from shipping aria snapshot, so I'd like to deal with this in a follow-up.

Or actually this is also about the API of toMatchDomainInlineSnapshot, so we might change that whole thing basically. I thought simply swapping to toMatchDomainInlineSnapshot("kv", "...snapshot..."), but current inline snapshot logic didn't support, so I went with swapping two arguments for now.

hi-ogawa and others added 10 commits March 23, 2026 12:23
Co-authored-by: Ari Perkkiö <ari.perkkio@gmail.com>
Co-authored-by: Ari Perkkiö <ari.perkkio@gmail.com>
Co-authored-by: Ari Perkkiö <ari.perkkio@gmail.com>
Co-authored-by: Ari Perkkiö <ari.perkkio@gmail.com>
Co-authored-by: Ari Perkkiö <ari.perkkio@gmail.com>
Comment on lines +123 to +125
get snapshotUpdateState(): SnapshotUpdateState {
return this._updateSnapshot
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realized this public getter is what's requested by

Comment on lines +2190 to +2201
## expect.addSnapshotDomain <Version type="experimental">4.1.1</Version> <Experimental /> {#expect-addsnapshotdomain}

- **Type:** `(adapter: DomainSnapshotAdapter) => void`

Registers a [domain snapshot adapter](/guide/snapshot#custom-snapshot-domain) for use with `toMatchDomainSnapshot` and `toMatchDomainInlineSnapshot`. Call this in [`setupFiles`](/config/setupfiles).

```ts
import { expect } from 'vitest'
import { kvAdapter } from './kv-adapter'

expect.addSnapshotDomain(kvAdapter)
```
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I'm way too late to the party, but would it be possible/worth considering adding this as a config entry rather than in a setup file?

Screenshot comparator algorithms are registered in browser.expect.toMatchScreenshot.comparators, this feels like a similar concept but it's done differently. I think it might be easier for users if something is done one way consistently.

I don't know what feels better to use or if it's possible unifying the behaviors at all, but I think it's something worth talking about.

Copy link
Copy Markdown
Member

@sheremet-va sheremet-va Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Domain snapshots run in test thread, browser comparators are running on the server, so I don't think it's possible to define them as functions, but they can follow the snapshotSerializers pattern, I think:

export default defineConfig({
  test: {
    snapshotDomains: ['./path-to-domain.js']
  }
})


The same as [`toMatchInlineSnapshot`](#tomatchinlinesnapshot), but expects the same value as [`toThrow`](#tothrow).

## toMatchAriaSnapshot <Version type="experimental">4.1.1</Version> <Experimental /> {#tomatcharisnapshot}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
## toMatchAriaSnapshot <Version type="experimental">4.1.1</Version> <Experimental /> {#tomatcharisnapshot}
## toMatchAriaSnapshot <Version type="experimental">4.1.3</Version> <Experimental /> {#tomatcharisnapshot}

(depends)


See the [ARIA Snapshots guide](/guide/browser/aria-snapshots) for more details.

## toMatchAriaInlineSnapshot <Version type="experimental">4.1.1</Version> <Experimental /> {#tomatchariainlinesnapshot}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
## toMatchAriaInlineSnapshot <Version type="experimental">4.1.1</Version> <Experimental /> {#tomatchariainlinesnapshot}
## toMatchAriaInlineSnapshot <Version type="experimental">4.1.3</Version> <Experimental /> {#tomatchariainlinesnapshot}

})
```

## toMatchDomainSnapshot <Version type="experimental">4.1.1</Version> <Experimental /> {#tomatchdomainsnapshot}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
## toMatchDomainSnapshot <Version type="experimental">4.1.1</Version> <Experimental /> {#tomatchdomainsnapshot}
## toMatchDomainSnapshot <Version type="experimental">4.1.3</Version> <Experimental /> {#tomatchdomainsnapshot}

expect(value).toMatchDomainSnapshot('my-domain')
```

## toMatchDomainInlineSnapshot <Version type="experimental">4.1.1</Version> <Experimental /> {#tomatchdomaininlinesnapshot}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
## toMatchDomainInlineSnapshot <Version type="experimental">4.1.1</Version> <Experimental /> {#tomatchdomaininlinesnapshot}
## toMatchDomainInlineSnapshot <Version type="experimental">4.1.3</Version> <Experimental /> {#tomatchdomaininlinesnapshot}

If you previously used Vue CLI with Jest, you might want to install [jest-serializer-vue](https://npmx.dev/package/jest-serializer-vue). Otherwise, your snapshots will be wrapped in a string, which cases `"` to be escaped.
:::

## expect.addSnapshotDomain <Version type="experimental">4.1.1</Version> <Experimental /> {#expect-addsnapshotdomain}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
## expect.addSnapshotDomain <Version type="experimental">4.1.1</Version> <Experimental /> {#expect-addsnapshotdomain}
## expect.addSnapshotDomain <Version type="experimental">4.1.3</Version> <Experimental /> {#expect-addsnapshotdomain}


## ARIA Snapshots

ARIA snapshots capture the accessibility tree of a DOM element and compare it against a stored template. Inspired by [Playwright's ARIA snapshots](https://playwright.dev/docs/aria-snapshots), they provide a semantic alternative to visual regression testing — asserting structure and meaning rather than pixels.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
ARIA snapshots capture the accessibility tree of a DOM element and compare it against a stored template. Inspired by [Playwright's ARIA snapshots](https://playwright.dev/docs/aria-snapshots), they provide a semantic alternative to visual regression testing — asserting structure and meaning rather than pixels.
ARIA snapshots capture the accessibility tree of a DOM element and compare it against a stored template. Based on [Playwright's ARIA snapshots](https://playwright.dev/docs/aria-snapshots), they provide a semantic alternative to visual regression testing — asserting structure and meaning rather than pixels.

const r = matchAriaTree(captured, expected)
return {
pass: r.pass,
message: r.pass ? undefined : 'Accessibility tree does not match expected template',
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's easier to just

if(r.pass) {
  return { pass: true }
}
return {...}

* @see https://vitest.dev/guide/browser/aria-snapshots
* @see https://vitest.dev/api/expect#tomatchariasnapshot
*/
toMatchAriaSnapshot: () => void
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

needs experimental tag

"birpc": "catalog:",
"flatted": "catalog:",
"ivya": "^1.7.1",
"ivya": "https://pkg.pr.new/vitest-dev/ivya/ivya@68f8735",
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new version is released

.toHaveTextContent('Diff')
await expect.element(page.getByRole('tabpanel').getByRole('img'))
.toBeInTheDocument()
expect(result.container).toMatchAriaInlineSnapshot(`
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is result.locator I think

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose updateSnapshot via SnapshotState [Brower Mode] Support aria snapshot matcher

4 participants