fix: close delete resume and SSE replay races
This commit is contained in:
@@ -29,6 +29,7 @@ function deferred() {
|
||||
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)),
|
||||
@@ -489,6 +490,139 @@ test("starting a new question invalidates a pending Resume intent", async () =>
|
||||
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("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({
|
||||
|
||||
@@ -240,16 +240,16 @@ export function AppShell() {
|
||||
}
|
||||
|
||||
async function deleteSessions(targets: SessionSummary[]) {
|
||||
if (targets.some((session) => session.id === activeSessionIdRef.current)) {
|
||||
invalidateResumeIntent();
|
||||
}
|
||||
try {
|
||||
const results = await Promise.allSettled(targets.map((session) => deleteSession(session.id)));
|
||||
const deletedIds = new Set(
|
||||
targets.filter((_, index) => results[index].status === "fulfilled").map((session) => session.id),
|
||||
);
|
||||
const deletedActiveSession = deletedIds.has(activeSessionIdRef.current ?? "");
|
||||
const deletedResumeTarget = deletedIds.has(latestResumeIntentRef.current?.id ?? "");
|
||||
if (deletedActiveSession || deletedResumeTarget) invalidateResumeIntent();
|
||||
if (deletedIds.has(panelSession?.id ?? "")) setPanelSession(null);
|
||||
if (deletedIds.has(activeSessionId ?? "")) { resetSession(); selectActiveSession(null); }
|
||||
if (deletedActiveSession) { resetSession(); selectActiveSession(null); }
|
||||
setSelectedSessionIds((current) => new Set([...current].filter((id) => !deletedIds.has(id))));
|
||||
refresh();
|
||||
if (deletedIds.size !== targets.length) {
|
||||
|
||||
Reference in New Issue
Block a user