From 78c1169cf61e025590e7c29767269b5330cd06c7 Mon Sep 17 00:00:00 2001 From: Hiroshi Ogawa Date: Wed, 13 May 2026 01:09:24 +0900 Subject: [PATCH] feat(browser): live update trace view on watch UI (#10296) Co-authored-by: Codex --- docs/guide/browser/trace-view.md | 2 +- packages/browser/src/client/tester/context.ts | 24 +- .../src/client/tester/expect-element.ts | 41 +- .../browser/src/client/tester/locators.ts | 4 +- packages/browser/src/client/tester/runner.ts | 12 +- .../browser/src/client/tester/tester-utils.ts | 24 +- packages/browser/src/client/tester/trace.ts | 67 +- packages/browser/src/client/utils.ts | 3 +- .../client/components/artifacts/Artifacts.vue | 10 +- .../trace/TraceArtifactLauncher.vue | 28 - .../components/trace/TraceArtifacts.vue | 48 + .../ui/client/components/trace/TraceView.vue | 53 +- .../client/components/trace/TraceViewPane.vue | 18 +- packages/ui/client/composables/trace-view.ts | 161 ++- packages/ui/client/pages/index.vue | 4 +- packages/vitest/src/integrations/chai/poll.ts | 2 + test/browser/fixtures/trace/mark.test.ts | 7 + test/browser/specs/trace.test.ts | 926 +++++++++++++++++- test/ui/fixtures-trace-stream/basic.test.ts | 18 + test/ui/fixtures-trace-stream/helper.ts | 21 + test/ui/fixtures-trace-stream/range.test.ts | 29 + .../ui/fixtures-trace-stream/vitest.config.ts | 21 + test/ui/fixtures-trace/basic.test.ts | 5 + test/ui/fixtures-trace/retry.test.ts | 40 + test/ui/test/trace-stream.spec.ts | 176 ++++ test/ui/test/trace.spec.ts | 47 +- test/ui/vitest.config.ts | 2 +- 27 files changed, 1605 insertions(+), 188 deletions(-) delete mode 100644 packages/ui/client/components/trace/TraceArtifactLauncher.vue create mode 100644 packages/ui/client/components/trace/TraceArtifacts.vue create mode 100644 test/ui/fixtures-trace-stream/basic.test.ts create mode 100644 test/ui/fixtures-trace-stream/helper.ts create mode 100644 test/ui/fixtures-trace-stream/range.test.ts create mode 100644 test/ui/fixtures-trace-stream/vitest.config.ts create mode 100644 test/ui/fixtures-trace/retry.test.ts create mode 100644 test/ui/test/trace-stream.spec.ts diff --git a/docs/guide/browser/trace-view.md b/docs/guide/browser/trace-view.md index 42683aa84651..8f7068e3c7db 100644 --- a/docs/guide/browser/trace-view.md +++ b/docs/guide/browser/trace-view.md @@ -102,7 +102,7 @@ Trace entries are recorded automatically for: Each entry captures the DOM state at that point, along with timing information, the selector, and the source location that triggered it. -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. +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. ## Custom Trace Entries diff --git a/packages/browser/src/client/tester/context.ts b/packages/browser/src/client/tester/context.ts index 9833fcc7a1b2..65e586e45f89 100644 --- a/packages/browser/src/client/tester/context.ts +++ b/packages/browser/src/client/tester/context.ts @@ -19,9 +19,9 @@ import type { Locator as LocatorAPI } from './locators' import type { BrowserTraceEntryStatus } from './trace' import { vi } from 'vitest' import { __INTERNAL, stringify } from 'vitest/internal/browser' -import { ensureAwaited, getBrowserState, getWorkerState, now } from '../utils' +import { ensureAwaited, getBrowserState, getWorkerState } from '../utils' import { isLocator, processTimeoutOptions, resolveUserEventWheelOptions, serializeElement } from './tester-utils' -import { recordBrowserTraceEntry } from './trace' +import { createBrowserTraceRangeId, recordBrowserTraceEntry } from './trace' // this file should not import anything directly, only types and utils @@ -368,7 +368,7 @@ export const page: BrowserPage = { if (typeof bodyOrOptions === 'function') { return ensureAwaited(async (error) => { let status: BrowserTraceEntryStatus = 'pass' - const startTime = now() + const traceRangeId = hasActiveTraceView ? createBrowserTraceRangeId() : undefined if (hasActiveTrace) { await triggerCommand( '__vitest_groupTraceStart', @@ -379,6 +379,14 @@ export const page: BrowserPage = { error, ) } + if (hasActiveTraceView) { + await recordBrowserTraceEntry(currentTest, { + name, + kind: 'mark', + range: { id: traceRangeId!, phase: 'start' }, + stack: options?.stack ?? error?.stack, + }) + } try { return await bodyOrOptions() } @@ -388,13 +396,11 @@ export const page: BrowserPage = { } finally { if (hasActiveTraceView) { - // TODO: support nested trace - recordBrowserTraceEntry(currentTest, { + await recordBrowserTraceEntry(currentTest, { name, kind: options?.kind ?? 'mark', + range: { id: traceRangeId!, phase: 'end' }, status, - startTime, - duration: now() - startTime, stack: options?.stack ?? error?.stack, }) } @@ -409,9 +415,9 @@ export const page: BrowserPage = { return Promise.resolve() } - return ensureAwaited((error) => { + return ensureAwaited(async (error) => { if (hasActiveTraceView) { - recordBrowserTraceEntry(currentTest, { + await recordBrowserTraceEntry(currentTest, { name, kind: bodyOrOptions?.kind ?? 'mark', stack: bodyOrOptions?.stack ?? error?.stack, diff --git a/packages/browser/src/client/tester/expect-element.ts b/packages/browser/src/client/tester/expect-element.ts index 3b2474d9b56a..b684e5f19852 100644 --- a/packages/browser/src/client/tester/expect-element.ts +++ b/packages/browser/src/client/tester/expect-element.ts @@ -7,7 +7,7 @@ import { getBrowserState, getWorkerState, now } from '../utils' import { ariaMatchers } from './aria' import { matchers } from './expect' import { processTimeoutOptions } from './tester-utils' -import { recordBrowserTraceEntry } from './trace' +import { createBrowserTraceRangeId, recordBrowserTraceEntry } from './trace' const kLocator = Symbol.for('$$vitest:locator') @@ -49,23 +49,36 @@ function element(elementOrL const hasActiveTraceView = !!currentTest && getBrowserState().browserTraceAttempts.has(currentTest.id) if (currentTest && (hasActiveTrace || hasActiveTraceView)) { const sourceError = new Error('__vitest_mark_trace__') - const startTime = now() - chai.util.flag(expectElement, '_poll.onSettled', async (meta: { assertion: Assertion; status: BrowserTraceEntryStatus }) => { - const isNot = chai.util.flag(meta.assertion, 'negate') - const name = chai.util.flag(meta.assertion, '_name') || '' + const traceRangeId = hasActiveTraceView ? createBrowserTraceRangeId() : undefined + const getSelector = () => !elementOrLocator || elementOrLocator instanceof Element + ? undefined + : elementOrLocator.serialize() + const getTraceName = (assertion: Assertion, status?: BrowserTraceEntryStatus) => { + const isNot = chai.util.flag(assertion, 'negate') + const name = chai.util.flag(assertion, '_name') || '' const baseName = `${isNot ? 'not.' : ''}${name}` - const traceName = meta.status === 'fail' ? `${baseName} [ERROR]` : baseName - const selector = !elementOrLocator || elementOrLocator instanceof Element - ? undefined - : elementOrLocator.serialize() + return status === 'fail' ? `${baseName} [ERROR]` : baseName + } + chai.util.flag(expectElement, '_poll.onStart', async (meta: { assertion: Assertion }) => { + if (hasActiveTraceView) { + await recordBrowserTraceEntry(currentTest, { + name: getTraceName(meta.assertion), + kind: 'expect', + range: { id: traceRangeId!, phase: 'start' }, + element: getSelector(), + stack: sourceError.stack, + }) + } + }) + chai.util.flag(expectElement, '_poll.onSettled', async (meta: { assertion: Assertion; status: BrowserTraceEntryStatus }) => { + const traceName = getTraceName(meta.assertion, meta.status) if (hasActiveTraceView) { - recordBrowserTraceEntry(currentTest, { + await recordBrowserTraceEntry(currentTest, { name: traceName, kind: 'expect', + range: { id: traceRangeId!, phase: 'end' }, status: meta.status, - startTime, - duration: now() - startTime, - element: selector, + element: getSelector(), stack: sourceError.stack, }) } @@ -74,7 +87,7 @@ function element(elementOrL '__vitest_markTrace', [{ name: traceName, - element: selector, + element: getSelector(), stack: sourceError.stack, }], sourceError, diff --git a/packages/browser/src/client/tester/locators.ts b/packages/browser/src/client/tester/locators.ts index bb8f718c69f1..1483d3ca5de8 100644 --- a/packages/browser/src/client/tester/locators.ts +++ b/packages/browser/src/client/tester/locators.ts @@ -218,9 +218,9 @@ export abstract class Locator { if (!currentTest || (!hasActiveTrace && !hasActiveTraceView)) { return Promise.resolve() } - return ensureAwaited((error) => { + return ensureAwaited(async (error) => { if (hasActiveTraceView) { - recordBrowserTraceEntry(currentTest, { + await recordBrowserTraceEntry(currentTest, { name, kind: options?.kind ?? 'mark', element: this.serialize(), diff --git a/packages/browser/src/client/tester/runner.ts b/packages/browser/src/client/tester/runner.ts index 945d55d91020..f6fc36feb8b0 100644 --- a/packages/browser/src/client/tester/runner.ts +++ b/packages/browser/src/client/tester/runner.ts @@ -29,7 +29,7 @@ import { createStackString, parseStacktrace } from '../../../../utils/src/source import { getBrowserState, getWorkerState, moduleRunner, now } from '../utils' import { rpc } from './rpc' import { VitestBrowserSnapshotEnvironment } from './snapshot' -import { getBrowserTrace, recordBrowserTraceEntry } from './trace' +import { recordBrowserTraceEntry } from './trace' interface BrowserRunnerOptions { config: SerializedConfig @@ -123,20 +123,12 @@ export function createBrowserRunner( const status = test.result?.state const stack = status === 'fail' ? test.result?.errors?.[0].stack : undefined const location = test.location ? { ...test.location, file: test.file.filepath } : undefined - recordBrowserTraceEntry(test, { + await recordBrowserTraceEntry(test, { name: `vitest:onAfterRetryTask`, kind: 'lifecycle', ...(status === 'pass' || status === 'fail' ? { status } : {}), ...(stack ? { stack } : location ? { location } : {}), }) - // TODO: model the same retention mechanism as playwright e.g. retain-on-failure - const traceData = getBrowserTrace(test.id, repeats, retry) - if (traceData) { - await this.commands.triggerCommand( - '__vitest_recordBrowserTrace', - [{ testId: test.id, data: traceData }], - ) - } getBrowserState().browserTraceAttempts.delete(test.id) } const hasActiveTrace = getBrowserState().activeTraceTaskIds.has(test.id) diff --git a/packages/browser/src/client/tester/tester-utils.ts b/packages/browser/src/client/tester/tester-utils.ts index 6a0fbc1d09dc..8acd995fc4eb 100644 --- a/packages/browser/src/client/tester/tester-utils.ts +++ b/packages/browser/src/client/tester/tester-utils.ts @@ -3,7 +3,7 @@ import type { BrowserRPC } from '../client' import type { BrowserTraceEntryStatus } from './trace' import { __INTERNAL } from 'vitest/internal/browser' import { getBrowserState, getWorkerState, now } from '../utils' -import { recordBrowserTraceEntry } from './trace' +import { createBrowserTraceRangeId, recordBrowserTraceEntry } from './trace' /* @__NO_SIDE_EFFECTS__ */ export function convertElementToCssSelector(element: Element): string { @@ -170,7 +170,20 @@ export class CommandsManager { ) } let status: BrowserTraceEntryStatus = 'pass' - const startTime = now() + const traceRangeId = hasActiveTraceView ? createBrowserTraceRangeId() : undefined + const element = typeof args[0] === 'object' && 'selector' in args[0] && 'locator' in args[0] ? args[0] : undefined + if (hasActiveTraceView) { + // Covers provider-backed actionability/waiting after command dispatch. + // Local pre-command resolution, such as serializeElement/findElement paths + // is not coverd within by this action trace range. + await recordBrowserTraceEntry(currentTest, { + name: actionTraceGroupName, + kind: 'action', + range: { id: traceRangeId!, phase: 'start' }, + element, + stack: clientError.stack, + }) + } try { return await rpc.triggerCommand(sessionId, command, filepath, args) } @@ -184,13 +197,12 @@ export class CommandsManager { } finally { if (hasActiveTraceView) { - recordBrowserTraceEntry(currentTest, { + await recordBrowserTraceEntry(currentTest, { name: actionTraceGroupName, kind: 'action', + range: { id: traceRangeId!, phase: 'end' }, status, - startTime, - duration: now() - startTime, - element: typeof args[0] === 'object' && 'selector' in args[0] && 'locator' in args[0] ? args[0] : undefined, + element, stack: clientError.stack, }) } diff --git a/packages/browser/src/client/tester/trace.ts b/packages/browser/src/client/tester/trace.ts index 24cceaf14d3a..07bf5de28445 100644 --- a/packages/browser/src/client/tester/trace.ts +++ b/packages/browser/src/client/tester/trace.ts @@ -1,23 +1,35 @@ import type { Task } from '@vitest/runner' import type { BrowserTraceEntryKind } from 'vitest/browser' +import type { BrowserRPC } from '../client' import type { SerializedLocator } from './locators' -import { getBrowserState, now } from '../utils' +import { getBrowserState, getWorkerState, now } from '../utils' export interface BrowserTraceData { retry: number repeats: number + // UI has access to original config but let artifact own this recordCanvas: boolean + // Each artifact currently carries one entry; the UI merges entries by attempt. + // TODO: revisit whether this should be modeled as a single entry. entries: BrowserTraceEntry[] } export type BrowserTraceEntryStatus = 'pass' | 'fail' +export type BrowserTraceEntryRangePhase = 'start' | 'end' export type BrowserTraceSelectorResolution = 'matched' | 'missing' | 'error' +export interface BrowserTraceEntryRange { + id: string + phase: BrowserTraceEntryRangePhase +} + export interface BrowserTraceEntry { name: string kind: BrowserTraceEntryKind + range?: BrowserTraceEntryRange status?: BrowserTraceEntryStatus startTime: number + // Derived on UI side from range start/end entries. duration?: number stack?: string // resolved server-side from stack in __vitest_recordBrowserTrace command @@ -61,31 +73,22 @@ const PSEUDO_CLASS_NAMES = [ ] as const type PseudoClassName = (typeof PSEUDO_CLASS_NAMES)[number] -export type BrowserTraceState = Record - export interface BrowserTraceAttempt { retry: number repeats: number startTime: number } -function getBrowserTraceState(): BrowserTraceState { - return getBrowserState().browserTraceState ??= {} -} - -function getTraceStateKey(testId: string, repeats: number, retry: number) { - return `${testId}:${repeats}:${retry}` +export function createBrowserTraceRangeId(): string { + return Math.random().toString(36).slice(2) } -// TODO: should we avoid accumulating? send and immediately clear each entry to save memory? -export function recordBrowserTraceEntry( +export async function recordBrowserTraceEntry( task: Task, - options: Omit & { - startTime?: number - }, -): void { + options: Omit, +): Promise { const attemptInfo = getBrowserState().browserTraceAttempts.get(task.id)! - const relativeStartTime = (options.startTime ?? now()) - attemptInfo.startTime + const relativeStartTime = now() - attemptInfo.startTime const snapshot = takeSnapshot(options.element) const entry: BrowserTraceEntry = { ...options, @@ -94,10 +97,24 @@ export function recordBrowserTraceEntry( } const { retry, repeats } = attemptInfo const { recordCanvas } = getBrowserState().config.browser.traceView - const state = getBrowserTraceState() - const traceKey = getTraceStateKey(task.id, repeats, retry) - state[traceKey] ??= { retry, repeats, recordCanvas, entries: [] } - state[traceKey].entries.push(entry) + + // An async lane could defer artifact recording and flush it at test-attempt end, + // but the synchronous snapshot work is already a comparable cost, and this path + // is mostly data passing after that. + // Keep it simple unless measurements show artifact recording is a bottleneck. + const data: BrowserTraceData = { + retry, + repeats, + recordCanvas, + entries: [entry], + } + const rpc = getWorkerState().rpc as any as BrowserRPC + await rpc.triggerCommand( + getBrowserState().sessionId, + '__vitest_recordBrowserTrace', + undefined, + [{ testId: task.id, data }], + ) } // Resolve ivya selector to a DOM element and take a snapshot with rrweb Mirror @@ -161,13 +178,3 @@ function takeSnapshot(serializedLocator?: SerializedLocator): TraceSnapshot { } return result } - -export function getBrowserTrace(testId: string, repeats: number, retry: number): BrowserTraceData | undefined { - const state = getBrowserTraceState() - const traceKey = getTraceStateKey(testId, repeats, retry) - const result = state[traceKey] - if (result) { - delete state[traceKey] - return result - } -} diff --git a/packages/browser/src/client/utils.ts b/packages/browser/src/client/utils.ts index 0639304684f2..1425fa89b245 100644 --- a/packages/browser/src/client/utils.ts +++ b/packages/browser/src/client/utils.ts @@ -4,7 +4,7 @@ import type { SerializedConfig, WorkerGlobalState } from 'vitest' import type { OTELCarrier, Traces } from 'vitest/internal/traces' import type { IframeOrchestrator } from './orchestrator' import type { CommandsManager } from './tester/tester-utils' -import type { BrowserTraceAttempt, BrowserTraceState } from './tester/trace' +import type { BrowserTraceAttempt } from './tester/trace' export async function importId(id: string): Promise { const name = `/@id/${id}`.replace(/\\/g, '/') @@ -99,7 +99,6 @@ export interface BrowserRunnerState { browserTraceAttempts: Map // lazily loaded only when traceView is enabled browserTraceDomSnapshot?: typeof import('rrweb-snapshot') - browserTraceState?: BrowserTraceState selectorEngine: Ivya traces: Traces cleanups: Array<() => unknown> diff --git a/packages/ui/client/components/artifacts/Artifacts.vue b/packages/ui/client/components/artifacts/Artifacts.vue index 3990eefc2673..96029ced7a67 100644 --- a/packages/ui/client/components/artifacts/Artifacts.vue +++ b/packages/ui/client/components/artifacts/Artifacts.vue @@ -3,7 +3,7 @@ import type { RunnerTestCase, TestArtifact } from 'vitest' import type { Component } from 'vue' import { computed } from 'vue' import { getLocationString, openLocation } from '~/composables/location' -import TraceArtifactLauncher from '../trace/TraceArtifactLauncher.vue' +import TraceArtifacts from '../trace/TraceArtifacts.vue' import VisualRegression from './visual-regression/VisualRegression.vue' const { test } = defineProps<{ test: RunnerTestCase }>() @@ -20,11 +20,7 @@ const handledArtifacts = computed(() => { for (const artifact of test.artifacts) { switch (artifact.type) { case 'internal:browserTrace': { - handledArtifacts.push({ - artifact, - component: TraceArtifactLauncher, - props: { trace: artifact, test } satisfies ComponentProps, - }) + // handled by continue } case 'internal:toMatchScreenshot': { @@ -46,6 +42,8 @@ const handledArtifacts = computed(() => {