Files
vue-tui/packages/runtime-tests/integration/lifecycle/exit.test.tsx
T
Yunfei He aa1c8bfb07 fix(runtime): treat cross-realm Error as an error on exit + thrown paths (Ink parity) (#86)
app.exit(crossRealmError) resolved waitUntilExit() with the error as a result value,
and a component-thrown cross-realm Error was re-wrapped (losing the original) -- both
because `instanceof Error` fails across VM realms. Adds Ink's isErrorInput check
(instanceof Error || Object.prototype.toString.call(v) === "[object Error]", the
realm-independent [object Error] brand) and uses it at the 3 exit() classification
sites and the 2 error-boundary normalization sites. A cross-realm Error now rejects
(exit) / is preserved (throw), matching Ink; genuine non-Error values still resolve /
still wrap.

Flips the previously-locked "cross-realm resolves" test to Ink's reject (the behavior
was never in ink-divergences.md, so per the repo rule it was an unverified gap, not a
sanctioned divergence). Adds component-throw cross-realm + a non-Error-still-wraps guard.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 07:22:27 +08:00

462 lines
14 KiB
TypeScript

import { Writable } from "node:stream";
import { defineComponent, onMounted, onScopeDispose } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { createApp, Text, useApp } from "@vue-tui/runtime";
import { makeFakeWritable, makeFakeStdin, isWriteBarrierChunk } from "./test-streams.ts";
test("useApp() triggers teardown and waitUntilExit resolves", async () => {
let exitFn!: () => void;
let disposed = false;
const App = defineComponent(() => {
const { exit } = useApp();
exitFn = exit;
onScopeDispose(() => {
disposed = true;
});
return () => <Text>running</Text>;
});
const { lastFrame, waitUntilExit } = await render(App);
expect(lastFrame()).toContain("running");
exitFn();
await waitUntilExit();
expect(disposed).toBe(true);
});
test("exit(error) rejects waitUntilExit with the error", async () => {
let exitFn!: (err: Error) => void;
const App = defineComponent(() => {
const { exit } = useApp();
exitFn = exit;
return () => <Text>x</Text>;
});
const { waitUntilExit } = await render(App);
const boom = new Error("boom");
exitFn(boom);
await expect(waitUntilExit()).rejects.toBe(boom);
});
test("unmount() resolves waitUntilExit", async () => {
const App = defineComponent(() => {
return () => <Text>x</Text>;
});
const { unmount, waitUntilExit } = await render(App);
unmount();
await waitUntilExit();
});
test("unmount() after exit() is idempotent", async () => {
let exitFn!: () => void;
const App = defineComponent(() => {
const { exit } = useApp();
exitFn = exit;
return () => <Text>x</Text>;
});
const { unmount, waitUntilExit } = await render(App);
exitFn();
await waitUntilExit();
expect(() => unmount()).not.toThrow();
});
test("exit() called multiple times is idempotent", async () => {
// Mirrors Ink's "exit normally without unmount() or exit()" pattern:
// verifies that calling exit() twice does not throw or reject again.
let exitFn!: () => void;
const App = defineComponent(() => {
exitFn = useApp().exit;
return () => <Text>x</Text>;
});
const { waitUntilExit } = await render(App);
exitFn();
exitFn(); // second call — must not throw or create a new promise
await waitUntilExit();
});
test("waitUntilExit() resolves with result value passed to exit()", async () => {
// Mirrors Ink's "exit on exit() with result value"
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useApp().exit;
return () => <Text>hello from vue-tui</Text>;
});
const { lastFrame, waitUntilExit } = await render(App);
expect(lastFrame()).toContain("hello from vue-tui");
exitFn("hello from ink");
await expect(waitUntilExit()).resolves.toBe("hello from ink");
});
test("waitUntilExit() resolves with object result value", async () => {
// Mirrors Ink's "exit on exit() with object result"
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useApp().exit;
return () => <Text>hello</Text>;
});
const { waitUntilExit } = await render(App);
const resultObj = { message: "hello from ink object" };
exitFn(resultObj);
await expect(waitUntilExit()).resolves.toBe(resultObj);
});
test("waitUntilExit() resolves with undefined when exit() called with no args", async () => {
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useApp().exit;
return () => <Text>hello</Text>;
});
const { waitUntilExit } = await render(App);
exitFn();
await expect(waitUntilExit()).resolves.toBeUndefined();
});
test("onScopeDispose fires when exit(error) is called", async () => {
// Mirrors Ink's "exit with thrown error" fixture — verifies teardown hooks
// still run even when the exit is error-driven.
let exitFn!: (err?: Error) => void;
let disposed = false;
const App = defineComponent(() => {
exitFn = useApp().exit;
onScopeDispose(() => {
disposed = true;
});
return () => <Text>running</Text>;
});
const { waitUntilExit } = await render(App);
exitFn(new Error("errored"));
await waitUntilExit().catch(() => {});
expect(disposed).toBe(true);
});
test("exit(error) followed by exit(value) still rejects", async () => {
// Edge case: when exit is called with an error first, a subsequent exit()
// call with a plain value should not override the rejection. The error
// should take precedence because the FIRST exit() call wins (Ink parity G33,
// isUnmounted||isUnmounting guard).
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useApp().exit;
return () => <Text>hello</Text>;
});
const { waitUntilExit } = await render(App);
const boom = new Error("first-error");
exitFn(boom);
exitFn("second-value");
await expect(waitUntilExit()).rejects.toThrow("first-error");
});
test("exit(value) resolves with the FIRST value when called rapidly twice", async () => {
// First-call-wins (Ink parity G33): the FIRST exit() captures the value and
// initiates teardown; the second is a no-op. waitUntilExit resolves "first".
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useApp().exit;
return () => <Text>hello</Text>;
});
const { waitUntilExit } = await render(App);
exitFn("first");
exitFn("second");
const result = await waitUntilExit();
expect(result).toBe("first");
});
test("exit(err1) then exit(err2) rejects with the FIRST error", async () => {
// First-call-wins for errors (Ink parity G33): the first error is captured
// and the second exit() is a no-op, so waitUntilExit rejects with err1.
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useApp().exit;
return () => <Text>hello</Text>;
});
const { waitUntilExit } = await render(App);
const err1 = new Error("err1");
const err2 = new Error("err2");
exitFn(err1);
exitFn(err2);
await expect(waitUntilExit()).rejects.toBe(err1);
});
test("exit(value) then exit(error) resolves with the FIRST value", async () => {
// value→error ordering (Ink parity G33): the FIRST exit() captures the value
// and initiates teardown; the later exit(error) is a complete no-op, so
// waitUntilExit RESOLVES with the original value rather than rejecting.
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useApp().exit;
return () => <Text>hello</Text>;
});
const { waitUntilExit } = await render(App);
exitFn("x");
exitFn(new Error("e"));
await expect(waitUntilExit()).resolves.toBe("x");
});
test("exit('late') after app.unmount() is a no-op (unmount value wins)", async () => {
// isUnmounting parity (Ink parity G33): app.unmount() runs teardown()+
// resolveExit() without setting exitInitiated. A retained exit() (from
// useApp()) called AFTER unmount has started teardown must be a
// complete no-op — it must not
// overwrite the resolved exit value. waitUntilExit resolves the original
// unmount value (undefined), NOT 'late'. Without the teardownStarted guard in
// exit(), the late exit captures 'late' into pendingExitResult and 'late'
// wins; the guard makes it a no-op.
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useApp().exit;
return () => <Text>hello</Text>;
});
const { unmount, waitUntilExit } = await render(App);
unmount();
exitFn("late");
await expect(waitUntilExit()).resolves.toBeUndefined();
});
test("retained exit() re-entered DURING unmount teardown writes is a no-op", async () => {
// isUnmounting parity (Ink parity G33), faithful reentrancy: an exit() (from
// useApp()) captured during setup is invoked re-entrantly from inside the stdout write
// that unmount()'s final commit performs. teardownStarted is already true at
// that point, so exit("reentrant") is a complete no-op and the original
// unmount value (undefined) wins. Without the teardownStarted guard the
// re-entrant exit would overwrite pendingExitResult before resolveExit runs.
let exitFn: ((value?: unknown) => void) | undefined;
let shouldReenterExit = false;
let didReenterExit = false;
const stdout = new Writable({
write(
_chunk: string | Uint8Array,
_encoding: BufferEncoding,
callback: (error?: Error) => void,
) {
if (shouldReenterExit && !didReenterExit && exitFn) {
didReenterExit = true;
exitFn("reentrant");
}
callback();
},
}) as unknown as NodeJS.WriteStream;
stdout.columns = 100;
stdout.isTTY = true;
const App = defineComponent(() => {
const { exit } = useApp();
onMounted(() => {
exitFn = exit;
});
return () => <Text>Hello</Text>;
});
const app = createApp(App);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
// Let the app mount and capture exitFn, then trigger unmount. unmount()'s
// final commit writes to stdout, which re-enters exit("reentrant") while
// teardownStarted is already true.
await new Promise((r) => setTimeout(r, 0));
shouldReenterExit = true;
app.unmount();
const result = await app.waitUntilExit();
expect(didReenterExit).toBe(true);
expect(result).toBeUndefined();
});
test("single exit('x') resolves with 'x' (control)", async () => {
let exitFn!: (errorOrResult?: unknown) => void;
const App = defineComponent(() => {
exitFn = useApp().exit;
return () => <Text>hello</Text>;
});
const { waitUntilExit } = await render(App);
exitFn("x");
await expect(waitUntilExit()).resolves.toBe("x");
});
// --- Exit re-entrance tests (ported from Ink render.tsx) ---
test("waitUntilExit resolves FIRST exit value when duplicate exits happen during teardown", async () => {
// First-call-wins (Ink parity G33): the FIRST exit() captures the value and
// initiates teardown; a later exit() is a complete no-op, so waitUntilExit
// resolves "first" regardless of write-barrier timing.
let barrierWriteCallback: (() => void) | undefined;
const stdout = new Writable({
write(
chunk: string | Uint8Array,
_encoding: BufferEncoding,
callback: (error?: Error) => void,
) {
if (isWriteBarrierChunk(chunk)) {
barrierWriteCallback = callback;
return;
}
callback();
},
}) as unknown as NodeJS.WriteStream;
stdout.columns = 100;
const App = defineComponent(() => {
const { exit } = useApp();
onMounted(() => {
exit("first");
setTimeout(() => exit("second"), 0);
});
return () => <Text>Hello</Text>;
});
const app = createApp(App);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
const exitPromise = app.waitUntilExit();
await new Promise((r) => setTimeout(r, 0));
if (barrierWriteCallback) {
barrierWriteCallback();
}
const result = await exitPromise;
expect(result).toBe("first");
});
test("waitUntilExit resolves FIRST exit value when exit is re-entered during unmount writes", async () => {
// First-call-wins (Ink parity G33): a re-entrant exit("second") during the
// unmount write is a no-op because exitInitiated is already set, so
// waitUntilExit resolves the original "first" value.
let exitFn: ((value?: unknown) => void) | undefined;
let shouldReenterExit = false;
let didReenterExit = false;
const stdout = new Writable({
write(
_chunk: string | Uint8Array,
_encoding: BufferEncoding,
callback: (error?: Error) => void,
) {
if (shouldReenterExit && !didReenterExit && exitFn) {
didReenterExit = true;
exitFn("second");
}
callback();
},
}) as unknown as NodeJS.WriteStream;
stdout.columns = 100;
stdout.isTTY = true;
const App = defineComponent(() => {
const { exit } = useApp();
onMounted(() => {
exitFn = exit;
shouldReenterExit = true;
exit("first");
});
return () => <Text>Hello</Text>;
});
const app = createApp(App);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
const result = await app.waitUntilExit();
expect(didReenterExit).toBe(true);
expect(result).toBe("first");
});
test("exit with cross-realm Error rejects after stdout write callback", async () => {
// A cross-realm Error (created in a different VM context) is a genuine Error
// but fails `instanceof Error` because its prototype comes from the other
// realm. We classify exit() input with Ink's isErrorInput (instanceof OR the
// [object Error] brand check), so a cross-realm Error REJECTS waitUntilExit()
// — matching Ink. The test also verifies the write-callback timing:
// resolution/rejection waits for the stdout write barrier to flush.
const vm = await import("node:vm");
let writeCallbackFired = false;
let barrierWriteCallbackFired = false;
const stdout = new Writable({
write(
chunk: string | Uint8Array,
_encoding: BufferEncoding,
callback: (error?: Error) => void,
) {
setTimeout(() => {
writeCallbackFired = true;
if (isWriteBarrierChunk(chunk)) {
barrierWriteCallbackFired = true;
}
callback();
}, 150);
},
}) as unknown as NodeJS.WriteStream;
stdout.columns = 100;
const foreignError = vm.runInNewContext("new Error('boom')") as Error;
const App = defineComponent(() => {
const { exit } = useApp();
onMounted(() => {
setTimeout(() => exit(foreignError), 0);
});
return () => <Text>Hello</Text>;
});
const app = createApp(App);
const stderr = makeFakeWritable();
const { stream: stdin } = makeFakeStdin();
app.mount({ stdout, stdin, stderr, exitOnCtrlC: false });
// Cross-realm Error is detected via the [object Error] brand check, so exit
// rejects with the foreign error (matching Ink) rather than resolving it as
// a value.
await expect(app.waitUntilExit()).rejects.toBe(foreignError);
await expect(app.waitUntilExit()).rejects.toMatchObject({ message: "boom" });
expect(writeCallbackFired).toBe(true);
expect(barrierWriteCallbackFired).toBe(true);
});