Skip to content
File

Blob: test/protocol.test.ts

typescript510 lines
1/**
2 * Unit tests for TerminalSocket — the WebSocket protocol adapter.
3 *
4 * Covers:
5 * - binary output handling
6 * - JSON control / status handling (ready, exit, error)
7 * - reconnect logic
8 * - resize serialization
9 * - input encoding as binary UTF-8
10 */
11import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
12import { TerminalSocket } from "../src/client/terminal/socket";
13import type { SocketState } from "../src/client/terminal/socket";
14 
15// ---------------------------------------------------------------------------
16// Mock WebSocket
17// ---------------------------------------------------------------------------
18 
19type WSListener = (event: { data?: unknown }) => void;
20 
21class MockWebSocket {
22 static CONNECTING = 0;
23 static OPEN = 1;
24 static CLOSING = 2;
25 static CLOSED = 3;
26 
27 binaryType = "";
28 readyState: number = MockWebSocket.CONNECTING;
29 private listeners: Record<string, WSListener[]> = {};
30 sent: unknown[] = [];
31 
32 constructor(public url: string) {
33 MockWebSocket.instances.push(this);
34 }
35 
36 addEventListener(event: string, handler: WSListener) {
37 (this.listeners[event] ??= []).push(handler);
38 }
39 
40 removeEventListener(event: string, handler: WSListener) {
41 const arr = this.listeners[event];
42 if (!arr) return;
43 this.listeners[event] = arr.filter((h) => h !== handler);
44 }
45 
46 send(data: unknown) {
47 this.sent.push(data);
48 }
49 
50 close() {
51 this.readyState = MockWebSocket.CLOSED;
52 this.emit("close");
53 }
54 
55 // -- test helpers --
56 
57 emit(event: string, data?: unknown) {
58 for (const h of this.listeners[event] ?? []) h({ data });
59 }
60 
61 simulateOpen() {
62 this.readyState = MockWebSocket.OPEN;
63 this.emit("open");
64 }
65 
66 simulateBinary(bytes: Uint8Array) {
67 this.emit("message", bytes.buffer);
68 }
69 
70 simulateText(json: object) {
71 this.emit("message", JSON.stringify(json));
72 }
73 
74 simulateClose() {
75 this.readyState = MockWebSocket.CLOSED;
76 this.emit("close");
77 }
78 
79 simulateError() {
80 this.emit("error");
81 }
82 
83 // Global registry for test assertions
84 static instances: MockWebSocket[] = [];
85 static reset() {
86 MockWebSocket.instances = [];
87 }
88}
89 
90// Patch global
91const originalWebSocket = globalThis.WebSocket;
92 
93beforeEach(() => {
94 MockWebSocket.reset();
95 (globalThis as Record<string, unknown>).WebSocket = MockWebSocket as unknown;
96 vi.useFakeTimers();
97});
98 
99afterEach(() => {
100 (globalThis as Record<string, unknown>).WebSocket = originalWebSocket;
101 vi.useRealTimers();
102});
103 
104// ---------------------------------------------------------------------------
105// Helpers
106// ---------------------------------------------------------------------------
107 
108function createSocket(overrides: Partial<ConstructorParameters<typeof TerminalSocket>[0]> = {}) {
109 const callbacks = {
110 onOutput: vi.fn(),
111 onStateChange: vi.fn(),
112 onReady: vi.fn(),
113 onExit: vi.fn(),
114 ...overrides,
115 };
116 const socket = new TerminalSocket({
117 url: "wss://test/ws/terminal?id=sb1",
118 ...callbacks,
119 });
120 return { socket, ...callbacks };
121}
122 
123function lastMock(): MockWebSocket {
124 return MockWebSocket.instances[MockWebSocket.instances.length - 1];
125}
126 
127// ---------------------------------------------------------------------------
128// Tests
129// ---------------------------------------------------------------------------
130 
131describe("TerminalSocket", () => {
132 // ---- connection lifecycle ----
133 
134 it("sets binaryType to arraybuffer", () => {
135 const { socket } = createSocket();
136 socket.connect();
137 expect(lastMock().binaryType).toBe("arraybuffer");
138 });
139 
140 it("transitions to connecting on connect()", () => {
141 const { socket, onStateChange } = createSocket();
142 socket.connect();
143 expect(onStateChange).toHaveBeenCalledWith("connecting", undefined);
144 });
145 
146 it("transitions to connected on ready message", () => {
147 const { socket, onStateChange, onReady } = createSocket();
148 socket.connect();
149 const ws = lastMock();
150 ws.simulateOpen();
151 ws.simulateText({ type: "ready" });
152 
153 expect(onStateChange).toHaveBeenCalledWith("connected", undefined);
154 expect(onReady).toHaveBeenCalled();
155 });
156 
157 // ---- binary output ----
158 
159 it("forwards binary frames via onOutput", () => {
160 const { socket, onOutput } = createSocket();
161 socket.connect();
162 const ws = lastMock();
163 ws.simulateOpen();
164 
165 const payload = new TextEncoder().encode("hello world");
166 ws.simulateBinary(payload);
167 
168 expect(onOutput).toHaveBeenCalledTimes(1);
169 const received = new Uint8Array(onOutput.mock.calls[0][0] as ArrayBuffer);
170 expect(new TextDecoder().decode(received)).toBe("hello world");
171 });
172 
173 it("accepts binary frames before ready (buffered replay)", () => {
174 const { socket, onOutput, onReady } = createSocket();
175 socket.connect();
176 const ws = lastMock();
177 ws.simulateOpen();
178 
179 // Binary output arrives before ready
180 ws.simulateBinary(new TextEncoder().encode("buffered"));
181 expect(onOutput).toHaveBeenCalledTimes(1);
182 expect(onReady).not.toHaveBeenCalled();
183 
184 // Then ready arrives
185 ws.simulateText({ type: "ready" });
186 expect(onReady).toHaveBeenCalled();
187 });
188 
189 // ---- status messages ----
190 
191 it("handles exit status", () => {
192 const { socket, onExit, onStateChange } = createSocket();
193 socket.connect();
194 const ws = lastMock();
195 ws.simulateOpen();
196 ws.simulateText({ type: "ready" });
197 ws.simulateText({ type: "exit", code: 0, signal: "SIGTERM" });
198 
199 expect(onExit).toHaveBeenCalledWith(0, "SIGTERM");
200 expect(onStateChange).toHaveBeenCalledWith("ended", undefined);
201 });
202 
203 it("handles error status", () => {
204 const { socket, onStateChange } = createSocket();
205 socket.connect();
206 const ws = lastMock();
207 ws.simulateOpen();
208 ws.simulateText({ type: "error", message: "Session not found" });
209 
210 expect(onStateChange).toHaveBeenCalledWith("error", expect.objectContaining({ message: "Session not found" }));
211 });
212 
213 it("ignores malformed JSON text frames", () => {
214 const { socket, onOutput, onStateChange } = createSocket();
215 socket.connect();
216 const ws = lastMock();
217 ws.simulateOpen();
218 
219 // Send raw string (not JSON) as text frame
220 ws.emit("message", "not json at all{{{");
221 // Should not throw or change state beyond "connecting"
222 expect(onOutput).not.toHaveBeenCalled();
223 });
224 
225 // ---- input encoding ----
226 
227 it("sends keystrokes as binary UTF-8", () => {
228 const { socket } = createSocket();
229 socket.connect();
230 const ws = lastMock();
231 ws.simulateOpen();
232 ws.simulateText({ type: "ready" });
233 
234 socket.sendInput("ls -la\r");
235 
236 expect(ws.sent).toHaveLength(1);
237 const sent = ws.sent[0] as Uint8Array;
238 expect(new TextDecoder().decode(sent)).toBe("ls -la\r");
239 });
240 
241 it("sends control codes (Ctrl+C = \\x03) as binary UTF-8", () => {
242 const { socket } = createSocket();
243 socket.connect();
244 const ws = lastMock();
245 ws.simulateOpen();
246 ws.simulateText({ type: "ready" });
247 
248 socket.sendInput("\x03"); // Ctrl+C
249 
250 expect(ws.sent).toHaveLength(1);
251 const sent = new Uint8Array(ws.sent[0] as Uint8Array);
252 expect(sent[0]).toBe(3); // 0x03 = ETX
253 expect(sent.length).toBe(1);
254 });
255 
256 it("sends Ctrl+D (\\x04) correctly", () => {
257 const { socket } = createSocket();
258 socket.connect();
259 const ws = lastMock();
260 ws.simulateOpen();
261 ws.simulateText({ type: "ready" });
262 
263 socket.sendInput("\x04"); // Ctrl+D (EOF)
264 
265 const sent = new Uint8Array(ws.sent[0] as Uint8Array);
266 expect(sent[0]).toBe(4);
267 });
268 
269 it("sends Ctrl+Z (\\x1a) correctly", () => {
270 const { socket } = createSocket();
271 socket.connect();
272 const ws = lastMock();
273 ws.simulateOpen();
274 ws.simulateText({ type: "ready" });
275 
276 socket.sendInput("\x1a"); // Ctrl+Z (SIGTSTP)
277 
278 const sent = new Uint8Array(ws.sent[0] as Uint8Array);
279 expect(sent[0]).toBe(26);
280 });
281 
282 it("does not send input when socket is not open", () => {
283 const { socket } = createSocket();
284 socket.connect();
285 // Socket is still in CONNECTING state
286 socket.sendInput("test");
287 expect(lastMock().sent).toHaveLength(0);
288 });
289 
290 // ---- resize ----
291 
292 it("sends resize as JSON text", () => {
293 const { socket } = createSocket();
294 socket.connect();
295 const ws = lastMock();
296 ws.simulateOpen();
297 ws.simulateText({ type: "ready" });
298 
299 socket.sendResize(120, 40);
300 
301 expect(ws.sent).toHaveLength(1);
302 expect(JSON.parse(ws.sent[0] as string)).toEqual({
303 type: "resize",
304 cols: 120,
305 rows: 40,
306 });
307 });
308 
309 it("rounds fractional dimensions", () => {
310 const { socket } = createSocket();
311 socket.connect();
312 const ws = lastMock();
313 ws.simulateOpen();
314 ws.simulateText({ type: "ready" });
315 
316 socket.sendResize(119.7, 39.2);
317 
318 const msg = JSON.parse(ws.sent[0] as string);
319 expect(msg.cols).toBe(120);
320 expect(msg.rows).toBe(39);
321 });
322 
323 it("rejects 0x0 dimensions", () => {
324 const { socket } = createSocket();
325 socket.connect();
326 const ws = lastMock();
327 ws.simulateOpen();
328 ws.simulateText({ type: "ready" });
329 
330 socket.sendResize(0, 0);
331 expect(ws.sent).toHaveLength(0);
332 });
333 
334 it("rejects negative dimensions", () => {
335 const { socket } = createSocket();
336 socket.connect();
337 const ws = lastMock();
338 ws.simulateOpen();
339 ws.simulateText({ type: "ready" });
340 
341 socket.sendResize(-1, 24);
342 expect(ws.sent).toHaveLength(0);
343 });
344 
345 // ---- reconnect ----
346 
347 it("reconnects with exponential backoff on unexpected close", () => {
348 const { socket, onStateChange } = createSocket();
349 socket.connect();
350 const ws1 = lastMock();
351 ws1.simulateOpen();
352 ws1.simulateText({ type: "ready" });
353 
354 // Unexpected close
355 ws1.simulateClose();
356 expect(onStateChange).toHaveBeenCalledWith("reconnecting", undefined);
357 
358 // First reconnect after 1s
359 vi.advanceTimersByTime(1000);
360 expect(MockWebSocket.instances).toHaveLength(2);
361 
362 // Second reconnect after 2s
363 const ws2 = lastMock();
364 ws2.simulateClose();
365 vi.advanceTimersByTime(2000);
366 expect(MockWebSocket.instances).toHaveLength(3);
367 });
368 
369 it("stops reconnecting after max attempts", () => {
370 const { socket, onStateChange } = createSocket({ maxReconnectAttempts: 2 });
371 socket.connect();
372 
373 for (let i = 0; i < 2; i++) {
374 lastMock().simulateClose();
375 vi.advanceTimersByTime(60_000); // enough for any backoff
376 }
377 
378 // After 2 attempts, should error out
379 lastMock().simulateClose();
380 expect(onStateChange).toHaveBeenCalledWith("error", expect.objectContaining({ message: "Connection lost" }));
381 });
382 
383 it("does not reconnect after intentional disconnect", () => {
384 const { socket } = createSocket();
385 socket.connect();
386 const ws = lastMock();
387 ws.simulateOpen();
388 
389 socket.disconnect();
390 expect(MockWebSocket.instances).toHaveLength(1);
391 vi.advanceTimersByTime(60_000);
392 expect(MockWebSocket.instances).toHaveLength(1);
393 });
394 
395 it("does not reconnect after exit", () => {
396 const { socket } = createSocket();
397 socket.connect();
398 const ws = lastMock();
399 ws.simulateOpen();
400 ws.simulateText({ type: "ready" });
401 ws.simulateText({ type: "exit", code: 0 });
402 
403 ws.simulateClose();
404 vi.advanceTimersByTime(60_000);
405 expect(MockWebSocket.instances).toHaveLength(1);
406 });
407 
408 it("does not stack multiple reconnect timers", () => {
409 const { socket } = createSocket();
410 socket.connect();
411 const ws = lastMock();
412 ws.simulateOpen();
413 ws.simulateText({ type: "ready" });
414 
415 // Simulate close + error in rapid succession
416 ws.simulateError();
417 ws.simulateClose();
418 
419 vi.advanceTimersByTime(1000);
420 // Should only create one new socket
421 expect(MockWebSocket.instances).toHaveLength(2);
422 });
423 
424 // ---- session attach indication ----
425 
426 it("reports resumed=false on first connect (no buffer replay)", () => {
427 const onSessionAttached = vi.fn();
428 const { socket } = createSocket({ onSessionAttached });
429 socket.connect();
430 const ws = lastMock();
431 ws.simulateOpen();
432 ws.simulateText({ type: "ready" });
433 
434 expect(onSessionAttached).toHaveBeenCalledWith({
435 resumed: false,
436 isReconnect: false,
437 });
438 });
439 
440 it("reports resumed=true when buffer replayed before ready", () => {
441 const onSessionAttached = vi.fn();
442 const { socket } = createSocket({ onSessionAttached });
443 socket.connect();
444 const ws = lastMock();
445 ws.simulateOpen();
446 ws.simulateBinary(new TextEncoder().encode("buffered output"));
447 ws.simulateText({ type: "ready" });
448 
449 expect(onSessionAttached).toHaveBeenCalledWith({
450 resumed: true,
451 isReconnect: false,
452 });
453 });
454 
455 it("reports isReconnect=true and resumed=true on reconnect with buffer replay", () => {
456 const onSessionAttached = vi.fn();
457 const { socket } = createSocket({ onSessionAttached });
458 socket.connect();
459 const ws1 = lastMock();
460 ws1.simulateOpen();
461 ws1.simulateText({ type: "ready" });
462 
463 // Unexpected close → reconnect
464 ws1.simulateClose();
465 vi.advanceTimersByTime(1000);
466 const ws2 = lastMock();
467 ws2.simulateOpen();
468 ws2.simulateBinary(new TextEncoder().encode("replayed"));
469 ws2.simulateText({ type: "ready" });
470 
471 expect(onSessionAttached).toHaveBeenLastCalledWith({
472 resumed: true,
473 isReconnect: true,
474 });
475 });
476 
477 it("reports isReconnect=true and resumed=false on reconnect without buffer (container restart)", () => {
478 const onSessionAttached = vi.fn();
479 const { socket } = createSocket({ onSessionAttached });
480 socket.connect();
481 const ws1 = lastMock();
482 ws1.simulateOpen();
483 ws1.simulateText({ type: "ready" });
484 
485 // Unexpected close → reconnect
486 ws1.simulateClose();
487 vi.advanceTimersByTime(1000);
488 const ws2 = lastMock();
489 ws2.simulateOpen();
490 // No binary frames before ready → container restarted
491 ws2.simulateText({ type: "ready" });
492 
493 expect(onSessionAttached).toHaveBeenLastCalledWith({
494 resumed: false,
495 isReconnect: true,
496 });
497 });
498 
499 // ---- disconnect ----
500 
501 it("transitions to disconnected on intentional close", () => {
502 const { socket, onStateChange } = createSocket();
503 socket.connect();
504 lastMock().simulateOpen();
505 
506 socket.disconnect();
507 expect(onStateChange).toHaveBeenCalledWith("disconnected", undefined);
508 });
509});