Files
cloudflare__vinext/tests/error-cause.test.ts
James Anderson 54c91f16f3 fix(app-router): surface Error.cause in dev-server error output (#912)
* fix(app-router): flatten Error.cause chain into dev-server error output

Vite's dev-server "Internal server error:" formatter
(buildErrorMessage in vite-plus-core) builds output from err.message
and err.stack only, silently dropping err.cause. A wrapper like
`new Error("Failed query", { cause: pgError })` would surface in the
dev console as just "Failed query" — the actual postgres ECONNREFUSED,
role-missing, or workerd socket error in .cause was invisible.

This commit adds flattenErrorCauses(err), called at the App Router RSC
handler boundary before any error escapes to Vite. It walks .cause
(depth-capped, cycle-safe) and embeds each cause's message into
err.message and stack frames into err.stack as `at` lines (so they
survive Vite's cleanStack /^\s*at/ filter). Marked idempotent via a
non-enumerable symbol so Node's util.inspect output is unaffected and
prod logs that already format causes correctly are not duplicated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix: address bonk review feedback

1. Gate flatten on NODE_ENV !== "production" at the call site. Without
   this, prod-server.ts:1106's `console.error("[vinext] Server error:", e)`
   would render the cause twice — once in the synthesized message line,
   once in util.inspect's native [cause]: block. Vite build-time-replaces
   NODE_ENV, so the prod bundle compiles the catch branch out entirely.

2. Replace the marker-enumerability test with one that actually invokes
   util.inspect and asserts the marker symbol is invisible to it.

3. Wrap the three mutating operations (defineProperty, .message =,
   .stack =) in try/catch so frozen / non-extensible errors are left
   untouched rather than masking the user's real failure with a
   TypeError from inside the helper.

4. Switch FLATTENED_MARKER from Symbol.for(...) to module-private
   Symbol(...) — there's no cross-module coordination needed.

Add a regression test for the frozen-error case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-26 20:46:12 +01:00

167 lines
6.4 KiB
TypeScript

/**
* Repro + regression tests for the silently-dropped `.cause` chain in
* single-pass error formatters (Vite's dev-server "Internal server error:"
* logger and similar).
*
* The bug: a wrapper like
* new Error("Failed query: ...", { cause: pgError })
* would surface in the dev console as just "Failed query: ..." — the actual
* postgres ECONNREFUSED / role-missing / socket error in `.cause` was lost
* because Vite's `buildErrorMessage` only reads `.message` and `.stack`.
*
* The fix: vinext flattens the cause chain into `err.message` and `err.stack`
* before the error escapes its handler boundary, so single-pass formatters
* surface the root cause.
*/
import { describe, it, expect } from "vite-plus/test";
import { inspect } from "node:util";
import { flattenErrorCauses } from "../packages/vinext/src/utils/error-cause.js";
// Mirror Vite's `cleanStack` from
// vite-plus-core/dist/vite/node/chunks/node.js (~line 7730) — keeps only
// `at`-prefixed frames. Used here to verify cause stack frames survive that
// filter when emitted into err.stack.
function cleanStack(stack: string): string {
return stack
.split("\n")
.filter((l) => /^\s*at/.test(l))
.join("\n");
}
// Mirror Vite's `buildErrorMessage` shape — what the dev-server actually
// prints. If a cause is present and we don't flatten, the cause is invisible
// in the rendered output.
function buildViteLikeMessage(err: Error): string {
return `Internal server error: ${err.message}\n${cleanStack(err.stack ?? "")}`;
}
describe("flattenErrorCauses", () => {
it("repro: without flattening, Vite-style formatter drops the cause", () => {
const cause = new Error("ECONNREFUSED 127.0.0.1:5432");
const wrapped = new Error("Failed query: select ...", { cause });
const rendered = buildViteLikeMessage(wrapped);
expect(rendered).toContain("Failed query: select ...");
// This is the bug the user reported:
expect(rendered).not.toContain("ECONNREFUSED");
});
it("after flattening, the cause message appears in the rendered output", () => {
const cause = new Error("ECONNREFUSED 127.0.0.1:5432");
const wrapped = new Error("Failed query: select ...", { cause });
flattenErrorCauses(wrapped);
const rendered = buildViteLikeMessage(wrapped);
expect(rendered).toContain("Failed query: select ...");
expect(rendered).toContain("ECONNREFUSED 127.0.0.1:5432");
expect(rendered).toContain("[cause]");
});
it("walks a multi-level cause chain", () => {
const root = new Error('role "vinext" does not exist');
const mid = new Error("connection failed", { cause: root });
const top = new Error("Failed query", { cause: mid });
flattenErrorCauses(top);
expect(top.message).toContain("Failed query");
expect(top.message).toContain("connection failed");
expect(top.message).toContain('role "vinext" does not exist');
});
it("preserves cause stack frames as `at` lines so cleanStack keeps them", () => {
const cause = new Error("inner");
const wrapped = new Error("outer", { cause });
flattenErrorCauses(wrapped);
// The marker line itself must start with " at " so Vite's cleanStack
// (which filters to /^\s*at/) doesn't strip it.
const cleaned = cleanStack(wrapped.stack ?? "");
expect(cleaned).toMatch(/^\s*at \[cause: inner\]/m);
});
it("is idempotent — calling twice does not double-append", () => {
const cause = new Error("inner");
const wrapped = new Error("outer", { cause });
flattenErrorCauses(wrapped);
const messageAfterFirst = wrapped.message;
const stackAfterFirst = wrapped.stack;
flattenErrorCauses(wrapped);
expect(wrapped.message).toBe(messageAfterFirst);
expect(wrapped.stack).toBe(stackAfterFirst);
});
it("is a no-op when there is no cause", () => {
const err = new Error("plain");
const originalMessage = err.message;
const originalStack = err.stack;
flattenErrorCauses(err);
expect(err.message).toBe(originalMessage);
expect(err.stack).toBe(originalStack);
});
it("handles non-Error causes (string, plain object)", () => {
const stringCause = new Error("wrap1", { cause: "raw string reason" });
flattenErrorCauses(stringCause);
expect(stringCause.message).toContain("raw string reason");
const objectCause = new Error("wrap2", { cause: { code: "ECONNRESET" } });
flattenErrorCauses(objectCause);
expect(objectCause.message).toContain("ECONNRESET");
});
it("handles cyclic cause graphs without looping forever", () => {
const a = new Error("a");
const b = new Error("b");
(a as { cause?: unknown }).cause = b;
(b as { cause?: unknown }).cause = a;
flattenErrorCauses(a);
expect(a.message).toContain("a");
expect(a.message).toContain("b");
});
it("ignores non-Error inputs gracefully", () => {
expect(() => flattenErrorCauses(undefined)).not.toThrow();
expect(() => flattenErrorCauses(null)).not.toThrow();
expect(() => flattenErrorCauses("oops")).not.toThrow();
expect(() => flattenErrorCauses({ message: "fake" })).not.toThrow();
});
it("the flatten marker is invisible to util.inspect (no symbol leak in error output)", () => {
const err = new Error("outer", { cause: new Error("inner") });
flattenErrorCauses(err);
// util.inspect is what console.error uses to render Errors. The marker
// must not appear in its output — otherwise prod logs (which call this
// function via the gated dev-only branch is fine, but local repros and
// tests still inspect flattened errors) would gain a stray
// "[Symbol(vinext.errorCausesFlattened)]: true" line.
const rendered = inspect(err, { showHidden: false });
expect(rendered).not.toContain("vinext.errorCausesFlattened");
expect(rendered).not.toContain("Symbol(");
});
it("never throws on a frozen Error — leaves it unmodified rather than masking the original failure", () => {
// If user code throws Object.freeze(new Error(...)), defineProperty and
// err.message = ... would each throw TypeError. The catch block in the
// generated handler would then propagate that TypeError instead of the
// user's real error, masking the actual failure during debugging. The
// helper's contract is best-effort: never throw.
const cause = new Error("inner");
const wrapped = new Error("outer", { cause });
Object.freeze(wrapped);
expect(() => flattenErrorCauses(wrapped)).not.toThrow();
expect(wrapped.message).toBe("outer");
});
});