798 lines
35 KiB
TypeScript
798 lines
35 KiB
TypeScript
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { http, HttpResponse } from "msw";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { server } from "../test/msw";
|
|
import { FakeEventSource } from "../test/fakeEventSource";
|
|
import { AppShell } from "./AppShell";
|
|
import { useSessionStore } from "../store/sessionStore";
|
|
|
|
function wrap() {
|
|
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
return render(<QueryClientProvider client={client}><AppShell /></QueryClientProvider>);
|
|
}
|
|
|
|
const LIST = [
|
|
{ id: "s1", status: "open", question: "Attiva uno", summary: null, created_at: "2026-01-02T00:00:00Z", updated_at: null, author: null, name: null, group: "Aritmologia", archived: false },
|
|
{ id: "s2", status: "finalized", question: "Archiviata due", summary: null, created_at: "2026-01-01T00:00:00Z", updated_at: null, author: null, name: null, group: null, archived: true },
|
|
];
|
|
|
|
const resumeResult = (id: string, alreadyActive = false) =>
|
|
HttpResponse.json({ id, alreadyActive });
|
|
|
|
function deferred() {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>((onResolve) => { resolve = onResolve; });
|
|
return { promise, resolve };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
FakeEventSource.instances = [];
|
|
(globalThis as any).EventSource = FakeEventSource;
|
|
(globalThis as any).PointerEvent = MouseEvent;
|
|
useSessionStore.getState().resetSession();
|
|
server.use(
|
|
http.get("http://localhost:8787/sessions", () => HttpResponse.json(LIST)),
|
|
http.get("http://localhost:8787/sessions/:id/documents", () => HttpResponse.json([
|
|
{ phase: "—", key: "question", title: "Domanda originale", format: "text", content: "Attiva uno" },
|
|
])),
|
|
http.post("http://localhost:8787/sessions/:id/archive", () => new HttpResponse(null, { status: 204 })),
|
|
);
|
|
});
|
|
|
|
test("active list shows group header and hides archived sessions", async () => {
|
|
wrap();
|
|
expect(await screen.findByText("Attiva uno")).toBeInTheDocument();
|
|
expect(screen.getByText("Aritmologia")).toBeInTheDocument(); // group header
|
|
expect(screen.queryByText("Archiviata due")).not.toBeInTheDocument(); // archived hidden
|
|
});
|
|
|
|
test("ungrouped sessions render after groups with no 'No group' label", async () => {
|
|
server.use(
|
|
http.get("http://localhost:8787/sessions", () => HttpResponse.json([
|
|
{ id: "g1", status: "open", question: "In gruppo", summary: null, created_at: "2026-01-02T00:00:00Z", updated_at: null, author: null, name: null, group: "Aritmologia", archived: false },
|
|
{ id: "u1", status: "open", question: "Senza gruppo", summary: null, created_at: "2026-01-03T00:00:00Z", updated_at: null, author: null, name: null, group: null, archived: false },
|
|
])),
|
|
);
|
|
wrap();
|
|
expect(await screen.findByText("In gruppo")).toBeInTheDocument(); // grouped
|
|
expect(screen.getByText("Senza gruppo")).toBeInTheDocument(); // ungrouped, still listed
|
|
expect(screen.queryByText(/no group/i)).not.toBeInTheDocument();
|
|
});
|
|
|
|
test("opening the panel shows the session documents", async () => {
|
|
wrap();
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
expect(await screen.findByText("Domanda originale")).toBeInTheDocument();
|
|
});
|
|
|
|
test("Archive accordion expands to reveal archived sessions", async () => {
|
|
wrap();
|
|
// archived hidden until the Archive accordion is expanded
|
|
expect(await screen.findByText("Attiva uno")).toBeInTheDocument();
|
|
expect(screen.queryByText("Archiviata due")).not.toBeInTheDocument();
|
|
await userEvent.click(screen.getByRole("button", { name: /archive/i }));
|
|
expect(await screen.findByText("Archiviata due")).toBeInTheDocument();
|
|
// Active sessions remain visible (it is a separate accordion, not a swap)
|
|
expect(screen.getByText("Attiva uno")).toBeInTheDocument();
|
|
});
|
|
|
|
test("Resume from the panel activates the session and closes the panel", async () => {
|
|
let resumed: string | null = null;
|
|
useSessionStore.setState({
|
|
activityLog: [{ kind: "status", phase: "F7", text: "Stale prior activity", level: "info" }],
|
|
});
|
|
server.use(
|
|
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => {
|
|
resumed = params.id as string;
|
|
return resumeResult(resumed);
|
|
}),
|
|
);
|
|
wrap();
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await screen.findByText("Domanda originale"); // panel open
|
|
await userEvent.click(screen.getByRole("button", { name: /resume/i }));
|
|
expect(useSessionStore.getState().activityLog).toEqual([
|
|
{ kind: "lifecycle", phase: null, text: "Resuming session" },
|
|
]);
|
|
await waitFor(() => expect(resumed).toBe("s1"));
|
|
await waitFor(() => expect(screen.queryByText("Domanda originale")).not.toBeInTheDocument()); // panel closed
|
|
});
|
|
|
|
test("Resume paints the re-entry phase from the manifest after the cold Resume succeeds", async () => {
|
|
useSessionStore.getState().resetSession();
|
|
server.use(
|
|
http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")),
|
|
http.get("http://localhost:8787/sessions/:id", () =>
|
|
HttpResponse.json({ id: "s1", status: "open", phase: 4 })),
|
|
);
|
|
wrap();
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await screen.findByText("Domanda originale"); // panel open
|
|
await userEvent.click(screen.getByRole("button", { name: /resume/i }));
|
|
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F4"));
|
|
});
|
|
|
|
test("an already-active same-session Resume preserves its EventSource and store", async () => {
|
|
let resumeCalls = 0;
|
|
server.use(
|
|
http.post("http://localhost:8787/sessions/:id/resume", () => {
|
|
resumeCalls += 1;
|
|
return resumeResult("s1", resumeCalls > 1);
|
|
}),
|
|
http.get("http://localhost:8787/sessions/:id", () =>
|
|
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
|
);
|
|
wrap();
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
|
const first = FakeEventSource.instances[0];
|
|
act(() => first.emitNamed("info", { type: "info", text: "Live state" }, "4"));
|
|
const before = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
|
|
|
|
await userEvent.click(screen.getByText("Attiva uno"));
|
|
await screen.findByText("Domanda originale");
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
|
|
await waitFor(() => expect(screen.queryByText("Domanda originale")).not.toBeInTheDocument());
|
|
expect(FakeEventSource.instances).toHaveLength(1);
|
|
expect(first.closed).toBe(false);
|
|
expect(useSessionStore.getState().activityLog).toEqual(before);
|
|
});
|
|
|
|
test("concurrent same-id Resume invocations share one cold request and replacement source", async () => {
|
|
let resumeCalls = 0;
|
|
const coldGate = deferred();
|
|
const coldStarted = deferred();
|
|
server.use(
|
|
http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
|
resumeCalls += 1;
|
|
if (resumeCalls === 1) return resumeResult("s1", false);
|
|
if (resumeCalls === 2) coldStarted.resolve();
|
|
await coldGate.promise;
|
|
return resumeResult("s1", false);
|
|
}),
|
|
http.get("http://localhost:8787/sessions/:id", () =>
|
|
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
|
);
|
|
wrap();
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
|
const oldSource = FakeEventSource.instances[0];
|
|
act(() => oldSource.emitNamed("info", { type: "info", text: "Old generation" }, "900"));
|
|
|
|
await userEvent.click(screen.getByText("Attiva uno"));
|
|
const resumeButton = await screen.findByRole("button", { name: /resume/i });
|
|
await userEvent.click(resumeButton);
|
|
await coldStarted.promise;
|
|
await userEvent.click(resumeButton);
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
expect(resumeCalls).toBe(2);
|
|
|
|
coldGate.resolve();
|
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
|
|
const replacement = FakeEventSource.instances[1];
|
|
expect(oldSource.closed).toBe(true);
|
|
expect(replacement.url).toBe("http://localhost:8787/sessions/s1/events");
|
|
expect(useSessionStore.getState().activityLog).toEqual([
|
|
{ kind: "lifecycle", phase: null, text: "Resuming session" },
|
|
]);
|
|
act(() => replacement.emitNamed(
|
|
"ui_request",
|
|
{ type: "ui_request", ui_request: { id: "cold-gate", widget: "select" } },
|
|
"1",
|
|
));
|
|
|
|
expect(useSessionStore.getState().pendingWidget?.id).toBe("cold-gate");
|
|
});
|
|
|
|
test("a committed Resume releases same-id single-flight before its manifest settles", async () => {
|
|
const other = {
|
|
...LIST[0], id: "s3", question: "Attiva tre", group: null,
|
|
created_at: "2026-01-03T00:00:00Z",
|
|
};
|
|
let s1ResumeCalls = 0;
|
|
let s1ManifestCalls = 0;
|
|
const firstS1ManifestGate = deferred();
|
|
const firstS1ManifestStarted = deferred();
|
|
server.use(
|
|
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
|
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) => {
|
|
const id = params.id as string;
|
|
if (id === "s1") s1ResumeCalls += 1;
|
|
return resumeResult(id);
|
|
}),
|
|
http.get("http://localhost:8787/sessions/:id", async ({ params }) => {
|
|
if (params.id === "s1") {
|
|
s1ManifestCalls += 1;
|
|
if (s1ManifestCalls === 1) {
|
|
firstS1ManifestStarted.resolve();
|
|
await firstS1ManifestGate.promise;
|
|
return HttpResponse.json({ id: "s1", status: "open", phase: 7 });
|
|
}
|
|
return HttpResponse.json({ id: "s1", status: "open", phase: 4 });
|
|
}
|
|
return HttpResponse.json({ id: "s3", status: "open", phase: 3 });
|
|
}),
|
|
);
|
|
wrap();
|
|
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await firstS1ManifestStarted.promise;
|
|
expect(s1ResumeCalls).toBe(1);
|
|
|
|
await userEvent.click(screen.getByText("Attiva tre"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F3"));
|
|
|
|
await userEvent.click(screen.getByText("Attiva uno"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await waitFor(() => expect(s1ResumeCalls).toBe(2));
|
|
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F4"));
|
|
expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s1/events");
|
|
|
|
firstS1ManifestGate.resolve();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(useSessionStore.getState().currentPhase).toBe("F4");
|
|
expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s1/events");
|
|
});
|
|
|
|
test("cold same-session Resume keeps the old stream until success then receives post-clear events once", async () => {
|
|
server.use(
|
|
http.post("http://localhost:8787/sessions/:id/resume", () =>
|
|
resumeResult("s1")),
|
|
http.get("http://localhost:8787/sessions/:id", () =>
|
|
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
|
);
|
|
wrap();
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
|
const first = FakeEventSource.instances[0];
|
|
act(() => first.emitNamed("info", { type: "info", text: "Before cold Resume" }, "900"));
|
|
|
|
let releaseResume!: () => void;
|
|
let markStarted!: () => void;
|
|
const resumeStarted = new Promise<void>((resolve) => { markStarted = resolve; });
|
|
const resumeReleased = new Promise<void>((resolve) => { releaseResume = resolve; });
|
|
server.use(http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
|
markStarted();
|
|
await resumeReleased;
|
|
return resumeResult("s1");
|
|
}));
|
|
|
|
await userEvent.click(screen.getByText("Attiva uno"));
|
|
await screen.findByText("Domanda originale");
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await resumeStarted;
|
|
expect(FakeEventSource.instances).toHaveLength(1);
|
|
expect(first.closed).toBe(false);
|
|
expect(screen.getByText("Domanda originale")).toBeInTheDocument();
|
|
expect(useSessionStore.getState().activityLog).toContainEqual({
|
|
kind: "status", phase: "F1", text: "Before cold Resume", level: "info",
|
|
});
|
|
act(() => first.emitNamed("text_delta", { type: "text_delta", text: "Still attached" }, "901"));
|
|
expect(useSessionStore.getState().transcript).toEqual([
|
|
{ role: "assistant", text: "Still attached" },
|
|
]);
|
|
|
|
releaseResume();
|
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
|
|
expect(first.closed).toBe(true);
|
|
const replacement = FakeEventSource.instances[1];
|
|
expect(replacement.url).toBe("http://localhost:8787/sessions/s1/events");
|
|
expect(useSessionStore.getState().transcript).toEqual([]);
|
|
expect(useSessionStore.getState().activityLog).toEqual([
|
|
{ kind: "lifecycle", phase: null, text: "Resuming session" },
|
|
]);
|
|
|
|
act(() => replacement.emitNamed(
|
|
"text_delta",
|
|
{ type: "text_delta", text: "Post-resume delivery" },
|
|
"1",
|
|
));
|
|
expect(useSessionStore.getState().transcript).toEqual([
|
|
{ role: "assistant", text: "Post-resume delivery" },
|
|
]);
|
|
expect(useSessionStore.getState().activityLog.filter((entry) => entry.kind === "assistant"))
|
|
.toEqual([{ kind: "assistant", phase: "F1", text: "Post-resume delivery" }]);
|
|
|
|
const gate = {
|
|
type: "ui_request" as const,
|
|
ui_request: { id: "post-resume-gate", widget: "select", title: "Review once" },
|
|
};
|
|
act(() => {
|
|
replacement.emitNamed("ui_request", gate, "2");
|
|
replacement.emitNamed("ui_request", gate, "3");
|
|
});
|
|
expect(useSessionStore.getState().pendingWidget?.id).toBe("post-resume-gate");
|
|
expect(useSessionStore.getState().activityLog.filter((entry) => entry.kind === "gate"))
|
|
.toEqual([{ kind: "gate", phase: "F1", text: "Review once" }]);
|
|
});
|
|
|
|
test("a failed same-session Resume preserves its source, activity, and document panel", async () => {
|
|
server.use(
|
|
http.post("http://localhost:8787/sessions/:id/resume", () =>
|
|
resumeResult("s1")),
|
|
http.get("http://localhost:8787/sessions/:id", () =>
|
|
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
|
);
|
|
wrap();
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
|
const first = FakeEventSource.instances[0];
|
|
act(() => first.emitNamed("info", { type: "info", text: "Keep me" }, "4"));
|
|
const before = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
|
|
|
|
server.use(http.post("http://localhost:8787/sessions/:id/resume", () =>
|
|
new HttpResponse(null, { status: 409 })));
|
|
await userEvent.click(screen.getByText("Attiva uno"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await waitFor(() => expect(screen.getByText("Domanda originale")).toBeInTheDocument());
|
|
|
|
expect(FakeEventSource.instances).toHaveLength(1);
|
|
expect(first.closed).toBe(false);
|
|
expect(useSessionStore.getState().activityLog).toEqual(before);
|
|
act(() => first.emitNamed("text_delta", { type: "text_delta", text: "After failure" }, "5"));
|
|
expect(useSessionStore.getState().transcript.at(-1)?.text).toBe("After failure");
|
|
});
|
|
|
|
test("resuming a different already-active session binds it only after success", async () => {
|
|
const other = {
|
|
...LIST[0], id: "s3", question: "Attiva tre", group: null,
|
|
created_at: "2026-01-03T00:00:00Z",
|
|
};
|
|
server.use(
|
|
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
|
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) =>
|
|
resumeResult(params.id as string, params.id === "s3")),
|
|
http.get("http://localhost:8787/sessions/:id", ({ params }) =>
|
|
HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })),
|
|
);
|
|
wrap();
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
|
const first = FakeEventSource.instances[0];
|
|
act(() => first.emitNamed("info", { type: "info", text: "Old target" }, "3"));
|
|
|
|
await userEvent.click(screen.getByText("Attiva tre"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F3"));
|
|
|
|
expect(FakeEventSource.instances).toHaveLength(2);
|
|
expect(first.closed).toBe(true);
|
|
expect(FakeEventSource.instances[1].url).toContain("/sessions/s3/events");
|
|
expect(FakeEventSource.instances[1].url).not.toContain("lastEventId");
|
|
expect(useSessionStore.getState().activityLog).not.toContainEqual(expect.objectContaining({
|
|
text: "Old target",
|
|
}));
|
|
});
|
|
|
|
test("competing Resume requests for different ids commit only the latest intent", async () => {
|
|
const other = {
|
|
...LIST[0], id: "s3", question: "Attiva tre", group: null,
|
|
created_at: "2026-01-03T00:00:00Z",
|
|
};
|
|
const s1Gate = deferred();
|
|
const s3Gate = deferred();
|
|
const s1Started = deferred();
|
|
const s3Started = deferred();
|
|
server.use(
|
|
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
|
http.post("http://localhost:8787/sessions/:id/resume", async ({ params }) => {
|
|
const id = params.id as string;
|
|
if (id === "s1") {
|
|
s1Started.resolve();
|
|
await s1Gate.promise;
|
|
} else {
|
|
s3Started.resolve();
|
|
await s3Gate.promise;
|
|
}
|
|
return resumeResult(id);
|
|
}),
|
|
http.get("http://localhost:8787/sessions/:id", ({ params }) =>
|
|
HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })),
|
|
);
|
|
wrap();
|
|
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await s1Started.promise;
|
|
await userEvent.click(screen.getByText("Attiva tre"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await s3Started.promise;
|
|
|
|
s3Gate.resolve();
|
|
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F3"));
|
|
expect(FakeEventSource.instances).toHaveLength(1);
|
|
const latestSource = FakeEventSource.instances[0];
|
|
expect(latestSource.url).toContain("/sessions/s3/events");
|
|
act(() => latestSource.emitNamed("info", { type: "info", text: "Latest target" }, "1"));
|
|
|
|
s1Gate.resolve();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(FakeEventSource.instances).toHaveLength(1);
|
|
expect(latestSource.closed).toBe(false);
|
|
expect(useSessionStore.getState().currentPhase).toBe("F3");
|
|
expect(useSessionStore.getState().activityLog).toContainEqual(expect.objectContaining({
|
|
text: "Latest target",
|
|
}));
|
|
});
|
|
|
|
test("a stale Resume manifest cannot repaint the latest session phase", async () => {
|
|
const other = {
|
|
...LIST[0], id: "s3", question: "Attiva tre", group: null,
|
|
created_at: "2026-01-03T00:00:00Z",
|
|
};
|
|
const s1ManifestGate = deferred();
|
|
const s1ManifestStarted = deferred();
|
|
server.use(
|
|
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
|
http.post("http://localhost:8787/sessions/:id/resume", ({ params }) =>
|
|
resumeResult(params.id as string)),
|
|
http.get("http://localhost:8787/sessions/:id", async ({ params }) => {
|
|
if (params.id === "s1") {
|
|
s1ManifestStarted.resolve();
|
|
await s1ManifestGate.promise;
|
|
return HttpResponse.json({ id: "s1", status: "open", phase: 7 });
|
|
}
|
|
return HttpResponse.json({ id: "s3", status: "open", phase: 3 });
|
|
}),
|
|
);
|
|
wrap();
|
|
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await s1ManifestStarted.promise;
|
|
await userEvent.click(screen.getByText("Attiva tre"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await waitFor(() => expect(useSessionStore.getState().currentPhase).toBe("F3"));
|
|
expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s3/events");
|
|
|
|
s1ManifestGate.resolve();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(useSessionStore.getState().currentPhase).toBe("F3");
|
|
expect(FakeEventSource.instances.at(-1)?.url).toContain("/sessions/s3/events");
|
|
});
|
|
|
|
test("starting a new question invalidates a pending Resume intent", async () => {
|
|
const resumeGate = deferred();
|
|
const resumeStarted = deferred();
|
|
server.use(
|
|
http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
|
resumeStarted.resolve();
|
|
await resumeGate.promise;
|
|
return resumeResult("s1");
|
|
}),
|
|
http.post("http://localhost:8787/runtime/prewarm", () =>
|
|
HttpResponse.json({ status: "warming" }, { status: 202 })),
|
|
);
|
|
wrap();
|
|
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await resumeStarted.promise;
|
|
await userEvent.click(screen.getByRole("button", { name: /^new session$/i }));
|
|
expect(await screen.findByText(/type your question/i)).toBeInTheDocument();
|
|
|
|
resumeGate.resolve();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(FakeEventSource.instances).toHaveLength(0);
|
|
expect(screen.getByText(/type your question/i)).toBeInTheDocument();
|
|
});
|
|
|
|
test("a successful Delete invalidates an earlier pending Resume for the same target", async () => {
|
|
const resumeGate = deferred();
|
|
const resumeStarted = deferred();
|
|
const deleteCompleted = deferred();
|
|
server.use(
|
|
http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
|
resumeStarted.resolve();
|
|
await resumeGate.promise;
|
|
return resumeResult("s1");
|
|
}),
|
|
http.delete("http://localhost:8787/sessions/:id", ({ params }) => {
|
|
expect(params.id).toBe("s1");
|
|
deleteCompleted.resolve();
|
|
return new HttpResponse(null, { status: 204 });
|
|
}),
|
|
);
|
|
wrap();
|
|
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await resumeStarted.promise;
|
|
screen.getByRole("checkbox", { name: "Select Attiva uno" }).focus();
|
|
await userEvent.keyboard(" ");
|
|
await userEvent.click(await screen.findByRole("button", { name: "Delete 1 selected sessions" }));
|
|
await deleteCompleted.promise;
|
|
|
|
resumeGate.resolve();
|
|
await new Promise((resolve) => setImmediate(resolve));
|
|
|
|
expect(FakeEventSource.instances).toHaveLength(0);
|
|
expect(screen.getByTestId("session-item-s1")).toHaveAttribute("data-active", "false");
|
|
});
|
|
|
|
test("a successful Delete detaches a Resume that commits while Delete is pending", async () => {
|
|
const deleteGate = deferred();
|
|
const deleteStarted = deferred();
|
|
server.use(
|
|
http.delete("http://localhost:8787/sessions/:id", async ({ params }) => {
|
|
expect(params.id).toBe("s1");
|
|
deleteStarted.resolve();
|
|
await deleteGate.promise;
|
|
return new HttpResponse(null, { status: 204 });
|
|
}),
|
|
http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")),
|
|
http.get("http://localhost:8787/sessions/:id", () =>
|
|
HttpResponse.json({ id: "s1", status: "open", phase: 1 })),
|
|
);
|
|
wrap();
|
|
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
screen.getByRole("checkbox", { name: "Select Attiva uno" }).focus();
|
|
await userEvent.keyboard(" ");
|
|
await userEvent.click(await screen.findByRole("button", { name: "Delete 1 selected sessions" }));
|
|
await deleteStarted.promise;
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
|
expect(screen.getByTestId("session-item-s1")).toHaveAttribute("data-active", "true");
|
|
|
|
deleteGate.resolve();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("session-item-s1")).toHaveAttribute("data-active", "false");
|
|
});
|
|
expect(FakeEventSource.instances[0].closed).toBe(true);
|
|
});
|
|
|
|
test("deleting another session does not invalidate a pending Resume", async () => {
|
|
const resumeGate = deferred();
|
|
const resumeStarted = deferred();
|
|
const deleteCompleted = deferred();
|
|
server.use(
|
|
http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
|
resumeStarted.resolve();
|
|
await resumeGate.promise;
|
|
return resumeResult("s1");
|
|
}),
|
|
http.delete("http://localhost:8787/sessions/:id", ({ params }) => {
|
|
expect(params.id).toBe("s2");
|
|
deleteCompleted.resolve();
|
|
return new HttpResponse(null, { status: 204 });
|
|
}),
|
|
);
|
|
wrap();
|
|
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await resumeStarted.promise;
|
|
await userEvent.click(screen.getByRole("button", { name: /archive/i }));
|
|
(await screen.findByRole("checkbox", { name: "Select Archiviata due" })).focus();
|
|
await userEvent.keyboard(" ");
|
|
await userEvent.click(await screen.findByRole("button", { name: "Delete 1 selected sessions" }));
|
|
await deleteCompleted.promise;
|
|
|
|
resumeGate.resolve();
|
|
|
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
|
expect(FakeEventSource.instances[0].url).toContain("/sessions/s1/events");
|
|
expect(screen.getByTestId("session-item-s1")).toHaveAttribute("data-active", "true");
|
|
});
|
|
|
|
test("deleting active A preserves a pending Resume for different session B", async () => {
|
|
const other = {
|
|
...LIST[0], id: "s3", question: "Attiva tre", group: null,
|
|
created_at: "2026-01-03T00:00:00Z",
|
|
};
|
|
const s3ResumeGate = deferred();
|
|
const s3ResumeStarted = deferred();
|
|
server.use(
|
|
http.get("http://localhost:8787/sessions", () => HttpResponse.json([LIST[0], other])),
|
|
http.post("http://localhost:8787/sessions/:id/resume", async ({ params }) => {
|
|
const id = params.id as string;
|
|
if (id === "s3") {
|
|
s3ResumeStarted.resolve();
|
|
await s3ResumeGate.promise;
|
|
}
|
|
return resumeResult(id);
|
|
}),
|
|
http.get("http://localhost:8787/sessions/:id", ({ params }) =>
|
|
HttpResponse.json({ id: params.id, status: "open", phase: params.id === "s3" ? 3 : 1 })),
|
|
http.delete("http://localhost:8787/sessions/:id", ({ params }) => {
|
|
expect(params.id).toBe("s1");
|
|
return new HttpResponse(null, { status: 204 });
|
|
}),
|
|
);
|
|
wrap();
|
|
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
|
const sourceA = FakeEventSource.instances[0];
|
|
expect(screen.getByTestId("session-item-s1")).toHaveAttribute("data-active", "true");
|
|
|
|
await userEvent.click(screen.getByText("Attiva tre"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await s3ResumeStarted.promise;
|
|
screen.getByRole("checkbox", { name: "Select Attiva uno" }).focus();
|
|
await userEvent.keyboard(" ");
|
|
await userEvent.click(await screen.findByRole("button", { name: "Delete 1 selected sessions" }));
|
|
await waitFor(() => expect(sourceA.closed).toBe(true));
|
|
expect(screen.getByTestId("session-item-s1")).toHaveAttribute("data-active", "false");
|
|
|
|
s3ResumeGate.resolve();
|
|
|
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2));
|
|
expect(FakeEventSource.instances[1].url).toContain("/sessions/s3/events");
|
|
expect(screen.getByTestId("session-item-s3")).toHaveAttribute("data-active", "true");
|
|
});
|
|
|
|
test("a failed Delete does not invalidate a pending Resume for its target", async () => {
|
|
const resumeGate = deferred();
|
|
const resumeStarted = deferred();
|
|
const deleteFailed = deferred();
|
|
server.use(
|
|
http.post("http://localhost:8787/sessions/:id/resume", async () => {
|
|
resumeStarted.resolve();
|
|
await resumeGate.promise;
|
|
return resumeResult("s1");
|
|
}),
|
|
http.delete("http://localhost:8787/sessions/:id", ({ params }) => {
|
|
expect(params.id).toBe("s1");
|
|
deleteFailed.resolve();
|
|
return new HttpResponse(null, { status: 500 });
|
|
}),
|
|
);
|
|
wrap();
|
|
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
await resumeStarted.promise;
|
|
screen.getByRole("checkbox", { name: "Select Attiva uno" }).focus();
|
|
await userEvent.keyboard(" ");
|
|
await userEvent.click(await screen.findByRole("button", { name: "Delete 1 selected sessions" }));
|
|
await deleteFailed.promise;
|
|
|
|
resumeGate.resolve();
|
|
|
|
await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1));
|
|
expect(FakeEventSource.instances[0].url).toContain("/sessions/s1/events");
|
|
expect(screen.getByTestId("session-item-s1")).toHaveAttribute("data-active", "true");
|
|
});
|
|
|
|
test("a failed Resume with no active session preserves the panel and existing activity", async () => {
|
|
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => new HttpResponse(null, { status: 409 })));
|
|
useSessionStore.setState({
|
|
activityLog: [{ kind: "status", phase: "F7", text: "Preserve activity", level: "info" }],
|
|
});
|
|
wrap();
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await screen.findByText("Domanda originale"); // panel open
|
|
await userEvent.click(screen.getByRole("button", { name: /resume/i }));
|
|
// panel stays open (document still visible) after the failed resume
|
|
await waitFor(() => expect(screen.getByText("Domanda originale")).toBeInTheDocument());
|
|
expect(FakeEventSource.instances).toHaveLength(0);
|
|
expect(useSessionStore.getState().activityLog).toEqual([
|
|
{ kind: "status", phase: "F7", text: "Preserve activity", level: "info" },
|
|
]);
|
|
});
|
|
|
|
test("closing and reopening Model activity preserves the complete activity log", async () => {
|
|
wrap();
|
|
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")));
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
act(() => {
|
|
const store = useSessionStore.getState();
|
|
store.setPhase("F4");
|
|
store.setLastUserEntry({ kind: "input", text: "Inspect patient cohort" });
|
|
store.applyEvent({
|
|
type: "activity_event",
|
|
activity: { kind: "tool", toolCallId: "tool-1", toolName: "bash", status: "completed" },
|
|
});
|
|
store.applyEvent({ type: "text_delta", text: "Cohort ready" });
|
|
store.applyEvent({ type: "activity_delta", text: "Inspecting **cohort**" });
|
|
store.applyEvent({ type: "info", level: "info", text: "Cohort status" });
|
|
});
|
|
const beforeClose = useSessionStore.getState().activityLog.map((entry) => ({ ...entry }));
|
|
|
|
expect(screen.queryByRole("heading", { name: /model activity/i })).not.toBeInTheDocument();
|
|
await userEvent.click(screen.getByRole("button", { name: /model activity/i }));
|
|
const panel = (await screen.findByRole("heading", { name: /model activity/i })).closest("aside");
|
|
expect(panel).not.toBeNull();
|
|
expect(within(panel!).getByText("cohort").tagName).toBe("STRONG");
|
|
expect(within(panel!).getByText("Cohort status")).toBeInTheDocument();
|
|
expect(within(panel!).queryByText("Inspect patient cohort")).not.toBeInTheDocument();
|
|
expect(within(panel!).queryByText("bash")).not.toBeInTheDocument();
|
|
expect(within(panel!).queryByText("Cohort ready")).not.toBeInTheDocument();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /close model activity/i }));
|
|
expect(screen.queryByRole("heading", { name: /model activity/i })).not.toBeInTheDocument();
|
|
expect(useSessionStore.getState().activityLog).toEqual(beforeClose);
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /show model activity/i }));
|
|
const reopened = (await screen.findByRole("heading", { name: /model activity/i })).closest("aside");
|
|
expect(reopened).not.toBeNull();
|
|
expect(within(reopened!).getByText("cohort").tagName).toBe("STRONG");
|
|
expect(within(reopened!).getByText("Cohort status")).toBeInTheDocument();
|
|
expect(within(reopened!).queryByText("Inspect patient cohort")).not.toBeInTheDocument();
|
|
expect(within(reopened!).queryByText("bash")).not.toBeInTheDocument();
|
|
expect(within(reopened!).queryByText("Cohort ready")).not.toBeInTheDocument();
|
|
expect(useSessionStore.getState().activityLog).toEqual(beforeClose);
|
|
});
|
|
|
|
test("session finalization shows the completion banner and returns to landing", async () => {
|
|
useSessionStore.getState().resetSession();
|
|
let finalized = false;
|
|
server.use(
|
|
http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")),
|
|
http.get("http://localhost:8787/sessions", () =>
|
|
HttpResponse.json(finalized ? [{ ...LIST[0], status: "finalized" }] : LIST)),
|
|
);
|
|
wrap();
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
// The final turn ends: the session is now finalized on disk and agent_end
|
|
// arrives — the shell must refetch immediately (not wait for the 10s poll)
|
|
// and surface the new-session invite.
|
|
finalized = true;
|
|
act(() => {
|
|
useSessionStore.getState().applyEvent({ type: "system_event", event: "agent_end" } as any);
|
|
});
|
|
const cta = await screen.findByRole("button", { name: /start a new question/i });
|
|
expect(screen.getByText(/session completed and finalized/i)).toBeInTheDocument();
|
|
await userEvent.click(cta);
|
|
// Landing state: the composer invites a brand-new question.
|
|
expect(await screen.findByText(/type your question/i)).toBeInTheDocument();
|
|
});
|
|
|
|
test("renaming a group reassigns its members via setSessionGroup", async () => {
|
|
const groupSets: Array<{ id: string; group: string }> = [];
|
|
server.use(
|
|
http.post("http://localhost:8787/sessions/:id/group", async ({ params, request }) => {
|
|
const body = (await request.json()) as { group: string };
|
|
groupSets.push({ id: params.id as string, group: body.group });
|
|
return new HttpResponse(null, { status: 204 });
|
|
}),
|
|
);
|
|
wrap();
|
|
await screen.findByText("Aritmologia");
|
|
await userEvent.click(screen.getByRole("button", { name: /rename group aritmologia/i }));
|
|
const input = await screen.findByLabelText(/name/i, { selector: "input" });
|
|
await userEvent.clear(input);
|
|
await userEvent.type(input, "Cardiologia");
|
|
await userEvent.click(screen.getByRole("button", { name: /save/i }));
|
|
await waitFor(() => expect(groupSets).toEqual([{ id: "s1", group: "Cardiologia" }]));
|
|
});
|
|
|
|
|
|
test("opening Model activity replaces the session rail with a 40/60 activity and chat layout", async () => {
|
|
server.use(http.post("http://localhost:8787/sessions/:id/resume", () => resumeResult("s1")));
|
|
wrap();
|
|
await userEvent.click(await screen.findByText("Attiva uno"));
|
|
await userEvent.click(await screen.findByRole("button", { name: /resume/i }));
|
|
|
|
expect(screen.getByText("Sessions")).toBeInTheDocument();
|
|
await userEvent.click(screen.getByRole("button", { name: /show model activity/i }));
|
|
|
|
const shell = screen.getByTestId("app-shell");
|
|
expect(shell).toHaveAttribute("data-activity-layout", "open");
|
|
expect(screen.getByRole("heading", { name: "Model activity" }).closest("aside")).toHaveClass("w-2/5");
|
|
expect(screen.queryByText("Sessions")).not.toBeInTheDocument();
|
|
|
|
await userEvent.click(screen.getByRole("button", { name: /hide model activity/i }));
|
|
expect(shell).toHaveAttribute("data-activity-layout", "closed");
|
|
expect(screen.getByText("Sessions")).toBeInTheDocument();
|
|
});
|