feat(frontend): polish web (logout, 401→pairing, reconnexion globale) (#13)

Flow logout : POST /api/logout puis retour à l'écran de pairing et
déconnexion du live. 401 renvoie au pairing sans boucle. Bannière de
reconnexion couvrant le cas live-only et le serveur indisponible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-16 08:23:11 +02:00
parent d538808a0d
commit 6117177957
10 changed files with 288 additions and 14 deletions

View File

@ -190,4 +190,66 @@ describe("WsLiveClient connection state + reconnection", () => {
expect(h.ws.getConnectionState()).toBe("closed");
expect(h.hasTimer()).toBe(false);
});
it("disconnect() closes and stops reconnecting but stays reusable (F6 sign-out)", async () => {
const h = harness();
h.ws.setDomainEventHandler(() => {});
await h.ws.ensureConnected();
await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected"));
// Sign-out: soft teardown. State is closed, the reconnect loop is dropped, and
// the detached socket's onclose cannot flip the state back to reconnecting.
h.ws.disconnect();
expect(h.ws.getConnectionState()).toBe("closed");
expect(h.hasTimer()).toBe(false);
expect(h.ws.needsReconnect()).toBe(false);
// Reusable: a later ensureConnected reconnects as a fresh socket (re-pairing).
await h.ws.ensureConnected();
await vi.waitFor(() => expect(h.sockets.length).toBe(2));
await vi.waitFor(() => expect(h.ws.getConnectionState()).toBe("connected"));
});
it("calls onReconnectFailed when a reconnect attempt fails (auth probe seam)", async () => {
const failed = vi.fn();
let openNextSocket = true;
let timerFn: (() => void) | null = null;
const sockets: FakeSocket[] = [];
const ws = new WsLiveClient({
wsUrl: "wss://host",
reconnectBaseMs: 1,
onReconnectFailed: failed,
socketFactory: () => {
const s = new FakeSocket();
sockets.push(s);
// First socket connects; the reconnect socket errors (upgrade rejected).
if (openNextSocket) queueMicrotask(() => s.onopen?.());
else queueMicrotask(() => s.onerror?.(new Error("upgrade rejected")));
return s;
},
setTimeoutImpl: (fn) => {
timerFn = fn;
return 1;
},
clearTimeoutImpl: () => {
timerFn = null;
},
});
ws.setDomainEventHandler(() => {});
await ws.ensureConnected();
await vi.waitFor(() => expect(ws.getConnectionState()).toBe("connected"));
// Drop the socket; the next connect attempt will fail the upgrade.
openNextSocket = false;
sockets[0].close();
expect(ws.getConnectionState()).toBe("reconnecting");
const fire = timerFn as (() => void) | null;
timerFn = null;
fire?.();
// The failed reconnect fires the probe hook and schedules another attempt.
await vi.waitFor(() => expect(failed).toHaveBeenCalled());
expect(timerFn).not.toBeNull();
});
});