Files
vue-tui/packages/runtime-tests/integration/lifecycle/message-for-non-error.test.ts
T
Yunfei He 670cca402a fix(runtime): stop pathological non-Error throws from wedging the error boundary (#180)
* fix(runtime): stop pathological non-Error throws from wedging the error boundary

A thrown value with a throwing coercion/getter could make three sibling
throw sites in the error-exit/display path re-throw with NO surrounding
try/catch, wedging Vue's post-flush scheduler — the app hangs and
waitUntilExit() never settles:

- messageForNonError's two String(value) fallbacks (a throwing
  Symbol.toPrimitive/toString/valueOf) — now routed through a throw-safe
  safeString() returning "[unserializable value]".
- isErrorInput's Object.prototype.toString.call (a throwing
  Symbol.toStringTag getter), which runs BEFORE messageForNonError on the
  error-exit path — now guarded; on throw the value is treated as non-Error
  and routed through messageForNonError.
- ErrorOverview's `.stack` read (a throwing `.stack` getter) during render
  — now read exactly once under try/catch; on throw it renders header-only.

Tests: unit coverage of messageForNonError plus an end-to-end "does not
wedge" mount test for all three pathological shapes, and an overview-frame
test proving the .stack guard is load-bearing for correctness.

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

* fix(runtime): close two more pathological-throw paths in the error boundary (Codex review)

Final review of the wedge fix found two reachable throw sites it hadn't closed:

- isErrorInput: `value instanceof Error` ran OUTSIDE the try/catch, but
  `instanceof` invokes the value's [[GetPrototypeOf]], which a Proxy with a
  throwing getPrototypeOf trap re-throws — wedging the boundary exactly like the
  Symbol.toStringTag case. Wrap the whole body (instanceof + brand check) in one
  try/catch → false on throw. (The old "instanceof CANNOT throw" comment was wrong.)

- ErrorOverview source excerpt: a crafted/stale `.stack` can parse to an existing
  DIRECTORY, so fs.existsSync passes and fs.readFileSync throws EISDIR during
  render — repainting the overview for the EISDIR error while waitUntilExit()
  rejects the original (a displayed-vs-rejected e17 disagreement). Guard the file
  read; on failure render header-only (no excerpt).

Tests: a Proxy whose getPrototypeOf throws does not wedge; a directory-pointing
`.stack` renders header-only with display==reject; and an e2e assertion that
"[unserializable value]" is both displayed AND rejected.

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

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 01:14:23 +08:00

68 lines
2.9 KiB
TypeScript

import { expect, test } from "vite-plus/test";
import { messageForNonError } from "@vue-tui/runtime/internal";
// messageForNonError feeds the error-display / reject path: render.ts's
// onErrorCaptured wraps `new Error(messageForNonError(err))` with NO surrounding
// try/catch (render.ts ~523, and the errorHandler exit path ~1276). Its docstring
// promises it "must not itself throw on a pathological thrown object", so every
// coercion inside it has to be throw-safe.
//
// Imported from the built `@vue-tui/runtime/internal` dist (not source): the
// source module imports box.vue/text.vue, and the runtime-tests vitest config
// has no @vitejs/plugin-vue, so a source-relative import fails to compile.
test("normal values coerce to their expected message", () => {
expect(messageForNonError(42)).toBe("42");
expect(messageForNonError("boom")).toBe("boom");
expect(messageForNonError({ message: "hi" })).toBe("hi");
expect(messageForNonError(null)).toBe("null");
expect(messageForNonError(undefined)).toBe("undefined");
expect(messageForNonError(new Error("x"))).toBe("x");
});
test("a throwing .message getter falls back without throwing", () => {
// The `.message` READ throws; String(value) on the plain object then yields
// "[object Object]" safely (toString is the default). Exercises the catch
// branch's coercion on a value that DOES coerce.
const pathological = {
get message(): string {
throw new Error("message getter boom");
},
};
expect(() => messageForNonError(pathological)).not.toThrow();
expect(messageForNonError(pathological)).toBe("[object Object]");
});
test("a value whose primitive coercion throws does not throw (the confirmed bug)", () => {
// HIGH-severity bug: `.message` is a non-string (so the typeof check selects
// the String(value) branch), and String(value) itself throws because
// Symbol.toPrimitive throws. Before the fix, messageForNonError re-throws here,
// wedging render.ts's onErrorCaptured hook (the app hangs, waitUntilExit()
// never settles).
const pathological = {
get message(): number {
return 42;
},
[Symbol.toPrimitive](): never {
throw new Error("toPrimitive boom");
},
};
expect(() => messageForNonError(pathological)).not.toThrow();
expect(messageForNonError(pathological)).toBe("[unserializable value]");
});
test("a value that throws in BOTH the message read and String() does not throw", () => {
// Both guarded spots fire: the message getter throws (catch branch), and the
// catch branch's String(value) also throws because toString throws.
const pathological = {
get message(): string {
throw new Error("message getter boom");
},
toString(): never {
throw new Error("toString boom");
},
};
expect(() => messageForNonError(pathological)).not.toThrow();
expect(messageForNonError(pathological)).toBe("[unserializable value]");
});