import { act, render, screen, waitFor, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { http, HttpResponse } from "msw"; import { useState } from "react"; import type { DescriptionGenerationEvent, DescriptionGenerationRun, } from "../../api/catalog-databases"; import { Toaster } from "../../components/ui/sonner"; import { FakeEventSource } from "../../test/fakeEventSource"; import { server } from "../../test/msw"; import { DescriptionGenerationDrawer } from "./DescriptionGenerationDrawer"; const runningRun: DescriptionGenerationRun = { id: "77777777-7777-4777-8777-777777777777", databaseId: "11111111-1111-4111-8111-111111111111", scope: "missing", modelId: "local-qwen", language: "it", status: "running", total: 4, processed: 1, generated: 1, nonGeneratable: 0, failed: 0, createdAt: "2026-08-28T08:00:00Z", startedAt: "2026-08-28T08:00:01Z", updatedAt: new Date().toISOString(), finishedAt: null, errorSummary: null, }; function event( sequence: number, message: string, level: DescriptionGenerationEvent["level"] = "info", ): DescriptionGenerationEvent { return { sequence, level, message, createdAt: `2026-08-28T08:00:0${sequence}Z`, }; } function renderDrawer({ initialRun = runningRun, onTerminal = vi.fn(), }: { initialRun?: DescriptionGenerationRun; onTerminal?: (run: DescriptionGenerationRun) => void; } = {}) { const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } }, }); function Harness() { const [run, setRun] = useState(initialRun); return ( undefined} onRunUpdate={setRun} onTerminal={onTerminal} /> ); } return { client, ...render( , ), }; } const nativeEventSource = globalThis.EventSource; beforeEach(() => { FakeEventSource.instances = []; (globalThis as unknown as { EventSource: typeof EventSource }).EventSource = ( FakeEventSource as unknown as typeof EventSource ); }); afterEach(() => { (globalThis as unknown as { EventSource: typeof EventSource }).EventSource = nativeEventSource; }); test("replays ordered SSE events, reconnects from the latest sequence, and keeps polling as a deduplicated fallback", async () => { let eventReads = 0; server.use( http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([runningRun])), http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(runningRun)), http.get("/api/catalog/description-generation-runs/:runId/events-list", () => { eventReads += 1; return HttpResponse.json(eventReads === 1 ? [event(1, "Run queued")] : [ event(2, "First description saved"), event(3, "Polling recovered this event"), ]); }), ); renderDrawer(); const log = await screen.findByRole("log", { name: "Description generation events" }); const progress = screen.getByRole("table", { name: "Description generation progress" }); expect(within(progress).getAllByRole("columnheader")).toHaveLength(5); expect(log).toHaveClass("thot-catalog-drawer__event-log"); expect(await within(log).findByText("Run queued")).toBeVisible(); await waitFor(() => expect(FakeEventSource.instances).toHaveLength(1)); expect(FakeEventSource.instances[0].url).toBe( `/api/catalog/description-generation-runs/${runningRun.id}/events?after=1`, ); act(() => { FakeEventSource.instances[0].emitNamed("log", event(2, "First description saved"), "2"); FakeEventSource.instances[0].emitNamed("log", event(2, "First description saved"), "2"); FakeEventSource.instances[0].onerror?.(); }); expect(await within(log).findByText("Polling recovered this event")).toBeVisible(); await waitFor(() => expect(FakeEventSource.instances).toHaveLength(2)); expect(FakeEventSource.instances[1].url).toBe( `/api/catalog/description-generation-runs/${runningRun.id}/events?after=3`, ); act(() => FakeEventSource.instances[0].onerror?.()); await new Promise((resolve) => window.setTimeout(resolve, 250)); expect(FakeEventSource.instances).toHaveLength(2); expect(FakeEventSource.instances[1].closed).toBe(false); act(() => { FakeEventSource.instances[1].emitNamed("log", event(3, "Polling recovered this event"), "3"); FakeEventSource.instances[1].emitNamed("log", event(4, "Replay continued in order"), "4"); }); await waitFor(() => expect(within(log).getAllByText("First description saved")).toHaveLength(1)); expect(within(log).getAllByText("Polling recovered this event")).toHaveLength(1); expect(within(log).getByText("Replay continued in order")).toBeVisible(); expect(log.textContent!.indexOf("Run queued")).toBeLessThan( log.textContent!.indexOf("First description saved"), ); expect(log.textContent!.indexOf("First description saved")).toBeLessThan( log.textContent!.indexOf("Polling recovered this event"), ); }); test("stops queued or running work without hiding earlier results or events", async () => { const user = userEvent.setup(); const cancelledRun: DescriptionGenerationRun = { ...runningRun, status: "cancelled", processed: 2, generated: 2, updatedAt: "2026-08-28T08:03:00Z", finishedAt: "2026-08-28T08:03:00Z", }; let cancelledRunId: string | undefined; server.use( http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([cancelledRun])), http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(runningRun)), http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([ event(1, "First description remains saved"), ])), http.post("/api/catalog/description-generation-runs/:runId/cancel", ({ params }) => { cancelledRunId = String(params.runId); return HttpResponse.json(cancelledRun); }), ); renderDrawer(); const drawer = await screen.findByRole("dialog", { name: "Description generation" }); expect(await within(drawer).findByText("First description remains saved")).toBeVisible(); await user.click(within(drawer).getByRole("button", { name: "Stop" })); expect(await within(drawer).findByRole("heading", { name: "Cancelled" })).toBeVisible(); expect(cancelledRunId).toBe(runningRun.id); expect(within(drawer).getByText("2", { selector: "[data-count='generated']" })).toBeVisible(); expect(within(drawer).getByText("First description remains saved")).toBeVisible(); }); test.each([ "completed", "completed_with_errors", "failed", "cancelled", "interrupted", ] satisfies DescriptionGenerationRun["status"][])( "notifies catalog consumers when a run becomes terminal with %s", async (status) => { const onTerminal = vi.fn(); const terminalRun: DescriptionGenerationRun = { ...runningRun, status, finishedAt: "2026-08-28T08:04:00Z", }; server.use( http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([terminalRun])), http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(terminalRun)), http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])), ); renderDrawer({ initialRun: terminalRun, onTerminal }); await waitFor(() => expect(onTerminal).toHaveBeenCalledOnce()); expect(onTerminal).toHaveBeenCalledWith(terminalRun); }, ); test("reopens a terminal run from newest-first persisted history", async () => { const user = userEvent.setup(); const completedRun: DescriptionGenerationRun = { ...runningRun, id: "88888888-8888-4888-8888-888888888888", scope: "selected_tables", status: "completed", total: 3, processed: 3, generated: 3, createdAt: "2026-08-28T07:00:00Z", updatedAt: "2026-08-28T07:02:00Z", finishedAt: "2026-08-28T07:02:00Z", }; server.use( http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([ runningRun, completedRun, ])), http.get("/api/catalog/description-generation-runs/:runId", ({ params }) => ( HttpResponse.json(params.runId === completedRun.id ? completedRun : runningRun) )), http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])), ); renderDrawer(); const history = await screen.findByRole("region", { name: "Description generation history", }); await user.click(within(history).getByRole("button", { name: /Completed.*selected tables/i, })); const drawer = screen.getByRole("dialog", { name: "Description generation" }); expect(await within(drawer).findByRole("heading", { name: "Completed" })).toBeVisible(); expect(within(drawer).getByText("3", { selector: "[data-count='generated']" })).toBeVisible(); }); test("styles a successfully completed run with the success background", async () => { const completedRun: DescriptionGenerationRun = { ...runningRun, status: "completed", total: 1, processed: 1, generated: 1, updatedAt: "2026-08-28T08:02:00Z", finishedAt: "2026-08-28T08:02:00Z", }; server.use( http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([completedRun])), http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(completedRun)), http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])), ); renderDrawer({ initialRun: completedRun }); const history = await screen.findByRole("region", { name: "Description generation history", }); const completedHistoryItem = within(history).getByRole("button", { name: /Completed.*missing/i, }); expect(completedHistoryItem).toHaveAttribute("data-status", "completed"); expect(completedHistoryItem).toHaveClass("bg-[oklch(var(--success)/0.1)]"); }); test("offers Unlock only for an apparently stale active run and explains the interruption", async () => { const user = userEvent.setup(); const staleRun: DescriptionGenerationRun = { ...runningRun, updatedAt: "2020-01-01T00:00:00Z", }; const interruptedRun: DescriptionGenerationRun = { ...staleRun, status: "interrupted", finishedAt: "2026-08-28T08:05:00Z", }; server.use( http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([staleRun])), http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(staleRun)), http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])), http.post("/api/catalog/description-generation-runs/unlock", () => ( HttpResponse.json(interruptedRun) )), ); renderDrawer({ initialRun: staleRun }); const drawer = await screen.findByRole("dialog", { name: "Description generation" }); expect(within(drawer).getByText( "Unlock interrupts apparently stale work and marks the run interrupted. A live worker will prevent it.", )).toBeVisible(); await user.click(within(drawer).getByRole("button", { name: "Unlock stale run" })); expect(await within(drawer).findByRole("heading", { name: "Interrupted" })).toBeVisible(); }); test("does not offer Unlock while an active run still appears current", async () => { server.use( http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([runningRun])), http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(runningRun)), http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])), ); renderDrawer(); const drawer = await screen.findByRole("dialog", { name: "Description generation" }); expect(within(drawer).queryByRole("button", { name: "Unlock stale run" })).not.toBeInTheDocument(); }); test("explains when Unlock is refused because a live worker still exists", async () => { const user = userEvent.setup(); const staleRun: DescriptionGenerationRun = { ...runningRun, updatedAt: "2020-01-01T00:00:00Z", }; server.use( http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([staleRun])), http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(staleRun)), http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])), http.post("/api/catalog/description-generation-runs/unlock", () => HttpResponse.json({ code: "description_generation_run_active", message: "Private worker detail must not be shown.", }, { status: 409 })), ); renderDrawer({ initialRun: staleRun }); const drawer = await screen.findByRole("dialog", { name: "Description generation" }); await user.click(within(drawer).getByRole("button", { name: "Unlock stale run" })); expect(await screen.findByText( "Unlock was refused because a live description worker is still running.", )).toBeVisible(); expect(screen.queryByText("Private worker detail must not be shown.")).not.toBeInTheDocument(); expect(within(drawer).getByRole("heading", { name: "Running" })).toBeVisible(); }); test("does not render stack traces or sensitive generation details from a final summary", async () => { const failedRun: DescriptionGenerationRun = { ...runningRun, status: "failed", finishedAt: "2026-08-28T08:05:00Z", errorSummary: "Traceback: provider payload included prompt and source sample\n at helper.py:42", }; server.use( http.get("/api/catalog/description-generation-runs", () => HttpResponse.json([failedRun])), http.get("/api/catalog/description-generation-runs/:runId", () => HttpResponse.json(failedRun)), http.get("/api/catalog/description-generation-runs/:runId/events-list", () => HttpResponse.json([])), ); renderDrawer({ initialRun: failedRun }); expect(await screen.findByText( "The run ended before all targets were processed. Review the event log for safe details.", )).toBeVisible(); expect(screen.queryByText(/Traceback|provider payload|source sample/i)).not.toBeInTheDocument(); });