From c246875f6d3bd95603d412188058c4531649a2f7 Mon Sep 17 00:00:00 2001 From: Blomios Date: Thu, 16 Jul 2026 09:14:37 +0200 Subject: [PATCH] fix(frontend): connecter le WS live sur /api/ws conforme au contrat (#13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le client live visait /ws/live alors que le serveur expose /api/ws : la connexion échouait et la reconnexion bouclait en permanence. Chemin corrigé et extrait en constante WS_PATH, avec deux tests de non-régression sur l'URL construite. Co-Authored-By: Claude Opus 4.8 --- .../src/adapters/http/wsLiveClient.test.ts | 43 +++++++++++++++++++ frontend/src/adapters/http/wsLiveClient.ts | 16 +++++-- 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/frontend/src/adapters/http/wsLiveClient.test.ts b/frontend/src/adapters/http/wsLiveClient.test.ts index 7a4afdd..3eebad1 100644 --- a/frontend/src/adapters/http/wsLiveClient.test.ts +++ b/frontend/src/adapters/http/wsLiveClient.test.ts @@ -46,6 +46,49 @@ function connectedClient(): { client: WsLiveClient; socket: FakeSocket } { return { client, socket }; } +/** Captures the URLs the client asks its socket factory to open. */ +function urlCapturingClient(token?: string): { client: WsLiveClient; urls: string[] } { + const urls: string[] = []; + const client = new WsLiveClient({ + wsUrl: "wss://host", + token, + socketFactory: (url) => { + urls.push(url); + const socket = new FakeSocket(); + queueMicrotask(() => socket.onopen?.()); + return socket; + }, + }); + return { client, urls }; +} + +/** + * Regression (live validation, ticket #13): the client connected to `/ws/live` + * while the server's `WS_PATH` is `/api/ws`. An unknown path is not treated as an + * upgrade — it falls through to the SPA static fallback (`200 index.html`) — so + * the socket never opened and the UI sat behind a permanent reconnection banner. + * No test asserted the URL (every `socketFactory` ignored its argument), which is + * exactly how the mismatch shipped. These pin the path to the server contract. + */ +describe("WsLiveClient live socket URL matches the server WS_PATH", () => { + it("connects to {wsUrl}/api/ws", async () => { + const { client, urls } = urlCapturingClient(); + await client.ensureConnected(); + + expect(urls).toEqual(["wss://host/api/ws"]); + }); + + it("keeps the /api/ws path when the dev/test token is used", async () => { + const { client, urls } = urlCapturingClient("dev tok/en"); + await client.ensureConnected(); + + expect(urls).toHaveLength(1); + // Path is unchanged; the token is a URL-encoded query convenience. + expect(new URL(urls[0]).pathname).toBe("/api/ws"); + expect(urls[0]).toBe("wss://host/api/ws?token=dev%20tok%2Fen"); + }); +}); + describe("base64 helpers round-trip binary", () => { it("encodes and decodes arbitrary bytes", () => { const bytes = new Uint8Array([0, 13, 27, 255, 128, 10]); diff --git a/frontend/src/adapters/http/wsLiveClient.ts b/frontend/src/adapters/http/wsLiveClient.ts index ffa992b..585b4ab 100644 --- a/frontend/src/adapters/http/wsLiveClient.ts +++ b/frontend/src/adapters/http/wsLiveClient.ts @@ -2,7 +2,7 @@ * WebSocket live client for the web transport — ticket #13, lot F3 (finalised * from the F1 skeleton). * - * Owns a single WS connection to `{wsUrl}/ws/live` (authenticated by the session + * Owns a single WS connection to `{wsUrl}/api/ws` (authenticated by the session * cookie at the upgrade — same-origin, no URL secret) and multiplexes over it, * aligned on the **B5 frozen frame contract** (`crates/app-tauri/src/server.rs`): * @@ -42,6 +42,16 @@ import { type StatusPayload, } from "./frames"; +/** + * Live WebSocket route — must match the server's `WS_PATH` + * (`crates/app-tauri/src/server.rs`). Any other path is not recognised as an + * upgrade and falls through to the SPA static fallback (a `200 index.html`), so + * the socket never opens and the client reconnects forever behind a permanent + * "connexion perdue" banner. Named once so the token/no-token branches below + * cannot drift apart. + */ +const WS_PATH = "/api/ws"; + /** Minimal WebSocket surface used here; injectable so tests pass a fake. */ export interface WebSocketLike { send(data: string): void; @@ -244,8 +254,8 @@ export class WsLiveClient { // Prod: the session cookie rides the upgrade (same-origin). The optional // token is a dev/test convenience only (never a prod secret in the URL). const url = this.token - ? `${this.wsUrl}/ws/live?token=${encodeURIComponent(this.token)}` - : `${this.wsUrl}/ws/live`; + ? `${this.wsUrl}${WS_PATH}?token=${encodeURIComponent(this.token)}` + : `${this.wsUrl}${WS_PATH}`; const socket = this.socketFactory(url); socket.onopen = () => { this.socket = socket;