// @vitest-environment happy-dom import { describe, expect, it, vi } from "vitest"; import { COMMAND_SPECS, CommandSet, commandSpecs, DEFAULT_LEADER, canonicalizeKey, controlByte, defineCommands, isBarTypingTarget, isTerminalTextarea, isTypingTarget, keyCaps, publishedCommands, type Command, } from "./commands.js"; function key(k: string, init: KeyboardEventInit = {}): KeyboardEvent { return new KeyboardEvent("keydown", { key: k, cancelable: true, ...init }); } function el(html: string): HTMLElement { const d = document.createElement("div"); d.innerHTML = html; return d.firstElementChild as HTMLElement; } /** Dispatch on a target, so `isTypingTarget` sees something other than null. */ function pressOn(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit): boolean { const e = key(k, init); Object.defineProperty(e, "target", { value: target }); return set.handle(e); } describe("command specs", () => { it("has a unique id and at least one chord per command", () => { const ids = COMMAND_SPECS.map((s) => s.id); expect(new Set(ids).size).toBe(ids.length); for (const spec of COMMAND_SPECS) { expect(spec.keys.length).toBeGreaterThan(0); expect(spec.label.length).toBeGreaterThan(0); } }); it("binds each chord once within a scope", () => { const seen = new Set(); for (const spec of COMMAND_SPECS) { for (const k of spec.keys) { const slot = `${spec.scope}:${k}`; expect(seen.has(slot)).toBe(false); seen.add(slot); } } }); it("rejects a handler for an id that isn't in the registry", () => { expect(() => defineCommands({ "nope.missing": () => {} })).toThrow(/unknown command id/); }); it("carries every spec field onto the defined command", () => { const [cmd] = defineCommands({ "spawn.open": () => {} }); expect(cmd).toMatchObject({ id: "spawn.open", keys: ["n"], scope: "global" }); }); }); describe("canonicalizeKey", () => { it("spells keys the way the TUI's InputKey::canonical does", () => { expect(canonicalizeKey(key("n"))).toBe("n"); expect(canonicalizeKey(key("N", { shiftKey: true }))).toBe("N"); expect(canonicalizeKey(key("?", { shiftKey: true }))).toBe("?"); expect(canonicalizeKey(key("a", { ctrlKey: true }))).toBe("ctrl-a"); expect(canonicalizeKey(key("Escape"))).toBe("esc"); expect(canonicalizeKey(key("Enter"))).toBe("enter"); expect(canonicalizeKey(key("Tab"))).toBe("tab"); expect(canonicalizeKey(key("Tab", { shiftKey: true }))).toBe("backtab"); expect(canonicalizeKey(key("ArrowDown"))).toBe("down"); }); it("folds a modified letter to lower case with an explicit shift-", () => { expect(canonicalizeKey(key("H", { ctrlKey: true, shiftKey: true }))).toBe("ctrl-shift-h"); expect(canonicalizeKey(key("h", { ctrlKey: true, shiftKey: true }))).toBe("ctrl-shift-h"); }); it("has no canonical form for a modifier pressed alone", () => { expect(canonicalizeKey(key("Control", { ctrlKey: true }))).toBeNull(); expect(canonicalizeKey(key("Shift", { shiftKey: true }))).toBeNull(); }); }); describe("keyCaps", () => { it("renders one cap per modifier and key", () => { expect(keyCaps("ctrl-shift-h")).toEqual(["Ctrl", "Shift", "H"]); expect(keyCaps("enter")).toEqual(["Enter"]); expect(keyCaps("j")).toEqual(["j"]); expect(keyCaps("N")).toEqual(["N"]); expect(keyCaps("?")).toEqual(["?"]); expect(keyCaps("ctrl-a ctrl-k")).toEqual(["Ctrl", "A", "then", "Ctrl", "K"]); }); }); describe("CommandSet", () => { it("runs the command bound to a chord, and swallows the browser default", () => { const run = vi.fn(); const set = new CommandSet(defineCommands({ "spawn.open": run })); const e = key("n"); expect(set.handle(e)).toBe(true); expect(run).toHaveBeenCalledTimes(1); expect(e.defaultPrevented).toBe(true); }); it("leaves an unbound key alone", () => { const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() })); const e = key("q"); expect(set.handle(e)).toBe(false); expect(e.defaultPrevented).toBe(false); }); it("treats a modified key as a different binding", () => { const run = vi.fn(); const set = new CommandSet(defineCommands({ "spawn.open": run })); expect(set.handle(key("n", { ctrlKey: true }))).toBe(false); expect(set.handle(key("n", { metaKey: true }))).toBe(false); expect(run).not.toHaveBeenCalled(); }); it("runs every alias of a command", () => { const run = vi.fn(); const set = new CommandSet(defineCommands({ "list.next": run })); set.handle(key("j")); set.handle(key("ArrowDown")); expect(run).toHaveBeenCalledTimes(2); }); it("keeps plain keys deaf inside a form field, and leader chords live from the pane", () => { const plain = vi.fn(); const chorded = vi.fn(); const set = new CommandSet( defineCommands({ "spawn.open": plain, "focus.toggle": chorded }, DEFAULT_LEADER), { leader: DEFAULT_LEADER }, ); const input = el(""); expect(pressOn(set, input, "n")).toBe(false); expect(plain).not.toHaveBeenCalled(); // focus.toggle hangs off the leader (#166): a plain, unmodified Escape is // left for the pane's shell/app instead. const terminal = el(''); pressOn(set, terminal, "a", { ctrlKey: true }); expect(pressOn(set, terminal, "h")).toBe(true); expect(chorded).toHaveBeenCalledTimes(1); }); it("leaves a plain Escape for the pane instead of binding it", () => { const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() })); const textarea = el(''); expect(pressOn(set, textarea, "Escape")).toBe(false); }); }); describe("the leader", () => { it("defaults to the TUI's ctrl-a", () => { expect(DEFAULT_LEADER).toBe("ctrl-a"); }); // #159: the leader is the operator's, served on `/api/info`. Every chord // behind it is built from it, so a rebound leader carries its escape hatches // with it instead of leaving them on a stale ctrl-a. it("builds the chords behind it from the configured leader", () => { const byId = new Map(commandSpecs("ctrl-b").map((s) => [s.id, s])); expect(byId.get("pane.literalCtrlK")?.keys).toEqual(["ctrl-b ctrl-k"]); expect(byId.get("pane.literalLeader")?.keys).toEqual(["ctrl-b ctrl-b"]); expect(byId.get("focus.toggle")?.keys).toEqual(["ctrl-b h"]); // Commands that do not hang off the leader are untouched. expect(byId.get("bar.open")?.keys).toEqual(["ctrl-k"]); }); it("dispatches a configured leader's chord and not the default's", () => { const literalCtrlK = vi.fn(); const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literalCtrlK }, "ctrl-b"), { leader: "ctrl-b", }); set.handle(key("b", { ctrlKey: true })); expect(set.handle(key("k", { ctrlKey: true }))).toBe(true); expect(literalCtrlK).toHaveBeenCalledTimes(1); // The old leader is an ordinary key again: it holds nothing, and the chord // that used to follow it runs nothing. set.handle(key("a", { ctrlKey: true })); set.handle(key("k", { ctrlKey: true })); expect(literalCtrlK).toHaveBeenCalledTimes(1); }); it("sends the configured leader's own byte for the doubled chord", () => { expect(controlByte("ctrl-b")).toBe("\x02"); }); it("holds the leader as a partial chord instead of selecting the page", () => { const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), { leader: DEFAULT_LEADER, }); const e = key("a", { ctrlKey: true }); expect(set.handle(e)).toBe(true); expect(e.defaultPrevented).toBe(true); }); it("swallows the key after the leader rather than running its plain binding", () => { const run = vi.fn(); const set = new CommandSet(defineCommands({ "spawn.open": run }), { leader: DEFAULT_LEADER, }); set.handle(key("a", { ctrlKey: true })); expect(set.handle(key("n"))).toBe(true); expect(run).not.toHaveBeenCalled(); // The next key is an ordinary one again. set.handle(key("n")); expect(run).toHaveBeenCalledTimes(1); }); it("forgets a held leader once the chord timeout passes", () => { const run = vi.fn(); let now = 0; const set = new CommandSet(defineCommands({ "spawn.open": run }), { leader: DEFAULT_LEADER, chordTimeoutMs: 1000, now: () => now, }); set.handle(key("a", { ctrlKey: true })); now = 1500; set.handle(key("n")); expect(run).toHaveBeenCalledTimes(1); }); it("leaves select-all alone in a real form field", () => { const set = new CommandSet(defineCommands({ "spawn.open": vi.fn() }), { leader: DEFAULT_LEADER, }); const input = el(""); const e = key("a", { ctrlKey: true }); Object.defineProperty(e, "target", { value: input }); expect(set.handle(e)).toBe(false); expect(e.defaultPrevented).toBe(false); }); }); describe("publishing", () => { it("lists a constructed set's commands, replacing an id rather than doubling it", () => { new CommandSet(defineCommands({ "list.kill": () => {} })); new CommandSet(defineCommands({ "list.kill": () => {} })); const ids = publishedCommands().map((c) => c.id); expect(ids.filter((id) => id === "list.kill")).toEqual(["list.kill"]); }); it("keeps published commands in registry order", () => { const order = COMMAND_SPECS.map((s) => s.id); const ids = publishedCommands().map((c) => c.id); expect(ids).toEqual(order.filter((id) => ids.includes(id))); }); }); describe("isTypingTarget", () => { it("is true for form fields and editable nodes, false otherwise", () => { expect(isTypingTarget(el(""))).toBe(true); expect(isTypingTarget(el(""))).toBe(true); expect(isTypingTarget(el(""))).toBe(true); expect(isTypingTarget(el('
'))).toBe(true); expect(isTypingTarget(el(""))).toBe(false); expect(isTypingTarget(null)).toBe(false); }); }); describe("isTerminalTextarea", () => { it("recognises xterm's offscreen textarea, and nothing else", () => { expect(isTerminalTextarea(el(''))).toBe(true); expect(isTerminalTextarea(el(""))).toBe(false); expect(isTerminalTextarea(el(""))).toBe(false); expect(isTerminalTextarea(null)).toBe(false); }); }); describe("isBarTypingTarget", () => { it("holds the bar's keys out of real form fields", () => { expect(isBarTypingTarget(el(""))).toBe(true); expect(isBarTypingTarget(el(""))).toBe(true); }); it("does not count the terminal's textarea, so ctrl-k reaches the bar there", () => { expect(isBarTypingTarget(el(''))).toBe(false); expect(isBarTypingTarget(el(""))).toBe(false); expect(isBarTypingTarget(null)).toBe(false); }); }); describe("ctrl-k over a focused pane (#152)", () => { const terminal = () => el(''); /** The global listener's routing: the bar's set only sees a key that is not * aimed at a real form field. */ function route(set: CommandSet, target: HTMLElement, k: string, init?: KeyboardEventInit) { if (isBarTypingTarget(target)) return false; return pressOn(set, target, k, init); } it("opens the bar when the terminal has the keyboard", () => { const open = vi.fn(); const set = new CommandSet(defineCommands({ "bar.open": open })); expect(route(set, terminal(), "k", { ctrlKey: true })).toBe(true); expect(open).toHaveBeenCalledTimes(1); }); it("leaves the spawn form's inputs their own Ctrl-K", () => { const open = vi.fn(); const set = new CommandSet(defineCommands({ "bar.open": open })); expect(route(set, el(""), "k", { ctrlKey: true })).toBe(false); expect(open).not.toHaveBeenCalled(); }); it("sends the literal 0x0B after the leader, without opening the bar", () => { const open = vi.fn(); const literal = vi.fn(); const bar = new CommandSet(defineCommands({ "bar.open": open })); const set = new CommandSet(defineCommands({ "pane.literalCtrlK": literal }), { leader: DEFAULT_LEADER, }); const target = terminal(); expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true); const e = key("k", { ctrlKey: true }); Object.defineProperty(e, "target", { value: target }); expect(set.handle(e)).toBe(true); expect(literal).toHaveBeenCalledTimes(1); // The leader's set consumed the key, so the bar's set never sees it. expect(open).not.toHaveBeenCalled(); expect(bar.handle(e)).toBe(true); // sanity: it would have opened otherwise expect(open).toHaveBeenCalledTimes(1); }); it("binds the escape hatch behind the default leader", () => { const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalCtrlK"); expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ctrl-k`]); }); }); describe("leader pressed twice (#158)", () => { it("sends one literal leader byte and clears the pending chord", () => { const literal = vi.fn(); const run = vi.fn(); const set = new CommandSet( defineCommands({ "pane.literalLeader": literal, "spawn.open": run }), { leader: DEFAULT_LEADER }, ); const target = el(''); expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true); expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(true); expect(literal).toHaveBeenCalledTimes(1); // The chord is cleared, not still held: the next key is ordinary again. expect(set.handle(key("n"))).toBe(true); expect(run).toHaveBeenCalledTimes(1); }); it("binds the escape hatch behind the default leader", () => { const spec = COMMAND_SPECS.find((s) => s.id === "pane.literalLeader"); expect(spec?.keys).toEqual([`${DEFAULT_LEADER} ${DEFAULT_LEADER}`]); }); // Not just the default: the chord is resolved from whatever leader this // set was constructed with, so a rebound leader carries the escape hatch // with it — the same proof #156 wrote for the TUI. The web has no config // for this yet (#159), but the mechanism must not be hardcoded to ctrl-a. it("follows a rebound leader, not just the default", () => { const literal = vi.fn(); const rebound: Command = { id: "pane.literalLeader", label: "Send a literal leader key to the pane", group: "Pane", scope: "global", keys: ["ctrl-b ctrl-b"], run: literal, }; const set = new CommandSet([rebound], { leader: "ctrl-b" }); const target = el(''); expect(pressOn(set, target, "b", { ctrlKey: true })).toBe(true); expect(pressOn(set, target, "b", { ctrlKey: true })).toBe(true); expect(literal).toHaveBeenCalledTimes(1); // The old leader no longer does anything special. expect(pressOn(set, target, "a", { ctrlKey: true })).toBe(false); }); }); describe("controlByte", () => { it("computes the raw control byte from a plain ctrl chord", () => { expect(controlByte("ctrl-a")).toBe("\x01"); expect(controlByte("ctrl-k")).toBe("\x0b"); }); it("rejects anything that isn't a single plain ctrl chord", () => { expect(() => controlByte("ctrl-shift-a")).toThrow(); expect(() => controlByte("a")).toThrow(); expect(() => controlByte("ctrl-a ctrl-a")).toThrow(); }); });