670cca402a
* 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>
68 lines
2.9 KiB
TypeScript
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]");
|
|
});
|