Trace View: Failure-Only Snapshot Capture
@hi-ogawa is already working on this.
Since May 11, 2026.
Assessment
This issue has not been assessed yet.
Description
Vitest browser trace view currently captures DOM snapshots as trace entries are recorded. This gives a useful step timeline for debugging, but it also means enabling trace view has a non-zero happy-path cost: every captured step pays for DOM snapshot serialization even when the test passes.
This is a proposal to add a capture policy where lightweight trace metadata is still recorded for the full timeline, but DOM snapshots are taken only when a failure is known. This is different from "retain on failure". The goal is not to record full snapshots throughout the test and discard them on pass. The goal is to keep the happy path low-cost by recording metadata only, then capture diagnostic DOM state at the point where a command/assertion/test fails.
Motivation
Browser trace view is client-driven, unlike Playwright tracing. There is no browser/provider-level tracing session to start just for turning the feature on. The meaningful local cost is snapshot capture.
There is still one global-ish cost today: trace view imports rrweb-snapshot when enabled for the attempt. For failure-gated snapshots, this can become lazy and move to the snapshot recording path so passing tests do not import the snapshot library at all.
Manual timing suggests artifact recording is mostly plumbing after snapshot capture, while takeSnapshot is often a comparable or dominant part of recordBrowserTraceEntry. A failure-gated snapshot mode is attractive because:
- passing tests avoid DOM snapshot serialization
- passing tests can avoid loading
rrweb-snapshot - failures still get high-value DOM state for replay and selector highlighting
- the model follows the same general principle as default-on
failureScreenshot, but with richer trace metadata
Proposal
Initial config shape:
browser: {
traceView: {
enabled: true,
snapshots: 'always' | 'on-failure',
},
}
always preserves the current trace-view behavior.
on-failure records trace metadata for the timeline, but attaches DOM snapshots only when a failure state is known. Successful actions/assertions should not take snapshots.
This is a snapshot capture policy, not an artifact retention policy:
retain-on-failure: record everything, keep only if the test fails. This saves artifact/UI noise, but not runtime cost.on-failure: record lightweight metadata during successful execution, but do not capture DOM snapshots. Capture snapshots only when failure is observed. This saves happy-path snapshot cost while preserving the timeline.
User Experience
The trace view remains useful even when snapshots are failure-gated:
- successful steps still appear in the timeline with metadata, timing, location, and selector information
- failed steps include a replayable DOM snapshot and selector highlight
- a failed test can also include a final lifecycle snapshot as fallback/final-state context
In the UI:
- full snapshot entry: show replay iframe and timing
- metadata-only entry: show step/timing and a "no snapshot captured" empty state
Future Direction
This could make default trace-view diagnostics more plausible. Browser failureScreenshot is already default-on and follows a similar cost model: capture only when a test fails. Failure-gated trace snapshots would extend that idea from pixels to replayable DOM state plus trace metadata.
Potential longer-term default:
browser: {
traceView: {
enabled: true,
snapshots: 'on-failure',
},
}
That would provide richer failure artifacts than screenshots alone while avoiding full snapshot recording cost for passing tests. Before making this default, metadata-only command overhead should be measured with the async lane in place.
Longer term, the capture policy may need more knobs:
- Metadata policy: if metadata-only entries cause noticeable RPC/server backpressure, add a way to skip successful metadata too. For example, a future
metadata: 'always' | 'on-failure'option could trade timeline completeness for lower happy-path overhead. - Snapshot filters/overrides: custom
page.markusers or integrations may want specific spans to always capture snapshots even when the global policy issnapshots: 'on-failure'. This could be modeled by mark options, labels, names, or kind-based rules.
Possible future shapes:
browser: {
traceView: {
snapshots: 'always' | 'on-failure',
metadata: 'always' | 'on-failure',
capture: [
{ kind: 'mark', name: 'checkout', snapshots: 'always' },
{ label: 'critical-flow', snapshots: 'always' },
],
},
}
await page.mark('checkout', { snapshot: 'always' }, async () => {
// ...
})
These should stay out of the initial implementation unless a concrete integration needs them.
Implementation notes
Decisions
- In
snapshots: 'on-failure', successful trace steps should still be recorded as metadata-only entries. BrowserTraceEntry.snapshotshould become optional so metadata-only entries can use the same artifact model.- Failed action/expect/mark range end entries should attach snapshots at the failure point.
- Failed test-end lifecycle should also attach a final snapshot. Treat this as a fallback/final-state snapshot, not a replacement for the failed command/assertion snapshot.
rrweb-snapshotshould be lazy-loaded from the snapshot recording path, not attempt setup.- Because metadata-only entries still call into artifact recording, use a per-attempt async lane for artifact command calls in the initial implementation. Otherwise the happy path still awaits command plumbing for every trace step.
Semantics
For snapshots: 'on-failure', snapshot capture points should be:
- action command range end when
status === 'fail' expect.elementpoll settled whenstatus === 'fail'page.mark(name, fn)range end when the callback throws- lifecycle/test end when
test.result?.state === 'fail'
Range metadata should still be recorded normally. The likely model:
- range start: metadata-only entry
- range end success: metadata-only entry
- range end failure: metadata entry with snapshot
- UI merge: preserve the existing start/end range merge and derive duration from start/end timestamps
This keeps the existing timeline model and avoids needing a separate single-entry failure range for the initial implementation. Direct duration remains useful if a later mode emits single-entry ranges, but it is not required for the normal metadata start/end model.
Relevant Locations
- Browser trace entry type and recording: ../../packages/browser/src/client/tester/trace.ts#L25
- Action command trace ranges: ../../packages/browser/src/client/tester/tester-utils.ts#L170
expect.elementtrace ranges: ../../packages/browser/src/client/tester/expect-element.ts#L49page.marktrace ranges: ../../packages/browser/src/client/tester/context.ts#L360- Test-end lifecycle entry: ../../packages/browser/src/client/tester/runner.ts#L121
- UI range merging: ../../packages/ui/client/composables/trace-view.ts#L19
- Trace view rendering: ../../packages/ui/client/components/trace/TraceView.vue#L124
Current recordBrowserTraceEntry always calls takeSnapshot, so it needs an internal policy for whether to attach a snapshot. That can be a narrow option or inferred from config/status/range phase. The important model change is:
interface BrowserTraceEntry {
// ...
snapshot?: TraceSnapshot
}
To get the full happy-path benefit, rrweb-snapshot import should be lazy. Current setup imports it before the test attempt:
getBrowserState().browserTraceDomSnapshot = await import('rrweb-snapshot')
Instead, snapshot recording can load it only when a snapshot is actually needed:
async function loadBrowserTraceDomSnapshot() {
return getBrowserState().browserTraceDomSnapshot
??= await import('rrweb-snapshot')
}
Then recordBrowserTraceEntry can await this loader only before calling takeSnapshot. In snapshots: 'always', the first trace entry pays the import cost. In snapshots: 'on-failure', passing tests never pay it.
Because metadata-only entries still send artifact commands, add a serialized async lane per trace attempt:
class AsyncLane {
promise: Promise<void> = Promise.resolve()
run<T>(fn: () => Promise<T>): Promise<T> {
const result = this.promise.then(fn)
this.promise = result.then(
() => {},
() => {},
)
return result
}
flush(): Promise<void> {
return this.promise
}
}
The lane can live on BrowserTraceAttempt, enqueue artifact command calls, and flush before browserTraceAttempts.delete(test.id) at test-attempt end. This is less important while takeSnapshot dominates, but it becomes the natural way to keep metadata-only happy-path recording cheap.
Open Questions
- Should
snapshots: 'on-failure'be the eventual default for trace view? - Should the final lifecycle failure snapshot include location/stack from the test failure, the final DOM state, or both?
- What should the UI copy be for metadata-only entries without snapshots?
Suggested Initial Scope
Start with a focused implementation:
- add config for snapshot capture policy
- make
BrowserTraceEntry.snapshotoptional - lazy-load
rrweb-snapshotfrom the snapshot recording path instead of attempt setup - keep current behavior for
snapshots: 'always' - for
snapshots: 'on-failure', emit metadata entries for all trace steps but attach snapshots only to failed action/expect/mark range ends and failed test-end lifecycle - preserve range start/end metadata and derive duration in the UI
- add a per-attempt async lane for artifact commands, flushed at attempt end
This delivers the main benefit: low happy-path trace overhead while preserving a useful timeline and failure diagnostics.
- Dominant language
- TypeScript
- Stars
- 17.1k
- Forks
- 2k
- Avg merge
- 1d 21h
- Merged PRs (30d)
- 92
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from vitest-dev/vitest
-
pending triage
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
vitest-dev/vitest#11276 · 3 reactions ·
-
p3-minor-bug
Difficulty 2/5 1-3 hours Newbie friendliness 82/100
vitest-dev/vitest#11144 · 1 comment ·
-
pending triage
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
vitest-dev/vitest#11019 · 5 comments ·
-
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
vitest-dev/vitest#10940 ·
-
p2-nice-to-have
Difficulty 2/5 1-3 hours Newbie friendliness 65/100
vitest-dev/vitest#10793 · 1 comment ·
All issues in vitest-dev/vitest
Similar issues
-
clawsweeper:fix-shape-clear clawsweeper:queueable-fix clawsweeper:source-repro impact:ux-friction issue-rating: 🦞 diamond lobster no-stale P3
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
-
community first-timers-only good first issue hacktoberfest help wanted low hanging fruit up-for-grabs
Difficulty 1/5 Under an hour Newbie friendliness 76/100
-
code-quality refactoring
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
github/gh-aw-firewall#8816 ·
-
integration:quickjs org:external priority:backlog topic:code-interpreter topic:middleware type:feature
Difficulty 2/5 1-3 hours Newbie friendliness 74/100
langchain-ai/deepagents#6450 ·
-
Difficulty 1/5 Under an hour Newbie friendliness 88/100
vercel/react-tweet#225 ·