Skip to content

fix(vitest): render non-Error causes from env setup so the actual diagnostic is not dropped#10567

Open
spokodev wants to merge 3 commits into
vitest-dev:mainfrom
spokodev:fix/non-error-cause-from-env-setup
Open

fix(vitest): render non-Error causes from env setup so the actual diagnostic is not dropped#10567
spokodev wants to merge 3 commits into
vitest-dev:mainfrom
spokodev:fix/non-error-cause-from-env-setup

Conversation

@spokodev

Copy link
Copy Markdown

What

When a custom test environment's setup() rejects with a non-Error value (a string, a plain object, or a wasm-bindgen JsValue), the actual diagnostic is silently dropped and the user only sees the generic, location-free [vitest-pool]: Failed to start <pool> worker for test files … from the pool wrapper.

Vitest 3.x rendered the same non-Error throw as Unknown Error: <value>, so the silent drop is a regression. After this PR the cause surfaces correctly for Error, plain-object, and primitive throws.

Closes #10557.

Why / root cause

The pool wrapper in packages/vitest/src/node/pools/pool.ts:127 already attaches the original failure as { cause: error } on the synthesised "Failed to start … worker" Error. The reporter in packages/vitest/src/node/printError.ts then descended into cause only when it satisfied a narrow shape check:

if (typeof e.cause === 'object' && e.cause && 'name' in e.cause) {
  (e.cause as any).name = `Caused by: ${(e.cause as any).name}`
  printErrorInner(e.cause, project, {...})
}

That filter passes for new Error(...) (object + truthy + has name) but excludes:

  • throw "string"typeof "string" === "object" is false, so the branch is skipped entirely.
  • throw { reason: '...' } — object + truthy, but no name property, so 'name' in cause is false.
  • wasm-bindgen JsValue — object + truthy, but no name (and in some bindings, 'name' in cause throws on the proxy).

In all three cases the value reaches printError via the cause chain but never reaches printErrorInner, so the actual diagnostic is lost.

The regression most likely landed alongside the worker-start error wrapping in #9337 (which correctly fixed the indefinite-hang behaviour from #9271). The wrapper is well-formed; only the reporter's filter is too narrow.

The fix normalises the cause into a printable shape before calling printErrorInner — which already knows how to handle primitives via its isPrimitive branch:

  • Primitive cause → wrap as { name: 'Caused by', message: String(cause), stack: String(cause) }.
  • Object without name → spread + name: 'Caused by'.
  • Object with name → preserve the existing behaviour of prefixing name with Caused by:.

printErrorInner then renders the message exactly as it would for a top-level non-Error throw, mirroring Vitest 3.x behaviour. Real-world impact: native/wasm environments (e.g. @stacks/clarinet-sdk's vitest-environment-clarinet) reject from their wasm setup() with a JsValue when contract compilation fails — the failure reason now surfaces instead of being lost behind "Failed to start forks worker".

Tests added

New e2e test at test/e2e/test/env-setup-non-error-cause.test.ts using runInlineTests from test/test-utils. Three cases, each booting Vitest against a fixture environment whose setup() throws a different shape:

  1. non-Error string thrown from env setup surfaces in stderr — the bug case from A non-Error value thrown from a test environment's setup() is dropped from the "Failed to start worker" report (regression from 3.x) #10557. Without this PR the string never appears in stderr; with it, the the real reason setup failed (a non-Error value) substring shows up.
  2. non-Error plain object thrown from env setup surfaces in stderr — boundary H-case (wasm JsValue analogue). Without the PR the object is dropped entirely; with it, the object's keys / serialised value appear in stderr.
  3. Error thrown from env setup still renders with Caused by prefix — happy-path regression. Confirms the existing Caused by: Error: … rendering is unchanged for proper Error instances.

Verified locally:

pnpm -F vitest build
CI=true pnpm test env-setup-non-error-cause   # 3/3 passing after fix
CI=true pnpm test print-error                 # 2/2 passing (no regression on existing reporter tests)
CI=true pnpm test pool.test                   # 16/16 passing (no regression on pool tests)

Before the fix: tests 1 and 2 fail with Expected to contain '…the real reason…' / Expected to match /wasm-init-failed|reason/. Test 3 already passed on master.

Repro

Minimal repro from the issue (https://stackblitz.com/edit/vitest-dev-vitest-ldzxobzz):

// throwing-env.js
export default {
  name: 'throwing-env',
  viteEnvironment: 'ssr',
  async setup() {
    throw 'the real reason setup failed (a non-Error value)'
  },
}
// vitest.config.js
import { defineConfig } from 'vitest/config'
export default defineConfig({
  test: { environment: './throwing-env.js' },
})

pnpm test shows only the generic Failed to start forks worker message on master; with this PR the cause string appears under Caused by:.

Validation

  • pnpm -F vitest build — green
  • CI=true pnpm test env-setup-non-error-cause — 3/3
  • CI=true pnpm test print-error — 2/2 (no regression)
  • CI=true pnpm test pool.test — 16/16 (no regression)
  • pnpm lint:fix — clean

AI usage disclosure

Drafted with Claude assistance. The root-cause analysis (the 'name' in cause narrow filter and its three failure modes), the fix design, the test surface choice (runInlineTests instead of unit-testing printError), and this PR text are mine. I verified the red baseline → fix → green baseline loop locally and have read every line of the diff. I will engage genuinely with any review feedback within the 3-day window described in AGENTS.md.

@github-actions github-actions Bot added the maybe automated User is likely an AI agent, or the content was generated by an AI assistant without user control label Jun 10, 2026
@github-actions

Copy link
Copy Markdown

Hello @spokodev. Your PR has been labeled maybe automated because it appears to have been fully generated by AI with no human involvement.

To keep your PR open, please follow these steps:

  • Confirm that you are a real human. If you are an automated agent, disclose that
  • Confirm you've read, reviewed and stand behind its content
  • Confirm you've read the full issue along with all of its comments, as well as any linked issues and their comments
  • Make sure it follows our contribution guidelines and uses the correct GitHub template
  • Disclose any AI tools you used (e.g. Claude, Copilot, Codex)

Please, do not generate or format the response with AI. If you do not speak English, reply in your native language or use translation software like Google Translate or Deepl. If the response is generated, the PR will be closed automatically.

These measures help us reduce maintenance burden and keep the team's work efficient. See our AI contributions policy for more context.

1 similar comment
@github-actions

Copy link
Copy Markdown

Hello @spokodev. Your PR has been labeled maybe automated because it appears to have been fully generated by AI with no human involvement.

To keep your PR open, please follow these steps:

  • Confirm that you are a real human. If you are an automated agent, disclose that
  • Confirm you've read, reviewed and stand behind its content
  • Confirm you've read the full issue along with all of its comments, as well as any linked issues and their comments
  • Make sure it follows our contribution guidelines and uses the correct GitHub template
  • Disclose any AI tools you used (e.g. Claude, Copilot, Codex)

Please, do not generate or format the response with AI. If you do not speak English, reply in your native language or use translation software like Google Translate or Deepl. If the response is generated, the PR will be closed automatically.

These measures help us reduce maintenance burden and keep the team's work efficient. See our AI contributions policy for more context.

@spokodev

Copy link
Copy Markdown
Author

Hi, this is Yarik. Spoko-dev.
Of course, I'm human.
I've read everything carefully and agree with everything. The fix is at
printError.ts:249 where the 'name' in cause filter let Error instances
through and silently dropped strings / plain objects / wasm JsValues.
Of course, I also use Claude for writing drafts. But rest assured, I'm
human:) I'm writing with my hands on the keyboard, everything is fine,
don't worry:)

@netlify

netlify Bot commented Jun 11, 2026

Copy link
Copy Markdown

Deploy Preview for vitest-dev ready!

Built without sensitive environment variables

Name Link
🔨 Latest commit 834fbb9
🔍 Latest deploy log https://app.netlify.com/projects/vitest-dev/deploys/6a3bb42da7c100000855f6ac
😎 Deploy Preview https://deploy-preview-10567--vitest-dev.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.
🤖 Make changes Run an agent on this branch

To edit notification comments on pull requests, go to your Netlify project configuration.

@sheremet-va sheremet-va removed the maybe automated User is likely an AI agent, or the content was generated by an AI assistant without user control label Jun 12, 2026
spokodev added 3 commits June 24, 2026 11:40
…gnostic is not dropped

The pool wrapper attaches the original failure via `{ cause: error }` when a
test environment's `setup()` rejects. The printError reporter then only
descended into `cause` when it was an object with a `name` property — i.e.
an Error-shaped value. Non-Error throws (`throw 'reason'`,
`throw { ... }`, or a wasm-bindgen `JsValue`) were silently dropped and the
user saw only the generic location-free
"[vitest-pool]: Failed to start <pool> worker for test files …".

Vitest 3.x rendered these as `Unknown Error: <value>`, so the silent drop
is a regression — likely a side-effect of the worker-start error wrapping
added in vitest-dev#9337 (which correctly closed the indefinite hang from vitest-dev#9271).

Coerce non-object/object-without-name causes into a printable shape before
delegating to `printErrorInner`. Real-world impact: native/wasm test
environments (e.g. `@stacks/clarinet-sdk`'s `vitest-environment-clarinet`)
reject from their wasm `setup()` with a JsValue; the actual contract
compilation diagnostic now surfaces instead of being lost.

Closes vitest-dev#10557
…or cause

Promise.reject(3) hits the same non-Error cause path that vitest-dev#10557 was filed
about: the pool/runtime wrapper attaches the rejected value (`3`) via
`{ cause: 3 }` on the synthesised "intentional unhandled rejection" Error,
and the printError change in this PR now renders it as `Caused by: 3`
instead of silently dropping it. Snapshot updated to match.

No code change; behaviour parity with the previous "3 is not an Error so
nothing to render" outcome was the underlying bug.
@spokodev spokodev force-pushed the fix/non-error-cause-from-env-setup branch from f6598d5 to 834fbb9 Compare June 24, 2026 10:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A non-Error value thrown from a test environment's setup() is dropped from the "Failed to start worker" report (regression from 3.x)

2 participants