silent: 'passed-only' reporter crash — caused by the VSCode Vitest extension shadowing onUserConsoleLog
When tests are run through the VSCode Vitest extension, the extension's
worker mutates every registered vitest reporter by assigning
onUserConsoleLog = undefined as an instance property:
// vitest.explorer-1.50.4/dist/workerNew.js (and workerLegacy.js)
p.reporters.forEach(e => { e instanceof Z || (e.onUserConsoleLog = void 0) });That shadows the inherited BaseReporter.prototype.onUserConsoleLog. Vitest's
BaseReporter.logFailedTask then calls this.onUserConsoleLog(log, "failed")
unguarded when silent: 'passed-only' is set and a failing test has captured
console output, producing:
⎯⎯ Unhandled Reporter Error ⎯⎯
TypeError: this.onUserConsoleLog is not a function
❯ DefaultReporter.logFailedTask …/base.ts logFailedTask
❯ DefaultReporter.onTestCaseResult …
The test run survives (the dispatcher catches the throw), but every failing
test's captured logs — the exact output passed-only exists to surface — is
silently dropped.
Verified in our environment: when the same configuration is run from the CLI
(no VSCode extension in the loop), this.onUserConsoleLog is defined and no
crash occurs. The mutation done by the extension is the trigger.
-
Primary — VSCode Vitest extension (
vitest-dev/vscode). Don't shadowonUserConsoleLogwithundefined. The intent is to suppress duplicate console output, which can be done without breaking the prototype contract:- Assign a no-op function:
e.onUserConsoleLog = () => {}. - Or override
e.shouldLog = () => false(already used byBaseReporterto gate output). - Or wrap the reporter in a proxy that filters the call rather than deleting the method.
- Assign a no-op function:
-
Secondary / defensive — vitest core. The call site in
BaseReporter.logFailedTaskshould not assumethis.onUserConsoleLogremains defined on the receiver. Either guard it (this.onUserConsoleLog?.(log, "failed"), only safe if combined with a real delivery path so logs aren't silently dropped) or, preferably, route the replay through the existing event broadcast (vitest.report("onUserConsoleLog", log)), which doesn't depend on the shape of any one reporter.
vitest@4.0.18andvitest@4.1.6— code path identical and also present onmain(5.0.0-beta.2).vitest.explorer@1.50.4— VSCode extension where the mutation lives.
This repo pins vitest@4.1.6.
npm install
npm run repro:forced # deterministic crash — mimics the extension's mutation
npm test # natural conditions, but only crashes when the
# mutation is also present (i.e. real VSCode runs).packages/vitest/src/node/reporters/base.ts → logFailedTask
(installed dist: node_modules/vitest/dist/chunks/index.UpGiHP7g.js:2238-2240):
logFailedTask(task) {
if (this.silent === "passed-only")
for (const log of task.logs || [])
this.onUserConsoleLog(log, "failed");
}The method is reachable on failing test cases, suites, and modules:
BaseReporter.onTestCaseResult→logFailedTaskon failed test casesBaseReporter.onTestSuiteResult→logFailedTaskon failed suitesBaseReporter.onTestModuleEnd→logFailedTaskon failed modules
onUserConsoleLog is defined on BaseReporter.prototype. The unguarded call
assumes it remains resolvable on the receiver. Nothing in vitest itself
breaks that assumption — but the extension does.
vitest.explorer-1.50.4/dist/workerNew.js:
p.reporters.forEach(e => {
e instanceof Z || (e.onUserConsoleLog = void 0)
});(Same pattern in workerLegacy.js with class kn instead of Z.) Z is
the extension's own RPC reporter that forwards logs over birpc to the
extension host UI. Reporters that are not Z get their
onUserConsoleLog cleared so vitest's default terminal output doesn't
double-print logs that the UI is already displaying.
Setting the instance property to undefined (rather than a no-op function,
or overriding the gating predicate) shadows the inherited method on
BaseReporter, which is what makes logFailedTask's call site crash.
broken-reporter.js constructs a DefaultReporter
instance and assigns onUserConsoleLog = undefined on it, identical to the
extension's mutation. vitest.forced.config.js
registers it as the sole reporter with silent: 'passed-only'.
forced-repro.test.js has one failing test with a
console.log before its assertion.
Output:
⎯⎯ Unhandled Reporter Error ⎯⎯
TypeError: this.onUserConsoleLog is not a function
❯ ExtensionShadowedReporter.logFailedTask node_modules/vitest/dist/chunks/index.UpGiHP7g.js:2239:78
❯ ExtensionShadowedReporter.onTestCaseResult node_modules/vitest/dist/chunks/index.UpGiHP7g.js:2229:50
❯ ExtensionShadowedReporter.onTestCaseResult node_modules/vitest/dist/chunks/index.UpGiHP7g.js:3123:9
❯ Vitest.report node_modules/vitest/dist/chunks/cli-api.B5majYba.js:13968:36
❯ TestRun.reportEvent node_modules/vitest/dist/chunks/cli-api.B5majYba.js:12653:85
❯ TestRun.updated node_modules/vitest/dist/chunks/cli-api.B5majYba.js:12577:54
❯ Proxy.onTaskUpdate node_modules/vitest/dist/chunks/cli-api.B5majYba.js:2725:9
This is a frame-for-frame match (modulo the reporter class name) of the stack observed when running daphne's test suite through the VSCode Vitest extension.
repro.test.js + vitest.config.js exercise the three conditions vitest's code path requires:
test.silent === 'passed-only'is set.- A test transitions to
failed. - The failing test has at least one entry in
task.logs.
Running from the CLI directly: no crash, because nothing has shadowed
onUserConsoleLog on the receiver. Running through the VSCode extension:
crash, because the extension's mutation has run.
We instrumented daphne's installed vitest dist (additively, keeping the existing guard so tests still pass) to dump the reporters list and the prototype-chain state of any reporter that hit the missing-method path:
[VITEST-DIAGNOSTIC] reporters list (n=1):
{"i":0,"hasOnUserConsoleLog":true,"hasOnTestCaseResult":true,
"hasLogFailedTask":true,
"chain":["MinimalReporter","MinimalReporter","DefaultReporter","BaseReporter","Object"]}
CLI run: hasOnUserConsoleLog: true — the method is resolvable, the bug
cannot fire. No reporter in the list lacks the method.
Grepping the VSCode Vitest extension dist for onUserConsoleLog then turned
up the explicit e.onUserConsoleLog = void 0 assignment in workerNew.js,
which is the only place we could find that shadows the method.
A yarn patch on vitest@4.1.6's dist/chunks/index.UpGiHP7g.js:
logFailedTask(task) {
if (this.silent !== "passed-only") return;
if (process.env.VITEST_VSCODE === "true") return;
for (const log of task.logs || []) {
if (typeof this.onUserConsoleLog === "function") {
this.onUserConsoleLog(log, "failed");
} else if (this.ctx && this.ctx.logger) {
const stream = log.type === "stdout"
? this.ctx.logger.outputStream
: this.ctx.logger.errorStream;
const taskName = log.taskId && this.ctx.state
? (this.ctx.state.idMap.get(log.taskId)?.name || log.taskId)
: "unknown test";
stream.write(`${log.type} | ${taskName}\n${log.content}\n`);
}
}
}The typeof === "function" guard prevents the crash. The fallback preserves
the "show captured logs on failure" semantic. The VITEST_VSCODE skip is
project-specific: when the extension is the host, its own RPC reporter is
already replaying logs through the UI, so replaying through the default
reporter here would duplicate. Once the extension stops nuking the method,
that skip can come out and the fallback can come out, leaving only the
defensive guard or the suggested broadcast-based replay.