diff --git a/.gitignore b/.gitignore index 5860fb1b..2edd0038 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,6 @@ soak-results/ # LSP originality-check reference cache (scripts/check-lsp-originality.sh) .lsp-refs/ + +# Local npm cache +graph-ui/.npm-cache-local/ diff --git a/graph-ui/src/components/StatsTab.test.tsx b/graph-ui/src/components/StatsTab.test.tsx new file mode 100644 index 00000000..4b288bfa --- /dev/null +++ b/graph-ui/src/components/StatsTab.test.tsx @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen, fireEvent, act } from "@testing-library/react"; +import { IndexProgress } from "./StatsTab"; +import "@testing-library/jest-dom"; + +describe("IndexProgress", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + it("polls and shows indexing in progress when active", async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve({ + json: () => Promise.resolve([ + { slot: 1, status: "indexing", path: "/path/to/project1" } + ]) + } as unknown as Response) + ); + vi.stubGlobal("fetch", fetchMock); + + const onDone = vi.fn(); + render(); + + // Fast-forward initial poll + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + + expect(fetchMock).toHaveBeenCalledWith("/api/index-status"); + expect(screen.getByText("Indexing in progress")).toBeInTheDocument(); + expect(screen.getByText("/path/to/project1")).toBeInTheDocument(); + expect(onDone).not.toHaveBeenCalled(); + }); + + it("stops polling and calls onDone when indexing finishes successfully", async () => { + let mockData = [ + { slot: 1, status: "indexing", path: "/path/to/project" } + ]; + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve({ + json: () => Promise.resolve(mockData) + } as unknown as Response) + ); + vi.stubGlobal("fetch", fetchMock); + + const onDone = vi.fn(); + render(); + + // First poll returns active + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + expect(onDone).not.toHaveBeenCalled(); + + // Indexing finishes + mockData = []; + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + + expect(onDone).toHaveBeenCalled(); + }); + + it("renders error banner and does NOT call onDone when indexing fails with error status", async () => { + const fetchMock = vi.fn().mockImplementation(() => + Promise.resolve({ + json: () => Promise.resolve([ + { slot: 1, status: "error", path: "/path/to/failed-project", error: "OOM Error" } + ]) + } as unknown as Response) + ); + vi.stubGlobal("fetch", fetchMock); + + const onDone = vi.fn(); + render(); + + await act(async () => { + await vi.advanceTimersByTimeAsync(2000); + }); + + // Error banner should show up + expect(screen.getByText("Indexing Failed")).toBeInTheDocument(); + expect(screen.getByText("/path/to/failed-project")).toBeInTheDocument(); + expect(screen.getByText("OOM Error")).toBeInTheDocument(); + + // onDone should not be called automatically + expect(onDone).not.toHaveBeenCalled(); + + // Click Dismiss button + const dismissBtn = screen.getByRole("button", { name: /Dismiss/i }); + expect(dismissBtn).toBeInTheDocument(); + + await act(async () => { + fireEvent.click(dismissBtn); + }); + + // onDone should be called after manual dismissal + expect(onDone).toHaveBeenCalled(); + }); +}); diff --git a/graph-ui/src/components/StatsTab.tsx b/graph-ui/src/components/StatsTab.tsx index 98890271..9b8fec10 100644 --- a/graph-ui/src/components/StatsTab.tsx +++ b/graph-ui/src/components/StatsTab.tsx @@ -273,20 +273,36 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat /* ── Index Progress ─────────────────────────────────────── */ -function IndexProgress({ onDone }: { onDone: () => void }) { - const [jobs, setJobs] = useState<{ slot: number; status: string; path: string }[]>([]); +export function IndexProgress({ onDone }: { onDone: () => void }) { + const [jobs, setJobs] = useState<{ slot: number; status: string; path: string; error?: string }[]>([]); + const [hasActive, setHasActive] = useState(true); + useEffect(() => { + if (!hasActive) return; const poll = setInterval(async () => { try { const data = await (await fetch("/api/index-status")).json(); setJobs(data); - if (data.length > 0 && data.every((j: { status: string }) => j.status !== "indexing")) onDone(); - } catch { /* */ } + const stillIndexing = data.some((j: { status: string }) => j.status === "indexing"); + if (!stillIndexing) { + setHasActive(false); + const hasErrors = data.some((j: { status: string }) => j.status === "error"); + if (!hasErrors) { + onDone(); + } + } + } catch (error) { + console.error("[IndexProgress] Poll failed:", error); + } }, 2000); return () => clearInterval(poll); - }, [onDone]); + }, [onDone, hasActive]); + const active = jobs.filter((j) => j.status === "indexing"); - if (active.length === 0) return null; + const errors = jobs.filter((j) => j.status === "error"); + + if (active.length === 0 && errors.length === 0) return null; + return (
{active.map((j) => ( @@ -298,6 +314,26 @@ function IndexProgress({ onDone }: { onDone: () => void }) {
))} + {errors.map((j) => ( +
+ ⚠️ +
+

Indexing Failed

+

{j.path}

+ {j.error &&

{j.error}

} +
+
+ ))} + {errors.length > 0 && ( +
+ +
+ )} ); } diff --git a/graph-ui/vite.config.ts b/graph-ui/vite.config.ts index 0d02daf8..d7a3a0b8 100644 --- a/graph-ui/vite.config.ts +++ b/graph-ui/vite.config.ts @@ -1,3 +1,4 @@ +/// import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; @@ -10,6 +11,10 @@ export default defineConfig({ "@": path.resolve(__dirname, "./src"), }, }, + test: { + environment: "jsdom", + globals: true, + }, build: { outDir: "dist", assetsDir: "assets",