Skip to content
Open
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
26 changes: 25 additions & 1 deletion packages/vitest/src/integrations/chai/poll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,18 @@ export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] {

let executionPhase: 'fn' | 'assertion' = 'fn'
let hasTimedOut = false
let lastAssertionError: unknown = null

let rejectOnTimeout!: (err: Error & { _isPollTimeout: true }) => void
const deadlinePromise = new Promise<never>((_, reject) => {
rejectOnTimeout = reject
})

const timerId = setTimeout(() => {
hasTimedOut = true
rejectOnTimeout(
Object.assign(new Error('Matcher did not succeed in time.'), { _isPollTimeout: true as const }),
)
}, timeout)

chai.util.flag(assertion, '_name', key)
Expand All @@ -107,7 +116,12 @@ export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] {

try {
executionPhase = 'fn'
const obj = await fn()
// On non-last attempts, race fn() against the deadline so a slow
// async poll function cannot outlive the configured timeout.
// On the last attempt we let fn() run to give it one final chance.
const obj = isLastAttempt
? await fn()
: await Promise.race([fn(), deadlinePromise])
chai.util.flag(assertion, 'object', obj)

executionPhase = 'assertion'
Expand All @@ -117,6 +131,16 @@ export function createExpectPoll(expect: ExpectStatic): ExpectStatic['poll'] {
return output
}
catch (err) {
if ((err as any)?._isPollTimeout === true) {
await onSettled?.({ assertion, status: 'fail' })
if (lastAssertionError != null) {
throwWithCause(lastAssertionError, STACK_TRACE_ERROR)
}
throw copyStackTrace(err as Error, STACK_TRACE_ERROR)
}

lastAssertionError = err

if (isLastAttempt || (executionPhase === 'assertion' && chai.util.flag(assertion, '_poll.assert_once'))) {
await onSettled?.({ assertion, status: 'fail' })
throwWithCause(err, STACK_TRACE_ERROR)
Expand Down
31 changes: 31 additions & 0 deletions test/core/test/expect-poll.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,3 +147,34 @@ test('should handle failure on last attempt', async () => {
}),
}))
})

test('slow async fn is interrupted when timeout expires', async () => {
const start = Date.now()
await expect(async () => {
await expect.poll(
async () => { await new Promise(r => setTimeout(r, 10_000)) },
{ timeout: 100 },
).toBe(1)
}).rejects.toThrow('Matcher did not succeed in time.')
expect(Date.now() - start).toBeLessThan(1000)
})

test('slow async fn timeout surfaces last assertion error when available', async () => {
let calls = 0
await expect(async () => {
await expect.poll(
async () => {
calls++
// First call is fast so an assertion error is recorded; subsequent calls are slow
if (calls > 1) {
await new Promise(r => setTimeout(r, 10_000))
}
return 42
},
{ timeout: 150, interval: 10 },
).toBe(1)
}).rejects.toThrow(expect.objectContaining({
message: 'expected 42 to be 1 // Object.is equality',
cause: expect.objectContaining({ message: 'Matcher did not succeed in time.' }),
}))
})
Loading