Files
IdeaSDK/frontend/src/features/web/WebWorkspaceLive.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

141 lines
5.1 KiB
TypeScript

/**
* F5 — the live web surfaces: a `event.domain` event refreshes the displayed
* work-state; background tasks render with status + cancel/retry wired to the
* gateway; a WS reconnect re-synchronises the read-model.
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { Gateways } from "@/ports";
import type { BackgroundCompletion, ProjectWorkState } from "@/domain";
import { DIProvider } from "@/app/di";
import { createMockGateways, MockSystemGateway, MockWorkStateGateway } from "@/adapters/mock";
import {
WsLiveClient,
setWebLiveClient,
WebSession,
} from "@/adapters/http";
import { WebApp } from "./WebApp";
afterEach(() => setWebLiveClient(null));
const okFetch = async () => ({
ok: true,
status: 200,
json: async () => ({ ok: true }),
text: async () => "{}",
});
function memStore() {
const map = new Map<string, string>();
return {
getItem: (k: string) => map.get(k) ?? null,
setItem: (k: string, v: string) => void map.set(k, v),
removeItem: (k: string) => void map.delete(k),
};
}
function state(agents: ProjectWorkState["agents"]): ProjectWorkState {
return { agents, conversations: [] };
}
async function seeded(initial: ProjectWorkState): Promise<{ gateways: Gateways; projectId: string }> {
const gateways = createMockGateways();
const project = await gateways.project.createProject("Demo", "/srv/demo");
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(project.id, initial);
return { gateways, projectId: project.id };
}
function renderPaired(gateways: Gateways) {
const session = new WebSession({ baseUrl: "https://h", fetchImpl: okFetch, store: memStore() });
session.markPaired();
return render(
<DIProvider gateways={gateways}>
<WebApp session={session} />
</DIProvider>,
);
}
const AGENT_IDLE = { agentId: "a1", name: "Archi", profileId: "p1", busy: { state: "idle" as const }, tickets: [] };
describe("WebWorkspace live surfaces (F5)", () => {
it("refreshes the work-state on a relevant event.domain event", async () => {
const { gateways, projectId } = await seeded(state([AGENT_IDLE]));
renderPaired(gateways);
fireEvent.click(await screen.findByText("Demo"));
const panel = await screen.findByTestId("web-workstate");
expect(panel.textContent).toContain("Idle");
// The agent goes busy server-side; the read-model now reflects it…
(gateways.workState as MockWorkStateGateway)._setProjectWorkState(
projectId,
state([{ ...AGENT_IDLE, busy: { state: "busy", ticket: "t-1", sinceMs: 1 } }]),
);
// …and an `agentBusyChanged` domain event drives a live refresh.
act(() => {
(gateways.system as MockSystemGateway).emit({ type: "agentBusyChanged", agentId: "a1", busy: true });
});
await waitFor(() =>
expect(screen.getByTestId("web-workstate").textContent).toContain("Busy"),
);
});
it("renders background tasks and wires cancel through the gateway", async () => {
const task: BackgroundCompletion = {
taskId: "bt-1",
ownerAgentId: "a1",
projectId: "p",
kind: "shell",
status: "running",
exitCode: null,
summary: null,
stdoutTail: null,
stderrTail: null,
updatedAtMs: 1,
};
const { gateways } = await seeded(state([{ ...AGENT_IDLE, backgroundTasks: [task] }]));
const cancelSpy = vi.spyOn(gateways.workState, "cancelBackgroundTask");
renderPaired(gateways);
fireEvent.click(await screen.findByText("Demo"));
const tasks = await screen.findByLabelText("Archi background tasks");
expect(tasks.textContent).toContain("Running");
expect(tasks.textContent).toContain("shell");
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(cancelSpy).toHaveBeenCalledWith("bt-1"));
});
it("re-synchronises the read-model when the WS reconnects", async () => {
const { gateways, projectId } = await seeded(state([AGENT_IDLE]));
// Register a live client whose connection state we can drive.
let notify: ((s: "connecting" | "connected" | "reconnecting" | "closed") => void) | null = null;
const fakeClient = {
getConnectionState: () => "connected" as const,
onConnectionStateChange: (l: (s: "connecting" | "connected" | "reconnecting" | "closed") => void) => {
notify = l;
return () => {
notify = null;
};
},
};
setWebLiveClient(fakeClient as unknown as WsLiveClient);
const refreshSpy = vi.spyOn(gateways.workState, "getProjectWorkState");
renderPaired(gateways);
fireEvent.click(await screen.findByText("Demo"));
await screen.findByTestId("web-workstate");
const callsAfterOpen = refreshSpy.mock.calls.length;
expect(projectId).toBeTruthy();
// Simulate a reconnect: reconnecting → connected must re-fetch the read-model.
act(() => {
notify?.("reconnecting");
notify?.("connected");
});
await waitFor(() => expect(refreshSpy.mock.calls.length).toBeGreaterThan(callsAfterOpen));
});
});