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: {}, +});