fix(frontend): connecter le WS live sur /api/ws conforme au contrat (#13)
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 <noreply@anthropic.com>
This commit is contained in:
@ -46,6 +46,49 @@ function connectedClient(): { client: WsLiveClient; socket: FakeSocket } {
|
|||||||
return { client, socket };
|
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", () => {
|
describe("base64 helpers round-trip binary", () => {
|
||||||
it("encodes and decodes arbitrary bytes", () => {
|
it("encodes and decodes arbitrary bytes", () => {
|
||||||
const bytes = new Uint8Array([0, 13, 27, 255, 128, 10]);
|
const bytes = new Uint8Array([0, 13, 27, 255, 128, 10]);
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
* WebSocket live client for the web transport — ticket #13, lot F3 (finalised
|
* WebSocket live client for the web transport — ticket #13, lot F3 (finalised
|
||||||
* from the F1 skeleton).
|
* 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,
|
* 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`):
|
* aligned on the **B5 frozen frame contract** (`crates/app-tauri/src/server.rs`):
|
||||||
*
|
*
|
||||||
@ -42,6 +42,16 @@ import {
|
|||||||
type StatusPayload,
|
type StatusPayload,
|
||||||
} from "./frames";
|
} 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. */
|
/** Minimal WebSocket surface used here; injectable so tests pass a fake. */
|
||||||
export interface WebSocketLike {
|
export interface WebSocketLike {
|
||||||
send(data: string): void;
|
send(data: string): void;
|
||||||
@ -244,8 +254,8 @@ export class WsLiveClient {
|
|||||||
// Prod: the session cookie rides the upgrade (same-origin). The optional
|
// 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).
|
// token is a dev/test convenience only (never a prod secret in the URL).
|
||||||
const url = this.token
|
const url = this.token
|
||||||
? `${this.wsUrl}/ws/live?token=${encodeURIComponent(this.token)}`
|
? `${this.wsUrl}${WS_PATH}?token=${encodeURIComponent(this.token)}`
|
||||||
: `${this.wsUrl}/ws/live`;
|
: `${this.wsUrl}${WS_PATH}`;
|
||||||
const socket = this.socketFactory(url);
|
const socket = this.socketFactory(url);
|
||||||
socket.onopen = () => {
|
socket.onopen = () => {
|
||||||
this.socket = socket;
|
this.socket = socket;
|
||||||
|
|||||||
Reference in New Issue
Block a user