When a test forgets to await an async assertion, failOnForgottenAwait
(packages/vitest/src/index.ts:207) throws:
@unthrown/vitest: 1 async assertion(s) (toBeOk) were still pending when the test
ended — a forgotten `await`. For an AsyncResult the matcher is asynchronous:
write `await expect(asyncResult).toBeOk()`.
It names the matcher and (via the failing test) the test, but not the line. In a
long spec with several toBeOk assertions — or under test.concurrent, where
the mechanism already has to disambiguate by test name — finding the one missing
await is a manual scan.
Proposal
settle() already builds an InFlight entry on the async path
(index.ts:124). Capture a stack there and carry it:
type InFlight = {
matcherName: string;
testName: string | undefined;
abandon: () => void;
callSite: Error; // new
};
Construct it right where the gate is created — the frame directly above is the
user's expect(...) call — and in failOnForgottenAwait attach it to the thrown
error, either as cause or by appending the first non-library frame to the
message.
Why it is cheap
- The
Error is only constructed on the async path, which is exactly the
path that can be forgotten. The sync path (index.ts:118, a plain Result)
never touches it.
- V8 stack formatting is lazy;
.stack is only read when the hook actually
fires. A correctly-awaited assertion pays for the capture and nothing else.
Points to settle in review
cause vs. inlining a frame into the message — cause is cleaner but some
reporters bury it.
- Whether to trim library frames before reporting, so the top frame is the user's
expect(...) rather than settle.
- The existing tests for the mechanism live alongside
failOnForgottenAwait's
exported entry point, so this stays directly testable without a real forgotten
await.
When a test forgets to
awaitan async assertion,failOnForgottenAwait(
packages/vitest/src/index.ts:207) throws:It names the matcher and (via the failing test) the test, but not the line. In a
long spec with several
toBeOkassertions — or undertest.concurrent, wherethe mechanism already has to disambiguate by test name — finding the one missing
awaitis a manual scan.Proposal
settle()already builds anInFlightentry on the async path(
index.ts:124). Capture a stack there and carry it:Construct it right where the gate is created — the frame directly above is the
user's
expect(...)call — and infailOnForgottenAwaitattach it to the thrownerror, either as
causeor by appending the first non-library frame to themessage.
Why it is cheap
Erroris only constructed on the async path, which is exactly thepath that can be forgotten. The sync path (
index.ts:118, a plainResult)never touches it.
.stackis only read when the hook actuallyfires. A correctly-awaited assertion pays for the capture and nothing else.
Points to settle in review
causevs. inlining a frame into the message —causeis cleaner but somereporters bury it.
expect(...)rather thansettle.failOnForgottenAwait'sexported entry point, so this stays directly testable without a real forgotten
await.