Skip to content

feat: interactively review failed snapshots - #11325

Open
macarie wants to merge 33 commits into
vitest-dev:mainfrom
macarie:feat/snapshot-review
Open

macarie wants to merge 33 commits into
vitest-dev:mainfrom
macarie:feat/snapshot-review

Conversation

@macarie

@macarie macarie commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

Description

Resolves #2229
Closes #9297

UX largely inspired by Insta's review command.

Demo on a project with different types of snapshots:

interactive-review.webm

Possible follow-ups:

  • Handle snapshot removal/obsolete snapshots
  • Flag to run snapshots as soft assertions (?)

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:.

@macarie
macarie force-pushed the feat/snapshot-review branch 2 times, most recently from 1241c1a to dfac52e Compare October 8, 2026 21:29
@macarie
macarie force-pushed the feat/snapshot-review branch from dfac52e to 15e35db Compare October 8, 2026 21:39
) => Promise<void>,
) => Promise<void>

abstract class SnapshotReviewer {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This class is totally unnecessary now, but I think it could be used as a base for having this feature in UI mode by creating a custom UISnapshotReviewer (like NodeSnapshotReviewer) with a custom renderer.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think we should avoid speculative abstraction for now.

try {
await this.collectReviews(this.#review)
await this.#updateSnapshots()
// @todo what should happen when there's no approved updates?

@macarie macarie Oct 8, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Should we rerun all tests if no updates were approved?

Comment thread packages/vitest/src/node/stdin.ts Resolved
Comment thread packages/vitest/src/node/watcher.ts Resolved
Comment on lines +25 to +27
export async function saveSnapshotUpdates(
snapshots: Map<SnapshotEnvironment, readonly SnapshotUpdate[]>,
): Promise<void> {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not sure if this is the best way to handle the updates or maybe change SnapshotState, but it seems to be working fine with all kinds of snapshots.

It should probably be marked as @internal tho 🤔

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think ideally we'd want get the failure/update information from SnapshotState since that's more like a ground truth source. I didn't think through what it would actually take for that though.

Code wise, the approach with the error meta and chai.ts stack fixup does look fairly clean, so it's not a big deal, but I'd like to see how the SnapshotState alternative approach looks like realistically.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I've created a poc for alternative approach #11790. It also discovered the independent bug #11791, which can be likely fixed on its own.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I thought that bug was just a weird interaction between soft, resolves/rejects, and the snapshot matchers. I should have checked 😅


export interface RawSnapshot extends RawSnapshotInfo {
snapshot: string
file: string

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This was a redundant property since it's set in RawSnapshotInfo already

Comment thread packages/vitest/src/node/stdin.ts Resolved
Comment thread packages/snapshot/src/update.ts Outdated
}
}

await Promise.all(fsEvents)

@macarie macarie Oct 13, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Not sure how I feel about waiting for these events to fire, since they might not be that reliable, but so far it seems it's working fine 👀

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

The watcher workaround looks quite a magic. For example, it looks like saveInlineSnapshots/saveSnapshotFile/saveRawSnapshots aren't awaited but is it also part of it?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

From the code perspective, it looks more natural for watcher workaround to live on snapshot-review.ts than saveSnapshotUpdates. Also I wonder if it would be enough to just add some delay of setTimeout and pray watcher is settled for practice cases. (If it's CI issue, then we may find different surgical workaround for that specifically).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Yes, it tracks file updates (with a timeout) and it doesn't await the updates themselves.

I agree with you, it would make more sense for it to live in the review process rather than here, I'm going to do that 👍🏼

Also I wonder if it would be enough to just add some delay of setTimeout and pray watcher is settled for practice cases.

I tried but fs events seem to be quite "random" in timing, so it's easy to get a re-run after enabling the watcher unless using a high timeout.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Now the update waits for the saves and then the reviewer waits for the events only after running the tests, just in case some are still pending being dispatched.

Comment thread packages/snapshot/src/update.ts Resolved
Comment on lines +88 to +93
review: {
shorthand: 'i',
// @todo implement
description:
'Run tests with soft snapshot assertions to collect all proposed changes, then review each one interactively to accept, reject, or skip it',
},

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The idea here would be to (as the description says) run snapshot assertions as soft assertions. This way we can collect all the failures and have a single review phase, otherwise users would have to run → review → [run → review → [...]].

If we decide to go with it, it should probably be in a follow-up task tho.

const DIFF_COL_WIDTH = 5
const HUNK_RE = /^@@ -(\d+)(?:,\d+)? \+(\d+)(?:,\d+)? @@/

function formatDiff(diff: string): string {

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

This might be unnecessary, but I like that it keeps the same layout throughout a review:

interactive-review.webm

@macarie
macarie force-pushed the feat/snapshot-review branch from d3a7a1c to fe628d9 Compare October 21, 2026 20:01
@macarie
macarie force-pushed the feat/snapshot-review branch from fe628d9 to 92d45e5 Compare October 21, 2026 20:14
@netlify

netlify Bot commented Oct 22, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for vitest-dev ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 68e358b
🔍 Latest deploy log https://app.netlify.com/projects/vitest-dev/deploys/6ae45f0392a337a7c0d9916b
😎 Deploy Preview https://deploy-preview-11325--vitest-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

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

@hi-ogawa hi-ogawa added this to the 5.1.0 milestone Oct 23, 2026
@macarie
macarie marked this pull request as ready for review October 23, 2026 11:12
message: () => errorMessage || 'Snapshot properties mismatched',
actual: received,
expected: properties,
meta: createMetaObject({

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This Snapshot properties mismatched case's meta isn't from snapshot failure but only matcher mismatch so it's not usable for review and seems to break the review update. Perhaps, we can remove meta thus exclude from review?

agent repro 🤖
import { appendFileSync, readFileSync } from 'node:fs'
import { expect, test } from 'vitest'
import { runInlineTests } from '../../../test-utils'

test('property matcher failure', async () => {
  const { vitest, ctx, fs } = await runInlineTests(
    {
      'basic.test.ts': `
import { expect, test } from 'vitest'
test('props', () => {
  expect({ id: 123, name: 'x' }).toMatchSnapshot({ id: expect.any(String) })
})
`,
      '__snapshots__/basic.test.ts.snap': `// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[\`props 1\`] = \`
{
  "id": Any<String>,
  "name": "x",
}
\`;
`,
    },
    { update: 'none', watch: true },
  )

  const before = ctx!.vite.watcher.listenerCount('change')
  vitest.write('i')
  await vitest.waitForStdout('Reviewing [1/1]')
  appendFileSync('/tmp/probe-out.txt', String(['--- REVIEW SCREEN ---\n' + vitest.stdout].map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ')) + '\n')
  vitest.write('a')
  await vitest.waitForStdout('Watching for file changes')
  await new Promise(r => setTimeout(r, 500))
  const after = ctx!.vite.watcher.listenerCount('change')
  appendFileSync('/tmp/probe-out.txt', String(['--- SNAP FILE ---\n' + readFileSync(fs.resolveFile('__snapshots__/basic.test.ts.snap'), 'utf-8')].map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ')) + '\n')
  appendFileSync('/tmp/probe-out.txt', String(['--- change listeners ---', { before, after }].map(x => typeof x === 'string' ? x : JSON.stringify(x)).join(' ')) + '\n')
})

@macarie macarie Oct 30, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Does #11790 cover this bit too?

Yes, it's been removed 😄

) => Promise<void>,
) => Promise<void>

abstract class SnapshotReviewer {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think we should avoid speculative abstraction for now.

Comment thread packages/snapshot/src/update.ts Outdated
}
}

await Promise.all(fsEvents)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

From the code perspective, it looks more natural for watcher workaround to live on snapshot-review.ts than saveSnapshotUpdates. Also I wonder if it would be enough to just add some delay of setTimeout and pray watcher is settled for practice cases. (If it's CI issue, then we may find different surgical workaround for that specifically).

This branch has not been deployed

No deployments
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.

Interactive snapshot update/create mode

2 participants