Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guide/browser/trace-view.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
24 changes: 15 additions & 9 deletions packages/browser/src/client/tester/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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',
Expand All @@ -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()
}
Expand All @@ -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,
})
}
Expand All @@ -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,
Expand Down
41 changes: 27 additions & 14 deletions packages/browser/src/client/tester/expect-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -49,23 +49,36 @@ function element<T extends HTMLElement | SVGElement | null | Locator>(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') || '<unknown>'
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') || '<unknown>'
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,
})
}
Expand All @@ -74,7 +87,7 @@ function element<T extends HTMLElement | SVGElement | null | Locator>(elementOrL
'__vitest_markTrace',
[{
name: traceName,
element: selector,
element: getSelector(),
stack: sourceError.stack,
}],
sourceError,
Expand Down
4 changes: 2 additions & 2 deletions packages/browser/src/client/tester/locators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
12 changes: 2 additions & 10 deletions packages/browser/src/client/tester/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
24 changes: 18 additions & 6 deletions packages/browser/src/client/tester/tester-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<T>(sessionId, command, filepath, args)
}
Expand All @@ -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,
})
}
Expand Down
67 changes: 37 additions & 30 deletions packages/browser/src/client/tester/trace.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -61,31 +73,22 @@ const PSEUDO_CLASS_NAMES = [
] as const
type PseudoClassName = (typeof PSEUDO_CLASS_NAMES)[number]

export type BrowserTraceState = Record<string, BrowserTraceData>

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<BrowserTraceEntry, 'snapshot' | 'startTime'> & {
startTime?: number
},
): void {
options: Omit<BrowserTraceEntry, 'snapshot' | 'startTime'>,
): Promise<void> {
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,
Expand All @@ -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<void>(
getBrowserState().sessionId,
'__vitest_recordBrowserTrace',
undefined,
[{ testId: task.id, data }],
)
}

// Resolve ivya selector to a DOM element and take a snapshot with rrweb Mirror
Expand Down Expand Up @@ -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
}
}
3 changes: 1 addition & 2 deletions packages/browser/src/client/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any> {
const name = `/@id/${id}`.replace(/\\/g, '/')
Expand Down Expand Up @@ -99,7 +99,6 @@ export interface BrowserRunnerState {
browserTraceAttempts: Map<string, BrowserTraceAttempt>
// lazily loaded only when traceView is enabled
browserTraceDomSnapshot?: typeof import('rrweb-snapshot')
browserTraceState?: BrowserTraceState
selectorEngine: Ivya
traces: Traces
cleanups: Array<() => unknown>
Expand Down
10 changes: 4 additions & 6 deletions packages/ui/client/components/artifacts/Artifacts.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>()
Expand All @@ -20,11 +20,7 @@ const handledArtifacts = computed<readonly HandledArtifact[]>(() => {
for (const artifact of test.artifacts) {
switch (artifact.type) {
case 'internal:browserTrace': {
handledArtifacts.push({
artifact,
component: TraceArtifactLauncher,
props: { trace: artifact, test } satisfies ComponentProps<typeof TraceArtifactLauncher>,
})
// handled by <TraceArtifacts />
continue
}
case 'internal:toMatchScreenshot': {
Expand All @@ -46,6 +42,8 @@ const handledArtifacts = computed<readonly HandledArtifact[]>(() => {
</script>

<template>
<TraceArtifacts :test="test" />

<template v-if="handledArtifacts.length">
<h1 m-2>
Test Artifacts
Expand Down
Loading
Loading