Files
vue-tui/packages/runtime-tests/integration/kitty-lifecycle.test.ts
T
Yunfei He ca63a5d04c refactor(runtime)!: prefix host primitive tags with tui- (align to Ink, drop *Impl)
The renderer's intrinsic elements were named with bare words (box/text/static/
transform/virtual-text), which collide with the same-named public components: a
template `<box>` PascalCase-resolves to `<Box>` under vue-tsc (no isCustomElement
at the type layer), forcing the BoxImpl/TextImpl/StaticImpl workaround.

Prefix the 5 host elements to `tui-*` (mirroring Ink's `ink-box`/`ink-text`):
the prefix + hyphen keeps them in their own namespace, so the components keep
their real names (Box/Text/Static) with no self-recursion — the *Impl rename is
removed. root/text-leaf/comment stay unprefixed (not template tags, not elements).

Mechanics: renamed the TuiNode discriminant literals + factories first, then let
vue-tsc enumerate all 145 stale `node.type === "box"` comparisons (the type-
driven finder also kept `position: "static"` and the ansi-tokenizer's separate
`type: "text"` union untouched). Updated createElement cases, HOST_TAGS,
the .vue templates, transform.ts h(), and raw `h("box")` host-op tests.

Two non-type-checked contaminations the sed caused were caught by tests and fixed:
- patchProp's `key === "transform"` (the PROP name, not the node type) must stay
  "transform" — the sed wrongly prefixed it, dropping the transform fn (identity).
- text-measure's `token.type === "text"` is an AnsiToken, not a TuiNode — reverted.

BREAKING CHANGE: the internal host element names are now tui-box/tui-text/
tui-virtual-text/tui-static/tui-transform. Public components (Box/Text/Static/
Spacer/Newline/Transform) and their props/types are unchanged; only raw host-op
callers (h("box") -> h("tui-box")) are affected.

vp run ready green: fmt, lint 0/0, vue-tsc, tests (runtime 350, integration 1161,
PTY 129).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 15:49:08 +08:00

506 lines
17 KiB
TypeScript

import { describe, test, expect, vi } from "vite-plus/test";
import EventEmitter from "node:events";
import {
createKittyKeyboardController,
matchKittyQueryResponse,
hasCompleteKittyQueryResponse,
stripKittyQueryResponsesAndTrailingPartial,
resolveFlags,
} from "@vue-tui/runtime/internal";
import { createApp, useInput } from "@vue-tui/runtime";
import { defineComponent, h } from "vue";
const textEncoder = new TextEncoder();
function createFakeStdout() {
const stdout = new EventEmitter() as unknown as NodeJS.WriteStream;
stdout.columns = 100;
(stdout as any).isTTY = true;
const written: string[] = [];
stdout.write = ((data: string) => {
written.push(data);
return true;
}) as typeof stdout.write;
return { stdout, written };
}
function createFakeStdin() {
const stdin = new EventEmitter() as unknown as NodeJS.ReadStream;
(stdin as any).isTTY = true;
(stdin as any).setRawMode = vi.fn();
(stdin as any).setEncoding = vi.fn();
(stdin as any).read = vi.fn();
const unshifted: Uint8Array[] = [];
stdin.unshift = ((chunk: Uint8Array) => {
unshifted.push(Uint8Array.from(chunk));
return true;
}) as typeof stdin.unshift;
return { stdin, unshifted };
}
describe("kitty query/response matching", () => {
test("matchKittyQueryResponse detects complete response", () => {
const buf = [...textEncoder.encode("\x1b[?1u")];
const match = matchKittyQueryResponse(buf, 0);
expect(match).toEqual({ state: "complete", endIndex: 4 });
});
test("matchKittyQueryResponse detects partial response", () => {
const buf = [...textEncoder.encode("\x1b[?1")];
const match = matchKittyQueryResponse(buf, 0);
expect(match).toEqual({ state: "partial" });
});
test("matchKittyQueryResponse returns undefined for non-match", () => {
const buf = [...textEncoder.encode("hello")];
expect(matchKittyQueryResponse(buf, 0)).toBeUndefined();
});
test("matchKittyQueryResponse returns undefined without digits", () => {
const buf = [...textEncoder.encode("\x1b[?u")];
expect(matchKittyQueryResponse(buf, 0)).toBeUndefined();
});
test("hasCompleteKittyQueryResponse finds response in buffer", () => {
const buf = [...textEncoder.encode("abc\x1b[?1udef")];
expect(hasCompleteKittyQueryResponse(buf)).toBe(true);
});
test("stripKittyQueryResponsesAndTrailingPartial removes responses", () => {
const buf = [...textEncoder.encode("a\x1b[?1ub")];
expect(stripKittyQueryResponsesAndTrailingPartial(buf)).toEqual([...textEncoder.encode("ab")]);
});
test("stripKittyQueryResponsesAndTrailingPartial removes trailing partial", () => {
const buf = [...textEncoder.encode("a\x1b[?1")];
expect(stripKittyQueryResponsesAndTrailingPartial(buf)).toEqual([...textEncoder.encode("a")]);
});
test("resolveFlags computes correct bitmask", () => {
expect(resolveFlags(["disambiguateEscapeCodes"])).toBe(1);
expect(resolveFlags(["disambiguateEscapeCodes", "reportEventTypes"])).toBe(3);
});
});
describe("kitty lifecycle - init/cleanup", () => {
test("writes enable sequence when mode is enabled", () => {
const { stdout, written } = createFakeStdout();
const { stdin } = createFakeStdin();
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "enabled" }, true);
expect(written).toContain("\x1b[>1u");
expect(ctrl.isEnabled).toBe(true);
ctrl.dispose();
});
test("writes disable sequence on dispose", () => {
const { stdout, written } = createFakeStdout();
const { stdin } = createFakeStdin();
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "enabled" }, true);
ctrl.dispose();
expect(written).toContain("\x1b[<u");
expect(ctrl.isEnabled).toBe(false);
});
test("not enabled when stdin is not TTY", () => {
const { stdout, written } = createFakeStdout();
const { stdin } = createFakeStdin();
(stdin as any).isTTY = false;
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "enabled" }, true);
expect(written).not.toContain("\x1b[>1u");
expect(ctrl.isEnabled).toBe(false);
ctrl.dispose();
});
test("not enabled when stdout is not TTY", () => {
const { stdout, written } = createFakeStdout();
(stdout as any).isTTY = false;
const { stdin } = createFakeStdin();
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "enabled" }, true);
expect(written).not.toContain("\x1b[>1u");
ctrl.dispose();
});
});
describe("kitty lifecycle - opt-in behavior", () => {
test("no-op when kittyKeyboard is absent", () => {
const { stdout, written } = createFakeStdout();
const { stdin } = createFakeStdin();
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init(undefined, true);
expect(written.filter((s) => s.includes("\x1b[>"))).toHaveLength(0);
expect(ctrl.isEnabled).toBe(false);
ctrl.dispose();
});
test("no-op when mode is disabled", () => {
const { stdout, written } = createFakeStdout();
const { stdin } = createFakeStdin();
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "disabled" }, true);
expect(written.filter((s) => s.includes("\x1b[>"))).toHaveLength(0);
expect(ctrl.isEnabled).toBe(false);
ctrl.dispose();
});
});
describe("kitty lifecycle - custom flags", () => {
test("enabled mode with custom flags writes correct bitmask", () => {
const { stdout, written } = createFakeStdout();
const { stdin } = createFakeStdin();
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "enabled", flags: ["disambiguateEscapeCodes", "reportEventTypes"] }, true);
expect(written).toContain("\x1b[>3u");
ctrl.dispose();
});
test("auto mode with custom flags passes them through", () => {
const { stdout } = createFakeStdout();
const { stdin } = createFakeStdin();
const writtenStrings: string[] = [];
stdout.write = ((data: string) => {
writtenStrings.push(data);
if (data === "\x1b[?u") {
stdin.emit("data", "\x1b[?1u");
}
return true;
}) as typeof stdout.write;
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "auto", flags: ["disambiguateEscapeCodes", "reportEventTypes"] }, true);
expect(writtenStrings).toContain("\x1b[>3u");
ctrl.dispose();
});
});
describe("kitty lifecycle - auto-detection", () => {
test("enables protocol when terminal responds", () => {
const { stdout, written } = createFakeStdout();
const { stdin } = createFakeStdin();
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "auto" }, true);
stdin.emit("data", "\x1b[?1u");
expect(written).toContain("\x1b[>1u");
expect(ctrl.isEnabled).toBe(true);
ctrl.dispose();
});
test("handles synchronous query response", () => {
const { stdout } = createFakeStdout();
const { stdin } = createFakeStdin();
const writtenStrings: string[] = [];
stdout.write = ((data: string) => {
writtenStrings.push(data);
if (data === "\x1b[?u") {
stdin.emit("data", "\x1b[?1u");
}
return true;
}) as typeof stdout.write;
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "auto" }, true);
expect(writtenStrings).toContain("\x1b[>1u");
ctrl.dispose();
});
test("handles Uint8Array response", () => {
const { stdout, written } = createFakeStdout();
const { stdin } = createFakeStdin();
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "auto" }, true);
stdin.emit("data", textEncoder.encode("\x1b[?1u"));
expect(written).toContain("\x1b[>1u");
ctrl.dispose();
});
test("does not enable after dispose", () => {
const { stdout, written } = createFakeStdout();
const { stdin } = createFakeStdin();
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "auto" }, true);
ctrl.dispose();
stdin.emit("data", "\x1b[?1u");
expect(written.filter((s) => s === "\x1b[>1u")).toHaveLength(0);
});
test("preserves split UTF-8 input bytes", async () => {
const { stdout } = createFakeStdout();
const { stdin, unshifted } = createFakeStdin();
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "auto" }, true);
stdin.emit("data", new Uint8Array([0xf0, 0x9f]));
stdin.emit("data", new Uint8Array([0x92, 0xa9]));
await new Promise((r) => setTimeout(r, 250));
const allBytes: number[] = [];
for (const chunk of unshifted) {
for (const b of chunk) allBytes.push(b);
}
expect(allBytes).toEqual([0xf0, 0x9f, 0x92, 0xa9]);
ctrl.dispose();
});
test("timeout does not leak partial query response", async () => {
const { stdout } = createFakeStdout();
const { stdin, unshifted } = createFakeStdin();
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "auto" }, true);
stdin.emit("data", "\x1b[?1");
await new Promise((r) => setTimeout(r, 250));
expect(unshifted).toHaveLength(0);
ctrl.dispose();
});
test("timeout preserves query prefix without digits", async () => {
const { stdout, written } = createFakeStdout();
const { stdin, unshifted } = createFakeStdin();
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "auto" }, true);
stdin.emit("data", "\x1b[?");
await new Promise((r) => setTimeout(r, 250));
expect(written.filter((s) => s === "\x1b[>1u")).toHaveLength(0);
expect(unshifted.map((c) => [...c])).toEqual([[0x1b, 0x5b, 0x3f]]);
ctrl.dispose();
});
test("ignores response without digits", async () => {
const { stdout, written } = createFakeStdout();
const { stdin, unshifted } = createFakeStdin();
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "auto" }, true);
stdin.emit("data", "\x1b[?u");
await new Promise((r) => setTimeout(r, 250));
expect(written.filter((s) => s === "\x1b[>1u")).toHaveLength(0);
expect(unshifted.map((c) => [...c])).toEqual([[0x1b, 0x5b, 0x3f, 0x75]]);
ctrl.dispose();
});
test("preserves invalid query-like escape sequence", async () => {
const { stdout, written } = createFakeStdout();
const { stdin, unshifted } = createFakeStdin();
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "auto" }, true);
stdin.emit("data", "\x1b[?1x");
await new Promise((r) => setTimeout(r, 250));
expect(written.filter((s) => s === "\x1b[>1u")).toHaveLength(0);
expect(unshifted.map((c) => [...c])).toEqual([[0x1b, 0x5b, 0x3f, 0x31, 0x78]]);
ctrl.dispose();
});
test("response \\x1b[?0u is valid support confirmation", () => {
const { stdout, written } = createFakeStdout();
const { stdin } = createFakeStdin();
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "auto" }, true);
stdin.emit("data", "\x1b[?0u");
expect(written).toContain("\x1b[>1u");
ctrl.dispose();
});
test("split response across two data chunks", () => {
const { stdout, written } = createFakeStdout();
const { stdin } = createFakeStdin();
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "auto" }, true);
stdin.emit("data", "\x1b[?");
stdin.emit("data", "1u");
expect(written).toContain("\x1b[>1u");
ctrl.dispose();
});
test("non-query bytes interleaved with response are re-emitted", () => {
const { stdout } = createFakeStdout();
const { stdin, unshifted } = createFakeStdin();
const ctrl = createKittyKeyboardController(stdin, stdout);
ctrl.init({ mode: "auto" }, true);
stdin.emit("data", "a\x1b[?1ub");
const allBytes: number[] = [];
for (const chunk of unshifted) {
for (const b of chunk) allBytes.push(b);
}
expect(allBytes).toEqual([0x61, 0x62]);
ctrl.dispose();
});
});
// --- Render-level integration tests ---
const Dummy = defineComponent(() => () => null);
describe("kitty lifecycle - mount/unmount integration", () => {
test("mount with kittyKeyboard enabled writes enable sequence", () => {
const { stdout, written } = createFakeStdout();
const { stdin } = createFakeStdin();
const app = createApp(Dummy);
app.mount({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
kittyKeyboard: { mode: "enabled" },
});
expect(written).toContain("\x1b[>1u");
app.unmount();
});
test("unmount writes disable sequence", () => {
const { stdout, written } = createFakeStdout();
const { stdin } = createFakeStdin();
const app = createApp(Dummy);
app.mount({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
kittyKeyboard: { mode: "enabled" },
});
app.unmount();
expect(written).toContain("\x1b[<u");
});
test("mount without kittyKeyboard does not write sequences", () => {
const { stdout, written } = createFakeStdout();
const { stdin } = createFakeStdin();
const app = createApp(Dummy);
app.mount({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
});
expect(written.filter((s) => s.includes("\x1b[>"))).toHaveLength(0);
app.unmount();
});
});
// --- Query-response must never reach a useInput handler (Layer 2 regression) ---
//
// vue-tui filters kitty query-responses (ESC[?Nu) in TWO places:
// Layer 1 — the one-shot auto-detection onData listener in kitty-keyboard.ts
// (a faithful port of Ink's ink.tsx detection): strips responses
// from ITS OWN private buffer and unshifts the non-query remainder.
// Layer 2 — parseKeypress returns {ignore:true} for ESC[?Nu; useInput drops it.
//
// Layer 1 alone does NOT cover the real input pipeline
// (stdin 'data' → inputParser → emitInput → useInput → parseKeypress):
// * In `enabled` mode no detection listener exists at all.
// * In `auto` mode the detection onData and the controller's handleData are
// both subscribed to the SAME 'data' event, so stripping Layer 1's private
// buffer doesn't stop the chunk reaching handleData.
// * After detection settles, the detection listener is gone entirely.
// In every case the query-response flows to parseKeypress, so Layer 2 is
// load-bearing — Ink lacks it (a documented additive divergence). These tests
// lock that: with Layer 2 removed they fail (handler sees a spurious "[?1u").
function mountWithInput(kittyKeyboard: { mode: "auto" | "enabled" }) {
const { stdout } = createFakeStdout();
const { stdin } = createFakeStdin();
(stdin as any).read = vi.fn(() => null);
(stdin as any).ref = vi.fn();
(stdin as any).unref = vi.fn();
const inputs: string[] = [];
const App = defineComponent(() => {
useInput((input) => {
inputs.push(input);
});
return () => h("tui-text", null, "x");
});
const app = createApp(App);
app.mount({
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
kittyKeyboard,
});
return { app, stdin, inputs };
}
describe("kitty query-response - end-to-end filtering", () => {
test("enabled mode: stray query-response never reaches useInput", () => {
// No auto-detection runs in `enabled` mode, so Layer 1 is absent here.
const { app, stdin, inputs } = mountWithInput({ mode: "enabled" });
stdin.emit("data", "\x1b[?1u");
expect(inputs).toEqual([]);
app.unmount();
});
test("auto mode: query-response during detection never reaches useInput", () => {
// Detection onData and the controller's handleData both see this chunk.
const { app, stdin, inputs } = mountWithInput({ mode: "auto" });
stdin.emit("data", "\x1b[?1u");
expect(inputs).toEqual([]);
app.unmount();
});
test("auto mode: query-response after detection settled never reaches useInput", () => {
const { app, stdin, inputs } = mountWithInput({ mode: "auto" });
stdin.emit("data", "\x1b[?1u"); // settles detection; removes Layer 1 listener
inputs.length = 0;
stdin.emit("data", "\x1b[?1u"); // stray, late response
expect(inputs).toEqual([]);
app.unmount();
});
test("enabled mode: query-response split across two chunks never reaches useInput", () => {
// inputParser reassembles "\x1b[?" + "1u" into a full CSI sequence before
// dispatch, so Layer 2 (not Layer 1's trailing-partial logic) is what filters it.
const { app, stdin, inputs } = mountWithInput({ mode: "enabled" });
stdin.emit("data", "\x1b[?");
stdin.emit("data", "1u");
expect(inputs).toEqual([]);
app.unmount();
});
});