Files
AgentHub/client/src/components/run/__tests__/TerminalView.test.tsx
T
nntrivi2001 22de5009ff feat: v2.7.8 — Artifacts hub, clone related repos as extra folders, quieter resume prompt
- Artifacts (/artifacts): claude.ai pages sessions publish (captured from the
  Artifact tool's hook), pinned workspace files, skills and plugins, each
  kept as a snapshot; shared ones appear in a Hub and a skill or plugin
  installs into the viewer's own account (server/lib/artifacts.js,
  /api/artifacts, docs/plans/artifacts/).
- Workspace extra folders: clone one or more related repos beside the
  workspace and add them in one step (POST /api/lanes/:id/sources/clone).
- A pane that ended by itself while tmux kept running is no longer offered
  for resume (tmux.serverStartedAt in reconcileOrphans).
- Changes panel: a folder that is not a git repo opens on Files, and its
  files open from there.
- Plus the parallel sessions' work in this tree: File Explorer button in the
  panel header, user delete, RSA SSH keys, chat/terminal and pet fixes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:06:54 +07:00

635 lines
27 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,
* and recolours live on a dark-theme change without re-attaching.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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 inputMock = vi.fn();
// The newest terminal's hidden input, where the browser's IME types.
let textarea: HTMLTextAreaElement;
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,
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() })),
}));
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(<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"
);
});
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("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 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(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
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(<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 })
);
});
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(<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 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");
});
// 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],
]);
});
});