feat: add AI catalog description generation
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
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" });
|
||||
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("complementary", { 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("complementary", { name: "Description generation" });
|
||||
expect(await within(drawer).findByRole("heading", { name: "Completed" })).toBeVisible();
|
||||
expect(within(drawer).getByText("3", { selector: "[data-count='generated']" })).toBeVisible();
|
||||
});
|
||||
|
||||
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("complementary", { 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("complementary", { 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("complementary", { 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();
|
||||
});
|
||||
Reference in New Issue
Block a user