From 9b8d9bbe397ae8e98549188a0b05e191dba1e369 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Wed, 12 Aug 2026 10:19:10 +0700 Subject: [PATCH] feat(run): add TerminalView xterm.js component for the PTY transport - TerminalView.tsx: xterm.js component with WebSocket attachment to /ws-pty/:runId - Test: validates WS connection URL, incoming terminal data, and outgoing keystrokes - Added ResizeObserver stub to test-setup.ts for jsdom environment --- client/src/components/run/TerminalView.tsx | 77 +++++++++++++++++ .../run/__tests__/TerminalView.test.tsx | 84 +++++++++++++++++++ client/src/test-setup.ts | 7 ++ 3 files changed, 168 insertions(+) create mode 100644 client/src/components/run/TerminalView.tsx create mode 100644 client/src/components/run/__tests__/TerminalView.test.tsx diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx new file mode 100644 index 0000000..bb48548 --- /dev/null +++ b/client/src/components/run/TerminalView.tsx @@ -0,0 +1,77 @@ +/** + * @file TerminalView.tsx + * @description Renders one lane's live terminal — a real `xterm.js` instance + * attached via WebSocket to the server's `/ws-pty/:runId` path (see + * server/lib/pty-attach.js), which is itself a `node-pty`-backed + * `tmux attach-session`. Binary WS frames are raw PTY bytes in both + * directions; a JSON text frame carries the initial `resize` on mount and + * the server's one-shot `exit` notice when the pane process ends. + * @author Nguyễn Ngọc Trí Vĩ + */ +import { useEffect, useRef } from "react"; +import { Terminal } from "@xterm/xterm"; +import { FitAddon } from "@xterm/addon-fit"; +import "@xterm/xterm/css/xterm.css"; + +interface TerminalViewProps { + runId: string; + wsBaseUrl: string; +} + +export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) { + const containerRef = useRef(null); + + useEffect(() => { + const term = new Terminal({ convertEol: true, fontSize: 13, cursorBlink: true }); + const fit = new FitAddon(); + term.loadAddon(fit); + if (containerRef.current) term.open(containerRef.current); + fit.fit(); + + const ws = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`); + + ws.onopen = () => { + ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); + }; + ws.onmessage = (event) => { + if (typeof event.data === "string") { + // Binary PTY output arrives as text here too (the browser WS API + // decodes non-Blob/ArrayBuffer frames as strings) — a JSON control + // frame is the only thing that starts with `{"type"`. + if (event.data.startsWith('{"type"')) { + try { + const msg = JSON.parse(event.data); + if (msg.type === "exit") { + term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`); + } + return; + } catch { + /* not JSON — fall through and render as PTY output */ + } + } + term.write(event.data); + } + }; + + const dataDisposable = term.onData((data) => { + if (ws.readyState === WebSocket.OPEN) ws.send(data); + }); + + const resizeObserver = new ResizeObserver(() => { + fit.fit(); + if (ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); + } + }); + if (containerRef.current) resizeObserver.observe(containerRef.current); + + return () => { + resizeObserver.disconnect(); + dataDisposable.dispose(); + ws.close(); + term.dispose(); + }; + }, [runId, wsBaseUrl]); + + return
; +} diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx new file mode 100644 index 0000000..3dfb73d --- /dev/null +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -0,0 +1,84 @@ +/** + * @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, and forwards typed input as outgoing binary frames. + * @author Nguyễn Ngọc Trí Vĩ + */ +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(); + +vi.mock("@xterm/xterm", () => ({ + Terminal: vi.fn().mockImplementation(() => ({ + open: openMock, + write: writeMock, + onData: (fn: (d: string) => void) => { + onDataHandlers.push(fn); + return { dispose: vi.fn() }; + }, + dispose: disposeMock, + loadAddon: vi.fn(), + })), +})); +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(() => { + MockWebSocket.instances = []; + onDataHandlers.length = 0; + writeMock.mockClear(); + openMock.mockClear(); + }); + + afterEach(cleanup); + + it("opens a WS connection to the run's ws-pty path", () => { + render(); + 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(); + const ws = MockWebSocket.instances[0]; + ws.onopen?.(); + ws.onmessage?.({ data: "hello" }); + expect(writeMock).toHaveBeenCalledWith("hello"); + }); + + it("forwards terminal keystrokes as outgoing WS sends", () => { + render(); + const ws = MockWebSocket.instances[0]; + onDataHandlers[0]("ls -la\r"); + expect(ws.sent).toEqual(["ls -la\r"]); + }); +}); diff --git a/client/src/test-setup.ts b/client/src/test-setup.ts index ea0c636..8a750c9 100644 --- a/client/src/test-setup.ts +++ b/client/src/test-setup.ts @@ -16,6 +16,13 @@ import { afterEach, beforeEach } from "vitest"; import "./i18n/index"; import i18n from "i18next"; +/** jsdom does not implement ResizeObserver — stub it for components that use it. */ +// @ts-expect-error global stub +global.ResizeObserver = class { + observe() {} + disconnect() {} +}; + /** Pin locale to English — LanguageDetector may otherwise pick up zh/vi from the host OS. */ beforeEach(() => { i18n.changeLanguage("en");