Files
vue-tui/packages/runtime-tests/integration/lifecycle/error-handling.test.tsx
T
Yunfei He bfd680490f refactor(runtime)!: rename useAppContext() to useApp() (full Ink alignment) (#73)
#69 added `useAppContext()` as a Vue-native rename of Ink's `useApp()`,
qualified to avoid reading as the Vue application instance. On reflection the
"Context" suffix borrowed the name of an internal grab-bag context and slightly
mislabels the hook — it returns app lifecycle controls, not that context. The
collision worry doesn't hold up: Vue has no `useApp()`, the returned
`{ exit, waitUntilRenderFlush }` is clearly not the Vue app instance, and "App"
in vue-tui already means the `TuiApp` from `createApp()`.

Rename to `useApp()` for full Ink fidelity (same name + same shape), and drop
the now-defunct "App composable" entry from ink-divergences.md — it ceases to
be a divergence.

Internal context cleanup (the grab-bag `AppContext` + the `StdinContext`
duplication) is intentionally out of scope here, tracked separately.

BREAKING CHANGE: `useAppContext()` is renamed to `useApp()`. Replace
`const { exit } = useAppContext()` with `const { exit } = useApp()`.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 23:32:22 +08:00

121 lines
3.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { defineComponent, nextTick, shallowRef } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
import { Box, Text, useApp } from "@vue-tui/runtime";
test("setup() throw rejects render()", async () => {
const Boom = defineComponent(() => {
throw new Error("setup boom");
});
await expect(render(Boom)).rejects.toThrow("setup boom");
});
test("render-time throw does not prevent unmount", async () => {
const trigger = shallowRef(false);
const App = defineComponent(() => {
return () => {
if (trigger.value) throw new Error("render boom");
return <Text>ok</Text>;
};
});
const { lastFrame, unmount } = await render(App);
expect(lastFrame()).toContain("ok");
trigger.value = true;
// Error boundary catches the render error and routes through exit()
await nextTick();
await nextTick();
await Promise.resolve();
// After exit(), teardown has run. unmount() should be idempotent/no-throw.
expect(() => unmount()).not.toThrow();
});
test("useApp() called with error rejects waitUntilExit", async () => {
// Mirrors Ink's "exit on exit() with error" fixture test, adapted for
// render-based testing. Verifies exit(err) rejects the promise cleanly.
// Also covered by exit.test.tsx "exit(error) rejects waitUntilExit with the error".
let exitFn!: (err?: Error) => void;
const App = defineComponent(() => {
exitFn = useApp().exit;
return () => <Text>running</Text>;
});
const { waitUntilExit } = await render(App);
const err = new Error("errored via useApp");
exitFn(err);
await expect(waitUntilExit()).rejects.toBe(err);
});
// --- Error boundary tests (previously blocked by yoga WASM crashes) ---
test("nested component setup error rejects waitUntilExit", async () => {
const err = new Error("setup boom nested");
const Child = defineComponent(() => {
throw err;
});
const App = defineComponent(() => () => <Child />);
await expect(render(App)).rejects.toThrow("setup boom nested");
});
test("does not emit unhandledRejection when render exits with an error and waitUntilExit is unused", async () => {
const unhandledErrors: Error[] = [];
const handler = (reason: unknown) => {
unhandledErrors.push(reason as Error);
};
process.on("unhandledRejection", handler);
try {
const Boom = defineComponent(() => {
throw new Error("no-listener boom");
});
await render(Boom).catch(() => {});
// Give a tick for any stray rejections to surface
await new Promise((r) => setTimeout(r, 10));
expect(unhandledErrors).toHaveLength(0);
} finally {
process.off("unhandledRejection", handler);
}
});
test("error in component triggered after mount routes through exit", async () => {
const trigger = shallowRef(false);
const App = defineComponent(() => {
return () => {
if (trigger.value) throw new Error("post-mount boom");
return <Text>ok</Text>;
};
});
const { waitUntilExit, lastFrame } = await render(App);
expect(lastFrame()).toContain("ok");
trigger.value = true;
// Flush the render + error boundary nextTick + exit microtask
await nextTick();
await nextTick();
await Promise.resolve();
await expect(waitUntilExit()).rejects.toThrow("post-mount boom");
});
// --- Ink error validation tests ---
test("fail when Box nested inside Text", async () => {
const App = defineComponent(() => () => (
<Text>
<Box />
</Text>
));
await expect(render(App)).rejects.toThrow("can’t be nested inside <Text>");
});
test("fail when text string not within Text component", async () => {
const App = defineComponent(() => () => <Box>bare text</Box>);
await expect(render(App)).rejects.toThrow("must be rendered inside <Text>");
});