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
105 changes: 105 additions & 0 deletions graph-ui/src/hooks/useProjects.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/* @vitest-environment jsdom */
import "@testing-library/jest-dom/vitest";
import { cleanup, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useProjects } from "./useProjects";

const { callToolMock } = vi.hoisted(() => ({ callToolMock: vi.fn() }));

vi.mock("../api/rpc", () => ({ callTool: callToolMock }));

function ProjectsHarness() {
const { projects } = useProjects();
return <output data-testid="project-count">{projects.length}</output>;
}

describe("useProjects", () => {
afterEach(() => {
cleanup();
vi.clearAllMocks();
});

it("loads every list_projects page", async () => {
const firstPage = Array.from({ length: 50 }, (_, index) => ({
name: `project-${index + 1}`,
root_path: `/repo/project-${index + 1}`,
indexed_at: "2026-01-01T00:00:00Z",
}));
const finalProject = {
name: "project-51",
root_path: "/repo/project-51",
indexed_at: "2026-01-01T00:00:00Z",
};

callToolMock.mockImplementation(
async (name: string, args: Record<string, unknown> = {}) => {
if (name === "list_projects") {
if (args.offset === 50) {
return {
projects: [finalProject],
total: 51,
offset: 50,
limit: 50,
returned: 1,
has_more: false,
};
}
return {
projects: firstPage,
total: 51,
offset: 0,
limit: 50,
returned: 50,
has_more: true,
};
}
return {
node_labels: [],
edge_types: [],
total_nodes: 0,
total_edges: 0,
};
},
);

render(<ProjectsHarness />);

await waitFor(() => {
expect(screen.getByTestId("project-count")).toHaveTextContent("51");
});
expect(callToolMock).toHaveBeenCalledWith("list_projects", {
offset: 50,
limit: 50,
});
});

it("renders projects before bounded schema hydration completes", async () => {
const projects = Array.from({ length: 10 }, (_, index) => ({
name: `project-${index + 1}`,
root_path: `/repo/project-${index + 1}`,
indexed_at: "2026-01-01T00:00:00Z",
}));

callToolMock.mockImplementation(async (name: string) => {
if (name === "list_projects") {
return {
projects,
total: projects.length,
offset: 0,
limit: 50,
returned: projects.length,
has_more: false,
};
}
return new Promise(() => {});
});

render(<ProjectsHarness />);

await waitFor(() => {
expect(screen.getByTestId("project-count")).toHaveTextContent("10");
});
const schemaCalls = callToolMock.mock.calls.filter(([name]) => name === "get_graph_schema");
expect(schemaCalls).toHaveLength(4);
});
});
80 changes: 63 additions & 17 deletions graph-ui/src/hooks/useProjects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,64 @@ interface UseProjectsResult {
refresh: () => void;
}

interface ProjectPage {
projects?: Project[];
offset?: number;
returned?: number;
has_more?: boolean;
}

const PROJECT_PAGE_SIZE = 50;
const SCHEMA_CONCURRENCY = 4;

async function fetchAllProjects(): Promise<Project[]> {
const projects: Project[] = [];
let offset = 0;

while (true) {
const page = await callTool<ProjectPage>("list_projects", {
offset,
limit: PROJECT_PAGE_SIZE,
});
const pageProjects = page.projects ?? [];
projects.push(...pageProjects);

if (!page.has_more) return projects;

const pageOffset = Number.isSafeInteger(page.offset) ? page.offset! : offset;
const returned = Number.isSafeInteger(page.returned) ? page.returned! : pageProjects.length;
const nextOffset = pageOffset + returned;
if (returned <= 0 || nextOffset <= offset) {
throw new Error("list_projects pagination did not advance");
}
offset = nextOffset;
}
}

async function hydrateSchemas(infos: ProjectInfo[]): Promise<void> {
let nextIndex = 0;

async function worker() {
while (nextIndex < infos.length) {
const index = nextIndex++;
const project = infos[index].project;
try {
infos[index] = {
project,
schema: await callTool<SchemaInfo>("get_graph_schema", {
project: project.name,
}),
};
} catch {
infos[index] = { project, schema: null };
}
}
}

const workerCount = Math.min(SCHEMA_CONCURRENCY, infos.length);
await Promise.all(Array.from({ length: workerCount }, () => worker()));
}

export function useProjects(): UseProjectsResult {
const [projects, setProjects] = useState<ProjectInfo[]>([]);
const [loading, setLoading] = useState(true);
Expand All @@ -23,24 +81,12 @@ export function useProjects(): UseProjectsResult {
setLoading(true);
setError(null);
try {
const result = await callTool<{ projects: Project[] }>("list_projects");
const list = result.projects ?? [];

/* Fetch schema for each project */
const infos: ProjectInfo[] = await Promise.all(
list.map(async (p) => {
try {
const schema = await callTool<SchemaInfo>("get_graph_schema", {
project: p.name,
});
return { project: p, schema };
} catch {
return { project: p, schema: null };
}
}),
);

const list = await fetchAllProjects();
const infos: ProjectInfo[] = list.map((project) => ({ project, schema: null }));
setProjects(infos);

await hydrateSchemas(infos);
setProjects([...infos]);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to fetch projects");
} finally {
Expand Down
Loading