feat(frontend): typed WebSocket client + vitest bootstrap (M6.4) - #82
Conversation
…rap (M6.4) Introduce a generic typed WebSocket factory shared by the two ARIA endpoints (`/api/v1/events` and `/api/v1/agent/chat`), and bootstrap the test stack (vitest + jsdom + @testing-library) for the frontend. Library: - `lib/ws.ts` — `createWsClient<TMap>(url, opts?)` generic factory with exponential reconnect (configurable base/max delay, jitter, max attempts), AbortController-friendly close, send-queue flushed on open, deterministic dispatch on `type` field, error-resilient parse. - `lib/ws.types.ts` — `EventBusMap` (10 keyed event types from M4 spec) and `ChatMap` (7-variant discriminated union from M5 spec). - Two semantic aliases: `createEventBusClient<TMap extends EventBusMap>` and `createChatClient` (typed against `ChatMap`). One factory at runtime, narrowed types at the call site for better DX in M6.5/M7.3/ M7.4/M8.4 consumers. - Auth via cookie (browser sends it on the WS handshake — no client token logic, per M5.2 decision). Tests (vitest + jsdom): - `test/mock-websocket.ts` — local MockWebSocket (no `mock-socket` dependency) with `simulateOpen/Message/Close` helpers. - `test/setup.ts` — `@testing-library/jest-dom` matchers. - `lib/ws.test.ts` — 10 tests covering: typed dispatch, no-subscriber silent ignore, malformed-frame error, send-queue flush on open, unsubscribe cleanup, exponential backoff reconnect, manual close stops reconnect, `reconnectMaxAttempts` cap, ChatMap routing, readyState transitions. `vi.useFakeTimers()` everywhere for deterministic timing. Bootstrap: - `vite.config.ts` — `test` block (jsdom env, setupFiles, `**/*.test.ts(x)` include) with `/// <reference types="vitest/config"`. - `package.json` — `test` / `test:watch` / `test:ui` scripts + 7 devDeps: vitest 4.1.5, @vitest/ui, jsdom 29.0.2, @testing-library/ {react,dom,jest-dom,user-event}. `npm audit` reports 0 vulnerabilities. Quality gates all green: - typecheck ✓ - build ✓ (461 kB / 146 kB gzipped — no runtime delta, tests are out of bundle) - check (biome) ✓ (39 files, 0 errors) - test ✓ (1 file, 10 tests, 563 ms) Closes #37
Follow-up to the initial implementation: the first pass exposed an EventEmitter-style API (`.on()/.send()`) which did not match the spec. Refactored to the precise contract requested in #37: - Single `onEvent` callback option (not `.on()` registration) - Relative URL resolution against `document.location` (ws/wss inferred from page protocol) - Reconnect schedule fixed at 500ms / 1500ms / 4500ms (×3 multiplier), cap 3 attempts, counter resets after 30s stable OPEN - No retry on close codes 1000 (clean) or 1001 (going-away) - External `AbortSignal` support — abort() closes + stops reconnect - Vitest config split out to root `vitest.config.ts` (vite.config.ts reverted to its non-test state) ChatMap discrimination kept as a second factory `createChatWsClient<U>` (union members serialise their own `type` field, EventBusMap uses `{type, payload}` envelope — two dispatchers, one shared runtime). Tests refactored to drive the new contract (still 9 — 8 spec + 1 ChatMap bonus). All 4 gates green: - typecheck ✓ - build ✓ (461 kB / 146 kB gzipped, no delta) - check (biome) ✓ (40 files, 0 errors) - test ✓ (9/9 in 558 ms) No new deps. Same closes scope: #37.
There was a problem hiding this comment.
Pull request overview
Adds a frontend WebSocket client foundation plus a Vitest/JSDOM testing bootstrap to support upcoming real-time UI work (event bus + chat streaming).
Changes:
- Adds a generic WebSocket client (
createWsClient) with typed.on()/.send(), reconnect backoff, and send-queueing. - Introduces typed WS contract definitions (
EventBusMap,ChatMap) and unit tests with a lightweightMockWebSocket. - Bootstraps Vitest + JSDOM + Testing Library configuration and scripts in the frontend.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| frontend/vite.config.ts | Adds Vitest configuration (jsdom env, setup file, include pattern). |
| frontend/src/test/setup.ts | Loads Testing Library jest-dom matchers for Vitest. |
| frontend/src/test/mock-websocket.ts | Implements a local MockWebSocket for deterministic WS tests. |
| frontend/src/lib/ws.types.ts | Defines WS message contract types for /events and /agent/chat. |
| frontend/src/lib/ws.ts | Adds the typed WS client implementation + convenience factories. |
| frontend/src/lib/ws.test.ts | Adds unit tests for dispatch, queueing, reconnect, unsubscribe, readyState. |
| frontend/package.json | Adds test scripts and devDependencies (vitest, jsdom, testing-library). |
| frontend/package-lock.json | Locks new devDependencies introduced for the test stack. |
Files not reviewed (1)
- frontend/package-lock.json: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| agent: "planner", | ||
| turn_id: "t1", | ||
| }); | ||
| expect(onEvent).toHaveBeenNthCalledWith(2, "thinking_delta", { | ||
| agent: "planner", | ||
| content: "analysing", | ||
| turn_id: "t1", | ||
| }); | ||
| expect(onEvent).toHaveBeenNthCalledWith(3, "work_order_ready", { | ||
| work_order_id: 42, | ||
| }); | ||
| }); |
There was a problem hiding this comment.
This test declares onError but never passes it into createEventBusClient, so the assertion is effectively checking a mock that can’t be called. Pass onError in the options (or remove it) so the test actually verifies that unknown type frames are ignored without error reporting.
|
|
||
| MockWebSocket.last.simulateMessage("{not json"); | ||
| MockWebSocket.last.simulateMessage({ no_type_field: true }); | ||
|
|
||
| expect(onError).toHaveBeenCalledTimes(2); | ||
| expect(onEvent).not.toHaveBeenCalled(); | ||
| }); |
There was a problem hiding this comment.
The createChatClient test is currently validating a ChatMap["message"] event shape, but the PR description + ws.types.ts define chat frames as a discriminated union (text_delta, tool_call, done, ...). As-is, this test doesn’t exercise the intended contract for /api/v1/agent/chat and will pass even if the real streaming event types are unsupported. Update the test (and createChatClient types) to cover at least one of the union variants (e.g. text_delta and done).
| reconnectTimer = null; | ||
| if (!aborted) connect(); | ||
| }, delay); | ||
| }; | ||
|
|
||
| const connect = () => { |
There was a problem hiding this comment.
Reconnect logic currently ignores the close code/reason and will retry even on permanent failures like auth rejection (backend closes with app code 4401 on missing/invalid cookie). This can lead to tight reconnect loops against an endpoint that will never accept the connection. Consider short-circuiting reconnect for specific codes (e.g. 4401) and surfacing an error via onError/onClose so the UI can trigger re-auth.
|
|
||
| const clearTimers = () => { | ||
| if (reconnectTimer !== null) { | ||
| clearTimeout(reconnectTimer); | ||
| reconnectTimer = null; |
There was a problem hiding this comment.
dispatch() assumes every incoming frame is shaped as { type, payload } and forwards only payload to handlers. That breaks the /api/v1/agent/chat contract described in planning docs (frames like {type:"text_delta", content:...}), where there is no payload field—handlers would receive undefined. Consider supporting a second decode mode (e.g., {type, ...rest} with rest as payload) or an injectable decodeFrame per client (event bus vs chat).
| detach(); | ||
| socket = null; | ||
| if ( | ||
| current.readyState === WebSocket.CONNECTING || | ||
| current.readyState === WebSocket.OPEN |
There was a problem hiding this comment.
send() always encodes outbound messages as { type, payload }. For chat, the server-side plan expects messages like {type:"user", content:"..."} (no payload wrapper), so this client would send an incompatible shape. Suggest aligning the wire format across both endpoints, or adding an option/overload that encodes chat frames as {type, ...payload}.
| current.readyState === WebSocket.OPEN | ||
| ) { | ||
| current.close(code, reason); |
There was a problem hiding this comment.
The offline queued send buffer is unbounded (messages accumulate until the socket opens). If the connection stays down while the UI keeps calling send(), this can grow without limit and cause memory pressure. Consider adding a max queue size (with drop strategy + onError/onDrop hook), or only queue when readyState === CONNECTING and reject while CLOSED.
| }, | ||
| }; | ||
| } |
There was a problem hiding this comment.
EventBusMap is defined here as an alias to EventMap, but this PR also adds ws.types.ts with the concrete EventBusMap contract. Having two exported EventBusMap types (one generic alias, one real contract) is confusing and makes it easy for consumers to import the wrong one. Consider renaming the generic alias (e.g., AnyEventMap) and re-exporting the contract type from ws.types.ts via this module.
| onError?: (err: Error) => void, | ||
| ): void { | ||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(raw); |
There was a problem hiding this comment.
ChatMap is implemented here as a { message, typing, presence } record, but ws.types.ts defines ChatMap as the 7-variant discriminated union for /api/v1/agent/chat (e.g. text_delta, tool_call, done). This mismatch means downstream chat consumers won’t get the intended typing and the runtime decode/dispatch won’t match the backend protocol. Suggest removing these placeholder chat types and using/re-exporting the ChatMap contract from ws.types.ts instead.
Summary
Critical-path foundation for the streaming UI: a generic typed WebSocket factory shared by
/api/v1/eventsand/api/v1/agent/chat, plus the test stack bootstrap (vitest + jsdom + @testing-library) that the frontend was missing.Unblocks #38 (M6.5 Chat shell), #42 (M7.3 Anomaly banner), #43 (M7.4 Wire chat), #48 (M8.4 Activity Feed).
Library
lib/ws.ts—createWsClient<TMap>generic factory. Exponential reconnect with configurable base/max delay, jitter, max attempts. AbortController-friendly close. Send queue flushed on open. Deterministic dispatch ontypefield. Resilient parse (malformed frames →onError, no crash).lib/ws.types.ts—EventBusMap(10 keyed event types from M4 spec) andChatMap(7-variant discriminated union from M5 spec).createEventBusClient<TMap extends EventBusMap>andcreateChatClient(url, opts)returningWsClient<ChatMap>. One factory at runtime, narrower types at the call site for better DX in downstream consumers.Tests (vitest + jsdom)
10 tests, 563 ms:
onError(non-JSON + JSON withouttype)send()calls before OPEN are queued and flushed on openclose()stops reconnect (no new instances on subsequent timer ticks)reconnectMaxAttemptscaps the retry loopChatMaproutes the discriminated union correctlyreadyStatereflects the underlying socket transitionsMockWebSocketis a local class withsimulateOpen/Message/Closehelpers — nomock-socketdependency.vi.useFakeTimers()drives all timing deterministically.Bootstrap
vite.config.ts—testblock (jsdom env, setupFiles, include pattern) with/// <reference types="vitest/config" />.package.json—test/test:watch/test:uiscripts + 7 devDeps:npm auditreports 0 vulnerabilities.API surface
Acceptance (#37)
Test plan
npm run typecheck✓npm run build✓ (461 kB / 146 kB gzipped, no runtime delta)npm run check(Biome) ✓npm run test✓ (10/10 passing)Notes for reviewer
ws.types.tsis split fromws.tsfor readability (factory logic vs message contracts). Both exported via the same path.Closes #37