369 lines
14 KiB
TypeScript
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();
|
|
});
|