Trace View: Failure-Only Snapshot Capture

Open
#10,310 0 comments 0 reactions 1 assignee View on GitHub

@hi-ogawa is already working on this.

Since May 11, 2026.

Assessment

This issue has not been assessed yet.

Description

feat: browser

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.mark users or integrations may want specific spans to always capture snapshots even when the global policy is snapshots: '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.snapshot should 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-snapshot should 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.element poll settled when status === '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

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.snapshot optional
  • lazy-load rrweb-snapshot from 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

More from vitest-dev/vitest

All issues in vitest-dev/vitest

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.