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:
120
frontend/src/features/web/WebApp.test.tsx
Normal file
120
frontend/src/features/web/WebApp.test.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* F2 — the web client routing + pairing flow:
|
||||
* - not paired ⇒ pairing screen; wrong code ⇒ clear error, stays on pairing;
|
||||
* - successful pair ⇒ read-only workspace (project list + open + snapshot);
|
||||
* - a 401 (session.notifyUnauthorized) ⇒ back to pairing.
|
||||
*/
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
|
||||
import type { Gateways } from "@/ports";
|
||||
import { DIProvider } from "@/app/di";
|
||||
import { createMockGateways, MockWorkStateGateway } from "@/adapters/mock";
|
||||
import { WebSession } from "@/adapters/http";
|
||||
import type { FetchLike } from "@/adapters/http/httpInvoker";
|
||||
import type { FlagStore } from "@/adapters/http/webSession";
|
||||
import { WebApp } from "./WebApp";
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
const okFetch: FetchLike = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ ok: true }),
|
||||
text: async () => "{}",
|
||||
});
|
||||
|
||||
const rejectFetch: FetchLike = async () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({ code: "INVALID", message: "bad" }),
|
||||
text: async () => "{}",
|
||||
});
|
||||
|
||||
/** Mock gateways seeded with one project + a work-state snapshot. */
|
||||
async function seededGateways(): Promise<Gateways> {
|
||||
const gateways = createMockGateways();
|
||||
const project = await gateways.project.createProject("Demo", "/srv/demo");
|
||||
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(project.id, {
|
||||
agents: [
|
||||
{ agentId: "a1", name: "Archi", profileId: "p1", busy: { state: "idle" }, tickets: [] },
|
||||
],
|
||||
conversations: [],
|
||||
});
|
||||
return gateways;
|
||||
}
|
||||
|
||||
function renderWebApp(session: WebSession, gateways: Gateways) {
|
||||
return render(
|
||||
<DIProvider gateways={gateways}>
|
||||
<WebApp session={session} />
|
||||
</DIProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("WebApp pairing routing", () => {
|
||||
it("shows the pairing screen when not paired", async () => {
|
||||
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
||||
renderWebApp(session, await seededGateways());
|
||||
|
||||
expect(screen.getByText("Appairer cet appareil")).toBeTruthy();
|
||||
expect(screen.queryByText("Projets")).toBeNull();
|
||||
});
|
||||
|
||||
it("advances to the read-only workspace after a successful pair", async () => {
|
||||
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
||||
renderWebApp(session, await seededGateways());
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Code d'appairage"), { target: { value: "4821-93" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
|
||||
|
||||
expect(await screen.findByText("Projets")).toBeTruthy();
|
||||
expect(await screen.findByText("Demo")).toBeTruthy();
|
||||
expect(session.isPaired()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows a clear error on a wrong code and stays on pairing", async () => {
|
||||
const session = new WebSession({ baseUrl: "https://h", fetchImpl: rejectFetch, store: memStore() });
|
||||
renderWebApp(session, await seededGateways());
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Code d'appairage"), { target: { value: "nope" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Appairer" }));
|
||||
|
||||
expect(await screen.findByTestId("pairing-error")).toBeTruthy();
|
||||
expect(screen.queryByText("Projets")).toBeNull();
|
||||
expect(session.isPaired()).toBe(false);
|
||||
});
|
||||
|
||||
it("opens a project read-only and renders the work-state snapshot", async () => {
|
||||
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
||||
session.markPaired();
|
||||
renderWebApp(session, await seededGateways());
|
||||
|
||||
// Workspace is shown directly (already paired); open the seeded project.
|
||||
fireEvent.click(await screen.findByText("Demo"));
|
||||
|
||||
const snapshot = await screen.findByTestId("web-workstate");
|
||||
expect(snapshot.textContent).toContain("Archi");
|
||||
expect(snapshot.textContent).toContain("idle");
|
||||
});
|
||||
|
||||
it("returns to pairing when the session reports unauthorized (401)", async () => {
|
||||
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
|
||||
session.markPaired();
|
||||
renderWebApp(session, await seededGateways());
|
||||
|
||||
expect(await screen.findByText("Projets")).toBeTruthy();
|
||||
|
||||
act(() => session.notifyUnauthorized());
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Appairer cet appareil")).toBeTruthy());
|
||||
expect(screen.queryByText("Projets")).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user