test(FN-1282): expand dashboard hook and modal coverage

- Add tests for useAgents and useProjectHealth hooks, including loading, success, and error flows
- Add useFileBrowser and useFileEditor hook tests for file navigation, save behavior, and edge cases
- Add component tests for AgentImportModal and AgentGenerationModal interaction paths
- Add MissionInterviewModal tests covering question flow, submission, and state transitions
- Stabilize dashboard test suite cleanup and mocking patterns to reduce flakiness
This commit is contained in:
gsxdsm
2026-04-08 10:48:42 -07:00
parent 3ee66cdda0
commit 914b93cc43
7 changed files with 1841 additions and 0 deletions

View File

@@ -0,0 +1,197 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useAgents } from "../useAgents";
import * as api from "../../api";
import type { Agent, AgentCapability, AgentState, AgentStats } from "../../api";
import { MockEventSource } from "../../../vitest.setup";
vi.mock("../../api", () => ({
fetchAgents: vi.fn(),
fetchAgentStats: vi.fn(),
}));
const mockFetchAgents = vi.mocked(api.fetchAgents);
const mockFetchAgentStats = vi.mocked(api.fetchAgentStats);
function createAgent(overrides: Partial<Agent> = {}): Agent {
return {
id: "agent-1",
name: "Agent One",
role: "executor" as AgentCapability,
state: "idle" as AgentState,
metadata: {},
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
const defaultStats: AgentStats = {
activeCount: 1,
assignedTaskCount: 2,
completedRuns: 10,
failedRuns: 1,
successRate: 0.9,
};
describe("useAgents", () => {
beforeEach(() => {
MockEventSource.instances = [];
mockFetchAgents.mockReset().mockResolvedValue([]);
mockFetchAgentStats.mockReset().mockResolvedValue(defaultStats);
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
for (const es of MockEventSource.instances) {
es.close();
}
MockEventSource.instances = [];
});
it("returns empty agents and null stats initially; loading settles after fetch", async () => {
const { result } = renderHook(() => useAgents());
expect(result.current.agents).toEqual([]);
expect(result.current.stats).toBeNull();
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(mockFetchAgents).toHaveBeenCalled();
expect(mockFetchAgentStats).toHaveBeenCalled();
});
it("fetches agents and stats on mount and populates state", async () => {
const agents = [
createAgent({ id: "a-1", name: "Alpha", state: "active" }),
createAgent({ id: "a-2", name: "Beta", state: "idle" }),
];
mockFetchAgents.mockResolvedValueOnce(agents);
mockFetchAgentStats.mockResolvedValueOnce({ ...defaultStats, activeCount: 1 });
const { result } = renderHook(() => useAgents());
await waitFor(() => {
expect(result.current.agents).toEqual(agents);
expect(result.current.stats).toEqual({ ...defaultStats, activeCount: 1 });
});
});
it("filters active agents from mixed states", async () => {
const agents = [
createAgent({ id: "a-idle", state: "idle" }),
createAgent({ id: "a-active", state: "active" }),
createAgent({ id: "a-running", state: "running" }),
createAgent({ id: "a-error", state: "error" }),
];
mockFetchAgents.mockResolvedValueOnce(agents);
const { result } = renderHook(() => useAgents());
await waitFor(() => {
expect(result.current.activeAgents.map((a) => a.id)).toEqual(["a-active", "a-running"]);
});
});
it("loadAgents accepts optional filters and passes them to fetchAgents", async () => {
const { result } = renderHook(() => useAgents());
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalled();
});
await act(async () => {
await result.current.loadAgents({ state: "active", role: "executor" });
});
expect(mockFetchAgents).toHaveBeenLastCalledWith({ state: "active", role: "executor" }, undefined);
});
it("handles fetchAgents rejection gracefully", async () => {
mockFetchAgents.mockRejectedValueOnce(new Error("agents failed"));
const { result } = renderHook(() => useAgents());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.agents).toEqual([]);
expect(console.error).toHaveBeenCalledWith("Failed to load agents:", expect.any(Error));
});
it("handles fetchAgentStats rejection gracefully", async () => {
mockFetchAgentStats.mockRejectedValueOnce(new Error("stats failed"));
const { result } = renderHook(() => useAgents());
await waitFor(() => {
expect(mockFetchAgentStats).toHaveBeenCalled();
});
expect(result.current.stats).toBeNull();
expect(console.error).toHaveBeenCalledWith("Failed to load agent stats:", expect.any(Error));
});
it("creates SSE subscription with correct URL without projectId", async () => {
renderHook(() => useAgents());
await waitFor(() => {
const urls = MockEventSource.instances.map((es) => es.url);
expect(urls).toContain("/api/events");
});
});
it("refreshes agents and stats on supported SSE events", async () => {
renderHook(() => useAgents());
await waitFor(() => {
expect(MockEventSource.instances.length).toBeGreaterThan(0);
});
const es = MockEventSource.instances[MockEventSource.instances.length - 1];
mockFetchAgents.mockClear();
mockFetchAgentStats.mockClear();
for (const event of ["agent:created", "agent:updated", "agent:deleted", "agent:stateChanged"]) {
act(() => {
es._emit(event);
});
}
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledTimes(4);
expect(mockFetchAgentStats).toHaveBeenCalledTimes(4);
});
});
it("closes SSE subscription on unmount", async () => {
const { unmount } = renderHook(() => useAgents());
await waitFor(() => {
expect(MockEventSource.instances.length).toBeGreaterThan(0);
});
const es = MockEventSource.instances[MockEventSource.instances.length - 1];
unmount();
expect(es.close).toHaveBeenCalled();
});
it("with projectId passes projectId to fetch calls and EventSource URL", async () => {
const projectId = "proj-123";
renderHook(() => useAgents(projectId));
await waitFor(() => {
expect(mockFetchAgents).toHaveBeenCalledWith(undefined, projectId);
expect(mockFetchAgentStats).toHaveBeenCalledWith(projectId);
});
const urls = MockEventSource.instances.map((es) => es.url);
expect(urls).toContain(`/api/events?projectId=${encodeURIComponent(projectId)}`);
});
});

View File

@@ -0,0 +1,185 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useFileBrowser } from "../useFileBrowser";
import * as api from "../../api";
import type { FileListResponse } from "../../api";
vi.mock("../../api", () => ({
fetchFileList: vi.fn(),
}));
const mockFetchFileList = vi.mocked(api.fetchFileList);
function response(path: string, names: string[]): FileListResponse {
return {
path,
entries: names.map((name) => ({
name,
type: name.includes(".") ? "file" : "directory",
mtime: "2026-01-01T00:00:00.000Z",
...(name.includes(".") ? { size: 123 } : {}),
})),
};
}
describe("useFileBrowser", () => {
beforeEach(() => {
mockFetchFileList.mockReset();
mockFetchFileList.mockResolvedValue(response(".", ["src", "README.md"]));
});
afterEach(() => {
vi.clearAllMocks();
});
it("returns empty entries and loading=false when enabled=false", async () => {
const { result } = renderHook(() => useFileBrowser("FN-001", false));
expect(result.current.entries).toEqual([]);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
await new Promise((resolve) => setTimeout(resolve, 10));
expect(mockFetchFileList).not.toHaveBeenCalled();
});
it("returns empty entries and loading=false when taskId is empty", async () => {
const { result } = renderHook(() => useFileBrowser("", true));
expect(result.current.entries).toEqual([]);
expect(result.current.loading).toBe(false);
await new Promise((resolve) => setTimeout(resolve, 10));
expect(mockFetchFileList).not.toHaveBeenCalled();
});
it("fetches file list when enabled and sets loading then entries", async () => {
const { result } = renderHook(() => useFileBrowser("FN-001", true));
await waitFor(() => {
expect(result.current.loading).toBe(true);
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.entries).toHaveLength(2);
});
});
it("populates entries from FileListResponse", async () => {
mockFetchFileList.mockResolvedValueOnce(response(".", ["docs", "notes.md", "index.ts"]));
const { result } = renderHook(() => useFileBrowser("FN-001", true));
await waitFor(() => {
expect(result.current.entries.map((entry) => entry.name)).toEqual(["docs", "notes.md", "index.ts"]);
});
});
it("handles fetch error by setting error message and clearing entries", async () => {
mockFetchFileList.mockRejectedValueOnce(new Error("Failed to load files"));
const { result } = renderHook(() => useFileBrowser("FN-001", true));
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe("Failed to load files");
});
expect(result.current.entries).toEqual([]);
});
it("setPath updates currentPath, clears error, and triggers new fetch", async () => {
mockFetchFileList
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce(response("src", ["index.ts"]));
const { result } = renderHook(() => useFileBrowser("FN-001", true));
await waitFor(() => {
expect(result.current.error).toBe("boom");
});
act(() => {
result.current.setPath("src");
});
expect(result.current.currentPath).toBe("src");
expect(result.current.error).toBeNull();
await waitFor(() => {
expect(mockFetchFileList).toHaveBeenLastCalledWith("FN-001", "src");
expect(result.current.entries.map((entry) => entry.name)).toEqual(["index.ts"]);
});
});
it("normalizes '.' path to undefined when calling fetchFileList", async () => {
const { result } = renderHook(() => useFileBrowser("FN-001", true));
await waitFor(() => {
expect(mockFetchFileList).toHaveBeenCalledWith("FN-001", undefined);
});
act(() => {
result.current.setPath(".");
});
await waitFor(() => {
expect(mockFetchFileList).toHaveBeenLastCalledWith("FN-001", undefined);
});
});
it("passes non-dot paths directly to fetchFileList", async () => {
const { result } = renderHook(() => useFileBrowser("FN-001", true));
await waitFor(() => {
expect(mockFetchFileList).toHaveBeenCalled();
});
act(() => {
result.current.setPath("subdir");
});
await waitFor(() => {
expect(mockFetchFileList).toHaveBeenLastCalledWith("FN-001", "subdir");
});
});
it("refresh increments refresh key and triggers re-fetch", async () => {
mockFetchFileList
.mockResolvedValueOnce(response(".", ["a.txt"]))
.mockResolvedValueOnce(response(".", ["a.txt", "b.txt"]));
const { result } = renderHook(() => useFileBrowser("FN-001", true));
await waitFor(() => {
expect(result.current.entries.map((entry) => entry.name)).toEqual(["a.txt"]);
});
act(() => {
result.current.refresh();
});
await waitFor(() => {
expect(result.current.entries.map((entry) => entry.name)).toEqual(["a.txt", "b.txt"]);
expect(mockFetchFileList).toHaveBeenCalledTimes(2);
});
});
it("cancels in-flight fetch on unmount", async () => {
let resolveFetch!: (value: FileListResponse) => void;
mockFetchFileList.mockReturnValueOnce(
new Promise<FileListResponse>((resolve) => {
resolveFetch = resolve;
}),
);
const { unmount } = renderHook(() => useFileBrowser("FN-001", true));
unmount();
resolveFetch(response(".", ["late.txt"]));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(mockFetchFileList).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,275 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useFileEditor } from "../useFileEditor";
import * as api from "../../api";
import type { FileContentResponse, SaveFileResponse } from "../../api";
vi.mock("../../api", () => ({
fetchFileContent: vi.fn(),
saveFileContent: vi.fn(),
}));
const mockFetchFileContent = vi.mocked(api.fetchFileContent);
const mockSaveFileContent = vi.mocked(api.saveFileContent);
function contentResponse(content: string, mtime = "2026-01-01T00:00:00.000Z"): FileContentResponse {
return {
content,
mtime,
size: content.length,
};
}
function saveResponse(mtime = "2026-01-02T00:00:00.000Z", size = 100): SaveFileResponse {
return {
success: true,
mtime,
size,
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
describe("useFileEditor", () => {
beforeEach(() => {
mockFetchFileContent.mockReset();
mockSaveFileContent.mockReset();
mockFetchFileContent.mockResolvedValue(contentResponse("hello"));
});
afterEach(() => {
vi.clearAllMocks();
});
it("returns empty content when disabled or filePath is null", async () => {
const disabled = renderHook(() => useFileEditor("FN-001", "README.md", false));
expect(disabled.result.current.content).toBe("");
expect(disabled.result.current.originalContent).toBe("");
expect(disabled.result.current.mtime).toBeNull();
const noPath = renderHook(() => useFileEditor("FN-001", null, true));
expect(noPath.result.current.content).toBe("");
expect(noPath.result.current.originalContent).toBe("");
expect(noPath.result.current.mtime).toBeNull();
await new Promise((resolve) => setTimeout(resolve, 10));
expect(mockFetchFileContent).not.toHaveBeenCalled();
});
it("fetches file content when enabled and filePath is set", async () => {
mockFetchFileContent.mockResolvedValueOnce(contentResponse("file body"));
const { result } = renderHook(() => useFileEditor("FN-001", "README.md", true));
await waitFor(() => {
expect(result.current.loading).toBe(true);
});
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.content).toBe("file body");
});
expect(mockFetchFileContent).toHaveBeenCalledWith("FN-001", "README.md");
});
it("sets content and originalContent from fetch response", async () => {
mockFetchFileContent.mockResolvedValueOnce(contentResponse("abc"));
const { result } = renderHook(() => useFileEditor("FN-001", "a.txt", true));
await waitFor(() => {
expect(result.current.content).toBe("abc");
expect(result.current.originalContent).toBe("abc");
});
});
it("sets mtime from fetchFileContent response", async () => {
mockFetchFileContent.mockResolvedValueOnce(contentResponse("abc", "2026-02-01T10:00:00.000Z"));
const { result } = renderHook(() => useFileEditor("FN-001", "a.txt", true));
await waitFor(() => {
expect(result.current.mtime).toBe("2026-02-01T10:00:00.000Z");
});
});
it("handles fetch error by setting error and clearing content/mtime", async () => {
mockFetchFileContent.mockRejectedValueOnce(new Error("load failed"));
const { result } = renderHook(() => useFileEditor("FN-001", "missing.txt", true));
await waitFor(() => {
expect(result.current.loading).toBe(false);
expect(result.current.error).toBe("load failed");
});
expect(result.current.content).toBe("");
expect(result.current.originalContent).toBe("");
expect(result.current.mtime).toBeNull();
});
it("setContent updates content and clears error", async () => {
mockFetchFileContent.mockRejectedValueOnce(new Error("broken"));
const { result } = renderHook(() => useFileEditor("FN-001", "bad.txt", true));
await waitFor(() => {
expect(result.current.error).toBe("broken");
});
act(() => {
result.current.setContent("new content");
});
expect(result.current.content).toBe("new content");
expect(result.current.error).toBeNull();
});
it("hasChanges is false when content equals originalContent", async () => {
mockFetchFileContent.mockResolvedValueOnce(contentResponse("same"));
const { result } = renderHook(() => useFileEditor("FN-001", "same.txt", true));
await waitFor(() => {
expect(result.current.hasChanges).toBe(false);
});
});
it("hasChanges is true when content differs from originalContent", async () => {
mockFetchFileContent.mockResolvedValueOnce(contentResponse("start"));
const { result } = renderHook(() => useFileEditor("FN-001", "edit.txt", true));
await waitFor(() => {
expect(result.current.content).toBe("start");
});
act(() => {
result.current.setContent("changed");
});
expect(result.current.hasChanges).toBe(true);
});
it("save calls saveFileContent with taskId, filePath, and current content", async () => {
mockFetchFileContent.mockResolvedValueOnce(contentResponse("original"));
mockSaveFileContent.mockResolvedValueOnce(saveResponse());
const { result } = renderHook(() => useFileEditor("FN-001", "file.txt", true));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
act(() => {
result.current.setContent("updated");
});
await act(async () => {
await result.current.save();
});
expect(mockSaveFileContent).toHaveBeenCalledWith("FN-001", "file.txt", "updated");
});
it("save updates originalContent and mtime after success", async () => {
mockFetchFileContent.mockResolvedValueOnce(contentResponse("before", "2026-01-01T00:00:00.000Z"));
mockSaveFileContent.mockResolvedValueOnce(saveResponse("2026-02-01T00:00:00.000Z"));
const { result } = renderHook(() => useFileEditor("FN-001", "file.txt", true));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
act(() => {
result.current.setContent("after");
});
await act(async () => {
await result.current.save();
});
expect(result.current.originalContent).toBe("after");
expect(result.current.mtime).toBe("2026-02-01T00:00:00.000Z");
expect(result.current.hasChanges).toBe(false);
});
it("save sets error on failure and re-throws", async () => {
mockFetchFileContent.mockResolvedValueOnce(contentResponse("before"));
mockSaveFileContent.mockRejectedValueOnce(new Error("save failed"));
const { result } = renderHook(() => useFileEditor("FN-001", "file.txt", true));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
act(() => {
result.current.setContent("changed");
});
await act(async () => {
await expect(result.current.save()).rejects.toThrow("save failed");
});
await waitFor(() => {
expect(result.current.error).toBe("save failed");
});
});
it("save is a no-op when hasChanges is false", async () => {
mockFetchFileContent.mockResolvedValueOnce(contentResponse("same"));
const { result } = renderHook(() => useFileEditor("FN-001", "file.txt", true));
await waitFor(() => {
expect(result.current.hasChanges).toBe(false);
});
await act(async () => {
await result.current.save();
});
expect(mockSaveFileContent).not.toHaveBeenCalled();
});
it("cancels in-flight load on filePath change and ignores stale response", async () => {
const first = deferred<FileContentResponse>();
const second = deferred<FileContentResponse>();
mockFetchFileContent
.mockReturnValueOnce(first.promise)
.mockReturnValueOnce(second.promise);
const { result, rerender } = renderHook(
({ filePath }) => useFileEditor("FN-001", filePath, true),
{ initialProps: { filePath: "first.txt" as string | null } },
);
rerender({ filePath: "second.txt" });
second.resolve(contentResponse("second-content", "2026-03-02T00:00:00.000Z"));
await waitFor(() => {
expect(result.current.content).toBe("second-content");
expect(result.current.mtime).toBe("2026-03-02T00:00:00.000Z");
});
first.resolve(contentResponse("stale-first", "2026-03-01T00:00:00.000Z"));
await new Promise((resolve) => setTimeout(resolve, 10));
expect(result.current.content).toBe("second-content");
expect(result.current.mtime).toBe("2026-03-02T00:00:00.000Z");
});
});

View File

@@ -0,0 +1,251 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
import { useProjectHealth } from "../useProjectHealth";
import * as api from "../../api";
import type { ProjectHealth } from "../../api";
vi.mock("../../api", () => ({
fetchProjectHealth: vi.fn(),
}));
const mockFetchProjectHealth = vi.mocked(api.fetchProjectHealth);
function createHealth(projectId: string, overrides: Partial<ProjectHealth> = {}): ProjectHealth {
return {
projectId,
status: "active",
activeTaskCount: 1,
inFlightAgentCount: 0,
totalTasksCompleted: 10,
totalTasksFailed: 1,
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
};
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function renderUseProjectHealth(projectIds: string[]) {
return renderHook(({ ids }) => useProjectHealth(ids), {
initialProps: { ids: projectIds },
});
}
describe("useProjectHealth", () => {
beforeEach(() => {
mockFetchProjectHealth.mockReset();
mockFetchProjectHealth.mockImplementation(async (id: string) => createHealth(id));
vi.spyOn(console, "error").mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("returns empty healthMap and no error when projectIds is empty", async () => {
const { result } = renderUseProjectHealth([]);
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
expect(result.current.healthMap).toEqual({});
expect(result.current.error).toBeNull();
expect(mockFetchProjectHealth).not.toHaveBeenCalled();
});
it("fetches health for each project ID and populates healthMap", async () => {
const ids = ["p1", "p2", "p3"];
const { result } = renderUseProjectHealth(ids);
await waitFor(() => {
expect(result.current.healthMap).toEqual({
p1: createHealth("p1"),
p2: createHealth("p2"),
p3: createHealth("p3"),
});
});
});
it("sets loading true during fetch and false after completion", async () => {
const pending = deferred<ProjectHealth>();
mockFetchProjectHealth.mockReturnValueOnce(pending.promise);
const { result } = renderUseProjectHealth(["p1"]);
await waitFor(() => {
expect(result.current.loading).toBe(true);
});
pending.resolve(createHealth("p1"));
await waitFor(() => {
expect(result.current.loading).toBe(false);
});
});
it("handles individual project fetch failures gracefully", async () => {
mockFetchProjectHealth.mockImplementation(async (id: string) => {
if (id === "p2") {
throw new Error("fetch failed");
}
return createHealth(id);
});
const { result } = renderUseProjectHealth(["p1", "p2", "p3"]);
await waitFor(() => {
expect(result.current.healthMap).toEqual({
p1: createHealth("p1"),
p2: null,
p3: createHealth("p3"),
});
});
expect(result.current.error).toBeNull();
});
it("batches fetches with 5 concurrent per batch", async () => {
const firstBatchDeferred = Array.from({ length: 5 }, () => deferred<ProjectHealth>());
const called: string[] = [];
mockFetchProjectHealth.mockImplementation((id: string) => {
called.push(id);
const index = Number(id.slice(1)) - 1;
if (index < 5) return firstBatchDeferred[index].promise;
return Promise.resolve(createHealth(id));
});
const ids = ["p1", "p2", "p3", "p4", "p5", "p6", "p7"];
renderUseProjectHealth(ids);
await waitFor(() => {
expect(called).toEqual(["p1", "p2", "p3", "p4", "p5"]);
});
expect(called).not.toContain("p6");
expect(called).not.toContain("p7");
firstBatchDeferred.forEach((d, idx) => {
d.resolve(createHealth(`p${idx + 1}`));
});
await waitFor(() => {
expect(called).toContain("p6");
expect(called).toContain("p7");
});
});
it("refresh aborts in-flight requests when called again", async () => {
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
const pending = deferred<ProjectHealth>();
mockFetchProjectHealth.mockReturnValue(pending.promise);
const { result } = renderUseProjectHealth(["p1"]);
await waitFor(() => {
expect(result.current.loading).toBe(true);
});
await act(async () => {
void result.current.refresh();
});
expect(abortSpy).toHaveBeenCalled();
pending.resolve(createHealth("p1"));
});
it("refreshProject updates a single project's health", async () => {
mockFetchProjectHealth.mockImplementation(async (id: string) => createHealth(id));
const { result } = renderUseProjectHealth(["p1", "p2"]);
await waitFor(() => {
expect(result.current.healthMap.p1).toEqual(createHealth("p1"));
expect(result.current.healthMap.p2).toEqual(createHealth("p2"));
});
mockFetchProjectHealth.mockResolvedValueOnce(
createHealth("p2", { activeTaskCount: 99, totalTasksCompleted: 42 }),
);
await act(async () => {
await result.current.refreshProject("p2");
});
expect(result.current.healthMap.p2).toEqual(
createHealth("p2", { activeTaskCount: 99, totalTasksCompleted: 42 }),
);
expect(result.current.healthMap.p1).toEqual(createHealth("p1"));
});
it("polling sets up interval and refreshes every 10 seconds", async () => {
vi.useFakeTimers();
mockFetchProjectHealth.mockResolvedValue(createHealth("p1"));
renderUseProjectHealth(["p1"]);
await act(async () => {
await Promise.resolve();
});
expect(mockFetchProjectHealth).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
await Promise.resolve();
});
expect(mockFetchProjectHealth).toHaveBeenCalledTimes(2);
});
it("polling clears interval on unmount", async () => {
vi.useFakeTimers();
mockFetchProjectHealth.mockResolvedValue(createHealth("p1"));
const { unmount } = renderUseProjectHealth(["p1"]);
await act(async () => {
await Promise.resolve();
});
expect(mockFetchProjectHealth).toHaveBeenCalledTimes(1);
unmount();
await act(async () => {
await vi.advanceTimersByTimeAsync(20_000);
await Promise.resolve();
});
expect(mockFetchProjectHealth).toHaveBeenCalledTimes(1);
});
it("cleanup aborts in-flight requests on unmount", async () => {
const abortSpy = vi.spyOn(AbortController.prototype, "abort");
const pending = deferred<ProjectHealth>();
mockFetchProjectHealth.mockReturnValue(pending.promise);
const { unmount } = renderUseProjectHealth(["p1"]);
await waitFor(() => {
expect(mockFetchProjectHealth).toHaveBeenCalled();
});
unmount();
expect(abortSpy).toHaveBeenCalled();
pending.resolve(createHealth("p1"));
});
});