forked from bots-garden/ori
| ✨ Workspace panel, selectors, previews, desktop app, sandbox template, resizable file tree, light/dark theme | 1 | import { render, waitFor } from "@testing-library/react"; |
| 2 | import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; | |
| 3 | import { TerminalPane, terminalWsUrl } from "./TerminalPane"; | |
| 4 | ||
| 5 | const { terminalInstances, TerminalMock, FitAddonMock } = vi.hoisted(() => { | |
| 6 | class TerminalStub { | |
| 7 | cols = 80; | |
| 8 | rows = 24; | |
| 9 | opened: HTMLElement | null = null; | |
| 10 | written: unknown[] = []; | |
| 11 | disposed = false; | |
| 12 | dataHandler: ((data: string) => void) | null = null; | |
| 13 | ||
| 14 | loadAddon() {} | |
| 15 | open(element: HTMLElement) { | |
| 16 | this.opened = element; | |
| 17 | } | |
| 18 | write(data: unknown) { | |
| 19 | this.written.push(data); | |
| 20 | } | |
| 21 | onData(handler: (data: string) => void) { | |
| 22 | this.dataHandler = handler; | |
| 23 | return { dispose: () => {} }; | |
| 24 | } | |
| 25 | onResize() { | |
| 26 | return { dispose: () => {} }; | |
| 27 | } | |
| 28 | dispose() { | |
| 29 | this.disposed = true; | |
| 30 | } | |
| 31 | } | |
| 32 | const instances: TerminalStub[] = []; | |
| 33 | return { | |
| 34 | terminalInstances: instances, | |
| 35 | TerminalMock: class extends TerminalStub { | |
| 36 | constructor() { | |
| 37 | super(); | |
| 38 | instances.push(this); | |
| 39 | } | |
| 40 | }, | |
| 41 | FitAddonMock: class { | |
| 42 | fit() {} | |
| 43 | }, | |
| 44 | }; | |
| 45 | }); | |
| 46 | ||
| 47 | vi.mock("@xterm/xterm", () => ({ Terminal: TerminalMock })); | |
| 48 | vi.mock("@xterm/addon-fit", () => ({ FitAddon: FitAddonMock })); | |
| 49 | vi.mock("@xterm/xterm/css/xterm.css", () => ({})); | |
| 50 | ||
| 51 | class FakeWebSocket { | |
| 52 | static OPEN = 1; | |
| 53 | static instances: FakeWebSocket[] = []; | |
| 54 | url: string; | |
| 55 | readyState = FakeWebSocket.OPEN; | |
| 56 | binaryType = ""; | |
| 57 | sent: string[] = []; | |
| 58 | onmessage: ((event: { data: ArrayBuffer }) => void) | null = null; | |
| 59 | onopen: (() => void) | null = null; | |
| 60 | onclose: (() => void) | null = null; | |
| 61 | ||
| 62 | constructor(url: string) { | |
| 63 | this.url = url; | |
| 64 | FakeWebSocket.instances.push(this); | |
| 65 | } | |
| 66 | send(data: string) { | |
| 67 | this.sent.push(data); | |
| 68 | } | |
| 69 | close() {} | |
| 70 | } | |
| 71 | ||
| 72 | describe("TerminalPane", () => { | |
| 73 | beforeEach(() => { | |
| 74 | terminalInstances.length = 0; | |
| 75 | FakeWebSocket.instances.length = 0; | |
| 76 | vi.stubGlobal("WebSocket", FakeWebSocket); | |
| 77 | vi.stubGlobal( | |
| 78 | "ResizeObserver", | |
| 79 | class { | |
| 80 | observe() {} | |
| 81 | disconnect() {} | |
| 82 | }, | |
| 83 | ); | |
| 84 | }); | |
| 85 | ||
| 86 | afterEach(() => { | |
| 87 | vi.unstubAllGlobals(); | |
| 88 | }); | |
| 89 | ||
| 90 | it("opens xterm in the pane and connects to /ws/terminal", async () => { | |
| 91 | render(<TerminalPane />); | |
| 92 | ||
| 93 | await waitFor(() => expect(terminalInstances).toHaveLength(1)); | |
| 94 | expect(terminalInstances[0].opened).not.toBeNull(); | |
| 95 | ||
| 96 | await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); | |
| 97 | expect(FakeWebSocket.instances[0].url).toBe(terminalWsUrl()); | |
| 98 | expect(FakeWebSocket.instances[0].binaryType).toBe("arraybuffer"); | |
| 99 | }); | |
| 100 | ||
| 101 | it("announces its size on open and forwards keystrokes as input frames", async () => { | |
| 102 | render(<TerminalPane />); | |
| 103 | await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); | |
| 104 | const socket = FakeWebSocket.instances[0]; | |
| 105 | ||
| 106 | socket.onopen?.(); | |
| 107 | expect(socket.sent[0]).toBe( | |
| 108 | JSON.stringify({ type: "resize", cols: 80, rows: 24 }), | |
| 109 | ); | |
| 110 | ||
| 111 | terminalInstances[0].dataHandler?.("ls\r"); | |
| 112 | expect(socket.sent[1]).toBe( | |
| 113 | JSON.stringify({ type: "input", data: "ls\r" }), | |
| 114 | ); | |
| 115 | }); | |
| 116 | ||
| 117 | it("writes incoming binary frames to the terminal", async () => { | |
| 118 | render(<TerminalPane />); | |
| 119 | await waitFor(() => expect(FakeWebSocket.instances).toHaveLength(1)); | |
| 120 | ||
| 121 | FakeWebSocket.instances[0].onmessage?.({ | |
| 122 | data: new TextEncoder().encode("hello").buffer as ArrayBuffer, | |
| 123 | }); | |
| 124 | expect(terminalInstances[0].written).toHaveLength(1); | |
| 125 | }); | |
| 126 | ||
| 127 | it("disposes the terminal on unmount", async () => { | |
| 128 | const { unmount } = render(<TerminalPane />); | |
| 129 | await waitFor(() => expect(terminalInstances).toHaveLength(1)); | |
| 130 | ||
| 131 | unmount(); | |
| 132 | await waitFor(() => expect(terminalInstances[0].disposed).toBe(true)); | |
| 133 | }); | |
| 134 | }); |