Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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/
106 changes: 106 additions & 0 deletions graph-ui/src/components/StatsTab.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<IndexProgress onDone={onDone} />);

// 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(<IndexProgress onDone={onDone} />);

// 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(<IndexProgress onDone={onDone} />);

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();
});
});
48 changes: 42 additions & 6 deletions graph-ui/src/components/StatsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="rounded-xl border border-primary/20 bg-primary/5 p-4 mb-6">
{active.map((j) => (
Expand All @@ -298,6 +314,26 @@ function IndexProgress({ onDone }: { onDone: () => void }) {
</div>
</div>
))}
{errors.map((j) => (
<div key={j.slot} className="flex items-start gap-3 mt-3 first:mt-0 p-3 rounded-lg border border-destructive/20 bg-destructive/5 text-destructive">
<span className="text-[14px]">⚠️</span>
<div className="flex-1 min-w-0">
<p className="text-[12px] font-semibold">Indexing Failed</p>
<p className="text-[11px] font-mono truncate">{j.path}</p>
{j.error && <p className="text-[10px] opacity-75 mt-1 font-mono">{j.error}</p>}
</div>
</div>
))}
{errors.length > 0 && (
<div className="flex justify-end mt-3">
<button
onClick={onDone}
className="px-3 py-1 rounded bg-destructive/10 hover:bg-destructive/20 text-destructive text-[11px] font-medium transition-all"
>
Dismiss
</button>
</div>
)}
</div>
);
}
Expand Down
5 changes: 5 additions & 0 deletions graph-ui/vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/// <reference types="vitest" />
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
Expand All @@ -10,6 +11,10 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
test: {
environment: "jsdom",
globals: true,
},
build: {
outDir: "dist",
assetsDir: "assets",
Expand Down
Loading