158783d5d5
- Changes panel at full screen: the whole Files view (finder, expand-all, tree) becomes a left sidebar and the Files tab is dropped; a working-copy file opens straight into the editor (Save/Cancel live once the text differs, Cancel restores it, Whole file toggles back to the diff), and the page is height-locked so the editor no longer collapses with no live run. - Overview splits into Staged Changes / Changes groups in the work range. - A changed file's whole tree row takes its status tint. - Drag an image onto the lane terminal: POST /api/run/drop-image saves it (png/jpeg/gif/webp, random name, 0600, pruned after a day) and the path is pasted into the pane, which is how claude attaches it. - Claude Opus 5.5 gets its own pricing row (4/20/0.2/5/8, fast 8/40); claude-opus-5% had been pricing it as Opus 5. - server/lib/pricing-sync.js: a model family used in token_usage with no row of its own is inserted from LiteLLM's public price list, never updated; off with AGENTHUB_PRICING_SYNC=0. repo-links allows exactly that one third-party GitHub raw URL.
351 lines
14 KiB
TypeScript
351 lines
14 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 as outgoing binary frames, and
|
||
* re-attaches after the socket drops (server restart) but not after `exit`,
|
||
* 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, beforeEach, afterEach } from "vitest";
|
||
import { render, cleanup, act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||
import { TerminalView } from "../TerminalView";
|
||
|
||
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 dropImageMock = vi.fn();
|
||
vi.mock("../../../lib/api", () => ({
|
||
api: { run: { dropImage: (f: Blob) => dropImageMock(f) } },
|
||
}));
|
||
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,
|
||
})),
|
||
}));
|
||
// 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: (() => 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", () => {
|
||
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();
|
||
});
|
||
|
||
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 }));
|
||
});
|
||
|
||
it("reports a size change xterm made on its own, with no container resize", () => {
|
||
// 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());
|
||
expect(ws.sent).toEqual([JSON.stringify({ type: "resize", cols: 129, rows: 28 })]);
|
||
});
|
||
|
||
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("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 () => {
|
||
dropImageMock.mockReset().mockResolvedValue({ path: "/tmp/agenthub-drops-1/ab.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 image to attach");
|
||
fireEvent.drop(target, { dataTransfer: { types: ["Files"], files: [img] } });
|
||
await waitFor(() => expect(pasteMock).toHaveBeenCalledWith("/tmp/agenthub-drops-1/ab.png "));
|
||
expect(dropImageMock).toHaveBeenCalledWith(img);
|
||
await waitFor(() => expect(screen.queryByTestId("terminal-drop")).toBeNull());
|
||
});
|
||
|
||
it("uploads nothing for a dropped non-image and says why", async () => {
|
||
dropImageMock.mockReset();
|
||
pasteMock.mockClear();
|
||
render(<TerminalView runId="r1" wsBaseUrl="ws://x" />);
|
||
const txt = new File(["hi"], "a.txt", { type: "text/plain" });
|
||
fireEvent.drop(screen.getByTestId("terminal-view"), {
|
||
dataTransfer: { types: ["Files"], files: [txt] },
|
||
});
|
||
expect(await screen.findByText("Only images can be dropped here")).toBeInTheDocument();
|
||
expect(dropImageMock).not.toHaveBeenCalled();
|
||
expect(pasteMock).not.toHaveBeenCalled();
|
||
});
|
||
});
|