feat(frontend): client web read-only pairing + snapshot état (#13)

Lot F2 du chantier server/client mode : client web read-only complétant le
premier incrément livrable — pairing, liste des projets, ouverture et
snapshot de l'état, sans PTY.

- frontend/src/adapters/http/webSession.ts : session web (pairing/cookie).
- frontend/src/features/web : PairingScreen, WebWorkspace, WebApp, index.
- Câblage main.tsx et adaptations httpInvoker.ts / index.ts (cas 401).
- Tests : webSession.test.ts, WebApp.test.tsx, cas 401 dans
  httpInvoker.test.ts.

Validé : frontend 736 tests verts, build vert, garde no-direct-invoke
verte, contrat B4↔F2 aligné, desktop non régressé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-15 13:21:35 +02:00
parent fa353f6c0b
commit e500e31663
11 changed files with 768 additions and 3 deletions

View File

@ -0,0 +1,92 @@
/**
* F2 — the web session: pairing handshake (success marks the flag; wrong code
* rejects with a clear message), and the 401 → unauthorized transition that
* clears the flag and notifies subscribers.
*/
import { describe, it, expect, vi } from "vitest";
import type { FetchLike } from "./httpInvoker";
import { WebSession, type FlagStore } from "./webSession";
function memStore(): FlagStore {
const map = new Map<string, string>();
return {
getItem: (k) => map.get(k) ?? null,
setItem: (k, v) => void map.set(k, v),
removeItem: (k) => void map.delete(k),
};
}
function fetchReturning(status: number, body: unknown): {
fetchImpl: FetchLike;
calls: { url: string; init: unknown }[];
} {
const calls: { url: string; init: unknown }[] = [];
const fetchImpl: FetchLike = async (url, init) => {
calls.push({ url, init });
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
text: async () => JSON.stringify(body),
};
};
return { fetchImpl, calls };
}
describe("WebSession pairing", () => {
it("POSTs the code to /api/pair and marks the flag on success", async () => {
const store = memStore();
const { fetchImpl, calls } = fetchReturning(200, { ok: true });
const session = new WebSession({ baseUrl: "https://host/", fetchImpl, store });
expect(session.isPaired()).toBe(false);
await session.pair("4821-93");
expect(session.isPaired()).toBe(true);
expect(calls[0].url).toBe("https://host/api/pair");
expect(JSON.parse((calls[0].init as { body: string }).body)).toEqual({ code: "4821-93" });
});
it("rejects a wrong code with a clear message and stays unpaired", async () => {
const store = memStore();
const { fetchImpl } = fetchReturning(401, { code: "INVALID", message: "bad code" });
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store });
await expect(session.pair("nope")).rejects.toMatchObject({ code: "INVALID_PAIRING_CODE" });
expect(session.isPaired()).toBe(false);
});
it("falls back to a default message when the server sends no body", async () => {
const { fetchImpl } = fetchReturning(403, undefined);
const session = new WebSession({ baseUrl: "https://host", fetchImpl, store: memStore() });
await expect(session.pair("x")).rejects.toMatchObject({
code: "INVALID_PAIRING_CODE",
message: "Code d'appairage invalide.",
});
});
});
describe("WebSession unauthorized handling", () => {
it("clears the flag and notifies subscribers on notifyUnauthorized", () => {
const store = memStore();
const session = new WebSession({ baseUrl: "https://host", store });
session.markPaired();
const handler = vi.fn();
session.onUnauthorized(handler);
session.notifyUnauthorized();
expect(session.isPaired()).toBe(false);
expect(handler).toHaveBeenCalledOnce();
});
it("forget() clears the flag (best-effort local sign-out)", () => {
const store = memStore();
const session = new WebSession({ baseUrl: "https://host", store });
session.markPaired();
session.forget();
expect(session.isPaired()).toBe(false);
});
});