Files
IdeaSDK/frontend/src/features/web/WebApp.test.tsx
Blomios dd1d083a1a feat(frontend): surfaces live web (workstate live, background, inbox, reconnect) (#13)
Lot F5 du chantier server/client mode : le client web consomme les frames
event.domain (B7) pour animer ses surfaces live.

- webLive.ts : consommation du flux event.domain (workstate, background,
  inbox).
- useLiveReconnect.ts : reconnexion du flux live.
- wsLiveClient.ts / index.ts : câblage du transport live.
- WebWorkspace.tsx : surfaces live branchées.
- Tests : WebWorkspaceLive.test.tsx, wsLiveClientReconnect.test.ts,
  WebApp.test.tsx.

Validé : frontend 753 tests verts, desktop non régressé.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 23:37:02 +02:00

136 lines
5.2 KiB
TypeScript

/**
* 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("opens a live agent cell for a work-state agent (F4 affordance)", async () => {
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
session.markPaired();
renderWebApp(session, await seededGateways());
fireEvent.click(await screen.findByText("Demo"));
await screen.findByTestId("web-workstate");
// The per-agent "Ouvrir" affordance mounts the reusable TerminalView cell,
// wired to the DI agent gateway (the CLI runs server-side).
expect(screen.queryByTestId("web-agent-cell")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Ouvrir" }));
expect(await screen.findByTestId("web-agent-cell")).toBeTruthy();
});
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();
});
});