Files
AgentHub/client/src/components/run/__tests__/TerminalView.test.tsx
T
nntrivi2001 a0cd808456 fix(terminal): a pane on a hidden tab never shrinks its tmux window
A terminal mounted on a hidden tab sits in a 0x0 box; its first fit shrank
xterm to 12x6 and the attach carried that size to tmux, so every background
pane was squeezed (Claude's TUI redrawn at 12 columns). It now neither fits
nor reports a size until shown, attaches with no size, and the server spawns
such an attach at the window's own size (tmux.windowSize). A shown tab
reports its size even when the fit did not change xterm's.

Settings > Agents shows the host's login profiles to staff only: a developer
got two 403s from reads that were never theirs.
2026-10-26 14:53:29 +07:00

964 lines
42 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @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 (Safari Telex word replacements
* included) as outgoing binary frames, and
* re-attaches after the socket drops (server restart) with backoff, but not
* after `exit` or a policy close,
* recolours live on a dark-theme change without re-attaching, draws through
* the WebGL addon (DOM renderer again on a lost context) and keeps xterm 5's
* Alt+arrow word moves.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, onTestFinished } from "vitest";
import {
render,
cleanup,
act,
createEvent,
fireEvent,
screen,
waitFor,
} from "@testing-library/react";
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
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 inputMock = vi.fn();
// The newest terminal's hidden input, where the browser's IME types.
let textarea: HTMLTextAreaElement;
const dropFileMock = vi.fn();
const localPathsMock = vi.fn();
const nativePasteMock = vi.fn(() => Promise.resolve({ native: true }));
type Held = { files: { path: string; size: number | null; dir: boolean }[] };
const clipboardFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
const selectedFilesMock = vi.fn((): Promise<Held> => Promise.resolve({ files: [] }));
vi.mock("../../../lib/api", () => ({
api: {
run: {
dropFile: (f: File, lane?: string) => dropFileMock(f, lane),
localPaths: (paths: string[]) => localPathsMock(paths),
nativePaste: () => nativePasteMock(),
clipboardFiles: () => clipboardFilesMock(),
selectedFiles: () => selectedFilesMock(),
},
},
}));
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,
input: inputMock,
textarea: (textarea = document.createElement("textarea")),
// xterm's own element, holding its textarea (lib/imeInput listens on it).
element: document.createElement("div").appendChild(textarea).parentElement,
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() })),
}));
// The newest WebGL addon, with the context-loss listener the view gave it.
const gpu: { last: { dispose: ReturnType<typeof vi.fn>; lose: () => void } | null } = {
last: null,
};
vi.mock("@xterm/addon-webgl", () => ({
WebglAddon: vi.fn().mockImplementation(() => {
let onLoss = () => {};
const addon = {
__webgl: true,
dispose: vi.fn(),
onContextLoss: (fn: () => void) => {
onLoss = fn;
return { dispose: vi.fn() };
},
};
gpu.last = { dispose: addon.dispose, lose: () => onLoss() };
return addon;
}),
}));
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;
/** Give every element a real box (jsdom's are 0×0, like a terminal on a hidden
* tab); returns the restore. */
function withBox(width = 800, height = 600) {
const w = vi.spyOn(HTMLElement.prototype, "clientWidth", "get").mockReturnValue(width);
const h = vi.spyOn(HTMLElement.prototype, "clientHeight", "get").mockReturnValue(height);
return () => {
w.mockRestore();
h.mockRestore();
};
}
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).
const restore = withBox();
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(MockWebSocket.instances).toHaveLength(1);
expect(MockWebSocket.instances[0]!.url).toBe(
"ws://localhost:4820/ws-pty/agenthub-lane-1?cols=131&rows=28"
);
} finally {
restore();
}
});
it("a terminal on a hidden tab sends no size until it is shown", () => {
// Its box is 0×0 (display:none): fitting there shrank xterm to 12×6 and
// that size resized the real tmux pane under every other client.
let observed: (() => void) | null = null;
const RO = globalThis.ResizeObserver;
// @ts-expect-error test override
globalThis.ResizeObserver = class {
constructor(cb: () => void) {
observed = cb;
}
observe() {}
disconnect() {}
};
vi.useFakeTimers();
let restore = () => {};
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
expect(ws.url).toBe("ws://localhost:4820/ws-pty/agenthub-lane-1");
ws.onopen?.();
expect(ws.sent).toEqual([]);
// The tab is shown: the box gets a size and its size reaches tmux.
restore = withBox();
observed!();
vi.advanceTimersByTime(200);
expect(ws.sent).toEqual([
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false }),
]);
} finally {
restore();
vi.useRealTimers();
globalThis.ResizeObserver = RO;
}
});
it("omits the size when the terminal has none yet (a collapsed console is 0x0)", () => {
termState.cols = 0;
termState.rows = 0;
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
});
it("sends the wheel itself once the server says its tmux never tracks the mouse", () => {
// psmux (Windows) never turns mouse tracking on for its client, so xterm
// never reports the wheel and the pane could not be scrolled at all.
termState.modes.mouseTrackingMode = "none";
termState.buffer.active.type = "alternate";
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]!;
ws.onmessage?.({ data: '{"type":"wheel"}' });
const wheel = wheelHandlerMock.mock.calls[wheelHandlerMock.mock.calls.length - 1]![0]!;
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
expect(wheel(new WheelEvent("wheel", { deltaY: 100 }))).toBe(false);
expect(ws.sent).toEqual(["\x1b[<64;1;1M", "\x1b[<65;1;1M"]);
// Tracked again (a tmux that does report): xterm sends it, never twice.
termState.modes.mouseTrackingMode = "any";
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(true);
expect(ws.sent).toHaveLength(2);
});
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="agenthub-lane-7" wsBaseUrl="ws://localhost:4820" />);
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) => (
<>
<TerminalView runId="agenthub-lane-9" wsBaseUrl="ws://x" autoFocus={focus === 0} />
<TerminalView runId="agenthub-lane-9-1" wsBaseUrl="ws://x" autoFocus={focus === 1} />
</>
);
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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(
<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" onExit={onExit} />
);
MockWebSocket.instances[0]!.onmessage?.({ data: '{"type":"exit","code":0}' });
expect(onExit).toHaveBeenCalledTimes(1);
});
it("renders through the WebGL 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 WebGL renderer draws each cell at a fixed position instead
// (xterm 6 removed the canvas one).
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
expect(loadAddonMock.mock.calls.some(([a]) => (a as { __webgl?: boolean }).__webgl)).toBe(
true
);
});
it("falls back to the DOM renderer when the browser takes the WebGL context back", () => {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const addon = gpu.last!;
expect(addon.dispose).not.toHaveBeenCalled();
addon.lose();
expect(addon.dispose).toHaveBeenCalledTimes(1);
expect(disposeMock).not.toHaveBeenCalled(); // the terminal itself lives on
});
// xterm 6 dropped its own Alt+arrow → word-move mapping; the pane keeps it.
it("sends Alt+arrow as the word-move keys xterm 5 sent", () => {
expect(altArrowSeq("ArrowLeft", false)).toBe("\x1b[1;5D");
expect(altArrowSeq("ArrowRight", false)).toBe("\x1b[1;5C");
expect(altArrowSeq("ArrowUp", false)).toBe("\x1b[1;5A");
expect(altArrowSeq("ArrowDown", false)).toBe("\x1b[1;5B");
expect(altArrowSeq("ArrowLeft", true)).toBe("\x1bb");
expect(altArrowSeq("ArrowRight", true)).toBe("\x1bf");
expect(altArrowSeq("ArrowUp", true)).toBeNull();
expect(altArrowSeq("a", false)).toBeNull();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
const ev = new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, cancelable: true });
expect(handler(ev)).toBe(false);
expect(ev.defaultPrevented).toBe(true);
expect(inputMock).toHaveBeenCalledWith(altArrowSeq("ArrowLeft"), true);
// Another modifier with it is xterm's own sequence.
expect(
handler(new KeyboardEvent("keydown", { key: "ArrowLeft", altKey: true, shiftKey: true }))
).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", () => {
const restore = withBox();
try {
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
const ws = MockWebSocket.instances[0]!;
ws.onopen?.();
expect(ws.sent).toContain(
JSON.stringify({ type: "resize", cols: 131, rows: 28, drive: false })
);
} finally {
restore();
}
});
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.
const restore = withBox();
onTestFinished(restore);
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
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 a local file manager's own paths, uploading nothing", async () => {
dropFileMock.mockReset();
localPathsMock.mockReset().mockResolvedValue({ sizes: [1] });
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
const big = new File(["x"], "demo.mp4", { type: "video/mp4" });
const data: Record<string, string> = {
"text/uri-list": "file:///home/u/My%20Videos/demo.mp4",
};
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { types: ["Files"], files: [big], getData: (k: string) => data[k] ?? "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith('@"/home/u/My Videos/demo.mp4" '));
expect(localPathsMock).toHaveBeenCalledWith(["/home/u/My Videos/demo.mp4"]);
expect(dropFileMock).not.toHaveBeenCalled();
});
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(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
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 "
)
);
});
it("pastes the text of a dragged Files-panel row, uploading nothing", () => {
dropFileMock.mockReset();
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const data: Record<string, string> = {
"application/x-agenthub-attach": '{"path":"@src/a.ts","name":"a.ts"}',
"text/plain": "src/a.ts",
};
const dataTransfer = {
types: Object.keys(data),
files: [],
getData: (k: string) => data[k] ?? "",
};
fireEvent.dragEnter(screen.getByTestId("terminal-view"), { dataTransfer });
expect(screen.getByTestId("terminal-drop")).toHaveTextContent(
"Drop to paste into the terminal"
);
fireEvent.drop(screen.getByTestId("terminal-view"), { dataTransfer });
expect(pasteMock).toHaveBeenCalledWith("src/a.ts ");
expect(screen.queryByTestId("terminal-drop")).toBeNull();
expect(dropFileMock).not.toHaveBeenCalled();
});
it("uploads a pasted image and pastes its path, like a drop", async () => {
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
await waitFor(() =>
expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab-image.png ")
);
expect(dropFileMock).toHaveBeenCalledWith(img, "r1");
});
it("hands a local claude pane's pasted image to claude as ^V, uploading nothing", async () => {
__resetNativePaste();
dropFileMock.mockReset();
inputMock.mockClear();
pasteMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
expect(inputMock).toHaveBeenCalledWith("\x16", true);
expect(dropFileMock).not.toHaveBeenCalled();
expect(pasteMock).not.toHaveBeenCalled();
});
// Claude Code on Windows binds its image paste to Alt+V; a ^V did nothing.
it("sends Alt+V instead of ^V when the dashboard runs on Windows", async () => {
__resetNativePaste();
nativePasteMock.mockResolvedValueOnce({ native: true, key: "alt+v" } as never);
dropFileMock.mockReset();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
expect(inputMock).toHaveBeenCalledWith("\x1bv", true);
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
expect(dropFileMock).not.toHaveBeenCalled();
});
// No wl-paste/xclip (or no display) on the dashboard's machine: ^V would
// paste nothing, so the screenshot is uploaded like a remote browser's.
it("uploads a claude pane's pasted image when the machine cannot read its clipboard", async () => {
__resetNativePaste();
nativePasteMock.mockResolvedValueOnce({ native: false });
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteMock).toHaveBeenCalled());
await Promise.resolve();
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(img, "r1"));
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
});
it("uploads a claude pane's pasted image once native paste is turned off", async () => {
localStorage.setItem("agenthub.terminal.nativePaste", "off");
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-image.png" });
inputMock.mockClear();
try {
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
const img = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [img], getData: () => "" },
});
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(img, "r1"));
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
} finally {
localStorage.removeItem("agenthub.terminal.nativePaste");
}
});
// A file manager's Copy reaches Chrome on Windows as bytes and a name only:
// the pane gets the file's own path off the OS clipboard — no upload, so
// no size limit (a 3 GB video too), as a native terminal pastes it.
it("pastes a copied file by the path the OS clipboard holds, uploading nothing", async () => {
__resetNativePaste();
dropFileMock.mockReset();
pasteMock.mockClear();
inputMock.mockClear();
clipboardFilesMock.mockResolvedValueOnce({
files: [{ path: "C:\\Users\\u\\Videos\\demo.mp4", size: 3_000_000_000, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const video = new File([new Uint8Array([1])], "demo.mp4", { type: "video/mp4" });
Object.defineProperty(video, "size", { value: 3_000_000_000 });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [video], getData: () => "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("@C:/Users/u/Videos/demo.mp4 "));
expect(dropFileMock).not.toHaveBeenCalled();
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
});
// Chrome hands a file dragged from Explorer no path; the drag carries the
// items selected there, so the pane gets their own paths — as a native
// terminal types them — and nothing is uploaded, whatever the size.
it("drops a file manager's file by the path selected there, uploading nothing", async () => {
dropFileMock.mockReset();
pasteMock.mockClear();
selectedFilesMock.mockResolvedValueOnce({
files: [{ path: "C:\\Users\\u\\Videos\\big.mkv", size: 5_000_000_000, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
const video = new File([new Uint8Array([1])], "big.mkv");
Object.defineProperty(video, "size", { value: 5_000_000_000 });
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { files: [video], types: ["Files"], getData: () => "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("@C:/Users/u/Videos/big.mkv "));
expect(dropFileMock).not.toHaveBeenCalled();
});
it("uploads a dropped file the file manager does not hold selected", async () => {
dropFileMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab-notes.txt" });
selectedFilesMock.mockResolvedValueOnce({
files: [{ path: "/home/u/other.txt", size: 1, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="codex" />);
const notes = new File(["x"], "notes.txt");
fireEvent.drop(screen.getByTestId("terminal-view"), {
dataTransfer: { files: [notes], types: ["Files"], getData: () => "" },
});
await waitFor(() => expect(dropFileMock).toHaveBeenCalledWith(notes, "r1"));
});
// A copied image FILE is not a screenshot: claude's own paste finds no
// picture on a clipboard holding a file, so it goes by path too.
it("pastes a copied image file by path instead of claude's image paste", async () => {
__resetNativePaste();
dropFileMock.mockReset();
pasteMock.mockClear();
inputMock.mockClear();
clipboardFilesMock.mockResolvedValueOnce({
files: [{ path: "/home/u/Pictures/logo.png", size: 2, dir: false }],
});
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const logo = new File([new Uint8Array([137, 80])], "logo.png", { type: "image/png" });
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [logo], getData: () => "" },
});
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/home/u/Pictures/logo.png "));
expect(inputMock).not.toHaveBeenCalledWith("\x16", true);
expect(dropFileMock).not.toHaveBeenCalled();
});
// Cells from a spreadsheet come with a picture of them; a terminal pastes
// the text, so the browser's paste is left to xterm.
it("leaves text that comes with a picture of it to xterm's paste", async () => {
__resetNativePaste();
dropFileMock.mockReset();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" tool="claude" />);
await waitFor(() => expect(nativePasteWorks()).toBe(true));
const pic = new File([new Uint8Array([137, 80])], "image.png", { type: "image/png" });
const ev = createEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [pic], getData: (t: string) => (t === "text/plain" ? "a\tb" : "") },
});
fireEvent(screen.getByTestId("terminal-view"), ev);
expect(ev.defaultPrevented).toBe(false);
expect(inputMock).not.toHaveBeenCalled();
expect(dropFileMock).not.toHaveBeenCalled();
});
// Ctrl+V is how claude pastes an image; xterm would send ^V, and claude then
// reads the SERVER's clipboard, not the browser's. Text on the clipboard is
// pasted like any browser paste: sending ^V for it pasted nothing.
it("lets Ctrl+V reach the browser's paste: text is pasted, ^V only for an empty clipboard", () => {
vi.useFakeTimers();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
expect(handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }))).toBe(false);
// Not cancelled: xterm's own paste listener takes the text.
expect(
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [], getData: () => "some text" },
})
).toBe(true);
vi.advanceTimersByTime(1);
expect(inputMock).not.toHaveBeenCalled();
handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }));
fireEvent.paste(screen.getByTestId("terminal-view"), {
clipboardData: { files: [], getData: () => "" },
});
vi.advanceTimersByTime(1);
expect(inputMock).toHaveBeenCalledTimes(1);
expect(inputMock).toHaveBeenCalledWith("\x16", true);
vi.useRealTimers();
});
it("sends ^V on Ctrl+V when the browser fires no paste at all", () => {
vi.useFakeTimers();
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
handler(new KeyboardEvent("keydown", { key: "v", ctrlKey: true }));
vi.advanceTimersByTime(1);
expect(inputMock).toHaveBeenCalledTimes(1);
expect(inputMock).toHaveBeenCalledWith("\x16", true);
vi.useRealTimers();
});
// ^Z suspended claude: the pane fell to its shell and the session looked
// closed. Ctrl+Z is undo in a browser, so it sends Ctrl+_ instead.
it("turns Ctrl+Z into undo (Ctrl+_) and never sends ^Z, which would suspend the session", () => {
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const handler = keyHandlerMock.mock.calls[0]![0]! as (e: KeyboardEvent) => boolean;
const ev = new KeyboardEvent("keydown", { key: "z", ctrlKey: true, cancelable: true });
expect(handler(ev)).toBe(false);
expect(ev.defaultPrevented).toBe(true);
expect(inputMock).toHaveBeenCalledWith("\x1f", true);
expect(inputMock).not.toHaveBeenCalledWith("\x1a", expect.anything());
// Other chords with Z are left to xterm (Ctrl+Shift+Z, plain z).
expect(handler(new KeyboardEvent("keydown", { key: "Z", ctrlKey: true, shiftKey: true }))).toBe(
true
);
expect(handler(new KeyboardEvent("keydown", { key: "z" }))).toBe(true);
});
// Safari + macOS's own Vietnamese Telex: the first letter is an insertText,
// every later key REPLACES the word — the event order captured on a Mac.
it("forwards Safari Telex's word replacements as backspaces plus the new tail", () => {
inputMock.mockClear();
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
const type = (value: string) => {
textarea.dispatchEvent(new InputEvent("beforeinput", { inputType: "insertReplacementText" }));
textarea.value = value;
textarea.dispatchEvent(new InputEvent("input", { inputType: "insertReplacementText" }));
};
textarea.value = "x"; // xterm itself sent this one (insertText)
type("xi");
type("xin");
textarea.value = "xin vie";
type("xin viet");
type("xin viêt");
type("xin việt");
expect(inputMock.mock.calls).toEqual([
["i", true],
["n", true],
["t", true],
["\x7f\x7fêt", true],
["\x7f\x7fệt", true],
]);
});
});