f77049c238
Terminal (issue #33): - `convertEol` is gone from xterm. It rewrote every bare LF as CR+LF, so a tmux pane that scrolled (tmux emits a bare LF and keeps drawing at the column it was on) had the rest of that draw land at column 0, stamping a line's first characters into the left margin of the rows below — the torn left edge that only showed up once content scrolled. - The attach carries the client's real size (`?cols=&rows=`, `sizeFromQuery` in `server/websocket.js`, bounded because it reaches a TIOCSWINSZ ioctl). `attach()` spawned `tmux attach-session` at a hardcoded 80x24 on every attach, and tmux sizes the window to the newest client, so `claude` redrew its whole UI at 80 columns until the first `resize` message landed. - The terminal takes the keyboard on mount, so typing reaches tmux without clicking the pane first. - A pane that ends (double Ctrl+C, `exit`, a crash) reports itself through a new `onExit` prop and `LaneConsolePane` runs `newRun()`, so the console returns to the setup form instead of leaving a dead pane behind a "New run" button. Pane environment: - `server/lib/desktop-env.js` forwards the graphical session's DISPLAY / WAYLAND_DISPLAY / XAUTHORITY / XDG_RUNTIME_DIR (from `systemctl --user show-environment`, this process's own values winning) into the pane. The server normally runs as a systemd user service with none of them, so `claude` there could not read the clipboard and answered "no image found in clipboard" on every image paste. Run setup: - `auto` is a permission mode and is now the default everywhere — allowlists, the `--permission-mode` fallback, the select, and the pane's initial state. - The model picker sends the CLI's aliases (`fable`/`opus`/`sonnet`/`haiku`) instead of pinned ids. `claude --model` resolves an alias to the latest model itself, so a release like Fable 5.1 needs no edit here; pricing is unaffected because it keys off the resolved id the transcript reports. - **Create/update readme file** moved from the Add-lane modal into the lane's Tools menu: creating a lane and writing its docs are separate decisions, and the second is worth re-running long after the first. - The Workspace's no-lane state is a real banner; the lane strip (add tile included) renders only once a lane exists. Refs #32, #33. Tests: npm run test:server (1828 pass), client vitest (558 pass).
290 lines
12 KiB
TypeScript
290 lines
12 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`.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||
import { render, cleanup } 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 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> = [];
|
||
|
||
vi.mock("@xterm/xterm", () => ({
|
||
Terminal: vi.fn().mockImplementation(() => ({
|
||
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,
|
||
})),
|
||
}));
|
||
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();
|
||
});
|
||
|
||
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("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();
|
||
});
|
||
});
|