镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Commit 4e98551

Browse files
hi-ogawaclaude
andauthored
feat(experimental): add trace timeline for node (#11753)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f4dd976 commit 4e98551

16 files changed

Lines changed: 547 additions & 35 deletions

File tree

‎docs/config/experimental.md‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,37 @@ If [`isolate`](/config/isolate) is disabled, test files that run in the same wor
460460
If [`experimental.viteModuleRunner`](#experimental-vitemodulerunner) is disabled, Vitest records the imports with a resolve hook of [`module.registerHooks`](https://nodejs.org/api/module.html#moduleregisterhooksoptions). Node.js versions without it do not write records, so every test runs.
461461
:::
462462

463+
## experimental.traceTimeline <Version type="experimental">5.1.0</Version> {#experimental-tracetimeline}
464+
465+
- **Type:** `boolean`
466+
- **Default:** `false`
467+
468+
Records the [`expect.poll`](/api/expect#poll), [`vi.waitFor`](/api/vi#vi-waitfor), and [`vi.waitUntil`](/api/vi#vi-waituntil) steps of each test, and prints them as a timeline under the error when the test fails. This shows what the test waited on before it failed and how long each wait took, which helps with flaky tests that wait on servers, queues, or file watchers.
469+
470+
```ts [vitest.config.ts]
471+
import { defineConfig } from 'vitest/config'
472+
473+
export default defineConfig({
474+
test: {
475+
experimental: {
476+
traceTimeline: true,
477+
},
478+
},
479+
})
480+
```
481+
482+
<<< ./snippets/trace-timeline-failure.ansi [Error report]
483+
484+
Steps are reported only for failed attempts, so the option adds almost no overhead to passing tests.
485+
486+
Step locations are resolved like error stacks, so a step in a helper file points at that file, and a step in a [`vi.defineHelper`](/api/vi#vi-definehelper) helper points at the line that called the helper.
487+
488+
This option has no effect in browser mode, which prints the same timeline with [`browser.traceView`](/config/browser/traceview).
489+
490+
::: warning
491+
`vi.waitFor`, `vi.waitUntil`, and polls from the global `expect` are attributed to the test that is currently running. When several tests run at the same time with [`test.concurrent`](/api/test#test-concurrent), `vi.waitFor` and `vi.waitUntil` are not recorded, and polls from the global `expect` can appear in another test's timeline. Use `expect` from the [test context](/guide/test-context) in concurrent tests to record polls for the right test.
492+
:::
493+
463494
## experimental.nodeLoader <Version type="experimental">4.1.0</Version> {#experimental-nodeloader}
464495

465496
- **Type:** `boolean`
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
FAIL src/search.test.ts > finds new user in search
2+
AssertionError: expected [] to have a length of 1 but got +0
3+
4+
- Expected
5+
+ Received
6+
7+
- 1
8+
+ 0
9+
10+
 ❯ src/search.test.ts:8:50
11+
 6| await api.post('/users', { name: 'alice' })
12+
 7| await vi.waitUntil(() => db.users.findByName('alice'))
13+
 8| await expect.poll(() => search.query('alice')).toHaveLength(1)
14+
 | ^
15+
 9| })
16+
17+
Caused by: Error: Matcher did not succeed in time.
18+
19+
Trace:
20+
 step start duration location
21+
vi.waitFor() +2ms 120ms src/search.test.ts:5:12
22+
vi.waitUntil() +171ms 42ms src/search.test.ts:7:12
23+
 expect.poll().toHaveLength() +214ms 1001ms src/search.test.ts:8:50

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ Vitest records a step for:
8585

8686
Each step records its timing, selector, and the source location that triggered it.
8787

88+
Tests outside browser mode can print the same timeline for their `expect.poll`, `vi.waitFor`, and `vi.waitUntil` steps with [`experimental.traceTimeline`](/config/experimental#experimental-tracetimeline).
89+
8890
## Custom Trace Entries
8991

9092
You can insert your own named entries with `page.mark()` and `locator.mark()`:

‎docs/guide/cli-generated.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1020,6 +1020,13 @@ Control whether Vitest uses Vite's module runner to run the code or fallback to
10201020

10211021
Controls whether Vitest will use Node.js Loader API to process in-source or mocked files. This has no effect if `viteModuleRunner` is enabled. Disabling this can increase performance. (default: `true`)
10221022

1023+
### experimental.traceTimeline
1024+
1025+
- **CLI:** `--experimental.traceTimeline`
1026+
- **Config:** [experimental.traceTimeline](/config/experimental#experimental-tracetimeline)
1027+
1028+
Record `expect.poll`, `vi.waitFor`, and `vi.waitUntil` steps of each test and print them as a timeline when the test fails. This has no effect in browser mode. (default: `false`)
1029+
10231030
### experimental.vcsProvider
10241031

10251032
- **CLI:** `--experimental.vcsProvider <path>`

‎packages/vitest/src/integrations/chai/poll.ts‎

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@ import type { Assertion, ExpectStatic } from '@vitest/expect'
22
import type { Test } from '../../runtime/runner/types'
33
import { chai } from '@vitest/expect'
44
import { delay, getSafeTimers } from '@vitest/utils/timers'
5+
import { createTraceRangeId, getTraceAttempt, recordTraceEntry } from '../../runtime/trace-timeline'
56
import { getWorkerState } from '../../runtime/utils'
67
import { copyStackTrace } from '../../utils/errors'
78
import { vi } from '../vi'
89

10+
type PollOnStart = (meta: { assertion: Assertion }) => void | Promise<void>
11+
type PollOnSettled = (meta: {
12+
assertion: Assertion
13+
status: 'pass' | 'fail'
14+
}) => void | Promise<void>
15+
916
// these matchers are not supported because they don't make sense with poll
1017
const unsupported = [
1118
// .poll is meant to retry matchers until they succeed, and
@@ -73,6 +80,7 @@ export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] {
7380
if (!test) {
7481
throw new Error('expect.poll() must be called inside a test')
7582
}
83+
setTraceHandlers(assertion, test)
7684
const proxy: any = new Proxy(assertion, {
7785
get(target, key, receiver) {
7886
const assertionFunction = Reflect.get(target, key, receiver)
@@ -115,8 +123,10 @@ export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] {
115123
chai.util.flag(assertion, '_name', key)
116124
chai.util.flag(assertion, 'error', STACK_TRACE_ERROR)
117125

118-
const onStart = chai.util.flag(assertion, '_poll.onStart') as Function | undefined
119-
const onSettled = chai.util.flag(assertion, '_poll.onSettled') as Function | undefined
126+
const onStart = chai.util.flag(assertion, '_poll.onStart') as PollOnStart | undefined
127+
const onSettled = chai.util.flag(assertion, '_poll.onSettled') as
128+
| PollOnSettled
129+
| undefined
120130
await onStart?.({ assertion })
121131

122132
// We use `matcher.__vitest_poll_takeover__` flag
@@ -245,6 +255,36 @@ export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] {
245255
}
246256
}
247257

258+
function setTraceHandlers(assertion: Assertion, test: Test) {
259+
const attempt = getTraceAttempt(test)
260+
if (!attempt) {
261+
return
262+
}
263+
const rangeId = createTraceRangeId()
264+
// TODO: compute the name once in `__VITEST_POLL_CHAIN__` and pass it to the hooks,
265+
// so this and `getTraceName` in packages/browser/src/client/tester/expect-element.ts can share it
266+
const getName = (assertion: Assertion) => {
267+
const negated = chai.util.flag(assertion, 'negate') ? 'not.' : ''
268+
return `expect.poll().${negated}${chai.util.flag(assertion, '_name')}()`
269+
}
270+
const onStart: PollOnStart = ({ assertion }) => {
271+
recordTraceEntry(
272+
attempt,
273+
{ name: getName(assertion), kind: 'expect', range: { id: rangeId, phase: 'start' } },
274+
chai.util.flag(assertion, 'error'),
275+
)
276+
}
277+
const onSettled: PollOnSettled = ({ assertion, status }) => {
278+
recordTraceEntry(
279+
attempt,
280+
{ name: getName(assertion), kind: 'expect', range: { id: rangeId, phase: 'end' }, status },
281+
chai.util.flag(assertion, 'error'),
282+
)
283+
}
284+
chai.util.flag(assertion, '_poll.onStart', onStart)
285+
chai.util.flag(assertion, '_poll.onSettled', onSettled)
286+
}
287+
248288
function raceWith<A, B>(
249289
promise: Promise<A>,
250290
other?: Promise<B>,

‎packages/vitest/src/integrations/wait.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { getSafeTimers } from '@vitest/utils/timers'
2+
import { startPromiseTraceRange } from '../runtime/trace-timeline'
23
import { copyStackTrace } from '../utils/errors'
34
import { vi } from './vi'
45

@@ -27,8 +28,9 @@ export function waitFor<T>(
2728
const { interval = 50, timeout = 1000 } =
2829
typeof options === 'number' ? { timeout: options } : options
2930
const STACK_TRACE_ERROR = new Error('STACK_TRACE_ERROR')
31+
const trackPromise = startPromiseTraceRange('vi.waitFor()', STACK_TRACE_ERROR)
3032

31-
return new Promise<T>((resolve, reject) => {
33+
const promise = new Promise<T>((resolve, reject) => {
3234
let lastError: unknown
3335
let promiseStatus: 'idle' | 'pending' | 'resolved' | 'rejected' = 'idle'
3436
let timeoutId: ReturnType<typeof setTimeout>
@@ -100,6 +102,7 @@ export function waitFor<T>(
100102
timeoutId = setTimeout(handleTimeout, timeout)
101103
intervalId = setInterval(checkCallback, interval)
102104
})
105+
return trackPromise(promise)
103106
}
104107

105108
export type WaitUntilCallback<T> = () => T | Promise<T>
@@ -116,8 +119,9 @@ export function waitUntil<T>(
116119
const { interval = 50, timeout = 1000 } =
117120
typeof options === 'number' ? { timeout: options } : options
118121
const STACK_TRACE_ERROR = new Error('STACK_TRACE_ERROR')
122+
const trackPromise = startPromiseTraceRange('vi.waitUntil()', STACK_TRACE_ERROR)
119123

120-
return new Promise<Truthy<T>>((resolve, reject) => {
124+
const promise = new Promise<Truthy<T>>((resolve, reject) => {
121125
let promiseStatus: 'idle' | 'pending' | 'resolved' | 'rejected' = 'idle'
122126
let timeoutId: ReturnType<typeof setTimeout>
123127
let intervalId: ReturnType<typeof setInterval>
@@ -190,4 +194,5 @@ export function waitUntil<T>(
190194
timeoutId = setTimeout(onReject, timeout)
191195
intervalId = setInterval(checkCallback, interval)
192196
})
197+
return trackPromise(promise)
193198
}

‎packages/vitest/src/node/cli/cli-config.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -957,6 +957,10 @@ export const cliOptionsConfig: VitestCLIOptions = {
957957
description:
958958
'Controls whether Vitest will use Node.js Loader API to process in-source or mocked files. This has no effect if `viteModuleRunner` is enabled. Disabling this can increase performance. (default: `true`)',
959959
},
960+
traceTimeline: {
961+
description:
962+
'Record `expect.poll`, `vi.waitFor`, and `vi.waitUntil` steps of each test and print them as a timeline when the test fails. This has no effect in browser mode. (default: `false`)',
963+
},
960964
vcsProvider: {
961965
argument: '<path>',
962966
description:

‎packages/vitest/src/node/config/serializeConfig.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,8 @@ export function serializeConfig(project: TestProject): SerializedConfig {
148148
viteModuleRunner: config.experimental.viteModuleRunner ?? true,
149149
nodeLoader: config.experimental.nodeLoader ?? true,
150150
recordDependencies: !!globalConfig.experimental.recordDependencies && !!globalConfig.cache,
151+
// browser mode records its own trace through `browser.traceView`
152+
traceTimeline: !!config.experimental.traceTimeline && !config.browser.enabled,
151153
openTelemetry: config.experimental.openTelemetry,
152154
},
153155
tags: config.tags || [],

‎packages/vitest/src/node/reporters/renderers/trace-timeline.ts‎

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,18 @@ export function formatTraceTimeline(
3333
const rows = [headerRow, ...shown.map((step, i) => formatStep(step, prefixes[i], root))]
3434

3535
const widths = headerRow.cells.map((_, i) => Math.max(...rows.map((row) => row.cells[i].length)))
36+
const hasLocator = shown.some((step) => step.element?.locator)
3637
const lines = rows.map((row) => {
3738
// the last column is left unpadded so rows have no trailing spaces inside its color codes
3839
const [name, locator, start, duration, location] = row.cells.map((cell, i) =>
3940
i === 4 ? cell : i === 2 || i === 3 ? cell.padStart(widths[i]) : cell.padEnd(widths[i]),
4041
)
4142
if (row === headerRow) {
42-
return c.dim(` ${name} ${locator} ${start} ${duration} ${location}`)
43+
const locatorColumn = hasLocator ? `${locator} ` : ''
44+
return c.dim(` ${name} ${locatorColumn}${start} ${duration} ${location}`)
4345
}
44-
const text = ` ${name} ${colorCell(locator, c.cyan)} ${start} ${colorCell(duration)} ${colorCell(location, c.dim)}`
46+
const locatorColumn = hasLocator ? `${colorCell(locator, c.cyan)} ` : ''
47+
const text = ` ${name} ${locatorColumn}${start} ${colorCell(duration)} ${colorCell(location, c.dim)}`
4548
return row.status === 'fail' ? c.red(text) : text
4649
})
4750
const omitted = visible.length - shown.length
@@ -110,9 +113,10 @@ function formatStepName(name: string): string {
110113
// TODO: everything below is copied from `BrowserTraceData`/`BrowserTraceEntry` in
111114
// packages/browser/src/client/tester/trace.ts (only the fields used here) and
112115
// `getTraceAttemptMap` in packages/ui/client/composables/trace-view.ts.
113-
// share them instead (https://github.com/vitest-dev/vitest/pull/11604)
116+
// move the trace types to runtime/runner/types.ts, merge them with `TraceEntry` in
117+
// runtime/trace-timeline.ts, and share them with browser mode (https://github.com/vitest-dev/vitest/pull/11604)
114118

115-
interface BrowserTraceData {
119+
export interface BrowserTraceData {
116120
retry: number
117121
repeats: number
118122
entries: BrowserTraceEntry[]
@@ -124,6 +128,7 @@ interface BrowserTraceEntry {
124128
status?: 'pass' | 'fail'
125129
startTime: number
126130
duration?: number
131+
stack?: string
127132
location?: { file: string; line: number; column: number }
128133
element?: { locator: string }
129134
}

‎packages/vitest/src/node/test-run.ts‎

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
import type { UserConsoleLog } from '../types/general'
1313
import type { Vitest } from './core'
1414
import type { TestProject } from './project'
15+
import type { BrowserTraceData } from './reporters/renderers/trace-timeline'
1516
import type {
1617
ReportedHookContext,
1718
TestCase,
@@ -26,7 +27,7 @@ import { existsSync, readFileSync } from 'node:fs'
2627
import { copyFile, mkdir, writeFile } from 'node:fs/promises'
2728
import { isPrimitive, sanitizeFilePath } from '@vitest/utils/helpers'
2829
import { serializeValue } from '@vitest/utils/serialize'
29-
import { parseErrorStacktrace } from '@vitest/utils/source-map'
30+
import { parseErrorStacktrace, parseStacktrace } from '@vitest/utils/source-map'
3031
import { extractSourcemapFromFile } from '@vitest/utils/source-map/node'
3132
import mime from 'mime/lite'
3233
import { basename, extname, resolve } from 'pathe'
@@ -95,6 +96,23 @@ export class TestRun {
9596
)
9697
}
9798

99+
// TODO: resolve browser trace locations here too, using `project.browser.parseStacktrace`
100+
// like `syncUpdateStacks`, and drop the mapping in `__vitest_recordBrowserTrace`
101+
if (artifact.type === 'internal:browserTrace' && testCase.task.file.pool !== 'browser') {
102+
const { entries } = artifact.data as BrowserTraceData
103+
for (const entry of entries) {
104+
if (entry.stack && !entry.location) {
105+
const [stack] = parseStacktrace(entry.stack, {
106+
firstOnly: true,
107+
getSourceMap: (file) => getExternalSourceMap(testCase.project, file),
108+
})
109+
if (stack) {
110+
entry.location = { file: stack.file, line: stack.line, column: stack.column }
111+
}
112+
}
113+
}
114+
}
115+
98116
testCase.task.artifacts.push(artifact)
99117

100118
await this.vitest.report('onTestCaseArtifactRecord', testCase, artifact)
@@ -199,20 +217,7 @@ export class TestRun {
199217
} else {
200218
error.stacks = parseErrorStacktrace(error, {
201219
frameFilter: project.config.onStackTrace,
202-
getSourceMap(file) {
203-
// This only handles external modules since
204-
// source map is already applied for inlined modules.
205-
// Module node exists due to Vitest fetch module,
206-
// but transformResult should be empty for external modules.
207-
// TODO: this uses client module graph and thus miss detects
208-
// inlined modules in ssr environment as if external.
209-
const mod = project.vite.moduleGraph.getModuleById(file)
210-
if (!mod?.transformResult && existsSync(file)) {
211-
const code = readFileSync(file, 'utf-8')
212-
const result = extractSourcemapFromFile(code, file)
213-
return result?.map
214-
}
215-
},
220+
getSourceMap: (file) => getExternalSourceMap(project, file),
216221
})
217222
}
218223
})
@@ -346,3 +351,18 @@ export class TestRun {
346351
}
347352
}
348353
}
354+
355+
function getExternalSourceMap(project: TestProject, file: string) {
356+
// This only handles external modules since
357+
// source map is already applied for inlined modules.
358+
// Module node exists due to Vitest fetch module,
359+
// but transformResult should be empty for external modules.
360+
// TODO: this uses client module graph and thus miss detects
361+
// inlined modules in ssr environment as if external.
362+
const mod = project.vite.moduleGraph.getModuleById(file)
363+
if (!mod?.transformResult && existsSync(file)) {
364+
const code = readFileSync(file, 'utf-8')
365+
const result = extractSourcemapFromFile(code, file)
366+
return result?.map
367+
}
368+
}

0 commit comments

Comments
 (0)