Render registered project cards immediately while optional health telemetry hydrates in the background. - Publish completed health batches progressively and prevent stale responses from overwriting current state. - Keep project controls usable while health metrics load and indicate per-card pending telemetry. - Add coverage and operator documentation for progressive overview hydration. Files changed: .changeset/fn-8703-project-overview-load.md | 7 ++ docs/dashboard-guide.md | 3 +- packages/dashboard/app/components/ProjectCard.tsx | 12 ++- .../dashboard/app/components/ProjectOverview.tsx | 15 +-- .../components/__tests__/ProjectOverview.test.tsx | 101 ++++++++++++++++++++ .../app/hooks/__tests__/useProjectHealth.test.ts | 98 ++++++++++++++++++++ packages/dashboard/app/hooks/useProjectHealth.ts | 102 ++++++++++----------- 7 files changed, 277 insertions(+), 61 deletions(-) Fusion-Task-Id: FN-8703 Fusion-Task-Lineage: eb816c9d-c808-4d52-a3f0-28bb9de5f149 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
409 lines
12 KiB
TypeScript
409 lines
12 KiB
TypeScript
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("publishes each completed batch before a later batch settles", async () => {
|
|
const firstBatch = Array.from({ length: 5 }, () => deferred<ProjectHealth>());
|
|
const sixthProject = deferred<ProjectHealth>();
|
|
mockFetchProjectHealth.mockImplementation((id: string) => {
|
|
const index = Number(id.slice(1)) - 1;
|
|
return index < 5 ? firstBatch[index].promise : sixthProject.promise;
|
|
});
|
|
|
|
const { result } = renderUseProjectHealth(["p1", "p2", "p3", "p4", "p5", "p6"]);
|
|
|
|
await waitFor(() => {
|
|
expect(mockFetchProjectHealth).toHaveBeenCalledTimes(5);
|
|
});
|
|
|
|
await act(async () => {
|
|
firstBatch.forEach((pending, index) => pending.resolve(createHealth(`p${index + 1}`)));
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.healthMap).toEqual({
|
|
p1: createHealth("p1"),
|
|
p2: createHealth("p2"),
|
|
p3: createHealth("p3"),
|
|
p4: createHealth("p4"),
|
|
p5: createHealth("p5"),
|
|
});
|
|
expect(result.current.loading).toBe(true);
|
|
expect(mockFetchProjectHealth).toHaveBeenCalledTimes(6);
|
|
});
|
|
|
|
sixthProject.resolve(createHealth("p6"));
|
|
await waitFor(() => {
|
|
expect(result.current.healthMap.p6).toEqual(createHealth("p6"));
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
});
|
|
|
|
it("deduplicates project IDs before fetching health", async () => {
|
|
const { result } = renderUseProjectHealth(["p1", "p1", "p2", "p2"]);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.healthMap).toEqual({
|
|
p1: createHealth("p1"),
|
|
p2: createHealth("p2"),
|
|
});
|
|
});
|
|
|
|
expect(mockFetchProjectHealth.mock.calls.map(([id]) => id)).toEqual(["p1", "p2"]);
|
|
});
|
|
|
|
it("does not publish an older project list after IDs change", async () => {
|
|
const oldHealth = deferred<ProjectHealth>();
|
|
const currentHealth = deferred<ProjectHealth>();
|
|
mockFetchProjectHealth.mockImplementation((id: string) => id === "old" ? oldHealth.promise : currentHealth.promise);
|
|
|
|
const { result, rerender } = renderUseProjectHealth(["old"]);
|
|
await waitFor(() => expect(mockFetchProjectHealth).toHaveBeenCalledWith("old"));
|
|
|
|
rerender({ ids: ["current"] });
|
|
await waitFor(() => expect(mockFetchProjectHealth).toHaveBeenCalledWith("current"));
|
|
|
|
currentHealth.resolve(createHealth("current"));
|
|
await waitFor(() => expect(result.current.healthMap).toEqual({ current: createHealth("current") }));
|
|
|
|
oldHealth.resolve(createHealth("old", { activeTaskCount: 99 }));
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(result.current.healthMap).toEqual({ current: createHealth("current") });
|
|
});
|
|
|
|
it("does not let an older manual refresh overwrite newer health", async () => {
|
|
const olderHealth = deferred<ProjectHealth>();
|
|
const newerHealth = deferred<ProjectHealth>();
|
|
mockFetchProjectHealth
|
|
.mockReturnValueOnce(olderHealth.promise)
|
|
.mockReturnValueOnce(newerHealth.promise);
|
|
|
|
const { result } = renderUseProjectHealth(["p1"]);
|
|
await waitFor(() => expect(mockFetchProjectHealth).toHaveBeenCalledTimes(1));
|
|
|
|
await act(async () => {
|
|
void result.current.refresh();
|
|
});
|
|
await waitFor(() => expect(mockFetchProjectHealth).toHaveBeenCalledTimes(2));
|
|
|
|
newerHealth.resolve(createHealth("p1", { activeTaskCount: 2 }));
|
|
await waitFor(() => expect(result.current.healthMap.p1?.activeTaskCount).toBe(2));
|
|
|
|
olderHealth.resolve(createHealth("p1", { activeTaskCount: 99 }));
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
expect(result.current.healthMap.p1?.activeTaskCount).toBe(2);
|
|
});
|
|
|
|
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"));
|
|
});
|
|
|
|
it("does not set loading to true during background polling refreshes", async () => {
|
|
// This is a regression test for FN-1734: polling refreshes should NOT
|
|
// set loading=true, as that would cause UI flicker and scroll position resets
|
|
vi.useFakeTimers();
|
|
mockFetchProjectHealth.mockResolvedValue(createHealth("p1"));
|
|
|
|
const { result } = renderUseProjectHealth(["p1"]);
|
|
|
|
// Flush initial effects
|
|
await act(async () => {
|
|
await Promise.resolve();
|
|
});
|
|
|
|
// Initial load should be complete - loading should be false
|
|
expect(result.current.loading).toBe(false);
|
|
expect(result.current.healthMap.p1).toEqual(createHealth("p1"));
|
|
|
|
// Capture loading state before polling
|
|
const loadingBeforePolling = result.current.loading;
|
|
|
|
// Advance timer for polling refresh (10 seconds)
|
|
await act(async () => {
|
|
await vi.advanceTimersByTimeAsync(10_000);
|
|
await Promise.resolve();
|
|
});
|
|
|
|
// After polling refresh: loading should still be false
|
|
// (Regression: this was the bug - loading was set to true on every refresh)
|
|
expect(result.current.loading).toBe(false);
|
|
expect(loadingBeforePolling).toBe(false);
|
|
|
|
// Health data should still be present
|
|
expect(result.current.healthMap.p1).toEqual(createHealth("p1"));
|
|
});
|
|
|
|
it("handles projectIds transition from empty to non-empty correctly", async () => {
|
|
// This tests that switching from no projects to having projects works correctly
|
|
const { result, rerender } = renderUseProjectHealth([]);
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
expect(result.current.healthMap).toEqual({});
|
|
|
|
// Now switch to having projects
|
|
rerender({ ids: ["p1"] });
|
|
|
|
// Should start loading again for the new project
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(true);
|
|
});
|
|
|
|
await waitFor(() => {
|
|
expect(result.current.loading).toBe(false);
|
|
});
|
|
|
|
expect(result.current.healthMap.p1).toEqual(createHealth("p1"));
|
|
});
|
|
});
|