diff --git a/packages/runtime-tests/integration/components/box.test.tsx b/packages/runtime-tests/integration/components/box.test.tsx
new file mode 100644
index 0000000..eeb1c70
--- /dev/null
+++ b/packages/runtime-tests/integration/components/box.test.tsx
@@ -0,0 +1,57 @@
+import { defineComponent, nextTick, ref } from "vue";
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Box, Text } from "@vue-tui/runtime";
+
+test("Box renders with border", async () => {
+ const { lastFrame } = await render(
+ () => (
+
+ hi
+
+ ),
+ { columns: 20 },
+ );
+ const frame = lastFrame()!;
+ expect(frame).toContain("┌");
+ expect(frame).toContain("hi");
+ expect(frame).toContain("└");
+});
+
+test("borderTop:false suppresses top edge", async () => {
+ const { lastFrame } = await render(
+ () => ,
+ { columns: 10 },
+ );
+ const lines = lastFrame()!.split("\n");
+ expect(lines[0]).not.toContain("┌");
+ expect(lines[0]).not.toContain("─");
+ expect(lastFrame()).toContain("└");
+});
+
+test("borderBottom:false suppresses bottom edge", async () => {
+ const { lastFrame } = await render(
+ () => ,
+ { columns: 10 },
+ );
+ const frame = lastFrame()!;
+ expect(frame).toContain("┌");
+ const lines = frame.split("\n");
+ expect(lines.at(-1)).not.toContain("└");
+});
+
+test("reactive borderTop:false update removes top edge", async () => {
+ const showTop = ref(true);
+ const App = defineComponent(() => {
+ return () => ;
+ });
+
+ const { lastFrame } = await render(App, { columns: 10 });
+ expect(lastFrame()).toContain("┌");
+
+ showTop.value = false;
+ await nextTick();
+ const lines = lastFrame()!.split("\n");
+ expect(lines[0]).not.toContain("┌");
+ expect(lastFrame()).toContain("└");
+});
diff --git a/packages/runtime-tests/integration/components/conditional-list.test.tsx b/packages/runtime-tests/integration/components/conditional-list.test.tsx
new file mode 100644
index 0000000..3db40e2
--- /dev/null
+++ b/packages/runtime-tests/integration/components/conditional-list.test.tsx
@@ -0,0 +1,88 @@
+import { defineComponent, nextTick, ref } from "vue";
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Box, Text } from "@vue-tui/runtime";
+
+test("v-if toggle preserves sibling order", async () => {
+ const show = ref(true);
+ const App = defineComponent(() => {
+ return () => (
+
+ A
+ {show.value ? B : null}
+ C
+
+ );
+ });
+
+ const { lastFrame } = await render(App, { columns: 10 });
+ expect(lastFrame()).toContain("A");
+ expect(lastFrame()).toContain("B");
+ expect(lastFrame()).toContain("C");
+
+ show.value = false;
+ await nextTick();
+ expect(lastFrame()).not.toContain("B");
+ expect(lastFrame()).toContain("A");
+ expect(lastFrame()).toContain("C");
+
+ show.value = true;
+ await nextTick();
+ const lines = lastFrame()!.split("\n").filter(Boolean);
+ const aIdx = lines.findIndex((l) => l.includes("A"));
+ const bIdx = lines.findIndex((l) => l.includes("B"));
+ const cIdx = lines.findIndex((l) => l.includes("C"));
+ expect(aIdx).toBeLessThan(bIdx);
+ expect(bIdx).toBeLessThan(cIdx);
+});
+
+test("keyed v-for reorder renders in new order", async () => {
+ const items = ref([1, 2, 3]);
+ const App = defineComponent(() => {
+ return () => (
+
+ {items.value.map((n) => (
+ item-{n}
+ ))}
+
+ );
+ });
+
+ const { lastFrame } = await render(App, { columns: 20 });
+ let lines = lastFrame()!.split("\n").filter(Boolean);
+ expect(lines[0]).toContain("item-1");
+ expect(lines[1]).toContain("item-2");
+ expect(lines[2]).toContain("item-3");
+
+ items.value = [3, 1, 2];
+ await nextTick();
+ lines = lastFrame()!.split("\n").filter(Boolean);
+ expect(lines[0]).toContain("item-3");
+ expect(lines[1]).toContain("item-1");
+ expect(lines[2]).toContain("item-2");
+});
+
+test("repeated list shuffles don't crash", async () => {
+ const items = ref([1, 2, 3, 4, 5]);
+ const App = defineComponent(() => {
+ return () => (
+
+ {items.value.map((n) => (
+ {String(n)}
+ ))}
+
+ );
+ });
+
+ const { lastFrame } = await render(App, { columns: 10 });
+
+ items.value = [5, 4, 3, 2, 1];
+ await nextTick();
+ items.value = [2, 4, 1, 5, 3];
+ await nextTick();
+ items.value = [1, 2, 3, 4, 5];
+ await nextTick();
+
+ expect(lastFrame()).toContain("1");
+ expect(lastFrame()).toContain("5");
+});
diff --git a/packages/runtime-tests/integration/components/newline-spacer.test.tsx b/packages/runtime-tests/integration/components/newline-spacer.test.tsx
new file mode 100644
index 0000000..cc351d5
--- /dev/null
+++ b/packages/runtime-tests/integration/components/newline-spacer.test.tsx
@@ -0,0 +1,29 @@
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Box, Text, Newline, Spacer } from "@vue-tui/runtime";
+
+test("Newline emits line breaks inside Text", async () => {
+ const { lastFrame } = await render(() => (
+
+ ab
+
+ ));
+ const lines = lastFrame()!.split("\n").filter(Boolean);
+ expect(lines.length).toBeGreaterThanOrEqual(2);
+});
+
+test("Spacer pushes siblings apart in row direction", async () => {
+ const { lastFrame } = await render(
+ () => (
+
+ L
+
+ R
+
+ ),
+ { columns: 10 },
+ );
+ const line = lastFrame()!.split("\n")[0]!;
+ expect(line.startsWith("L")).toBe(true);
+ expect(line.trimEnd().endsWith("R")).toBe(true);
+});
diff --git a/packages/runtime-tests/integration/components/static.test.tsx b/packages/runtime-tests/integration/components/static.test.tsx
new file mode 100644
index 0000000..dd5215d
--- /dev/null
+++ b/packages/runtime-tests/integration/components/static.test.tsx
@@ -0,0 +1,68 @@
+import { defineComponent, nextTick, ref } from "vue";
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Box, Text, Static } from "@vue-tui/runtime";
+
+test("Static appends new items above the dynamic frame", async () => {
+ const items = ref([]);
+
+ const App = defineComponent(() => {
+ return () => (
+
+
+ {{
+ default: ({ item, index }: { item: string; index: number }) => (
+ {item}
+ ),
+ }}
+
+ [dynamic]
+
+ );
+ });
+
+ const { lastFrame, frames } = await render(App);
+ expect(lastFrame()).toContain("[dynamic]");
+
+ items.value = ["log-1"];
+ await nextTick();
+
+ const allOutput = frames.join("");
+ expect(allOutput).toContain("log-1");
+ expect(lastFrame()).toContain("[dynamic]");
+});
+
+test("Static preserves prior items when new ones are added", async () => {
+ const logs = ref([]);
+ const status = ref("idle");
+
+ const App = defineComponent(() => {
+ return () => (
+
+
+ {{
+ default: ({ item, index }: { item: string; index: number }) => (
+ {item}
+ ),
+ }}
+
+ status: {status.value}
+
+ );
+ });
+
+ const { lastFrame, frames } = await render(App);
+ expect(lastFrame()).toContain("status: idle");
+
+ logs.value = [...logs.value, "log A"];
+ await nextTick();
+ logs.value = [...logs.value, "log B"];
+ await nextTick();
+ status.value = "running";
+ await nextTick();
+
+ const allOutput = frames.join("");
+ expect(allOutput).toContain("log A");
+ expect(allOutput).toContain("log B");
+ expect(lastFrame()).toContain("status: running");
+});
diff --git a/packages/runtime-tests/integration/components/text.test.tsx b/packages/runtime-tests/integration/components/text.test.tsx
new file mode 100644
index 0000000..81170e0
--- /dev/null
+++ b/packages/runtime-tests/integration/components/text.test.tsx
@@ -0,0 +1,20 @@
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Text } from "@vue-tui/runtime";
+
+test("nested Text renders inline without independent layout", async () => {
+ const { lastFrame } = await render(() => (
+
+ Hello world
+
+ ));
+ const frame = lastFrame()!;
+ expect(frame).toContain("Hello");
+ expect(frame).toContain("world");
+});
+
+test("CJK wide characters render without corruption", async () => {
+ const { lastFrame } = await render(() => 中文测试, { columns: 20 });
+ const frame = lastFrame()!;
+ expect(frame).toContain("中文测试");
+});
diff --git a/packages/runtime-tests/integration/components/transform.test.tsx b/packages/runtime-tests/integration/components/transform.test.tsx
new file mode 100644
index 0000000..78ff518
--- /dev/null
+++ b/packages/runtime-tests/integration/components/transform.test.tsx
@@ -0,0 +1,12 @@
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Text, Transform } from "@vue-tui/runtime";
+
+test("Transform uppercases descendant text", async () => {
+ const { lastFrame } = await render(() => (
+ line.toUpperCase()}>
+ abc
+
+ ));
+ expect(lastFrame()).toContain("ABC");
+});
diff --git a/packages/runtime-tests/integration/composables/terminal-size.test.tsx b/packages/runtime-tests/integration/composables/terminal-size.test.tsx
new file mode 100644
index 0000000..93b408b
--- /dev/null
+++ b/packages/runtime-tests/integration/composables/terminal-size.test.tsx
@@ -0,0 +1,21 @@
+import { defineComponent } from "vue";
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Text, useTerminalSize } from "@vue-tui/runtime";
+
+test("useTerminalSize reacts to resize event", 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(120, 40);
+ expect(lastFrame()).toContain("120x40");
+});
diff --git a/packages/runtime-tests/integration/composables/use-input.test.tsx b/packages/runtime-tests/integration/composables/use-input.test.tsx
new file mode 100644
index 0000000..54cff55
--- /dev/null
+++ b/packages/runtime-tests/integration/composables/use-input.test.tsx
@@ -0,0 +1,60 @@
+import { defineComponent, ref } from "vue";
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Text, useInput, type Key } from "@vue-tui/runtime";
+
+test("useInput receives keyboard input", async () => {
+ const calls: Array<{ input: string; key: Key }> = [];
+ const App = defineComponent(() => {
+ useInput((input, key) => calls.push({ input, key }));
+ return () => listening;
+ });
+
+ const { stdin } = await render(App);
+ await stdin.write("x");
+ expect(calls[0]?.input).toBe("x");
+});
+
+test("useInput receives arrow keys", async () => {
+ const calls: Array<{ input: string; key: Key }> = [];
+ const App = defineComponent(() => {
+ useInput((input, key) => calls.push({ input, key }));
+ return () => listening;
+ });
+
+ const { stdin } = await render(App);
+ await stdin.write("\x1b[A");
+ expect(calls[0]?.key.upArrow).toBe(true);
+});
+
+test("useInput respects isActive ref", async () => {
+ const calls: string[] = [];
+ const active = ref(false);
+ const App = defineComponent(() => {
+ useInput((input) => calls.push(input), { isActive: active });
+ return () => x;
+ });
+
+ const { stdin } = await render(App);
+ await stdin.write("a");
+ expect(calls.length).toBe(0);
+
+ active.value = true;
+ await stdin.write("b");
+ expect(calls).toEqual(["b"]);
+});
+
+test("two useInput hooks both receive the same input", async () => {
+ const a: string[] = [];
+ const b: string[] = [];
+ const App = defineComponent(() => {
+ useInput((c) => a.push(c));
+ useInput((c) => b.push(c));
+ return () => x;
+ });
+
+ const { stdin } = await render(App);
+ await stdin.write("z");
+ expect(a).toEqual(["z"]);
+ expect(b).toEqual(["z"]);
+});
diff --git a/packages/runtime-tests/integration/counter.test.tsx b/packages/runtime-tests/integration/counter.test.tsx
new file mode 100644
index 0000000..76c6aee
--- /dev/null
+++ b/packages/runtime-tests/integration/counter.test.tsx
@@ -0,0 +1,32 @@
+import { defineComponent, ref } from "vue";
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Box, Text, useInput } from "@vue-tui/runtime";
+
+test("counter responds to + and - keys", async () => {
+ const Counter = defineComponent(() => {
+ const count = ref(0);
+ useInput((input) => {
+ if (input === "+") count.value++;
+ if (input === "-") count.value--;
+ });
+ return () => (
+
+ Count: {count.value}
+
+ );
+ });
+
+ const { lastFrame, stdin } = await render(Counter);
+ expect(lastFrame()).toContain("Count: 0");
+
+ await stdin.write("+");
+ expect(lastFrame()).toContain("Count: 1");
+
+ await stdin.write("+");
+ await stdin.write("+");
+ expect(lastFrame()).toContain("Count: 3");
+
+ await stdin.write("-");
+ expect(lastFrame()).toContain("Count: 2");
+});
diff --git a/packages/runtime-tests/integration/focus/focus-manager.test.tsx b/packages/runtime-tests/integration/focus/focus-manager.test.tsx
new file mode 100644
index 0000000..759e09e
--- /dev/null
+++ b/packages/runtime-tests/integration/focus/focus-manager.test.tsx
@@ -0,0 +1,42 @@
+import { defineComponent } from "vue";
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Box, Text, useFocus, useFocusManager } from "@vue-tui/runtime";
+
+test("useFocusManager().activeId tracks the currently focused component", async () => {
+ let activeId!: ReturnType["activeId"];
+
+ const Item = defineComponent({
+ props: { id: { type: String, required: true } },
+ setup(props) {
+ const { isFocused } = useFocus({ id: props.id, autoFocus: props.id === "a" });
+ return () => (
+
+ {isFocused.value ? "▶ " : " "}
+ {props.id}
+
+ );
+ },
+ });
+
+ const App = defineComponent(() => {
+ const manager = useFocusManager();
+ activeId = manager.activeId;
+ return () => (
+
+
+
+
+ );
+ });
+
+ const { stdin } = await render(App);
+
+ expect(activeId.value).toBe("a");
+
+ await stdin.write("\t");
+ expect(activeId.value).toBe("b");
+
+ await stdin.write("\t");
+ expect(activeId.value).toBe("a");
+});
diff --git a/packages/runtime-tests/integration/focus/programmatic-focus.test.tsx b/packages/runtime-tests/integration/focus/programmatic-focus.test.tsx
new file mode 100644
index 0000000..990de97
--- /dev/null
+++ b/packages/runtime-tests/integration/focus/programmatic-focus.test.tsx
@@ -0,0 +1,109 @@
+import { defineComponent, nextTick, ref } from "vue";
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Box, Text, useFocus } from "@vue-tui/runtime";
+
+test("focus(id) programmatically focuses another component", async () => {
+ let focusFn!: (id: string) => void;
+
+ const Item = defineComponent({
+ props: { id: { type: String, required: true } },
+ setup(props) {
+ const { isFocused, focus } = useFocus({ id: props.id });
+ if (props.id === "a") focusFn = focus;
+ return () => (
+
+ {isFocused.value ? "▶ " : " "}
+ {props.id}
+
+ );
+ },
+ });
+
+ const { lastFrame, stdin } = await render(() => (
+
+
+
+
+
+ ));
+
+ // Need to send a Tab to activate focus system (raw mode)
+ await stdin.write("\t");
+
+ focusFn("c");
+ // Need to wait for Vue reactivity
+ const { nextTick } = await import("vue");
+ await nextTick();
+
+ expect(lastFrame()).toContain("▶ c");
+ expect(lastFrame()).not.toContain("▶ a");
+});
+
+test("isActive=false prevents component from receiving focus", async () => {
+ const active = ref(false);
+
+ const Item = defineComponent({
+ props: { id: { type: String, required: true } },
+ setup(props) {
+ const opts = props.id === "skip" ? { id: props.id, isActive: active } : { id: props.id };
+ const { isFocused } = useFocus(opts);
+ return () => (
+
+ {isFocused.value ? "▶ " : " "}
+ {props.id}
+
+ );
+ },
+ });
+
+ const { lastFrame, stdin } = await render(() => (
+
+
+
+
+
+ ));
+
+ // Tab to first
+ await stdin.write("\t");
+ expect(lastFrame()).toContain("▶ first");
+
+ // Tab should skip "skip" and go to "last"
+ await stdin.write("\t");
+ expect(lastFrame()).toContain("▶ last");
+ expect(lastFrame()).not.toContain("▶ skip");
+});
+
+test("autoFocus + isActive=false does not focus at mount", async () => {
+ const active = ref(false);
+
+ const App = defineComponent(() => {
+ const { isFocused } = useFocus({ id: "item", autoFocus: true, isActive: active });
+ return () => {isFocused.value ? "focused" : "unfocused"};
+ });
+
+ const { lastFrame } = await render(App);
+ expect(lastFrame()).toContain("unfocused");
+
+ active.value = true;
+ await nextTick();
+ // Becoming active doesn't auto-focus retroactively
+ expect(lastFrame()).toContain("unfocused");
+});
+
+test("flipping isActive to false on focused item blurs it", async () => {
+ const active = ref(true);
+
+ const App = defineComponent(() => {
+ const { isFocused } = useFocus({ id: "item", autoFocus: true, isActive: active });
+ return () => {isFocused.value ? "focused" : "unfocused"};
+ });
+
+ const { lastFrame } = await render(App);
+ expect(lastFrame()).toContain("focused");
+
+ active.value = false;
+ await nextTick();
+ expect(lastFrame()).toContain("unfocused");
+});
diff --git a/packages/runtime-tests/integration/focus/raw-mode-swap.test.tsx b/packages/runtime-tests/integration/focus/raw-mode-swap.test.tsx
new file mode 100644
index 0000000..9a7001e
--- /dev/null
+++ b/packages/runtime-tests/integration/focus/raw-mode-swap.test.tsx
@@ -0,0 +1,30 @@
+import { defineComponent, nextTick, ref } from "vue";
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Text, useFocus } from "@vue-tui/runtime";
+
+test("swapping focusable components never disables raw mode", async () => {
+ const showA = ref(true);
+
+ const Item = defineComponent(() => {
+ useFocus();
+ return () => x;
+ });
+
+ const Root = defineComponent(() => {
+ return () => (showA.value ? : );
+ });
+
+ const { terminal, unmount } = await render(Root);
+ expect(terminal.rawMode.current).toBe(true);
+ const historyBefore = terminal.rawMode.history.length;
+
+ showA.value = false;
+ await nextTick();
+
+ expect(terminal.rawMode.current).toBe(true);
+ const swapHistory = terminal.rawMode.history.slice(historyBefore);
+ expect(swapHistory).not.toContain(false);
+
+ unmount();
+});
diff --git a/packages/runtime-tests/integration/focus/tab-cycling.test.tsx b/packages/runtime-tests/integration/focus/tab-cycling.test.tsx
new file mode 100644
index 0000000..6cbddbd
--- /dev/null
+++ b/packages/runtime-tests/integration/focus/tab-cycling.test.tsx
@@ -0,0 +1,65 @@
+import { defineComponent } from "vue";
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Box, Text, useFocus } from "@vue-tui/runtime";
+
+test("Tab cycles focus between three menu items", async () => {
+ const Item = defineComponent({
+ props: { id: { type: String, required: true } },
+ setup(props) {
+ const { isFocused } = useFocus({ id: props.id, autoFocus: props.id === "one" });
+ return () => (
+
+ {isFocused.value ? "▶ " : " "}
+ {props.id}
+
+ );
+ },
+ });
+
+ const { lastFrame, stdin } = await render(() => (
+
+
+
+
+
+ ));
+
+ expect(lastFrame()).toContain("▶ one");
+
+ await stdin.write("\t");
+ expect(lastFrame()).toContain("▶ two");
+
+ await stdin.write("\t");
+ expect(lastFrame()).toContain("▶ three");
+
+ await stdin.write("\t");
+ expect(lastFrame()).toContain("▶ one");
+});
+
+test("Escape clears all focus", async () => {
+ const Item = defineComponent({
+ props: { id: { type: String, required: true } },
+ setup(props) {
+ const { isFocused } = useFocus({ id: props.id, autoFocus: props.id === "one" });
+ return () => (
+
+ {isFocused.value ? "▶ " : " "}
+ {props.id}
+
+ );
+ },
+ });
+
+ const { lastFrame, stdin } = await render(() => (
+
+
+
+
+ ));
+
+ expect(lastFrame()).toContain("▶ one");
+
+ await stdin.write("\x1b");
+ expect(lastFrame()).not.toContain("▶");
+});
diff --git a/packages/runtime-tests/integration/lifecycle/error-handling.test.tsx b/packages/runtime-tests/integration/lifecycle/error-handling.test.tsx
new file mode 100644
index 0000000..26dd220
--- /dev/null
+++ b/packages/runtime-tests/integration/lifecycle/error-handling.test.tsx
@@ -0,0 +1,33 @@
+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";
+
+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 = ref(false);
+ const App = defineComponent(() => {
+ return () => {
+ if (trigger.value) throw new Error("render boom");
+ return ok;
+ };
+ });
+
+ const { lastFrame, unmount } = await render(App);
+ expect(lastFrame()).toContain("ok");
+
+ trigger.value = true;
+ try {
+ await nextTick();
+ } catch {
+ // swallow the render error
+ }
+
+ expect(() => unmount()).not.toThrow();
+});
diff --git a/packages/runtime-tests/integration/lifecycle/exit.test.tsx b/packages/runtime-tests/integration/lifecycle/exit.test.tsx
new file mode 100644
index 0000000..841df8e
--- /dev/null
+++ b/packages/runtime-tests/integration/lifecycle/exit.test.tsx
@@ -0,0 +1,67 @@
+import { defineComponent, onScopeDispose } from "vue";
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Text, useExit } from "@vue-tui/runtime";
+
+test("useExit() triggers teardown and waitUntilExit resolves", async () => {
+ let exitFn!: () => void;
+ let disposed = false;
+
+ const App = defineComponent(() => {
+ const exit = useExit();
+ exitFn = exit;
+ onScopeDispose(() => {
+ disposed = true;
+ });
+ return () => running;
+ });
+
+ 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 = useExit();
+ exitFn = exit;
+ return () => x;
+ });
+
+ 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 () => x;
+ });
+
+ const { unmount, waitUntilExit } = await render(App);
+ unmount();
+ await waitUntilExit();
+});
+
+test("unmount() after exit() is idempotent", async () => {
+ let exitFn!: () => void;
+
+ const App = defineComponent(() => {
+ const exit = useExit();
+ exitFn = exit;
+ return () => x;
+ });
+
+ const { unmount, waitUntilExit } = await render(App);
+ exitFn();
+ await waitUntilExit();
+
+ expect(() => unmount()).not.toThrow();
+});
diff --git a/packages/runtime-tests/integration/lifecycle/leak.test.tsx b/packages/runtime-tests/integration/lifecycle/leak.test.tsx
new file mode 100644
index 0000000..7ada70e
--- /dev/null
+++ b/packages/runtime-tests/integration/lifecycle/leak.test.tsx
@@ -0,0 +1,58 @@
+import { defineComponent, nextTick, ref } from "vue";
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Box, Text, useInput } from "@vue-tui/runtime";
+import { yogaNodeTracker } from "@vue-tui/runtime/internal";
+
+test("50 render/unmount cycles leak zero process listeners", async () => {
+ const exitBefore = process.listenerCount("exit");
+ const sigintBefore = process.listenerCount("SIGINT");
+
+ const App = defineComponent(() => () => x);
+
+ for (let i = 0; i < 50; i++) {
+ const { unmount } = await render(App);
+ unmount();
+ }
+
+ expect(process.listenerCount("exit")).toBe(exitBefore);
+ expect(process.listenerCount("SIGINT")).toBe(sigintBefore);
+});
+
+test("100 render/unmount cycles leak zero yoga nodes", async () => {
+ yogaNodeTracker.reset();
+
+ const App = defineComponent(() => () => x);
+
+ for (let i = 0; i < 100; i++) {
+ const { unmount } = await render(App);
+ unmount();
+ }
+
+ expect(yogaNodeTracker.snapshot().live).toBe(0);
+});
+
+test("raw mode stays on when one of two useInput components unmounts", async () => {
+ const showB = ref(true);
+
+ const Listener = defineComponent(() => {
+ useInput(() => {});
+ return () => x;
+ });
+
+ const App = defineComponent(() => {
+ return () => (
+
+
+ {showB.value ? : null}
+
+ );
+ });
+
+ const { terminal } = await render(App);
+ expect(terminal.rawMode.current).toBe(true);
+
+ showB.value = false;
+ await nextTick();
+ expect(terminal.rawMode.current).toBe(true);
+});
diff --git a/packages/runtime-tests/integration/lifecycle/multi-app.test.tsx b/packages/runtime-tests/integration/lifecycle/multi-app.test.tsx
new file mode 100644
index 0000000..e6de04f
--- /dev/null
+++ b/packages/runtime-tests/integration/lifecycle/multi-app.test.tsx
@@ -0,0 +1,19 @@
+import { defineComponent } from "vue";
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Text } from "@vue-tui/runtime";
+
+test("two concurrent render() calls coexist independently", async () => {
+ const App = defineComponent(() => () => hello);
+
+ const a = await render(App);
+ const b = await render(App);
+
+ expect(a.lastFrame()).toContain("hello");
+ expect(b.lastFrame()).toContain("hello");
+
+ a.unmount();
+ expect(b.lastFrame()).toContain("hello");
+
+ b.unmount();
+});
diff --git a/packages/runtime-tests/integration/lifecycle/sigint.test.tsx b/packages/runtime-tests/integration/lifecycle/sigint.test.tsx
new file mode 100644
index 0000000..e4d53c9
--- /dev/null
+++ b/packages/runtime-tests/integration/lifecycle/sigint.test.tsx
@@ -0,0 +1,25 @@
+import { defineComponent } from "vue";
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Text } from "@vue-tui/runtime";
+
+test("exitOnCtrlC registers a SIGINT handler that unmount removes", async () => {
+ const before = process.listenerCount("SIGINT");
+
+ const App = defineComponent(() => () => x);
+ const { unmount } = await render(App, { exitOnCtrlC: true });
+
+ expect(process.listenerCount("SIGINT")).toBe(before + 1);
+ unmount();
+ expect(process.listenerCount("SIGINT")).toBe(before);
+});
+
+test("exitOnCtrlC=false registers no SIGINT handler", async () => {
+ const before = process.listenerCount("SIGINT");
+
+ const App = defineComponent(() => () => x);
+ const { unmount } = await render(App, { exitOnCtrlC: false });
+
+ expect(process.listenerCount("SIGINT")).toBe(before);
+ unmount();
+});
diff --git a/packages/runtime-tests/integration/public-api.test.ts b/packages/runtime-tests/integration/public-api.test.ts
new file mode 100644
index 0000000..4588695
--- /dev/null
+++ b/packages/runtime-tests/integration/public-api.test.ts
@@ -0,0 +1,24 @@
+import { expect, test } from "vite-plus/test";
+import * as api from "@vue-tui/runtime";
+
+test("public API exposes documented members", () => {
+ for (const k of [
+ "createApp",
+ "Box",
+ "Text",
+ "Newline",
+ "Spacer",
+ "Static",
+ "Transform",
+ "useExit",
+ "useInput",
+ "useFocus",
+ "useFocusManager",
+ "useStdin",
+ "useStdout",
+ "useStderr",
+ "useTerminalSize",
+ ]) {
+ expect(api).toHaveProperty(k);
+ }
+});
diff --git a/packages/runtime-tests/integration/quickstart.test.tsx b/packages/runtime-tests/integration/quickstart.test.tsx
new file mode 100644
index 0000000..4c39965
--- /dev/null
+++ b/packages/runtime-tests/integration/quickstart.test.tsx
@@ -0,0 +1,22 @@
+import { defineComponent, ref } from "vue";
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Box, Text, useInput } from "@vue-tui/runtime";
+
+test("README quickstart code runs to a Count: 0 frame", async () => {
+ const Counter = defineComponent(() => {
+ const count = ref(0);
+ useInput((input) => {
+ if (input === "+") count.value++;
+ if (input === "-") count.value--;
+ });
+ return () => (
+
+ Count: {count.value}
+
+ );
+ });
+
+ const { lastFrame } = await render(Counter);
+ expect(lastFrame()).toContain("Count: 0");
+});
diff --git a/packages/runtime-tests/integration/scheduler.test.tsx b/packages/runtime-tests/integration/scheduler.test.tsx
new file mode 100644
index 0000000..9a6ec63
--- /dev/null
+++ b/packages/runtime-tests/integration/scheduler.test.tsx
@@ -0,0 +1,66 @@
+import { defineComponent, nextTick, ref, watch } from "vue";
+import { expect, test } from "vite-plus/test";
+import { render } from "@vue-tui/testing";
+import { Box, Text } from "@vue-tui/runtime";
+
+test("multiple mutations in one tick produce at most 1 new frame", async () => {
+ const count = ref(0);
+ const App = defineComponent(() => {
+ return () => {String(count.value)};
+ });
+
+ const { frames, lastFrame } = await render(App);
+ const before = frames.length;
+
+ count.value = 1;
+ count.value = 2;
+ count.value = 3;
+ await nextTick();
+ await nextTick();
+
+ expect(frames.length - before).toBeLessThanOrEqual(1);
+ expect(lastFrame()).toContain("3");
+});
+
+test("post-flush watch sees the same value the commit paints", async () => {
+ const count = ref(0);
+ let observed = 0;
+
+ const App = defineComponent(() => {
+ watch(
+ count,
+ (v) => {
+ observed = v;
+ },
+ { flush: "post" },
+ );
+ return () => {String(count.value)};
+ });
+
+ const { lastFrame } = await render(App);
+
+ count.value = 42;
+ await nextTick();
+ await nextTick();
+
+ expect(observed).toBe(42);
+ expect(lastFrame()).toContain("42");
+});
+
+test("resize event schedules a re-render", async () => {
+ const App = defineComponent(() => {
+ return () => (
+
+ x
+
+ );
+ });
+
+ const { frames, terminal } = await render(App, { columns: 80 });
+ const before = frames.length;
+
+ await terminal.resize(120, 40);
+ await nextTick();
+
+ expect(frames.length).toBeGreaterThan(before);
+});
diff --git a/packages/runtime-tests/package.json b/packages/runtime-tests/package.json
new file mode 100644
index 0000000..a712add
--- /dev/null
+++ b/packages/runtime-tests/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@vue-tui/runtime-tests",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "test": "vp test",
+ "check": "vp check"
+ },
+ "devDependencies": {
+ "@types/node": "^25.6.2",
+ "@vitejs/plugin-vue-jsx": "catalog:",
+ "@vue-tui/runtime": "workspace:*",
+ "@vue-tui/testing": "workspace:*",
+ "typescript": "^6.0.3",
+ "vite-plus": "^0.1.20",
+ "vue": "^3.4.0"
+ }
+}
diff --git a/packages/runtime-tests/tsconfig.json b/packages/runtime-tests/tsconfig.json
new file mode 100644
index 0000000..e8347c1
--- /dev/null
+++ b/packages/runtime-tests/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "compilerOptions": {
+ "target": "esnext",
+ "lib": ["es2023"],
+ "moduleDetection": "force",
+ "module": "nodenext",
+ "moduleResolution": "nodenext",
+ "resolveJsonModule": true,
+ "types": ["node"],
+ "strict": true,
+ "noUnusedLocals": true,
+ "noEmit": true,
+ "allowImportingTsExtensions": true,
+ "esModuleInterop": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "skipLibCheck": true,
+ "jsx": "preserve",
+ "jsxImportSource": "vue"
+ }
+}
diff --git a/packages/runtime-tests/vite.config.ts b/packages/runtime-tests/vite.config.ts
new file mode 100644
index 0000000..d59da30
--- /dev/null
+++ b/packages/runtime-tests/vite.config.ts
@@ -0,0 +1,10 @@
+import { defineConfig } from "vite-plus";
+import vueJsx from "@vitejs/plugin-vue-jsx";
+
+export default defineConfig({
+ plugins: [vueJsx()],
+ lint: {
+ options: { typeAware: true, typeCheck: true },
+ },
+ fmt: {},
+});