/** * @file TerminalView.test.tsx * @description Tests for the xterm.js-backed terminal view: verifies it opens * a WS connection to the right URL, writes incoming binary frames to the * mocked terminal, forwards typed input as outgoing binary frames, and * re-attaches after the socket drops (server restart) with backoff, but not * after `exit` or a policy close, * and recolours live on a dark-theme change without re-attaching. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest"; import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react"; import { TerminalView, loadXterm } from "../TerminalView"; import { pasteToLane, pasteToRun } from "../../../lib/panePaste"; const writeMock = vi.fn(); const onDataHandlers: Array<(d: string) => void> = []; const openMock = vi.fn(); const disposeMock = vi.fn(); const wheelHandlerMock = vi.fn(); const keyHandlerMock = vi.fn(); const focusMock = vi.fn(); const pasteMock = vi.fn(); const dropFileMock = vi.fn(); vi.mock("../../../lib/api", () => ({ api: { run: { dropFile: (f: File, lane?: string) => dropFileMock(f, lane) } }, })); const loadAddonMock = vi.fn(); // Mutable stand-ins for the two live terminal facts the wheel handler reads. const termState = { modes: { mouseTrackingMode: "none" as "none" | "x10" | "vt200" | "drag" | "any" }, buffer: { active: { type: "alternate" as "normal" | "alternate" } }, cols: 131, rows: 28, }; const onResizeHandlers: Array<() => void> = []; const termOptions: { theme?: unknown } = {}; const ctorArgs: Array<{ theme?: unknown }> = []; vi.mock("@xterm/xterm", () => ({ Terminal: vi.fn().mockImplementation((opts: { theme?: unknown }) => ({ ...(ctorArgs.push(opts), {}), options: termOptions, get modes() { return termState.modes; }, get buffer() { return termState.buffer; }, get cols() { return termState.cols; }, get rows() { return termState.rows; }, onResize: (fn: () => void) => { onResizeHandlers.push(fn); return { dispose: vi.fn() }; }, open: openMock, write: writeMock, onData: (fn: (d: string) => void) => { onDataHandlers.push(fn); return { dispose: vi.fn() }; }, dispose: disposeMock, loadAddon: loadAddonMock, attachCustomWheelEventHandler: wheelHandlerMock, attachCustomKeyEventHandler: keyHandlerMock, focus: focusMock, paste: pasteMock, resize: (cols: number, rows: number) => Object.assign(termState, { cols, rows }), })), })); // jsdom loads no stylesheet, so stand in for the CSS-variable probe. vi.mock("../../../lib/terminalTheme", () => ({ terminalThemeFor: (id: string) => ({ background: `bg-${id}` }), })); vi.mock("@xterm/addon-fit", () => ({ FitAddon: vi.fn().mockImplementation(() => ({ fit: vi.fn() })), })); vi.mock("@xterm/addon-canvas", () => ({ CanvasAddon: vi.fn().mockImplementation(() => ({ __canvas: true, dispose: vi.fn() })), })); class MockWebSocket { static instances: MockWebSocket[] = []; url: string; sent: unknown[] = []; onopen: (() => void) | null = null; onmessage: ((e: { data: unknown }) => void) | null = null; onclose: ((e?: { code: number }) => void) | null = null; readyState = 1; // OPEN — the component only sends on an open socket static OPEN = 1; constructor(url: string) { this.url = url; MockWebSocket.instances.push(this); } send(data: unknown) { this.sent.push(data); } close() { this.onclose?.(); } } // @ts-expect-error test override global.WebSocket = MockWebSocket; describe("TerminalView", () => { // xterm is a lazy chunk; once loaded every terminal sets up synchronously. beforeAll(() => loadXterm()); beforeEach(() => { termState.modes.mouseTrackingMode = "none"; termState.buffer.active.type = "alternate"; termState.cols = 131; termState.rows = 28; MockWebSocket.instances = []; onDataHandlers.length = 0; onResizeHandlers.length = 0; writeMock.mockClear(); wheelHandlerMock.mockClear(); keyHandlerMock.mockClear(); loadAddonMock.mockClear(); openMock.mockClear(); focusMock.mockClear(); disposeMock.mockClear(); ctorArgs.length = 0; delete termOptions.theme; localStorage.clear(); }); afterEach(cleanup); it("opens a WS connection to the run's ws-pty path, carrying its own size", () => { // The size rides on the attach URL, not on a `resize` message sent after // it: tmux resizes the window to the newest client, so attaching at the // server's default 80x24 makes claude redraw its UI at 80 columns and // leaves that width in the scrollback (issue #33). render(); expect(MockWebSocket.instances).toHaveLength(1); expect(MockWebSocket.instances[0]!.url).toBe( "ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28" ); }); it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => { termState.cols = 0; termState.rows = 0; render(); expect(MockWebSocket.instances[0]!.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1"); }); it("paints the chosen DARK theme and recolours live without re-attaching", () => { localStorage.setItem("theme", "light"); // app in light mode: terminal stays dark localStorage.setItem("agenthub.theme.dark", "apple-dark"); render(); expect(ctorArgs[0]!.theme).toEqual({ background: "bg-apple-dark" }); act(() => { localStorage.setItem("agenthub.theme.dark", "darcula"); window.dispatchEvent(new Event("agenthub-theme")); }); expect(termOptions.theme).toEqual({ background: "bg-darcula" }); expect(MockWebSocket.instances).toHaveLength(1); expect(disposeMock).not.toHaveBeenCalled(); }); it("writes incoming WS data to the terminal", () => { render(); const ws = MockWebSocket.instances[0]!; ws.onopen?.(); ws.onmessage?.({ data: "hello" }); expect(writeMock).toHaveBeenCalledWith("hello"); }); it("decodes binary ArrayBuffer frames (server sends PTY output as binary)", () => { render(); const ws = MockWebSocket.instances[0]!; ws.onopen?.(); const bytes = new TextEncoder().encode("hello-binary").buffer; ws.onmessage?.({ data: bytes }); expect(writeMock).toHaveBeenCalledWith("hello-binary"); }); it("forwards terminal keystrokes as outgoing WS sends", () => { render(); const ws = MockWebSocket.instances[0]!; onDataHandlers[0]!("ls -la\r"); expect(ws.sent).toEqual(["ls -la\r"]); }); it("swallows wheel events xterm would otherwise turn into arrow keys", () => { termState.modes.mouseTrackingMode = "none"; termState.buffer.active.type = "alternate"; render(); expect(wheelHandlerMock).toHaveBeenCalledTimes(1); // false = xterm skips its alt-screen wheel→cursor-key emulation, which in a // claude pane would walk the prompt history instead of scrolling. expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false); }); it("lets the wheel through once the pane's program tracks the mouse", () => { // tmux with `mouse on` sets this; the wheel must reach it as a real SGR // report, so returning false here would kill scrolling entirely. termState.modes.mouseTrackingMode = "any"; termState.buffer.active.type = "alternate"; render(); expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(true); }); it("lets the wheel through on a normal buffer, where xterm scrolls scrollback", () => { termState.modes.mouseTrackingMode = "none"; termState.buffer.active.type = "normal"; render(); expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(true); }); it("treats x10 tracking as no wheel tracking (x10 never reports the wheel)", () => { termState.modes.mouseTrackingMode = "x10"; termState.buffer.active.type = "alternate"; render(); expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false); }); it("keeps Shift+Tab in the terminal instead of letting focus escape", () => { render(); const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean; const shiftTab = new KeyboardEvent("keydown", { key: "Tab", shiftKey: true }); const preventShiftTab = vi.spyOn(shiftTab, "preventDefault"); // true = xterm still processes it and sends ESC[Z; only the browser's // focus-moving default is suppressed. expect(handler(shiftTab)).toBe(true); expect(preventShiftTab).toHaveBeenCalled(); const plainTab = new KeyboardEvent("keydown", { key: "Tab" }); const preventPlainTab = vi.spyOn(plainTab, "preventDefault"); expect(handler(plainTab)).toBe(true); expect(preventPlainTab).not.toHaveBeenCalled(); }); // WCAG 2.1.2: the terminal keeps Tab, so Ctrl+M must let the keyboard out. it("lets Ctrl+M toggle Tab into moving focus out of the terminal", () => { render(); const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean; const ctrlM = () => new KeyboardEvent("keydown", { key: "m", ctrlKey: true }); // Not sent to the pane. act(() => { expect(handler(ctrlM())).toBe(false); }); expect(screen.getByTestId("terminal-tab-moves")).toBeInTheDocument(); // false = xterm neither sends nor cancels it: the browser moves focus. const tab = new KeyboardEvent("keydown", { key: "Tab" }); const prevent = vi.spyOn(tab, "preventDefault"); expect(handler(tab)).toBe(false); expect(prevent).not.toHaveBeenCalled(); expect(handler(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true }))).toBe(false); act(() => { handler(ctrlM()); }); expect(screen.queryByTestId("terminal-tab-moves")).toBeNull(); expect(handler(new KeyboardEvent("keydown", { key: "Tab" }))).toBe(true); }); it("opens find on Ctrl+Shift+F or Cmd+F, never on plain Ctrl+F (readline's forward-char)", () => { render(); const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean; expect(handler(new KeyboardEvent("keydown", { key: "f", ctrlKey: true }))).toBe(true); expect(screen.queryByTestId("terminal-find")).toBeNull(); act(() => { expect( handler(new KeyboardEvent("keydown", { key: "F", ctrlKey: true, shiftKey: true })) ).toBe(false); }); expect(screen.getByTestId("terminal-find")).toBeInTheDocument(); cleanup(); render(); const mac = keyHandlerMock.mock.calls[keyHandlerMock.mock.calls.length - 1]![0]! as ( e: KeyboardEvent ) => boolean; act(() => { expect(mac(new KeyboardEvent("keydown", { key: "f", metaKey: true }))).toBe(false); }); expect(screen.getByTestId("terminal-find")).toBeInTheDocument(); }); it("says it is reconnecting while the socket is down, and clears it on reopen", () => { render(); const ws = MockWebSocket.instances[0]!; act(() => ws.onopen?.()); expect(screen.queryByTestId("terminal-reconnecting")).toBeNull(); act(() => ws.onclose?.()); expect(screen.getByTestId("terminal-reconnecting")).toHaveTextContent("Reconnecting"); }); it("takes no paste while its socket is not open, so the text is not dropped unseen", () => { const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(800); try { render(); const ws = MockWebSocket.instances[0]!; ws.readyState = 0; // CONNECTING expect(pasteToRun("agenthub-lane-7", "hello")).toBe(false); ws.readyState = 1; act(() => ws.onopen?.()); expect(pasteToRun("agenthub-lane-7", "hello")).toBe(true); ws.readyState = 3; // CLOSED: Reconnecting… act(() => ws.onclose?.()); expect(pasteToRun("agenthub-lane-7", "hello")).toBe(false); } finally { width.mockRestore(); } }); it("takes the workspace's paste once it is the focused pane, clicked into or not", () => { const width = vi.spyOn(HTMLElement.prototype, "offsetWidth", "get").mockReturnValue(800); try { const both = (focus: number) => ( <> ); const { rerender } = render(both(0)); expect(pasteToLane(9, "a")).toBe("agenthub-lane-9"); // Alt+2, a tab click: the Workspace moves the focus, the DOM may not. rerender(both(1)); expect(pasteToLane(9, "a")).toBe("agenthub-lane-9-1"); } finally { width.mockRestore(); } }); it("takes the keyboard as soon as it mounts", () => { // The terminal IS the page here — without this the first thing typed after // opening a lane went nowhere until the user clicked the pane. render(); expect(focusMock).toHaveBeenCalled(); }); it("tells its owner when the pane exited, so the setup form can come back", () => { // A double Ctrl+C out of `claude` ends the tmux session; the dead pane used // to sit there until the user found the "New run" button. const onExit = vi.fn(); render( ); MockWebSocket.instances[0]!.onmessage?.({ data: '{"type":"exit","code":0}' }); expect(onExit).toHaveBeenCalledTimes(1); }); it("renders through the canvas addon, not xterm's DOM renderer", () => { // The DOM renderer aligns a row with one `letter-spacing`, which holds only // while every glyph shares a font. Vietnamese diacritics and CJK fall to a // fallback face with a different advance, so glyphs shove their neighbours // and the row's tail spills onto the row below — buffer clean, painting // wrong. The canvas renderer draws each cell at a fixed position instead. render(); expect(loadAddonMock.mock.calls.some(([a]) => (a as { __canvas?: boolean }).__canvas)).toBe( true ); }); describe("size reporting (tmux must never be wider than the terminal)", () => { // A tmux pane wider than xterm wraps each over-long row's tail onto the // next row's left margin, and tmux never repaints a row it believes // unchanged — the stray characters glued to the left edge after scrolling. it("reports its size when the socket opens", () => { render(); const ws = MockWebSocket.instances[0]!; ws.onopen?.(); expect(ws.sent).toContain( JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false }) ); }); it("reports a size change xterm made on its own, with no container resize", async () => { // Webfont load and page zoom both move cols without resizing the element // ResizeObserver watches, which is why onResize is the hook. render(); const ws = MockWebSocket.instances[0]!; ws.onopen?.(); ws.sent.length = 0; termState.cols = 129; onResizeHandlers.forEach((fn) => fn()); // A burst (a divider drag) reaches tmux once, with the last size. termState.cols = 127; onResizeHandlers.forEach((fn) => fn()); expect(ws.sent).toEqual([]); await new Promise((r) => setTimeout(r, 200)); expect(ws.sent).toEqual([ JSON.stringify({ type: "resize", cols: 127, rows: 28, drive: false }), ]); }); it("follows another browser's size until a keystroke here takes the lead", () => { // Two browsers of different sizes on one pane: the one not in use renders // the driver's grid instead of resizing tmux under it. const w = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(800); const h = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(600); try { render(); const ws = MockWebSocket.instances[0]!; ws.onopen?.(); ws.onmessage?.({ data: '{"type":"size","cols":90,"rows":20}' }); expect([termState.cols, termState.rows]).toEqual([90, 20]); ws.sent.length = 0; onDataHandlers.forEach((fn) => fn("\x1b[<64;5;5M")); // a wheel notch expect(ws.sent).toEqual(["\x1b[<64;5;5M"]); onDataHandlers.forEach((fn) => fn("a")); expect(ws.sent[1]).toBe( JSON.stringify({ type: "resize", cols: 90, rows: 20, drive: true }) ); expect(ws.sent[2]).toBe("a"); } finally { w.mockRestore(); h.mockRestore(); } }); it("never reports a collapsed console's zero size", () => { // The Workspace console collapses with CSS, so the container can be 0×0 // while mounted; resizing the real tmux pane to that would wreck it. render(); const ws = MockWebSocket.instances[0]!; ws.sent.length = 0; termState.cols = 0; termState.rows = 0; onResizeHandlers.forEach((fn) => fn()); ws.onopen?.(); expect(ws.sent).toEqual([]); }); }); it("re-attaches after the socket drops (a server restart must not freeze the pane)", () => { vi.useFakeTimers(); render(); MockWebSocket.instances[0]!.close(); vi.advanceTimersByTime(1600); expect(MockWebSocket.instances).toHaveLength(2); vi.useRealTimers(); }); it("backs off between failed re-attaches, and stops on a policy close (1008)", () => { vi.useFakeTimers(); render(); MockWebSocket.instances[0]!.close(); vi.advanceTimersByTime(1600); expect(MockWebSocket.instances).toHaveLength(2); MockWebSocket.instances[1]!.close(); // failed again: now 3 s vi.advanceTimersByTime(1600); expect(MockWebSocket.instances).toHaveLength(2); vi.advanceTimersByTime(1500); expect(MockWebSocket.instances).toHaveLength(3); act(() => MockWebSocket.instances[2]!.onclose?.({ code: 1008 })); vi.advanceTimersByTime(60_000); expect(MockWebSocket.instances).toHaveLength(3); vi.useRealTimers(); }); it("does not re-attach once the server reported the pane exited", () => { vi.useFakeTimers(); render(); const ws = MockWebSocket.instances[0]!; ws.onmessage?.({ data: '{"type":"exit","code":0}' }); ws.close(); vi.advanceTimersByTime(5000); expect(MockWebSocket.instances).toHaveLength(1); vi.useRealTimers(); }); it("pastes the server-side path of a dropped image into the pane", async () => { dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-shot.png" }); pasteMock.mockClear(); render(); const img = new File([new Uint8Array([137, 80])], "shot.png", { type: "image/png" }); const target = screen.getByTestId("terminal-view"); fireEvent.dragEnter(target, { dataTransfer: { types: ["Files"], files: [img] } }); expect(screen.getByTestId("terminal-drop")).toHaveTextContent("Drop files or images to attach"); fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } }); await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-shot.png ") ); // The pane's run id: saved where that pane can read it. expect(dropFileMock).toHaveBeenCalledWith(img, "r1"); await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull()); }); it("pastes every path of several dropped files, images or not", async () => { dropFileMock .mockReset() .mockImplementation(async (f: File) => ({ path: `/tmp/agenthub-drops-1/x-${f.name}` })); pasteMock.mockClear(); render(); const txt = new File(["hi"], "a.txt", { type: "text/plain" }); const img = new File(["x"], "b.png", { type: "image/png" }); fireEvent.drop(screen.getByTestId("terminal-view"), { dataTransfer: { types: ["Files"], files: [txt, img] }, }); await waitFor(() => expect(pasteMock).toHaveBeenCalledWith( "/tmp/agenthub-drops-1/x-a.txt /tmp/agenthub-drops-1/x-b.png " ) ); }); });