Skip to content

Commit fd145d8

Browse files
hi-ogawaclaude
andauthored
feat(experimental): add message option to vi.waitFor and vi.waitUntil (#11754)
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 4e98551 commit fd145d8

8 files changed

Lines changed: 159 additions & 13 deletions

File tree

‎docs/api/expect.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ The callback receives an `AbortSignal` that is aborted when the poll timeout is
118118

119119
If an error is thrown inside the `expect.poll` callback, Vitest will retry again until the timeout runs out.
120120

121-
The `message` option describes what the poll waits for. If the poll fails, Vitest adds the message to the start of the error.
121+
The `message` option describes what the poll waits for. If the poll fails, Vitest adds the message to the start of the error. The message also names the step in the [trace timeline](/config/experimental#experimental-tracetimeline).
122122

123123
```ts
124124
await expect.poll(() => queue.count(), { message: 'queue drained' }).toBe(0)

‎docs/api/vi.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1362,6 +1362,13 @@ test('Element exists in a DOM', async () => {
13621362
})
13631363
```
13641364

1365+
The `message` option describes what the callback waits for. If the wait fails, Vitest adds the message to the start of the error. The message also names the step in the [trace timeline](/config/experimental#experimental-tracetimeline).
1366+
1367+
```ts
1368+
await vi.waitFor(() => server.ping(), { message: 'server started' })
1369+
// Error: server started: connect ECONNREFUSED
1370+
```
1371+
13651372
If `vi.useFakeTimers` is used, `vi.waitFor` automatically calls `vi.advanceTimersByTime(interval)` in every check callback. See [Automatic Advancing](/guide/mocking/timers#automatic-advancing) for details.
13661373

13671374
### vi.waitUntil {#vi-waituntil}
@@ -1394,6 +1401,13 @@ test('Element render correctly', async () => {
13941401
})
13951402
```
13961403

1404+
Like `vi.waitFor`, `vi.waitUntil` accepts a `message` option that prefixes the error and names the step in the [trace timeline](/config/experimental#experimental-tracetimeline).
1405+
1406+
```ts
1407+
await vi.waitUntil(() => db.users.findByName('alice'), { message: 'user saved' })
1408+
// Error: user saved: Timed out in waitUntil!
1409+
```
1410+
13971411
If `vi.useFakeTimers` is used, `vi.waitUntil` automatically calls `vi.advanceTimersByTime(interval)` in every check callback. See [Automatic Advancing](/guide/mocking/timers#automatic-advancing) for details.
13981412

13991413
### vi.hoisted {#vi-hoisted}

‎docs/config/experimental.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,8 @@ Steps are reported only for failed attempts, so the option adds almost no overhe
485485

486486
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.
487487

488+
The `message` option of `expect.poll`, `vi.waitFor`, and `vi.waitUntil` names the step, as with `user saved (vi.waitUntil)` in the report above.
489+
488490
This option has no effect in browser mode, which prints the same timeline with [`browser.traceView`](/config/browser/traceview).
489491

490492
::: warning

‎docs/config/snippets/trace-timeline-failure.ansi‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
 ❯ src/search.test.ts:8:50
1111
 6| await api.post('/users', { name: 'alice' })
12-
 7| await vi.waitUntil(() => db.users.findByName('alice'))
12+
 7| await vi.waitUntil(() => db.users.findByName('alice'), { message: 'user saved' })
1313
 8| await expect.poll(() => search.query('alice')).toHaveLength(1)
1414
 | ^
1515
 9| })
@@ -19,5 +19,5 @@
1919
Trace:
2020
 step start duration location
2121
vi.waitFor() +2ms 120ms src/search.test.ts:5:12
22-
vi.waitUntil() +171ms 42ms src/search.test.ts:7:12
22+
user saved (vi.waitUntil) +171ms 42ms src/search.test.ts:7:12
2323
 expect.poll().toHaveLength() +214ms 1001ms src/search.test.ts:8:50

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,9 @@ function setTraceHandlers(assertion: Assertion, test: Test) {
265265
// so this and `getTraceName` in packages/browser/src/client/tester/expect-element.ts can share it
266266
const getName = (assertion: Assertion) => {
267267
const negated = chai.util.flag(assertion, 'negate') ? 'not.' : ''
268-
return `expect.poll().${negated}${chai.util.flag(assertion, '_name')}()`
268+
const message = chai.util.flag(assertion, 'message')
269+
const name = `expect.poll().${negated}${chai.util.flag(assertion, '_name')}`
270+
return message ? `${message} (${name})` : `${name}()`
269271
}
270272
const onStart: PollOnStart = ({ assertion }) => {
271273
recordTraceEntry(

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

Lines changed: 48 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,17 +18,47 @@ export interface WaitForOptions {
1818
* @default 1000ms
1919
*/
2020
timeout?: number
21+
/**
22+
* @description Message that describes what is being waited for. It prefixes the error and names the trace step
23+
*/
24+
message?: string
25+
}
26+
27+
function prefixMessage(error: unknown, message: string | undefined) {
28+
if (message && error instanceof Error) {
29+
const original = error.message
30+
try {
31+
error.message = `${message}: ${original}`
32+
} catch {
33+
// message can be read-only, e.g. DOMException
34+
return error
35+
}
36+
if (error.stack !== undefined) {
37+
error.stack = error.stack.replace(original, error.message)
38+
}
39+
}
40+
return error
41+
}
42+
43+
function getTraceName(name: string, message: string | undefined) {
44+
return message ? `${message} (${name})` : `${name}()`
2145
}
2246

2347
export function waitFor<T>(
2448
callback: WaitForCallback<T>,
2549
options: number | WaitForOptions = {},
2650
): Promise<T> {
2751
const { setTimeout, setInterval, clearTimeout, clearInterval } = getSafeTimers()
28-
const { interval = 50, timeout = 1000 } =
29-
typeof options === 'number' ? { timeout: options } : options
52+
const {
53+
interval = 50,
54+
timeout = 1000,
55+
message,
56+
} = typeof options === 'number' ? { timeout: options } : options
3057
const STACK_TRACE_ERROR = new Error('STACK_TRACE_ERROR')
31-
const trackPromise = startPromiseTraceRange('vi.waitFor()', STACK_TRACE_ERROR)
58+
const trackPromise = startPromiseTraceRange(
59+
getTraceName('vi.waitFor', message),
60+
STACK_TRACE_ERROR,
61+
)
3262

3363
const promise = new Promise<T>((resolve, reject) => {
3464
let lastError: unknown
@@ -56,7 +86,7 @@ export function waitFor<T>(
5686
error = copyStackTrace(new Error('Timed out in waitFor!'), STACK_TRACE_ERROR)
5787
}
5888

59-
reject(error)
89+
reject(prefixMessage(error, message))
6090
}
6191

6292
const checkCallback = () => {
@@ -107,7 +137,10 @@ export function waitFor<T>(
107137

108138
export type WaitUntilCallback<T> = () => T | Promise<T>
109139

110-
export interface WaitUntilOptions extends Pick<WaitForOptions, 'interval' | 'timeout'> {}
140+
export interface WaitUntilOptions extends Pick<
141+
WaitForOptions,
142+
'interval' | 'timeout' | 'message'
143+
> {}
111144

112145
type Truthy<T> = T extends false | '' | 0 | null | undefined ? never : T
113146

@@ -116,10 +149,16 @@ export function waitUntil<T>(
116149
options: number | WaitUntilOptions = {},
117150
): Promise<Truthy<T>> {
118151
const { setTimeout, setInterval, clearTimeout, clearInterval } = getSafeTimers()
119-
const { interval = 50, timeout = 1000 } =
120-
typeof options === 'number' ? { timeout: options } : options
152+
const {
153+
interval = 50,
154+
timeout = 1000,
155+
message,
156+
} = typeof options === 'number' ? { timeout: options } : options
121157
const STACK_TRACE_ERROR = new Error('STACK_TRACE_ERROR')
122-
const trackPromise = startPromiseTraceRange('vi.waitUntil()', STACK_TRACE_ERROR)
158+
const trackPromise = startPromiseTraceRange(
159+
getTraceName('vi.waitUntil', message),
160+
STACK_TRACE_ERROR,
161+
)
123162

124163
const promise = new Promise<Truthy<T>>((resolve, reject) => {
125164
let promiseStatus: 'idle' | 'pending' | 'resolved' | 'rejected' = 'idle'
@@ -133,7 +172,7 @@ export function waitUntil<T>(
133172
if (!error) {
134173
error = copyStackTrace(new Error('Timed out in waitUntil!'), STACK_TRACE_ERROR)
135174
}
136-
reject(error)
175+
reject(prefixMessage(error, message))
137176
}
138177

139178
const onResolve = (result: T) => {

‎test/e2e/test/trace-timeline.test.ts‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,45 @@ test('prints vi.waitFor and vi.waitUntil steps on failure', async () => {
117117
`)
118118
})
119119

120+
test('names steps with message', async () => {
121+
const { stderr, errorTree } = await runInlineTests(
122+
{
123+
'basic.test.ts': /* ts */ `
124+
import { expect, test, vi } from 'vitest'
125+
126+
test('fail', async () => {
127+
await vi.waitFor(() => {}, { message: 'server ready' })
128+
await vi.waitUntil(() => true, { message: 'user saved' })
129+
await expect.poll(() => 1, { timeout: 100, message: 'user indexed' }).toBe(10)
130+
})
131+
`,
132+
},
133+
{
134+
reporters: 'default',
135+
experimental: { traceTimeline: true },
136+
},
137+
)
138+
139+
expect(errorTree()).toMatchInlineSnapshot(`
140+
{
141+
"basic.test.ts": {
142+
"fail": [
143+
"user indexed: expected 1 to be 10 // Object.is equality",
144+
],
145+
},
146+
}
147+
`)
148+
expect(extractTimelines(stderr)).toMatchInlineSnapshot(`
149+
[
150+
"Trace:
151+
step start duration location
152+
server ready (vi.waitFor) +<time> <time> basic.test.ts:5:18
153+
user saved (vi.waitUntil) +<time> <time> basic.test.ts:6:18
154+
user indexed (expect.poll().toBe) +<time> <time> basic.test.ts:7:79",
155+
]
156+
`)
157+
})
158+
120159
test('skips waits while tests run concurrently', async () => {
121160
const { stderr, errorTree } = await runInlineTests(
122161
{

‎test/unit/test/wait.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,31 @@ describe('waitFor', () => {
2828

2929
expect(callback).toHaveBeenCalledTimes(2)
3030
})
31+
32+
test('message', async () => {
33+
await expect(
34+
vi.waitFor(
35+
() => {
36+
throw new Error('server not ready')
37+
},
38+
{ timeout: 50, message: 'server started' },
39+
),
40+
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: server started: server not ready]`)
41+
42+
await expect(
43+
vi.waitFor(() => new Promise(() => {}), { timeout: 50, message: 'server started' }),
44+
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: server started: Timed out in waitFor!]`)
45+
46+
// read-only message is left as is
47+
await expect(
48+
vi.waitFor(
49+
() => {
50+
throw new DOMException('aborted', 'AbortError')
51+
},
52+
{ timeout: 50, message: 'server started' },
53+
),
54+
).rejects.toThrow(expect.objectContaining({ name: 'AbortError', message: 'aborted' }))
55+
})
3156
})
3257

3358
test('basic', async () => {
@@ -149,6 +174,31 @@ describe('waitUntil', () => {
149174

150175
expect(callback).toHaveBeenCalledTimes(2)
151176
})
177+
178+
test('message', async () => {
179+
await expect(
180+
vi.waitUntil(() => false, { timeout: 50, message: 'user saved' }),
181+
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: user saved: Timed out in waitUntil!]`)
182+
183+
await expect(
184+
vi.waitUntil(
185+
() => {
186+
throw new Error('db closed')
187+
},
188+
{ timeout: 50, message: 'user saved' },
189+
),
190+
).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: user saved: db closed]`)
191+
192+
// read-only message is left as is
193+
await expect(
194+
vi.waitUntil(
195+
() => {
196+
throw new DOMException('aborted', 'AbortError')
197+
},
198+
{ timeout: 50, message: 'user saved' },
199+
),
200+
).rejects.toThrow(expect.objectContaining({ name: 'AbortError', message: 'aborted' }))
201+
})
152202
})
153203

154204
test('basic', async () => {

0 commit comments

Comments
 (0)