Skip to content

Commit 426a23b

Browse files
authored
Merge pull request #82 from zestones/feat/37-ws-client-typed
feat(frontend): typed WebSocket client + vitest bootstrap (M6.4)
2 parents 9c41a00 + 05e2f92 commit 426a23b

8 files changed

Lines changed: 1975 additions & 37 deletions

File tree

frontend/package-lock.json

Lines changed: 1247 additions & 35 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

frontend/package.json

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,10 @@
1313
"format:check": "biome format ./src",
1414
"check": "biome check ./src",
1515
"check:fix": "biome check --write ./src",
16-
"typecheck": "tsc -b --noEmit"
16+
"typecheck": "tsc -b --noEmit",
17+
"test": "vitest run",
18+
"test:watch": "vitest",
19+
"test:ui": "vitest --ui"
1720
},
1821
"dependencies": {
1922
"@tailwindcss/vite": "^4.1.18",
@@ -33,12 +36,19 @@
3336
},
3437
"devDependencies": {
3538
"@biomejs/biome": "^2.3.4",
39+
"@testing-library/dom": "^10.4.1",
40+
"@testing-library/jest-dom": "^6.9.1",
41+
"@testing-library/react": "^16.3.2",
42+
"@testing-library/user-event": "^14.6.1",
3643
"@types/node": "^22.10.5",
3744
"@types/qrcode": "^1.5.6",
3845
"@types/react": "^19.2.5",
3946
"@types/react-dom": "^19.2.3",
4047
"@vitejs/plugin-react": "^5.1.1",
48+
"@vitest/ui": "^4.1.5",
49+
"jsdom": "^29.0.2",
4150
"typescript": "~5.9.3",
42-
"vite": "^7.2.4"
51+
"vite": "^7.2.4",
52+
"vitest": "^4.1.5"
4353
}
4454
}

frontend/src/lib/ws.test.ts

Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
import { installMockWebSocket, MockWebSocket, restoreWebSocket } from "../test/mock-websocket";
3+
import { createChatWsClient, createWsClient } from "./ws";
4+
import type { ChatMap, EventBusMap } from "./ws.types";
5+
6+
beforeEach(() => {
7+
installMockWebSocket();
8+
vi.useFakeTimers();
9+
});
10+
11+
afterEach(() => {
12+
vi.useRealTimers();
13+
restoreWebSocket();
14+
});
15+
16+
describe("createWsClient (EventBusMap)", () => {
17+
it("parses fixture multi-event and dispatches typed events", () => {
18+
const onEvent = vi.fn();
19+
createWsClient<EventBusMap>({
20+
url: "ws://localhost/api/v1/events",
21+
onEvent,
22+
});
23+
MockWebSocket.last.simulateOpen();
24+
25+
MockWebSocket.last.simulateMessage({
26+
type: "agent_start",
27+
payload: { agent: "planner", turn_id: "t1" },
28+
});
29+
MockWebSocket.last.simulateMessage({
30+
type: "thinking_delta",
31+
payload: { agent: "planner", content: "analysing", turn_id: "t1" },
32+
});
33+
MockWebSocket.last.simulateMessage({
34+
type: "work_order_ready",
35+
payload: { work_order_id: 42 },
36+
});
37+
38+
expect(onEvent).toHaveBeenCalledTimes(3);
39+
expect(onEvent).toHaveBeenNthCalledWith(1, "agent_start", {
40+
agent: "planner",
41+
turn_id: "t1",
42+
});
43+
expect(onEvent).toHaveBeenNthCalledWith(2, "thinking_delta", {
44+
agent: "planner",
45+
content: "analysing",
46+
turn_id: "t1",
47+
});
48+
expect(onEvent).toHaveBeenNthCalledWith(3, "work_order_ready", {
49+
work_order_id: 42,
50+
});
51+
});
52+
53+
it("reconnects with exponential backoff after unexpected close (1006)", () => {
54+
createWsClient<EventBusMap>({
55+
url: "ws://localhost/api/v1/events",
56+
onEvent: vi.fn(),
57+
});
58+
MockWebSocket.last.simulateOpen();
59+
expect(MockWebSocket.instances).toHaveLength(1);
60+
61+
MockWebSocket.last.simulateClose(1006, "abnormal");
62+
63+
// Attempt #1: 500ms
64+
vi.advanceTimersByTime(499);
65+
expect(MockWebSocket.instances).toHaveLength(1);
66+
vi.advanceTimersByTime(1);
67+
expect(MockWebSocket.instances).toHaveLength(2);
68+
69+
// Attempt #2: 1500ms
70+
MockWebSocket.last.simulateClose(1006, "abnormal");
71+
vi.advanceTimersByTime(1499);
72+
expect(MockWebSocket.instances).toHaveLength(2);
73+
vi.advanceTimersByTime(1);
74+
expect(MockWebSocket.instances).toHaveLength(3);
75+
});
76+
77+
it("cleans up listeners after close()", () => {
78+
const client = createWsClient<EventBusMap>({
79+
url: "ws://localhost/api/v1/events",
80+
onEvent: vi.fn(),
81+
});
82+
MockWebSocket.last.simulateOpen();
83+
const mock = MockWebSocket.last;
84+
expect(mock.totalListenerCount()).toBeGreaterThan(0);
85+
86+
client.close();
87+
88+
expect(mock.totalListenerCount()).toBe(0);
89+
expect(mock.readyState).toBe(MockWebSocket.CLOSED);
90+
});
91+
92+
it("calls onError with 'Max reconnect attempts' after 3 failed retries", () => {
93+
const onError = vi.fn();
94+
createWsClient<EventBusMap>({
95+
url: "ws://localhost/api/v1/events",
96+
onEvent: vi.fn(),
97+
onError,
98+
});
99+
100+
// Attempt #1: initial close → retry after 500ms
101+
MockWebSocket.last.simulateClose(1006);
102+
vi.advanceTimersByTime(500);
103+
expect(MockWebSocket.instances).toHaveLength(2);
104+
105+
// Attempt #2: close again → retry after 1500ms
106+
MockWebSocket.last.simulateClose(1006);
107+
vi.advanceTimersByTime(1500);
108+
expect(MockWebSocket.instances).toHaveLength(3);
109+
110+
// Attempt #3: close again → retry after 4500ms
111+
MockWebSocket.last.simulateClose(1006);
112+
vi.advanceTimersByTime(4500);
113+
expect(MockWebSocket.instances).toHaveLength(4);
114+
115+
// 4th close → max reached, no new instance, onError fires
116+
MockWebSocket.last.simulateClose(1006);
117+
vi.advanceTimersByTime(10_000);
118+
expect(MockWebSocket.instances).toHaveLength(4);
119+
expect(onError).toHaveBeenCalledWith(
120+
expect.objectContaining({ message: "Max reconnect attempts reached" }),
121+
);
122+
});
123+
124+
it("does not reconnect on clean close (code 1000)", () => {
125+
createWsClient<EventBusMap>({
126+
url: "ws://localhost/api/v1/events",
127+
onEvent: vi.fn(),
128+
});
129+
MockWebSocket.last.simulateOpen();
130+
131+
MockWebSocket.last.simulateClose(1000, "normal");
132+
vi.advanceTimersByTime(10_000);
133+
134+
expect(MockWebSocket.instances).toHaveLength(1);
135+
});
136+
137+
it("resets retry counter after 30s of stable OPEN connection", () => {
138+
createWsClient<EventBusMap>({
139+
url: "ws://localhost/api/v1/events",
140+
onEvent: vi.fn(),
141+
});
142+
143+
// Burn two failed attempts quickly
144+
MockWebSocket.last.simulateClose(1006);
145+
vi.advanceTimersByTime(500);
146+
MockWebSocket.last.simulateClose(1006);
147+
vi.advanceTimersByTime(1500);
148+
expect(MockWebSocket.instances).toHaveLength(3);
149+
150+
// Open stable for 30s+ → counter resets
151+
MockWebSocket.last.simulateOpen();
152+
vi.advanceTimersByTime(30_001);
153+
154+
// Close again → should retry with attempt #1 delay (500ms), not #3 (4500ms)
155+
MockWebSocket.last.simulateClose(1006);
156+
vi.advanceTimersByTime(499);
157+
expect(MockWebSocket.instances).toHaveLength(3);
158+
vi.advanceTimersByTime(1);
159+
expect(MockWebSocket.instances).toHaveLength(4);
160+
});
161+
162+
it("calls onError on invalid JSON without crashing", () => {
163+
const onError = vi.fn();
164+
const onEvent = vi.fn();
165+
createWsClient<EventBusMap>({
166+
url: "ws://localhost/api/v1/events",
167+
onEvent,
168+
onError,
169+
});
170+
MockWebSocket.last.simulateOpen();
171+
172+
MockWebSocket.last.simulateMessage("{not json");
173+
MockWebSocket.last.simulateMessage({ no_type_field: true });
174+
175+
expect(onError).toHaveBeenCalledTimes(2);
176+
expect(onEvent).not.toHaveBeenCalled();
177+
});
178+
179+
it("honors external AbortSignal — closes socket and stops reconnect", () => {
180+
const controller = new AbortController();
181+
createWsClient<EventBusMap>({
182+
url: "ws://localhost/api/v1/events",
183+
onEvent: vi.fn(),
184+
signal: controller.signal,
185+
});
186+
MockWebSocket.last.simulateOpen();
187+
188+
controller.abort();
189+
190+
expect(MockWebSocket.last.readyState).toBe(MockWebSocket.CLOSED);
191+
vi.advanceTimersByTime(10_000);
192+
expect(MockWebSocket.instances).toHaveLength(1);
193+
});
194+
});
195+
196+
describe("createChatWsClient (ChatMap)", () => {
197+
it("dispatches discriminated-union messages with full object", () => {
198+
const onEvent = vi.fn();
199+
createChatWsClient<ChatMap>({
200+
url: "ws://localhost/api/v1/agent/chat",
201+
onEvent,
202+
});
203+
MockWebSocket.last.simulateOpen();
204+
205+
MockWebSocket.last.simulateMessage({ type: "text_delta", content: "Hel" });
206+
MockWebSocket.last.simulateMessage({
207+
type: "tool_call",
208+
name: "query_db",
209+
args: { sql: "SELECT 1" },
210+
});
211+
MockWebSocket.last.simulateMessage({ type: "done" });
212+
213+
expect(onEvent).toHaveBeenNthCalledWith(1, "text_delta", {
214+
type: "text_delta",
215+
content: "Hel",
216+
});
217+
expect(onEvent).toHaveBeenNthCalledWith(2, "tool_call", {
218+
type: "tool_call",
219+
name: "query_db",
220+
args: { sql: "SELECT 1" },
221+
});
222+
expect(onEvent).toHaveBeenNthCalledWith(3, "done", { type: "done" });
223+
});
224+
});

0 commit comments

Comments
 (0)