9721941962
lib/wheelReports.ts SCROLL_SPEED = 0.7: a touchpad's travel is scaled, and a wheel notch's reports are carried as a fraction (rounded, so the first notch still moves; reset when the direction turns). xterm's own scrollback gets the same scrollSensitivity.
1058 lines
46 KiB
TypeScript
1058 lines
46 KiB
TypeScript
/**
|
||
* @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 { Terminal } from "@xterm/xterm";
|
||
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 refreshMock = vi.fn();
|
||
/** The atlas the next WebGL addon draws from; null = an addon of another shape. */
|
||
const nextAtlas: { pages: { version: number }[] | null } = { pages: null };
|
||
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,
|
||
refresh: refreshMock,
|
||
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;
|
||
merge: () => void;
|
||
grow: () => void;
|
||
} | null;
|
||
} = {
|
||
last: null,
|
||
};
|
||
vi.mock("@xterm/addon-webgl", () => ({
|
||
WebglAddon: vi.fn().mockImplementation(() => {
|
||
let onLoss = () => {};
|
||
let onMerge = () => {};
|
||
let onGrow = () => {};
|
||
const addon = {
|
||
__webgl: true,
|
||
...(nextAtlas.pages ? { _renderer: { _charAtlas: { pages: nextAtlas.pages } } } : {}),
|
||
dispose: vi.fn(),
|
||
onContextLoss: (fn: () => void) => {
|
||
onLoss = fn;
|
||
return { dispose: vi.fn() };
|
||
},
|
||
onRemoveTextureAtlasCanvas: (fn: () => void) => {
|
||
onMerge = fn;
|
||
return { dispose: vi.fn() };
|
||
},
|
||
onAddTextureAtlasCanvas: (fn: () => void) => {
|
||
onGrow = fn;
|
||
return { dispose: vi.fn() };
|
||
},
|
||
};
|
||
gpu.last = {
|
||
dispose: addon.dispose,
|
||
lose: () => onLoss(),
|
||
merge: () => onMerge(),
|
||
grow: () => onGrow(),
|
||
};
|
||
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);
|
||
});
|
||
|
||
/** Gives the mocked terminal an on-screen box: 10px per column and per row. */
|
||
const screenBox = () => {
|
||
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!
|
||
.value as { element: HTMLElement };
|
||
const screen = document.createElement("div");
|
||
screen.className = "xterm-screen";
|
||
screen.getBoundingClientRect = () =>
|
||
({ left: 0, top: 0, width: termState.cols * 10, height: termState.rows * 10 }) as DOMRect;
|
||
term.element.appendChild(screen);
|
||
};
|
||
|
||
it("sends a tracked wheel itself, one report per LINES_PER_REPORT rows, at the pointer", () => {
|
||
// tmux with `mouse on` sets this. xterm would send ONE report per event and
|
||
// damp a touchpad ×0.3 — a swipe crawled — so the pane sends its own.
|
||
termState.modes.mouseTrackingMode = "any";
|
||
termState.buffer.active.type = "alternate";
|
||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||
screenBox();
|
||
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]!;
|
||
ws.sent.length = 0;
|
||
const wheel = wheelHandlerMock.mock.calls[0]![0]!;
|
||
// A notch of 6 rows: one report, at the cell under the pointer.
|
||
expect(wheel(new WheelEvent("wheel", { deltaY: -60, clientX: 45, clientY: 72 }))).toBe(false);
|
||
expect(ws.sent).toEqual(["\x1b[<64;5;8M"]);
|
||
// A touchpad: 6 steps of 25px = 15 rows, ×0.7 scroll speed = 10.5 rows =
|
||
// two reports, sent as they add up.
|
||
ws.sent.length = 0;
|
||
for (let i = 0; i < 6; i++)
|
||
wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
|
||
expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2));
|
||
});
|
||
|
||
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): sent once, by the tracked path.
|
||
termState.modes.mouseTrackingMode = "any";
|
||
screenBox();
|
||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
|
||
expect(ws.sent).toHaveLength(3);
|
||
});
|
||
|
||
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
|
||
});
|
||
|
||
it("restamps every atlas page at once when the page list changes", () => {
|
||
// addon-webgl 0.19 counts versions per page, so after a merge shifts the
|
||
// pages a texture slot can keep a stale page whose counter matches. Every
|
||
// page gets a version no slot has uploaded — in every pane on the atlas.
|
||
const pages = [{ version: 3 }, { version: 7 }, { version: 1 }];
|
||
nextAtlas.pages = pages;
|
||
const raf = vi.spyOn(window, "requestAnimationFrame");
|
||
try {
|
||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||
gpu.last!.merge();
|
||
const first = pages.map((p) => p.version);
|
||
expect(new Set(first).size).toBe(3);
|
||
expect(first.every((v) => v > 7)).toBe(true);
|
||
gpu.last!.grow();
|
||
expect(pages.every((p, i) => p.version > Math.max(...first) && p.version !== first[i])).toBe(
|
||
true
|
||
);
|
||
expect(raf).not.toHaveBeenCalled(); // synchronous, before any pane draws
|
||
// The page coming back re-uploads and repaints this pane.
|
||
refreshMock.mockClear();
|
||
window.dispatchEvent(new Event("focus"));
|
||
expect(refreshMock).toHaveBeenCalledWith(0, 27);
|
||
} finally {
|
||
nextAtlas.pages = null;
|
||
raf.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("re-acquires the glyph atlas on the next frame after a page merge", () => {
|
||
// An addon whose atlas this cannot reach: any option change makes each
|
||
// renderer re-acquire its atlas and re-upload every page.
|
||
const raf = vi.spyOn(window, "requestAnimationFrame").mockImplementation(() => 1);
|
||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||
const opts = termOptions as { fontFamily?: string };
|
||
opts.fontFamily = "JetBrains Mono";
|
||
gpu.last!.merge();
|
||
gpu.last!.merge(); // coalesced into one frame
|
||
expect(raf).toHaveBeenCalledTimes(1);
|
||
expect(opts.fontFamily).toBe("JetBrains Mono"); // never inside the render that merged
|
||
(raf.mock.calls[0]![0] as FrameRequestCallback)(0);
|
||
expect(opts.fontFamily).toBe("JetBrains Mono ");
|
||
raf.mockRestore();
|
||
});
|
||
|
||
// 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],
|
||
]);
|
||
});
|
||
});
|