Skip to content

Commit 78c1169

Browse files
hi-ogawacodex
andauthored
feat(browser): live update trace view on watch UI (#10296)
Co-authored-by: Codex <[email protected]>
1 parent dd0207f commit 78c1169

27 files changed

Lines changed: 1605 additions & 188 deletions

File tree

‎docs/guide/browser/trace-view.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ Trace entries are recorded automatically for:
102102

103103
Each entry captures the DOM state at that point, along with timing information, the selector, and the source location that triggered it.
104104

105-
Element highlighting is best-effort. Some provider-specific selectors, shadow DOM selectors, or elements that are not present in the captured snapshot may not be highlighted.
105+
In Vitest UI, trace entries are streamed as the test runs, so you can inspect recorded steps before the test finishes. Long-running actions, `expect.element(...)` assertions, and callback `page.mark()` entries appear as in-progress steps first, then update with their final status and duration.
106106

107107
## Custom Trace Entries
108108

‎packages/browser/src/client/tester/context.ts‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@ import type { Locator as LocatorAPI } from './locators'
1919
import type { BrowserTraceEntryStatus } from './trace'
2020
import { vi } from 'vitest'
2121
import { __INTERNAL, stringify } from 'vitest/internal/browser'
22-
import { ensureAwaited, getBrowserState, getWorkerState, now } from '../utils'
22+
import { ensureAwaited, getBrowserState, getWorkerState } from '../utils'
2323
import { isLocator, processTimeoutOptions, resolveUserEventWheelOptions, serializeElement } from './tester-utils'
24-
import { recordBrowserTraceEntry } from './trace'
24+
import { createBrowserTraceRangeId, recordBrowserTraceEntry } from './trace'
2525

2626
// this file should not import anything directly, only types and utils
2727

@@ -368,7 +368,7 @@ export const page: BrowserPage = {
368368
if (typeof bodyOrOptions === 'function') {
369369
return ensureAwaited(async (error) => {
370370
let status: BrowserTraceEntryStatus = 'pass'
371-
const startTime = now()
371+
const traceRangeId = hasActiveTraceView ? createBrowserTraceRangeId() : undefined
372372
if (hasActiveTrace) {
373373
await triggerCommand(
374374
'__vitest_groupTraceStart',
@@ -379,6 +379,14 @@ export const page: BrowserPage = {
379379
error,
380380
)
381381
}
382+
if (hasActiveTraceView) {
383+
await recordBrowserTraceEntry(currentTest, {
384+
name,
385+
kind: 'mark',
386+
range: { id: traceRangeId!, phase: 'start' },
387+
stack: options?.stack ?? error?.stack,
388+
})
389+
}
382390
try {
383391
return await bodyOrOptions()
384392
}
@@ -388,13 +396,11 @@ export const page: BrowserPage = {
388396
}
389397
finally {
390398
if (hasActiveTraceView) {
391-
// TODO: support nested trace
392-
recordBrowserTraceEntry(currentTest, {
399+
await recordBrowserTraceEntry(currentTest, {
393400
name,
394401
kind: options?.kind ?? 'mark',
402+
range: { id: traceRangeId!, phase: 'end' },
395403
status,
396-
startTime,
397-
duration: now() - startTime,
398404
stack: options?.stack ?? error?.stack,
399405
})
400406
}
@@ -409,9 +415,9 @@ export const page: BrowserPage = {
409415
return Promise.resolve()
410416
}
411417

412-
return ensureAwaited((error) => {
418+
return ensureAwaited(async (error) => {
413419
if (hasActiveTraceView) {
414-
recordBrowserTraceEntry(currentTest, {
420+
await recordBrowserTraceEntry(currentTest, {
415421
name,
416422
kind: bodyOrOptions?.kind ?? 'mark',
417423
stack: bodyOrOptions?.stack ?? error?.stack,

‎packages/browser/src/client/tester/expect-element.ts‎

Lines changed: 27 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { getBrowserState, getWorkerState, now } from '../utils'
77
import { ariaMatchers } from './aria'
88
import { matchers } from './expect'
99
import { processTimeoutOptions } from './tester-utils'
10-
import { recordBrowserTraceEntry } from './trace'
10+
import { createBrowserTraceRangeId, recordBrowserTraceEntry } from './trace'
1111

1212
const kLocator = Symbol.for('$$vitest:locator')
1313

@@ -49,23 +49,36 @@ function element<T extends HTMLElement | SVGElement | null | Locator>(elementOrL
4949
const hasActiveTraceView = !!currentTest && getBrowserState().browserTraceAttempts.has(currentTest.id)
5050
if (currentTest && (hasActiveTrace || hasActiveTraceView)) {
5151
const sourceError = new Error('__vitest_mark_trace__')
52-
const startTime = now()
53-
chai.util.flag(expectElement, '_poll.onSettled', async (meta: { assertion: Assertion; status: BrowserTraceEntryStatus }) => {
54-
const isNot = chai.util.flag(meta.assertion, 'negate')
55-
const name = chai.util.flag(meta.assertion, '_name') || '<unknown>'
52+
const traceRangeId = hasActiveTraceView ? createBrowserTraceRangeId() : undefined
53+
const getSelector = () => !elementOrLocator || elementOrLocator instanceof Element
54+
? undefined
55+
: elementOrLocator.serialize()
56+
const getTraceName = (assertion: Assertion, status?: BrowserTraceEntryStatus) => {
57+
const isNot = chai.util.flag(assertion, 'negate')
58+
const name = chai.util.flag(assertion, '_name') || '<unknown>'
5659
const baseName = `${isNot ? 'not.' : ''}${name}`
57-
const traceName = meta.status === 'fail' ? `${baseName} [ERROR]` : baseName
58-
const selector = !elementOrLocator || elementOrLocator instanceof Element
59-
? undefined
60-
: elementOrLocator.serialize()
60+
return status === 'fail' ? `${baseName} [ERROR]` : baseName
61+
}
62+
chai.util.flag(expectElement, '_poll.onStart', async (meta: { assertion: Assertion }) => {
63+
if (hasActiveTraceView) {
64+
await recordBrowserTraceEntry(currentTest, {
65+
name: getTraceName(meta.assertion),
66+
kind: 'expect',
67+
range: { id: traceRangeId!, phase: 'start' },
68+
element: getSelector(),
69+
stack: sourceError.stack,
70+
})
71+
}
72+
})
73+
chai.util.flag(expectElement, '_poll.onSettled', async (meta: { assertion: Assertion; status: BrowserTraceEntryStatus }) => {
74+
const traceName = getTraceName(meta.assertion, meta.status)
6175
if (hasActiveTraceView) {
62-
recordBrowserTraceEntry(currentTest, {
76+
await recordBrowserTraceEntry(currentTest, {
6377
name: traceName,
6478
kind: 'expect',
79+
range: { id: traceRangeId!, phase: 'end' },
6580
status: meta.status,
66-
startTime,
67-
duration: now() - startTime,
68-
element: selector,
81+
element: getSelector(),
6982
stack: sourceError.stack,
7083
})
7184
}
@@ -74,7 +87,7 @@ function element<T extends HTMLElement | SVGElement | null | Locator>(elementOrL
7487
'__vitest_markTrace',
7588
[{
7689
name: traceName,
77-
element: selector,
90+
element: getSelector(),
7891
stack: sourceError.stack,
7992
}],
8093
sourceError,

‎packages/browser/src/client/tester/locators.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -218,9 +218,9 @@ export abstract class Locator {
218218
if (!currentTest || (!hasActiveTrace && !hasActiveTraceView)) {
219219
return Promise.resolve()
220220
}
221-
return ensureAwaited((error) => {
221+
return ensureAwaited(async (error) => {
222222
if (hasActiveTraceView) {
223-
recordBrowserTraceEntry(currentTest, {
223+
await recordBrowserTraceEntry(currentTest, {
224224
name,
225225
kind: options?.kind ?? 'mark',
226226
element: this.serialize(),

‎packages/browser/src/client/tester/runner.ts‎

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import { createStackString, parseStacktrace } from '../../../../utils/src/source
2929
import { getBrowserState, getWorkerState, moduleRunner, now } from '../utils'
3030
import { rpc } from './rpc'
3131
import { VitestBrowserSnapshotEnvironment } from './snapshot'
32-
import { getBrowserTrace, recordBrowserTraceEntry } from './trace'
32+
import { recordBrowserTraceEntry } from './trace'
3333

3434
interface BrowserRunnerOptions {
3535
config: SerializedConfig
@@ -123,20 +123,12 @@ export function createBrowserRunner(
123123
const status = test.result?.state
124124
const stack = status === 'fail' ? test.result?.errors?.[0].stack : undefined
125125
const location = test.location ? { ...test.location, file: test.file.filepath } : undefined
126-
recordBrowserTraceEntry(test, {
126+
await recordBrowserTraceEntry(test, {
127127
name: `vitest:onAfterRetryTask`,
128128
kind: 'lifecycle',
129129
...(status === 'pass' || status === 'fail' ? { status } : {}),
130130
...(stack ? { stack } : location ? { location } : {}),
131131
})
132-
// TODO: model the same retention mechanism as playwright e.g. retain-on-failure
133-
const traceData = getBrowserTrace(test.id, repeats, retry)
134-
if (traceData) {
135-
await this.commands.triggerCommand(
136-
'__vitest_recordBrowserTrace',
137-
[{ testId: test.id, data: traceData }],
138-
)
139-
}
140132
getBrowserState().browserTraceAttempts.delete(test.id)
141133
}
142134
const hasActiveTrace = getBrowserState().activeTraceTaskIds.has(test.id)

‎packages/browser/src/client/tester/tester-utils.ts‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { BrowserRPC } from '../client'
33
import type { BrowserTraceEntryStatus } from './trace'
44
import { __INTERNAL } from 'vitest/internal/browser'
55
import { getBrowserState, getWorkerState, now } from '../utils'
6-
import { recordBrowserTraceEntry } from './trace'
6+
import { createBrowserTraceRangeId, recordBrowserTraceEntry } from './trace'
77

88
/* @__NO_SIDE_EFFECTS__ */
99
export function convertElementToCssSelector(element: Element): string {
@@ -170,7 +170,20 @@ export class CommandsManager {
170170
)
171171
}
172172
let status: BrowserTraceEntryStatus = 'pass'
173-
const startTime = now()
173+
const traceRangeId = hasActiveTraceView ? createBrowserTraceRangeId() : undefined
174+
const element = typeof args[0] === 'object' && 'selector' in args[0] && 'locator' in args[0] ? args[0] : undefined
175+
if (hasActiveTraceView) {
176+
// Covers provider-backed actionability/waiting after command dispatch.
177+
// Local pre-command resolution, such as serializeElement/findElement paths
178+
// is not coverd within by this action trace range.
179+
await recordBrowserTraceEntry(currentTest, {
180+
name: actionTraceGroupName,
181+
kind: 'action',
182+
range: { id: traceRangeId!, phase: 'start' },
183+
element,
184+
stack: clientError.stack,
185+
})
186+
}
174187
try {
175188
return await rpc.triggerCommand<T>(sessionId, command, filepath, args)
176189
}
@@ -184,13 +197,12 @@ export class CommandsManager {
184197
}
185198
finally {
186199
if (hasActiveTraceView) {
187-
recordBrowserTraceEntry(currentTest, {
200+
await recordBrowserTraceEntry(currentTest, {
188201
name: actionTraceGroupName,
189202
kind: 'action',
203+
range: { id: traceRangeId!, phase: 'end' },
190204
status,
191-
startTime,
192-
duration: now() - startTime,
193-
element: typeof args[0] === 'object' && 'selector' in args[0] && 'locator' in args[0] ? args[0] : undefined,
205+
element,
194206
stack: clientError.stack,
195207
})
196208
}

‎packages/browser/src/client/tester/trace.ts‎

Lines changed: 37 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,35 @@
11
import type { Task } from '@vitest/runner'
22
import type { BrowserTraceEntryKind } from 'vitest/browser'
3+
import type { BrowserRPC } from '../client'
34
import type { SerializedLocator } from './locators'
4-
import { getBrowserState, now } from '../utils'
5+
import { getBrowserState, getWorkerState, now } from '../utils'
56

67
export interface BrowserTraceData {
78
retry: number
89
repeats: number
10+
// UI has access to original config but let artifact own this
911
recordCanvas: boolean
12+
// Each artifact currently carries one entry; the UI merges entries by attempt.
13+
// TODO: revisit whether this should be modeled as a single entry.
1014
entries: BrowserTraceEntry[]
1115
}
1216

1317
export type BrowserTraceEntryStatus = 'pass' | 'fail'
18+
export type BrowserTraceEntryRangePhase = 'start' | 'end'
1419
export type BrowserTraceSelectorResolution = 'matched' | 'missing' | 'error'
1520

21+
export interface BrowserTraceEntryRange {
22+
id: string
23+
phase: BrowserTraceEntryRangePhase
24+
}
25+
1626
export interface BrowserTraceEntry {
1727
name: string
1828
kind: BrowserTraceEntryKind
29+
range?: BrowserTraceEntryRange
1930
status?: BrowserTraceEntryStatus
2031
startTime: number
32+
// Derived on UI side from range start/end entries.
2133
duration?: number
2234
stack?: string
2335
// resolved server-side from stack in __vitest_recordBrowserTrace command
@@ -61,31 +73,22 @@ const PSEUDO_CLASS_NAMES = [
6173
] as const
6274
type PseudoClassName = (typeof PSEUDO_CLASS_NAMES)[number]
6375

64-
export type BrowserTraceState = Record<string, BrowserTraceData>
65-
6676
export interface BrowserTraceAttempt {
6777
retry: number
6878
repeats: number
6979
startTime: number
7080
}
7181

72-
function getBrowserTraceState(): BrowserTraceState {
73-
return getBrowserState().browserTraceState ??= {}
74-
}
75-
76-
function getTraceStateKey(testId: string, repeats: number, retry: number) {
77-
return `${testId}:${repeats}:${retry}`
82+
export function createBrowserTraceRangeId(): string {
83+
return Math.random().toString(36).slice(2)
7884
}
7985

80-
// TODO: should we avoid accumulating? send and immediately clear each entry to save memory?
81-
export function recordBrowserTraceEntry(
86+
export async function recordBrowserTraceEntry(
8287
task: Task,
83-
options: Omit<BrowserTraceEntry, 'snapshot' | 'startTime'> & {
84-
startTime?: number
85-
},
86-
): void {
88+
options: Omit<BrowserTraceEntry, 'snapshot' | 'startTime'>,
89+
): Promise<void> {
8790
const attemptInfo = getBrowserState().browserTraceAttempts.get(task.id)!
88-
const relativeStartTime = (options.startTime ?? now()) - attemptInfo.startTime
91+
const relativeStartTime = now() - attemptInfo.startTime
8992
const snapshot = takeSnapshot(options.element)
9093
const entry: BrowserTraceEntry = {
9194
...options,
@@ -94,10 +97,24 @@ export function recordBrowserTraceEntry(
9497
}
9598
const { retry, repeats } = attemptInfo
9699
const { recordCanvas } = getBrowserState().config.browser.traceView
97-
const state = getBrowserTraceState()
98-
const traceKey = getTraceStateKey(task.id, repeats, retry)
99-
state[traceKey] ??= { retry, repeats, recordCanvas, entries: [] }
100-
state[traceKey].entries.push(entry)
100+
101+
// An async lane could defer artifact recording and flush it at test-attempt end,
102+
// but the synchronous snapshot work is already a comparable cost, and this path
103+
// is mostly data passing after that.
104+
// Keep it simple unless measurements show artifact recording is a bottleneck.
105+
const data: BrowserTraceData = {
106+
retry,
107+
repeats,
108+
recordCanvas,
109+
entries: [entry],
110+
}
111+
const rpc = getWorkerState().rpc as any as BrowserRPC
112+
await rpc.triggerCommand<void>(
113+
getBrowserState().sessionId,
114+
'__vitest_recordBrowserTrace',
115+
undefined,
116+
[{ testId: task.id, data }],
117+
)
101118
}
102119

103120
// Resolve ivya selector to a DOM element and take a snapshot with rrweb Mirror
@@ -161,13 +178,3 @@ function takeSnapshot(serializedLocator?: SerializedLocator): TraceSnapshot {
161178
}
162179
return result
163180
}
164-
165-
export function getBrowserTrace(testId: string, repeats: number, retry: number): BrowserTraceData | undefined {
166-
const state = getBrowserTraceState()
167-
const traceKey = getTraceStateKey(testId, repeats, retry)
168-
const result = state[traceKey]
169-
if (result) {
170-
delete state[traceKey]
171-
return result
172-
}
173-
}

‎packages/browser/src/client/utils.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { SerializedConfig, WorkerGlobalState } from 'vitest'
44
import type { OTELCarrier, Traces } from 'vitest/internal/traces'
55
import type { IframeOrchestrator } from './orchestrator'
66
import type { CommandsManager } from './tester/tester-utils'
7-
import type { BrowserTraceAttempt, BrowserTraceState } from './tester/trace'
7+
import type { BrowserTraceAttempt } from './tester/trace'
88

99
export async function importId(id: string): Promise<any> {
1010
const name = `/@id/${id}`.replace(/\\/g, '/')
@@ -99,7 +99,6 @@ export interface BrowserRunnerState {
9999
browserTraceAttempts: Map<string, BrowserTraceAttempt>
100100
// lazily loaded only when traceView is enabled
101101
browserTraceDomSnapshot?: typeof import('rrweb-snapshot')
102-
browserTraceState?: BrowserTraceState
103102
selectorEngine: Ivya
104103
traces: Traces
105104
cleanups: Array<() => unknown>

‎packages/ui/client/components/artifacts/Artifacts.vue‎

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { RunnerTestCase, TestArtifact } from 'vitest'
33
import type { Component } from 'vue'
44
import { computed } from 'vue'
55
import { getLocationString, openLocation } from '~/composables/location'
6-
import TraceArtifactLauncher from '../trace/TraceArtifactLauncher.vue'
6+
import TraceArtifacts from '../trace/TraceArtifacts.vue'
77
import VisualRegression from './visual-regression/VisualRegression.vue'
88
99
const { test } = defineProps<{ test: RunnerTestCase }>()
@@ -20,11 +20,7 @@ const handledArtifacts = computed<readonly HandledArtifact[]>(() => {
2020
for (const artifact of test.artifacts) {
2121
switch (artifact.type) {
2222
case 'internal:browserTrace': {
23-
handledArtifacts.push({
24-
artifact,
25-
component: TraceArtifactLauncher,
26-
props: { trace: artifact, test } satisfies ComponentProps<typeof TraceArtifactLauncher>,
27-
})
23+
// handled by <TraceArtifacts />
2824
continue
2925
}
3026
case 'internal:toMatchScreenshot': {
@@ -46,6 +42,8 @@ const handledArtifacts = computed<readonly HandledArtifact[]>(() => {
4642
</script>
4743

4844
<template>
45+
<TraceArtifacts :test="test" />
46+
4947
<template v-if="handledArtifacts.length">
5048
<h1 m-2>
5149
Test Artifacts

0 commit comments

Comments
 (0)