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>
141 lines
5.1 KiB
TypeScript
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));
|
|
});
|
|
});
|