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>
This commit is contained in:
140
frontend/src/features/web/WebWorkspaceLive.test.tsx
Normal file
140
frontend/src/features/web/WebWorkspaceLive.test.tsx
Normal file
@ -0,0 +1,140 @@
|
||||
/**
|
||||
* 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));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user