Files
ThothII/frontend/src/shell/database-management/DescriptionGenerationDrawer.test.tsx
T

369 lines
14 KiB
TypeScript

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 (
<DescriptionGenerationDrawer
open
run={run}
modelLabel="Local Qwen"
onClose={() => undefined}
onRunUpdate={setRun}
onTerminal={onTerminal}
/>
);
}
return {
client,
...render(
<QueryClientProvider client={client}>
<Harness />
<Toaster duration={Infinity} />
</QueryClientProvider>,
),
};
}
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();
});