Multiple coordinated fixes for the perceived "dashboard takes forever to load" complaint. Per-page-load HTTP requests drop from ~177 to ~101 and duplicate per-project InProcessRuntime creation is eliminated. - engine: shouldUseHybridExecutor no longer auto-enables for local-only multi-project setups (set FUSION_HYBRID_EXECUTOR=1 to force). The duplicate-runtime path was running self-healing twice per project and contending on the same SQLite file. ProjectEngineManager already handles N local projects with one InProcessRuntime each. - dashboard cli: parallelized independent store inits, started CentralCore.init early in background, ran plugin loading concurrently with extension resolution. Sequenced SQLite store inits to avoid a TOCTOU race in addColumnIfMissing migrations across TaskStore / AutomationStore / PluginStore / AgentStore (all open the same .fusion/fusion.db). Restored try/catch around HybridExecutor.initialize and engineManager.ensureEngine so a paused or broken cwd project no longer aborts dashboard startup. - dashboard client: added in-flight request dedupe wrapped around the top API offenders. /api/plugins/ui-slots drops from 17x to 1x per load. dedupe.forceFresh redirects ALL in-flight waiters to receive the fresh post-mutation response, not just the forcing caller. Generation counters in useAgents and AgentListModal protect against slow polls overwriting fresh state. - dashboard SSE: agent event handler now debounces 250ms with a trailing-edge guard so multi-agent activity bursts coalesce to at most 2 refetches per burst. - dashboard route: PATCH /api/projects/:id with isolationMode change returns 503 with actionable guidance when HybridExecutor is unavailable, instead of silently persisting a config the live runtime won't honor. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
308 lines
10 KiB
TypeScript
308 lines
10 KiB
TypeScript
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";
|
||
import { SWR_CACHE_KEYS } from "../../utils/swrCache";
|
||
|
||
vi.mock("../../api", () => ({
|
||
fetchAgents: vi.fn(),
|
||
fetchAgentStats: vi.fn(),
|
||
}));
|
||
|
||
const mockFetchAgents = vi.mocked(api.fetchAgents);
|
||
const mockFetchAgentStats = vi.mocked(api.fetchAgentStats);
|
||
|
||
function expectEventsUrl(url: string, projectId?: string) {
|
||
const parsed = new URL(url, "http://localhost");
|
||
expect(parsed.pathname).toBe("/api/events");
|
||
expect(parsed.searchParams.get("projectId")).toBe(projectId ?? null);
|
||
expect(parsed.searchParams.get("clientId")).toBeTruthy();
|
||
}
|
||
|
||
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 = [];
|
||
window.sessionStorage.clear();
|
||
window.localStorage.clear();
|
||
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("hydrates cached agents and stats synchronously before revalidation", async () => {
|
||
const cachedAgents = [createAgent({ id: "cached-agent", name: "Cached", state: "active" })];
|
||
const cachedStats = { ...defaultStats, activeCount: 9 };
|
||
window.localStorage.setItem(SWR_CACHE_KEYS.AGENTS, JSON.stringify(cachedAgents));
|
||
window.localStorage.setItem(SWR_CACHE_KEYS.AGENT_STATS, JSON.stringify(cachedStats));
|
||
|
||
let resolveAgents: ((agents: Agent[]) => void) | undefined;
|
||
mockFetchAgents.mockImplementationOnce(
|
||
() =>
|
||
new Promise<Agent[]>((resolve) => {
|
||
resolveAgents = resolve;
|
||
}),
|
||
);
|
||
|
||
const { result } = renderHook(() => useAgents());
|
||
|
||
expect(result.current.agents).toEqual(cachedAgents);
|
||
expect(result.current.stats).toEqual(cachedStats);
|
||
expect(result.current.isLoading).toBe(false);
|
||
|
||
await act(async () => {
|
||
resolveAgents?.([createAgent({ id: "live-agent", state: "active" })]);
|
||
await Promise.resolve();
|
||
});
|
||
});
|
||
|
||
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", includeEphemeral: false }, undefined);
|
||
});
|
||
|
||
it("refreshAgents reloads both list and stats in one call", async () => {
|
||
const { result } = renderHook(() => useAgents());
|
||
|
||
await waitFor(() => {
|
||
expect(mockFetchAgents).toHaveBeenCalled();
|
||
expect(mockFetchAgentStats).toHaveBeenCalled();
|
||
});
|
||
|
||
mockFetchAgents.mockClear();
|
||
mockFetchAgentStats.mockClear();
|
||
|
||
await act(async () => {
|
||
await result.current.refreshAgents();
|
||
});
|
||
|
||
expect(mockFetchAgents).toHaveBeenCalledTimes(1);
|
||
expect(mockFetchAgentStats).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
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.length).toBeGreaterThan(0);
|
||
expectEventsUrl(urls[urls.length - 1]!);
|
||
});
|
||
});
|
||
|
||
it("writes through agents and stats cache after successful fetch", async () => {
|
||
const agents = [createAgent({ id: "live-agent", state: "active" })];
|
||
const stats = { ...defaultStats, activeCount: 4 };
|
||
mockFetchAgents.mockResolvedValueOnce(agents);
|
||
mockFetchAgentStats.mockResolvedValueOnce(stats);
|
||
|
||
renderHook(() => useAgents());
|
||
|
||
await waitFor(() => {
|
||
const agentsEnvelope = JSON.parse(window.localStorage.getItem(SWR_CACHE_KEYS.AGENTS) ?? "null") as {
|
||
savedAt?: number;
|
||
data?: unknown;
|
||
};
|
||
const statsEnvelope = JSON.parse(window.localStorage.getItem(SWR_CACHE_KEYS.AGENT_STATS) ?? "null") as {
|
||
savedAt?: number;
|
||
data?: unknown;
|
||
};
|
||
expect(typeof agentsEnvelope.savedAt).toBe("number");
|
||
expect(agentsEnvelope.data).toEqual(agents);
|
||
expect(typeof statsEnvelope.savedAt).toBe("number");
|
||
expect(statsEnvelope.data).toEqual(stats);
|
||
});
|
||
});
|
||
|
||
it("refreshes agents and stats on supported SSE events (debounced — burst collapses to 1 refetch)", async () => {
|
||
renderHook(() => useAgents());
|
||
|
||
await waitFor(() => {
|
||
expect(MockEventSource.instances.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
const es = MockEventSource.instances[MockEventSource.instances.length - 1];
|
||
mockFetchAgents.mockClear();
|
||
mockFetchAgentStats.mockClear();
|
||
|
||
// Burst of 7 SSE events arriving within the debounce window. The
|
||
// refresh handler coalesces them into a single fetchAgents/fetchAgentStats
|
||
// round trip — previously this fired 7× per event, which became a
|
||
// request storm during multi-agent activity bursts.
|
||
for (const event of ["agent:created", "agent:updated", "agent:deleted", "agent:stateChanged", "approval:requested", "approval:updated", "approval:decided"]) {
|
||
act(() => {
|
||
es._emit(event);
|
||
});
|
||
}
|
||
|
||
await waitFor(() => {
|
||
expect(mockFetchAgents).toHaveBeenCalledTimes(1);
|
||
expect(mockFetchAgentStats).toHaveBeenCalledTimes(1);
|
||
});
|
||
});
|
||
|
||
it("reconciles SSE updates on top of cached hydration", async () => {
|
||
const cachedAgents = [createAgent({ id: "cached-agent", state: "active" })];
|
||
window.localStorage.setItem(SWR_CACHE_KEYS.AGENTS, JSON.stringify(cachedAgents));
|
||
|
||
mockFetchAgents.mockResolvedValueOnce(cachedAgents);
|
||
|
||
const { result } = renderHook(() => useAgents());
|
||
|
||
await waitFor(() => {
|
||
expect(MockEventSource.instances.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
const es = MockEventSource.instances[MockEventSource.instances.length - 1];
|
||
mockFetchAgents.mockResolvedValueOnce([
|
||
...cachedAgents,
|
||
createAgent({ id: "from-sse", state: "running" }),
|
||
]);
|
||
|
||
act(() => {
|
||
es._emit("agent:updated");
|
||
});
|
||
|
||
await waitFor(() => {
|
||
expect(result.current.agents.some((agent) => agent.id === "from-sse")).toBe(true);
|
||
});
|
||
});
|
||
|
||
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({ includeEphemeral: false }, projectId);
|
||
expect(mockFetchAgentStats).toHaveBeenCalledWith(projectId);
|
||
});
|
||
|
||
const urls = MockEventSource.instances.map((es) => es.url);
|
||
expect(urls.length).toBeGreaterThan(0);
|
||
expectEventsUrl(urls[urls.length - 1]!, projectId);
|
||
});
|
||
});
|