diff --git a/packages/runtime-tests/integration/composables/terminal-size.test.tsx b/packages/runtime-tests/integration/composables/terminal-size.test.tsx
index 93b408b..45bcede 100644
--- a/packages/runtime-tests/integration/composables/terminal-size.test.tsx
+++ b/packages/runtime-tests/integration/composables/terminal-size.test.tsx
@@ -1,7 +1,7 @@
-import { defineComponent } from "vue";
+import { defineComponent, onScopeDispose } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
-import { Text, useTerminalSize } from "@vue-tui/runtime";
+import { Box, Text, useTerminalSize } from "@vue-tui/runtime";
test("useTerminalSize reacts to resize event", async () => {
const App = defineComponent(() => {
@@ -19,3 +19,144 @@ test("useTerminalSize reacts to resize event", async () => {
await terminal.resize(120, 40);
expect(lastFrame()).toContain("120x40");
});
+
+test("useTerminalSize returns initial terminal dimensions", async () => {
+ const App = defineComponent(() => {
+ const { columns, rows } = useTerminalSize();
+ return () => (
+
+ {columns.value}x{rows.value}
+
+ );
+ });
+
+ const { lastFrame } = await render(App, { columns: 100, rows: 40 });
+ expect(lastFrame()).toContain("100x40");
+});
+
+test("useTerminalSize removes resize listener on unmount", async () => {
+ // After unmount, further resize events should not cause errors
+ const App = defineComponent(() => {
+ const { columns, rows } = useTerminalSize();
+ return () => (
+
+ {columns.value}x{rows.value}
+
+ );
+ });
+
+ const { lastFrame, unmount, terminal } = await render(App, { columns: 80, rows: 24 });
+ expect(lastFrame()).toContain("80x24");
+
+ unmount();
+
+ // Resize after unmount should not throw
+ await expect(terminal.resize(60, 20)).resolves.toBeUndefined();
+});
+
+test("useTerminalSize does not crash when resize fires after unmount", async () => {
+ const App = defineComponent(() => {
+ const { columns, rows } = useTerminalSize();
+ return () => (
+
+ {columns.value}x{rows.value}
+
+ );
+ });
+
+ const { unmount, terminal } = await render(App, { columns: 80, rows: 24 });
+ unmount();
+
+ // Emitting resize after unmount should not crash
+ await terminal.resize(60, 20);
+ // If we reach here without throwing, the test passes
+});
+
+test("layout responds to terminal width change", async () => {
+ const App = defineComponent(() => {
+ return () => (
+
+ Hello World
+
+ );
+ });
+
+ const { lastFrame, terminal } = await render(App, { columns: 100, rows: 24 });
+ const initialFrame = lastFrame()!;
+ expect(initialFrame).toContain("Hello World");
+
+ await terminal.resize(50, 24);
+ const resizedFrame = lastFrame()!;
+ expect(resizedFrame).toContain("Hello World");
+ // Output should differ because column count changed
+ expect(initialFrame).not.toBe(resizedFrame);
+});
+
+test("multiple consecutive resizes all take effect", async () => {
+ const App = defineComponent(() => {
+ const { columns, rows } = useTerminalSize();
+ return () => (
+
+ {columns.value}x{rows.value}
+
+ );
+ });
+
+ const { lastFrame, terminal } = await render(App, { columns: 80, rows: 24 });
+ expect(lastFrame()).toContain("80x24");
+
+ await terminal.resize(100, 30);
+ expect(lastFrame()).toContain("100x30");
+
+ await terminal.resize(60, 20);
+ expect(lastFrame()).toContain("60x20");
+
+ await terminal.resize(120, 40);
+ expect(lastFrame()).toContain("120x40");
+});
+
+test("terminal width decrease triggers rerender", async () => {
+ const App = defineComponent(() => {
+ const { columns } = useTerminalSize();
+ return () => {columns.value};
+ });
+
+ const { lastFrame, terminal } = await render(App, { columns: 100, rows: 24 });
+ expect(lastFrame()).toContain("100");
+
+ await terminal.resize(50, 24);
+ expect(lastFrame()).toContain("50");
+});
+
+test("terminal width increase triggers rerender", async () => {
+ const App = defineComponent(() => {
+ const { columns } = useTerminalSize();
+ return () => {columns.value};
+ });
+
+ const { lastFrame, terminal } = await render(App, { columns: 50, rows: 24 });
+ expect(lastFrame()).toContain("50");
+
+ await terminal.resize(100, 24);
+ expect(lastFrame()).toContain("100");
+});
+
+test("resize listener is cleaned up via onScopeDispose", async () => {
+ let disposeCalled = false;
+
+ const App = defineComponent(() => {
+ // useTerminalSize registers an onScopeDispose listener internally;
+ // we also register one to verify the scope is properly disposed on unmount.
+ useTerminalSize();
+ onScopeDispose(() => {
+ disposeCalled = true;
+ });
+ return () => watching;
+ });
+
+ const { unmount } = await render(App, { columns: 80, rows: 24 });
+ expect(disposeCalled).toBe(false);
+
+ unmount();
+ expect(disposeCalled).toBe(true);
+});
diff --git a/packages/runtime-tests/integration/lifecycle/error-handling.test.tsx b/packages/runtime-tests/integration/lifecycle/error-handling.test.tsx
index 328e3d3..73ebcfe 100644
--- a/packages/runtime-tests/integration/lifecycle/error-handling.test.tsx
+++ b/packages/runtime-tests/integration/lifecycle/error-handling.test.tsx
@@ -1,7 +1,7 @@
import { defineComponent, nextTick, ref } from "vue";
import { expect, test } from "vite-plus/test";
import { render } from "@vue-tui/testing";
-import { Text } from "@vue-tui/runtime";
+import { Text, useExit } from "@vue-tui/runtime";
test("setup() throw rejects render()", async () => {
const Boom = defineComponent(() => {
@@ -32,6 +32,50 @@ test("render-time throw does not prevent unmount", async () => {
expect(() => unmount()).not.toThrow();
});
+test("useExit() 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 = useExit();
+ return () => running;
+ });
+
+ const { waitUntilExit } = await render(App);
+
+ const err = new Error("errored via useExit");
+ exitFn(err);
+
+ await expect(waitUntilExit()).rejects.toBe(err);
+});
+
+// --- Tests that cannot be ported due to vue-tui runtime limitations ---
+
+test.todo(
+ "nested component setup error rejects waitUntilExit — " +
+ "errorHandler is installed AFTER mount, so errors during initial mount " +
+ "propagate synchronously instead of routing through exit(err). " +
+ "Additionally, throwing during mount corrupts the WASM yoga tree, " +
+ "making teardown unreliable. Requires runtime-level pre-mount error handling.",
+);
+
+test.todo(
+ "does not emit unhandledRejection when render exits with an error and waitUntilExit is unused — " +
+ "in vue-tui, setup errors thrown during mount surface via render() rejection " +
+ "and may also produce unhandledRejection events from Vue's internal promise chains " +
+ "before our exitPromise.catch() guard takes effect. Requires engine-level fix.",
+);
+
+test.todo(
+ "error in component triggered after mount routes through errorHandler — " +
+ "render-function throws during a reactive re-render (post-mount) cause " +
+ "yoga WASM table index out-of-bounds crashes that corrupt the layout engine. " +
+ "The errorHandler is called but the process state is unrecoverable. " +
+ "Requires WASM error isolation or render-phase error recovery in the runtime.",
+);
+
// --- Ink error validation tests ---
// In Ink these tests use React error boundaries to validate that:
// 1. Raw text strings inside (not inside ) throw an error
diff --git a/packages/runtime-tests/integration/lifecycle/exit.test.tsx b/packages/runtime-tests/integration/lifecycle/exit.test.tsx
index 841df8e..96c3824 100644
--- a/packages/runtime-tests/integration/lifecycle/exit.test.tsx
+++ b/packages/runtime-tests/integration/lifecycle/exit.test.tsx
@@ -65,3 +65,93 @@ test("unmount() after exit() is idempotent", async () => {
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 = useExit();
+ return () => x;
+ });
+
+ const { waitUntilExit } = await render(App);
+ exitFn();
+ exitFn(); // second call — must not throw or create a new promise
+ await waitUntilExit();
+});
+
+test("waitUntilExit() resolves after exit() with result value", async () => {
+ // Mirrors Ink's "exit on exit() with result value" — vue-tui's exit()
+ // resolves cleanly (no value); just verifies the promise resolves.
+ let exitFn!: () => void;
+
+ const App = defineComponent(() => {
+ exitFn = useExit();
+ return () => hello from vue-tui;
+ });
+
+ const { lastFrame, waitUntilExit } = await render(App);
+ expect(lastFrame()).toContain("hello from vue-tui");
+
+ 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 = useExit();
+ onScopeDispose(() => {
+ disposed = true;
+ });
+ return () => running;
+ });
+
+ const { waitUntilExit } = await render(App);
+ exitFn(new Error("errored"));
+ await waitUntilExit().catch(() => {});
+ expect(disposed).toBe(true);
+});
+
+// --- Ink subprocess-fixture tests (not portable to render-based testing) ---
+// The following Ink tests drive real TTY subprocesses via node-pty and cannot
+// be translated to in-process render() calls. The covered behaviors are
+// exercised indirectly by the render-based tests above.
+
+test.todo(
+ "exit normally without unmount() or exit() — covered by 'unmount() resolves waitUntilExit'",
+);
+
+test.todo(
+ "exit when app finishes execution — subprocess fixture; covered by exit()/unmount() tests",
+);
+
+test.todo(
+ "exit on exit() with raw mode — subprocess fixture; raw-mode cleanup covered by error-handling tests",
+);
+
+test.todo(
+ "exit on exit() with raw mode with error — subprocess fixture; raw-mode cleanup covered by error-handling tests",
+);
+
+test.todo(
+ "exit on unmount() with raw mode — subprocess fixture; raw-mode cleanup covered by error-handling tests",
+);
+
+test.todo(
+ "don't exit while raw mode is active — requires node-pty TTY subprocess; not portable to render-based testing",
+);
+
+test.todo(
+ "exit when DEV is set — subprocess fixture with DEV env var; not portable to render-based testing",
+);
+
+test.todo(
+ "exit on exit() with error and static output — subprocess fixture; static output behavior covered by static.test.tsx",
+);