77ca6e0188
The previous `attachCustomWheelEventHandler(() => false)` killed scrolling outright. xterm's `bindMouse` consults that handler in BOTH wheel paths — the one that emits a real SGR mouse report and the fallback that turns a notch on an alt-screen buffer into ESC[A/ESC[B — so a blanket false also blocked tmux's own wheel scrolling (tmux runs with `mouse on`, i.e. tracking is active). Now the handler returns false only for the exact bad case: no wheel tracking AND an alternate buffer. `x10` counts as untracked because that protocol reports button presses only, never the wheel, so xterm takes the emulation path for it too.
184 lines
7.0 KiB
TypeScript
184 lines
7.0 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();
|
|
// 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" } },
|
|
};
|
|
|
|
vi.mock("@xterm/xterm", () => ({
|
|
Terminal: vi.fn().mockImplementation(() => ({
|
|
get modes() {
|
|
return termState.modes;
|
|
},
|
|
get buffer() {
|
|
return termState.buffer;
|
|
},
|
|
open: openMock,
|
|
write: writeMock,
|
|
onData: (fn: (d: string) => void) => {
|
|
onDataHandlers.push(fn);
|
|
return { dispose: vi.fn() };
|
|
},
|
|
dispose: disposeMock,
|
|
loadAddon: vi.fn(),
|
|
attachCustomWheelEventHandler: wheelHandlerMock,
|
|
attachCustomKeyEventHandler: keyHandlerMock,
|
|
})),
|
|
}));
|
|
vi.mock("@xterm/addon-fit", () => ({
|
|
FitAddon: vi.fn().mockImplementation(() => ({ fit: 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;
|
|
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";
|
|
MockWebSocket.instances = [];
|
|
onDataHandlers.length = 0;
|
|
writeMock.mockClear();
|
|
wheelHandlerMock.mockClear();
|
|
keyHandlerMock.mockClear();
|
|
openMock.mockClear();
|
|
});
|
|
|
|
afterEach(cleanup);
|
|
|
|
it("opens a WS connection to the run's ws-pty path", () => {
|
|
render(<TerminalView runId="ccam-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
|
expect(MockWebSocket.instances).toHaveLength(1);
|
|
expect(MockWebSocket.instances[0]!.url).toBe("ws://localhost:4820/ws-pty/ccam-lane-1");
|
|
});
|
|
|
|
it("writes incoming WS data to the terminal", () => {
|
|
render(<TerminalView runId="ccam-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="ccam-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="ccam-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="ccam-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="ccam-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="ccam-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="ccam-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="ccam-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("re-attaches after the socket drops (a server restart must not freeze the pane)", () => {
|
|
vi.useFakeTimers();
|
|
render(<TerminalView runId="ccam-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="ccam-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();
|
|
});
|
|
});
|