chore(test): split dashboard api/routes monoliths and document core fork-only constraint

Split packages/dashboard/app/__tests__/api.test.ts (~6.4k lines) into per-area files (auth, git, missions, projects, settings, tasks), and src/__tests__/routes.test.ts (~20k lines) into per-area files (agents, auth, automation, git, github, planning, settings, system, tasks, tasks-ops). The monolith files were dominating wall-clock for the dashboard suite under file-parallel execution.

Also document in packages/core/vitest.config.ts why the core suite cannot move to "threads": vitest-setup gates per-worker cwd on isMainThread (false in worker_threads, so isolation breaks), and setup-test-isolation writes process.env.HOME unconditionally (threads share env, so concurrent workers race).

Drop an unused execFileSync import from scripts/test-with-lock.mjs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-05-02 17:25:40 -07:00
parent 012714430b
commit a01ae60088
20 changed files with 30508 additions and 26796 deletions

View File

@@ -17,6 +17,17 @@ export default defineConfig({
"./src/__test-utils__/vitest-setup.ts",
],
globalSetup: ["./src/__test-utils__/vitest-teardown.ts"],
// Must stay "forks". Two thread-unsafe patterns block migration to "threads":
//
// 1. vitest-setup.ts:123 — `process.chdir(workerTempDir)` is gated by
// `isMainThread`, which is `false` in worker_threads, so each thread
// worker never gets its isolated cwd. Tests that rely on cwd being a
// disposable temp dir would silently operate in the repo root.
//
// 2. setup-test-isolation.ts:15-16 — `process.env.HOME` is written
// unconditionally in every setupFile invocation. Threads share
// `process.env`, so concurrent workers race on HOME and the last writer
// wins, breaking isolation for all other workers in the same run.
pool: "forks",
maxWorkers,
poolOptions: { forks: { minForks: 1, maxForks: maxWorkers } },

View File

@@ -0,0 +1,812 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
fetchTaskDetail,
uploadAttachment,
fetchAgentLogsWithMeta,
fetchAiSessions,
fetchAiSession,
deleteAiSession,
updateTask,
createTask,
connectPlanningStream,
connectSubtaskStream,
connectMissionInterviewStream,
assignTask,
fetchAgentTasks,
archiveTask,
unarchiveTask,
deleteTask,
ApiRequestError,
moveTask,
mergeTask,
retryTask,
duplicateTask,
pauseTask,
unpauseTask,
fetchAuthStatus,
loginProvider,
logoutProvider,
fetchModels,
addSteeringComment,
addTaskComment,
updateTaskComment,
deleteTaskComment,
fetchTaskComments,
fetchGitRemotes,
refineTask,
fetchBatchStatus,
fetchWorkspaces,
fetchWorkspaceFileList,
fetchWorkspaceFileContent,
saveWorkspaceFileContent,
deleteFile,
startPlanningStreaming,
startAgentOnboardingStreaming,
respondToAgentOnboarding,
retryAgentOnboardingSession,
stopAgentOnboardingGeneration,
cancelAgentOnboarding,
fetchTasks,
summarizeTitle,
fetchProjects,
registerProject,
unregisterProject,
fetchProjectHealth,
fetchActivityFeed,
pauseProject,
resumeProject,
fetchFirstRunStatus,
fetchGlobalConcurrency,
updateGlobalConcurrency,
fetchPiSettings,
updatePiSettings,
installPiPackage,
reinstallFusionPiPackage,
fetchPiExtensions,
updatePiExtensions,
fetchProjectTasks,
fetchProjectConfig,
fetchExecutorStats,
fetchAgentRunAudit,
fetchAgentRunTimeline,
streamChatResponse,
fetchMemoryBackendStatus,
type ProjectInfo,
type ProjectHealth,
type ActivityFeedEntry,
type FirstRunStatus,
type GlobalConcurrencyState,
type ExecutorStats,
type ExecutorState,
} from "../api";
import type { Task, TaskDetail, BatchStatusResponse, MergeResult } from "@fusion/core";
import { clearAuthToken } from "../auth";
const TASK_TOKEN_USAGE_FIXTURE = {
inputTokens: 1000,
outputTokens: 300,
cachedTokens: 125,
totalTokens: 1425,
firstUsedAt: "2026-04-24T08:00:00.000Z",
lastUsedAt: "2026-04-24T09:30:00.000Z",
};
const FAKE_DETAIL: TaskDetail = {
id: "FN-001",
description: "Test",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
tokenUsage: TASK_TOKEN_USAGE_FIXTURE,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# FN-001",
};
function mockFetchResponse(
ok: boolean,
body: unknown,
status = ok ? 200 : 500,
contentType = "application/json"
) {
const bodyText = JSON.stringify(body);
return Promise.resolve({
ok,
status,
statusText: ok ? "OK" : "Error",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? contentType : null,
},
json: () => Promise.resolve(body),
text: () => Promise.resolve(bodyText),
} as unknown as Response);
}
beforeEach(() => {
clearAuthToken();
localStorage.removeItem("fn.authToken");
});
afterEach(() => {
clearAuthToken();
localStorage.removeItem("fn.authToken");
});
import {
fetchGitRemotesDetailed,
addGitRemote,
removeGitRemote,
renameGitRemote,
updateGitRemoteUrl,
} from "../api";
import { approvePlan, rejectPlan } from "../api";
import {
startAgentRun,
createAgent,
updateAgent,
fetchGitStatus,
fetchGitCommits,
fetchCommitDiff,
fetchAheadCommits,
fetchRemoteCommits,
fetchGitBranches,
fetchGitWorktrees,
createBranch,
checkoutBranch,
deleteBranch,
fetchRemote,
pullBranch,
pushBranch,
} from "../api";
describe("fetchAuthStatus", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns providers with auth status", async () => {
const response = { providers: [{ id: "anthropic", name: "Anthropic", authenticated: true }] };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, response));
const result = await fetchAuthStatus();
expect(result.providers).toEqual([{ id: "anthropic", name: "Anthropic", authenticated: true }]);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/auth/status", {
headers: { "Content-Type": "application/json" },
});
});
it("throws on error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Server error" }));
await expect(fetchAuthStatus()).rejects.toThrow("Server error");
});
});
describe("loginProvider", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("sends POST and returns auth URL", async () => {
const response = { url: "https://auth.example.com/login", instructions: "Open in browser" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, response));
const result = await loginProvider("anthropic");
expect(result.url).toBe("https://auth.example.com/login");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/auth/login", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ provider: "anthropic", origin: window.location.origin }),
});
});
it("throws on error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Unknown provider" }));
await expect(loginProvider("bad")).rejects.toThrow("Unknown provider");
});
});
describe("logoutProvider", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("sends POST to logout", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { success: true }));
const result = await logoutProvider("anthropic");
expect(result.success).toBe(true);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/auth/logout", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ provider: "anthropic" }),
});
});
it("throws on error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "logout failed" }));
await expect(logoutProvider("anthropic")).rejects.toThrow("logout failed");
});
});
describe("addSteeringComment", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
const FAKE_TASK: Task = {
id: "FN-001",
description: "Test",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
steeringComments: [
{
id: "1234567890-abc123",
text: "Please handle the edge case",
createdAt: "2026-01-01T00:00:00.000Z",
author: "user",
},
],
};
it("sends POST with text and returns updated task", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
const result = await addSteeringComment("FN-001", "Please handle the edge case");
expect(result.id).toBe("FN-001");
expect(result.steeringComments).toHaveLength(1);
expect(result.steeringComments![0].text).toBe("Please handle the edge case");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/steer", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ text: "Please handle the edge case" }),
});
});
it("throws on error response", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Task not found" })
);
await expect(addSteeringComment("FN-001", "Test comment")).rejects.toThrow("Task not found");
});
});
describe("fetchGitRemotes", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns array of GitHub remotes", async () => {
const remotes = [
{ name: "origin", owner: "dustinbyrne", repo: "kb", url: "https://github.com/dustinbyrne/kb.git" },
];
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, remotes));
const result = await fetchGitRemotes();
expect(result).toEqual(remotes);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/remotes", {
headers: { "Content-Type": "application/json" },
});
});
it("returns empty array when no remotes", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
const result = await fetchGitRemotes();
expect(result).toEqual([]);
});
it("throws on error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Failed to execute git command" }));
await expect(fetchGitRemotes()).rejects.toThrow("Failed to execute git command");
});
});
describe("fetchGitRemotesDetailed", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns array of remotes with fetch and push URLs", async () => {
const remotes = [
{ name: "origin", fetchUrl: "https://github.com/dustinbyrne/kb.git", pushUrl: "https://github.com/dustinbyrne/kb.git" },
{ name: "upstream", fetchUrl: "https://github.com/upstream/kb.git", pushUrl: "git@github.com:upstream/kb.git" },
];
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, remotes));
const result = await fetchGitRemotesDetailed();
expect(result).toEqual(remotes);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/remotes/detailed", {
headers: { "Content-Type": "application/json" },
});
});
it("returns empty array when no remotes", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
const result = await fetchGitRemotesDetailed();
expect(result).toEqual([]);
});
it("throws on error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Not a git repository" }, 400));
await expect(fetchGitRemotesDetailed()).rejects.toThrow("Not a git repository");
});
});
describe("addGitRemote", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("adds a new remote successfully", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { name: "origin", added: true }, 201));
await addGitRemote("origin", "https://github.com/dustinbyrne/kb.git");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/remotes", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "origin", url: "https://github.com/dustinbyrne/kb.git" }),
});
});
it("throws on invalid name", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Invalid remote name" }, 400));
await expect(addGitRemote("invalid;cmd", "https://github.com/test/repo.git")).rejects.toThrow("Invalid remote name");
});
it("throws on invalid URL", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Invalid git URL format" }, 400));
await expect(addGitRemote("origin", "not-a-valid-url")).rejects.toThrow("Invalid git URL format");
});
it("throws on duplicate remote", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Remote 'origin' already exists" }, 409));
await expect(addGitRemote("origin", "https://github.com/test/repo.git")).rejects.toThrow("Remote 'origin' already exists");
});
});
describe("removeGitRemote", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("removes a remote successfully", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { name: "origin", removed: true }));
await removeGitRemote("origin");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/remotes/origin", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
});
});
it("throws on invalid name", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Invalid remote name" }, 400));
await expect(removeGitRemote("invalid;cmd")).rejects.toThrow("Invalid remote name");
});
it("throws when remote does not exist", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Remote 'origin' does not exist" }, 404));
await expect(removeGitRemote("origin")).rejects.toThrow("Remote 'origin' does not exist");
});
});
describe("renameGitRemote", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("renames a remote successfully", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { oldName: "origin", newName: "upstream", renamed: true }));
await renameGitRemote("origin", "upstream");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/remotes/origin", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ newName: "upstream" }),
});
});
it("throws on invalid name", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Invalid remote name" }, 400));
await expect(renameGitRemote("invalid;cmd", "upstream")).rejects.toThrow("Invalid remote name");
});
it("throws when remote does not exist", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Remote 'origin' does not exist" }, 404));
await expect(renameGitRemote("origin", "upstream")).rejects.toThrow("Remote 'origin' does not exist");
});
it("throws when new name already exists", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Remote 'upstream' already exists" }, 409));
await expect(renameGitRemote("origin", "upstream")).rejects.toThrow("Remote 'upstream' already exists");
});
});
describe("updateGitRemoteUrl", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("updates remote URL successfully", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { name: "origin", url: "https://new-url.com/repo.git", updated: true }));
await updateGitRemoteUrl("origin", "https://new-url.com/repo.git");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/git/remotes/origin/url", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://new-url.com/repo.git" }),
});
});
it("throws on invalid name", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Invalid remote name" }, 400));
await expect(updateGitRemoteUrl("invalid;cmd", "https://github.com/test/repo.git")).rejects.toThrow("Invalid remote name");
});
it("throws on invalid URL", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Invalid git URL format" }, 400));
await expect(updateGitRemoteUrl("origin", "not-a-valid-url")).rejects.toThrow("Invalid git URL format");
});
it("throws when remote does not exist", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Remote 'origin' does not exist" }, 404));
await expect(updateGitRemoteUrl("origin", "https://github.com/test/repo.git")).rejects.toThrow("Remote 'origin' does not exist");
});
});
describe("approvePlan", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("approves plan and returns updated task", async () => {
const approvedTask: Task = {
...FAKE_DETAIL,
column: "todo",
status: undefined,
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, approvedTask));
const result = await approvePlan("FN-001");
expect(result.column).toBe("todo");
expect(result.status).toBeUndefined();
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/approve-plan", {
headers: { "Content-Type": "application/json" },
method: "POST",
});
});
it("throws on error response", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Task must be in 'triage' column to approve plan" }, 400)
);
await expect(approvePlan("FN-001")).rejects.toThrow("triage");
});
});
describe("rejectPlan", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("rejects plan and returns updated task", async () => {
const rejectedTask: Task = {
...FAKE_DETAIL,
column: "triage",
status: undefined,
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, rejectedTask));
const result = await rejectPlan("FN-001");
expect(result.column).toBe("triage");
expect(result.status).toBeUndefined();
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/reject-plan", {
headers: { "Content-Type": "application/json" },
method: "POST",
});
});
it("throws on error response", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Task must have status 'awaiting-approval' to reject plan" }, 400)
);
await expect(rejectPlan("FN-001")).rejects.toThrow("awaiting-approval");
});
});
// --- Refinement API tests ---
describe("refineTask", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
const FAKE_REFINED_TASK: Task = {
id: "FN-002",
description: "Refinement of FN-001",
column: "triage",
dependencies: ["FN-001"],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
it("sends POST with feedback and returns new refinement task", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_REFINED_TASK));
const result = await refineTask("FN-001", "Need to add more tests and improve error handling");
expect(result.id).toBe("FN-002");
expect(result.column).toBe("triage");
expect(result.dependencies).toContain("FN-001");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/refine", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ feedback: "Need to add more tests and improve error handling" }),
});
});
it("throws on error response when task not found", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Task not found" }, 404)
);
await expect(refineTask("KB-999", "feedback")).rejects.toThrow("Task not found");
});
it("throws on error response when task not in done/in-review", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Task must be in 'done' or 'in-review' column to refine" }, 400)
);
await expect(refineTask("FN-001", "feedback")).rejects.toThrow("done' or 'in-review'");
});
});
describe("agent API wrappers", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("creates agents with full create payload and project scope", async () => {
const createdAgent = { id: "agent-001", name: "reviewer", role: "reviewer", state: "idle" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, createdAgent, 201));
await createAgent({
name: "reviewer",
role: "reviewer",
title: "Review Agent",
icon: "🔍",
reportsTo: "agent-parent",
runtimeConfig: { heartbeatIntervalMs: 15000, maxConcurrentRuns: 2 },
permissions: { read: true, write: false },
instructionsPath: ".fusion/agents/reviewer.md",
instructionsText: "Prioritize security and edge cases.",
}, "proj_123");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents?projectId=proj_123", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({
name: "reviewer",
role: "reviewer",
title: "Review Agent",
icon: "🔍",
reportsTo: "agent-parent",
runtimeConfig: { heartbeatIntervalMs: 15000, maxConcurrentRuns: 2 },
permissions: { read: true, write: false },
instructionsPath: ".fusion/agents/reviewer.md",
instructionsText: "Prioritize security and edge cases.",
}),
});
});
it("updates agents with runtime + instruction fields", async () => {
const updatedAgent = { id: "agent-001", name: "reviewer", role: "reviewer", state: "active" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, updatedAgent));
await updateAgent("agent-001", {
runtimeConfig: { heartbeatTimeoutMs: 45000, maxConcurrentRuns: 3 },
instructionsPath: ".fusion/agents/reviewer.md",
instructionsText: "Handle migrations cautiously.",
pauseReason: "maintenance",
reportsTo: undefined,
}, "proj_123");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents/agent-001?projectId=proj_123", {
headers: { "Content-Type": "application/json" },
method: "PATCH",
body: JSON.stringify({
runtimeConfig: { heartbeatTimeoutMs: 45000, maxConcurrentRuns: 3 },
instructionsPath: ".fusion/agents/reviewer.md",
instructionsText: "Handle migrations cautiously.",
pauseReason: "maintenance",
}),
});
});
});
describe("startAgentRun", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("sends POST to start a run for an agent", async () => {
const mockRun = {
id: "run-001",
agentId: "agent-001",
startedAt: "2026-01-01T00:00:00.000Z",
endedAt: null,
status: "active",
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockRun, 201));
const result = await startAgentRun("agent-001");
expect(result.id).toBe("run-001");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents/agent-001/runs", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ source: "manual", triggerDetail: "Agent activated via dashboard" }),
});
});
it("passes projectId as query param", async () => {
const mockRun = { id: "run-001", agentId: "agent-001", startedAt: "", endedAt: null, status: "active" };
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockRun, 201));
await startAgentRun("agent-001", "proj_123");
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/agents/agent-001/runs?projectId=proj_123",
expect.objectContaining({ method: "POST" }),
);
});
it("throws on 404 when agent not found", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Agent agent-999 not found" }, 404),
);
await expect(startAgentRun("agent-999")).rejects.toThrow("not found");
});
});
describe("fetchAgentChildren", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("fetches children for an agent", async () => {
const mockChildren = [
{ id: "child-1", name: "Child Agent 1", state: "active", reportsTo: "agent-001" },
{ id: "child-2", name: "Child Agent 2", state: "idle", reportsTo: "agent-001" },
];
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, mockChildren));
const { fetchAgentChildren } = await import("../api");
const result = await fetchAgentChildren("agent-001");
expect(result).toHaveLength(2);
expect(result[0].id).toBe("child-1");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents/agent-001/children", {
headers: { "Content-Type": "application/json" },
});
});
it("passes projectId as query param", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, []));
const { fetchAgentChildren } = await import("../api");
await fetchAgentChildren("agent-001", "proj_123");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents/agent-001/children?projectId=proj_123", {
headers: { "Content-Type": "application/json" },
});
});
it("returns empty array for 404 (agent not found)", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Agent not found" }, 404),
);
const { fetchAgentChildren } = await import("../api");
const result = await fetchAgentChildren("agent-999");
expect(result).toEqual([]);
});
it("throws on non-404 errors", async () => {
globalThis.fetch = vi.fn().mockReturnValue(
mockFetchResponse(false, { error: "Internal server error" }, 500),
);
const { fetchAgentChildren } = await import("../api");
await expect(fetchAgentChildren("agent-001")).rejects.toThrow("Internal server error");
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,938 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
fetchTaskDetail,
uploadAttachment,
fetchAgentLogsWithMeta,
fetchAiSessions,
fetchAiSession,
deleteAiSession,
updateTask,
createTask,
connectPlanningStream,
connectSubtaskStream,
connectMissionInterviewStream,
assignTask,
fetchAgentTasks,
archiveTask,
unarchiveTask,
deleteTask,
ApiRequestError,
moveTask,
mergeTask,
retryTask,
duplicateTask,
pauseTask,
unpauseTask,
fetchAuthStatus,
loginProvider,
logoutProvider,
fetchModels,
addSteeringComment,
addTaskComment,
updateTaskComment,
deleteTaskComment,
fetchTaskComments,
fetchGitRemotes,
refineTask,
fetchBatchStatus,
fetchWorkspaces,
fetchWorkspaceFileList,
fetchWorkspaceFileContent,
saveWorkspaceFileContent,
deleteFile,
startPlanningStreaming,
startAgentOnboardingStreaming,
respondToAgentOnboarding,
retryAgentOnboardingSession,
stopAgentOnboardingGeneration,
cancelAgentOnboarding,
fetchTasks,
summarizeTitle,
fetchProjects,
registerProject,
unregisterProject,
fetchProjectHealth,
fetchActivityFeed,
pauseProject,
resumeProject,
fetchFirstRunStatus,
fetchGlobalConcurrency,
updateGlobalConcurrency,
fetchPiSettings,
updatePiSettings,
installPiPackage,
reinstallFusionPiPackage,
fetchPiExtensions,
updatePiExtensions,
fetchProjectTasks,
fetchProjectConfig,
fetchExecutorStats,
fetchAgentRunAudit,
fetchAgentRunTimeline,
streamChatResponse,
fetchMemoryBackendStatus,
type ProjectInfo,
type ProjectHealth,
type ActivityFeedEntry,
type FirstRunStatus,
type GlobalConcurrencyState,
type ExecutorStats,
type ExecutorState,
} from "../api";
import type { Task, TaskDetail, BatchStatusResponse, MergeResult } from "@fusion/core";
import { clearAuthToken } from "../auth";
const TASK_TOKEN_USAGE_FIXTURE = {
inputTokens: 1000,
outputTokens: 300,
cachedTokens: 125,
totalTokens: 1425,
firstUsedAt: "2026-04-24T08:00:00.000Z",
lastUsedAt: "2026-04-24T09:30:00.000Z",
};
const FAKE_DETAIL: TaskDetail = {
id: "FN-001",
description: "Test",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
tokenUsage: TASK_TOKEN_USAGE_FIXTURE,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# FN-001",
};
function mockFetchResponse(
ok: boolean,
body: unknown,
status = ok ? 200 : 500,
contentType = "application/json"
) {
const bodyText = JSON.stringify(body);
return Promise.resolve({
ok,
status,
statusText: ok ? "OK" : "Error",
headers: {
get: (name: string) =>
name.toLowerCase() === "content-type" ? contentType : null,
},
json: () => Promise.resolve(body),
text: () => Promise.resolve(bodyText),
} as unknown as Response);
}
beforeEach(() => {
clearAuthToken();
localStorage.removeItem("fn.authToken");
});
afterEach(() => {
clearAuthToken();
localStorage.removeItem("fn.authToken");
});
describe("fetchTaskDetail", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
});
afterEach(() => {
globalThis.fetch = originalFetch;
vi.useRealTimers();
});
it("returns data on first success", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_DETAIL));
const result = await fetchTaskDetail("FN-001");
expect(result.id).toBe("FN-001");
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001", {
headers: { "Content-Type": "application/json" },
});
});
it("preserves full tokenUsage payload from task detail responses", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_DETAIL));
const result = await fetchTaskDetail("FN-001");
expect(result.tokenUsage).toEqual({
inputTokens: 1000,
outputTokens: 300,
cachedTokens: 125,
totalTokens: 1425,
firstUsedAt: "2026-04-24T08:00:00.000Z",
lastUsedAt: "2026-04-24T09:30:00.000Z",
});
});
it("keeps tokenUsage undefined when server response omits task usage", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {
...FAKE_DETAIL,
tokenUsage: undefined,
}));
const result = await fetchTaskDetail("FN-001");
expect(result.tokenUsage).toBeUndefined();
});
it("adds Authorization header when daemon token is present", async () => {
localStorage.setItem("fn.authToken", "daemon-token");
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_DETAIL));
await fetchTaskDetail("FN-001");
const call = vi.mocked(globalThis.fetch).mock.calls[0];
expect(call[0]).toBe("/api/tasks/FN-001");
expect(new Headers((call[1] as RequestInit).headers).get("Authorization")).toBe("Bearer daemon-token");
expect(new Headers((call[1] as RequestInit).headers).get("Content-Type")).toBe("application/json");
});
it("retries once on failure then succeeds", async () => {
globalThis.fetch = vi.fn()
.mockReturnValueOnce(mockFetchResponse(false, { error: "Transient error" }))
.mockReturnValueOnce(mockFetchResponse(true, FAKE_DETAIL));
const result = await fetchTaskDetail("FN-001");
expect(result.id).toBe("FN-001");
expect(globalThis.fetch).toHaveBeenCalledTimes(2);
});
it("throws after retry exhaustion", async () => {
globalThis.fetch = vi.fn()
.mockReturnValue(mockFetchResponse(false, { error: "Server error" }));
await expect(fetchTaskDetail("FN-001")).rejects.toThrow("Server error");
expect(globalThis.fetch).toHaveBeenCalledTimes(2); // initial + 1 retry
});
});
describe("uploadAttachment", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("does not send Authorization header when no daemon token is present", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {
filename: "shot.png",
originalName: "shot.png",
mimeType: "image/png",
size: 42,
createdAt: "2026-01-01T00:00:00.000Z",
}));
const file = new File(["img"], "shot.png", { type: "image/png" });
await uploadAttachment("FN-001", file);
const call = vi.mocked(globalThis.fetch).mock.calls[0];
expect(call[0]).toBe("/api/tasks/FN-001/attachments");
expect((call[1] as RequestInit).method).toBe("POST");
expect((call[1] as RequestInit).headers).toBeUndefined();
});
it("sends Authorization header when daemon token is present", async () => {
localStorage.setItem("fn.authToken", "daemon-token");
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {
filename: "shot.png",
originalName: "shot.png",
mimeType: "image/png",
size: 42,
createdAt: "2026-01-01T00:00:00.000Z",
}));
const file = new File(["img"], "shot.png", { type: "image/png" });
await uploadAttachment("FN-001", file);
const call = vi.mocked(globalThis.fetch).mock.calls[0];
const headers = new Headers((call[1] as RequestInit).headers);
expect(headers.get("Authorization")).toBe("Bearer daemon-token");
});
});
describe("fetchAgentLogsWithMeta", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("keeps headers empty when token is absent", async () => {
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
has: vi.fn((name: string) => name === "X-Total-Count"),
get: vi.fn((name: string) => (name === "X-Total-Count" ? "1" : null)),
},
json: vi.fn().mockResolvedValue([{ timestamp: "t", taskId: "FN-001", text: "x", type: "text" }]),
} as unknown as Response);
await fetchAgentLogsWithMeta("FN-001");
const call = vi.mocked(globalThis.fetch).mock.calls[0];
expect(call[0]).toBe("/api/tasks/FN-001/logs");
expect((call[1] as RequestInit).headers).toBeUndefined();
});
it("injects Authorization header when token exists", async () => {
localStorage.setItem("fn.authToken", "daemon-token");
globalThis.fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
headers: {
has: vi.fn(() => false),
get: vi.fn(() => null),
},
json: vi.fn().mockResolvedValue([]),
} as unknown as Response);
await fetchAgentLogsWithMeta("FN-001", undefined, { limit: 10, offset: 5 });
const call = vi.mocked(globalThis.fetch).mock.calls[0];
expect(call[0]).toBe("/api/tasks/FN-001/logs?limit=10&offset=5");
const headers = new Headers((call[1] as RequestInit).headers);
expect(headers.get("Authorization")).toBe("Bearer daemon-token");
});
});
describe("AI session raw fetch auth headers", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("fetchAiSessions omits Authorization header when no token is stored", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { sessions: [{ id: "s1" }] }));
await fetchAiSessions();
const call = vi.mocked(globalThis.fetch).mock.calls[0];
expect(call[0]).toBe("/api/ai-sessions");
expect((call[1] as RequestInit).headers).toBeUndefined();
});
it("fetchAiSessions includes Authorization header when token is stored", async () => {
localStorage.setItem("fn.authToken", "daemon-token");
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { sessions: [{ id: "s1" }] }));
await fetchAiSessions("proj-1");
const call = vi.mocked(globalThis.fetch).mock.calls[0];
expect(call[0]).toBe("/api/ai-sessions?projectId=proj-1");
const headers = new Headers((call[1] as RequestInit).headers);
expect(headers.get("Authorization")).toBe("Bearer daemon-token");
});
it("fetchAiSession and deleteAiSession both include Authorization header with token", async () => {
localStorage.setItem("fn.authToken", "daemon-token");
globalThis.fetch = vi.fn()
.mockReturnValueOnce(mockFetchResponse(true, { id: "s1" }))
.mockReturnValueOnce(mockFetchResponse(true, {}));
await fetchAiSession("s1");
await deleteAiSession("s1");
const fetchSessionCall = vi.mocked(globalThis.fetch).mock.calls[0];
const deleteCall = vi.mocked(globalThis.fetch).mock.calls[1];
expect(new Headers((fetchSessionCall[1] as RequestInit).headers).get("Authorization")).toBe("Bearer daemon-token");
expect((deleteCall[1] as RequestInit).method).toBe("DELETE");
expect(new Headers((deleteCall[1] as RequestInit).headers).get("Authorization")).toBe("Bearer daemon-token");
});
});
describe("updateTask", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
const FAKE_TASK: Task = {
id: "FN-001",
description: "Test",
column: "in-progress",
dependencies: ["FN-002"],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
it("sends PATCH with dependencies and returns updated task", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
const result = await updateTask("FN-001", { dependencies: ["FN-002"] });
expect(result.dependencies).toEqual(["FN-002"]);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001", {
headers: { "Content-Type": "application/json" },
method: "PATCH",
body: JSON.stringify({ dependencies: ["FN-002"] }),
});
});
it("throws on error response", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Not found" }));
await expect(updateTask("FN-001", { dependencies: [] })).rejects.toThrow("Not found");
});
it("sends PATCH with executionMode 'fast' when provided", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { ...FAKE_TASK, executionMode: "fast" }));
const result = await updateTask("FN-001", { executionMode: "fast" });
expect(result.executionMode).toBe("fast");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001", {
headers: { "Content-Type": "application/json" },
method: "PATCH",
body: JSON.stringify({ executionMode: "fast" }),
});
});
it("sends PATCH with executionMode 'standard' when provided", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { ...FAKE_TASK, executionMode: "standard" }));
const result = await updateTask("FN-001", { executionMode: "standard" });
expect(result.executionMode).toBe("standard");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001", {
headers: { "Content-Type": "application/json" },
method: "PATCH",
body: JSON.stringify({ executionMode: "standard" }),
});
});
it("sends PATCH with null to clear executionMode", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { ...FAKE_TASK, executionMode: undefined }));
const result = await updateTask("FN-001", { executionMode: null });
expect(result.executionMode).toBeUndefined();
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001", {
headers: { "Content-Type": "application/json" },
method: "PATCH",
body: JSON.stringify({ executionMode: null }),
});
});
it("omits executionMode key when not provided in update", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { ...FAKE_TASK, title: "Updated" }));
await updateTask("FN-001", { title: "Updated" });
const call = vi.mocked(globalThis.fetch).mock.calls[0];
const body = JSON.parse((call[1] as RequestInit).body as string);
expect(body).not.toHaveProperty("executionMode");
});
it("sends sourceIssue object when source metadata is provided", async () => {
const sourceIssue = {
provider: "github",
repository: "runfusion/fusion",
externalIssueId: "I_kgDOExample",
issueNumber: 2473,
url: "https://github.com/runfusion/fusion/issues/2473",
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { ...FAKE_TASK, sourceIssue }));
const result = await updateTask("FN-001", { sourceIssue });
expect(result.sourceIssue).toEqual(sourceIssue);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001", {
headers: { "Content-Type": "application/json" },
method: "PATCH",
body: JSON.stringify({ sourceIssue }),
});
});
it("sends sourceIssue: null when clearing source metadata", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { ...FAKE_TASK, sourceIssue: undefined }));
await updateTask("FN-001", { sourceIssue: null });
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001", {
headers: { "Content-Type": "application/json" },
method: "PATCH",
body: JSON.stringify({ sourceIssue: null }),
});
});
});
describe("createTask", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
const FAKE_CREATED_TASK: Task = {
id: "FN-001",
description: "Test task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
it("sends POST with executionMode 'fast' when provided", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { ...FAKE_CREATED_TASK, executionMode: "fast" }));
const result = await createTask({ description: "Fast task", executionMode: "fast" });
expect(result.executionMode).toBe("fast");
const call = vi.mocked(globalThis.fetch).mock.calls[0];
const body = JSON.parse((call[1] as RequestInit).body as string);
expect(body.executionMode).toBe("fast");
expect(body.description).toBe("Fast task");
});
it("sends POST with executionMode 'standard' when provided", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { ...FAKE_CREATED_TASK, executionMode: "standard" }));
const result = await createTask({ description: "Standard task", executionMode: "standard" });
expect(result.executionMode).toBe("standard");
const call = vi.mocked(globalThis.fetch).mock.calls[0];
const body = JSON.parse((call[1] as RequestInit).body as string);
expect(body.executionMode).toBe("standard");
expect(body.description).toBe("Standard task");
});
it("omits executionMode key when not provided", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_CREATED_TASK));
await createTask({ description: "Task without execution mode" });
const call = vi.mocked(globalThis.fetch).mock.calls[0];
const body = JSON.parse((call[1] as RequestInit).body as string);
expect(body).not.toHaveProperty("executionMode");
});
it("passes source provenance through createTask payload", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_CREATED_TASK));
await createTask({
description: "Sourced task",
source: { sourceType: "dashboard_ui" },
});
const call = vi.mocked(globalThis.fetch).mock.calls[0];
const body = JSON.parse((call[1] as RequestInit).body as string);
expect(body.source).toEqual({ sourceType: "dashboard_ui" });
});
it("serializes priority in createTask payload when provided", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, { ...FAKE_CREATED_TASK, priority: "urgent" }));
await createTask({
description: "Priority task",
priority: "urgent",
});
const call = vi.mocked(globalThis.fetch).mock.calls[0];
const body = JSON.parse((call[1] as RequestInit).body as string);
expect(body.priority).toBe("urgent");
});
it("sends POST with multiple fields including executionMode", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, {
...FAKE_CREATED_TASK,
executionMode: "fast",
title: "Test Title",
dependencies: ["FN-002"],
}));
const result = await createTask({
description: "Full task",
title: "Test Title",
dependencies: ["FN-002"],
executionMode: "fast",
});
expect(result.executionMode).toBe("fast");
const call = vi.mocked(globalThis.fetch).mock.calls[0];
const body = JSON.parse((call[1] as RequestInit).body as string);
expect(body.description).toBe("Full task");
expect(body.title).toBe("Test Title");
expect(body.dependencies).toEqual(["FN-002"]);
expect(body.executionMode).toBe("fast");
});
});
describe("assignTask and fetchAgentTasks", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
const ASSIGNED_TASK: Task = {
id: "FN-001",
description: "Test",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
assignedAgentId: "agent-001",
};
it("assignTask sends PATCH with agentId payload", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, ASSIGNED_TASK));
const result = await assignTask("FN-001", "agent-001");
expect(result.assignedAgentId).toBe("agent-001");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/assign", {
headers: { "Content-Type": "application/json" },
method: "PATCH",
body: JSON.stringify({ agentId: "agent-001" }),
});
});
it("fetchAgentTasks requests assigned tasks for an agent", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, [ASSIGNED_TASK]));
const result = await fetchAgentTasks("agent-001");
expect(result).toHaveLength(1);
expect(result[0]?.id).toBe("FN-001");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/agents/agent-001/tasks", {
headers: { "Content-Type": "application/json" },
});
});
});
describe("task comments api", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
const FAKE_TASK: Task = {
id: "FN-001",
description: "Test",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
comments: [{ id: "c1", text: "Hello", author: "user", createdAt: "2026-01-01T00:00:00.000Z" }],
};
it("fetches task comments", async () => {
const comments = FAKE_TASK.comments!;
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, comments));
const result = await fetchTaskComments("FN-001");
expect(result).toEqual(comments);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments", {
headers: { "Content-Type": "application/json" },
});
});
it("adds a task comment", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
const result = await addTaskComment("FN-001", "Hello", "user");
expect(result).toEqual(FAKE_TASK);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ text: "Hello", author: "user" }),
});
});
it("updates a task comment", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
await updateTaskComment("FN-001", "c1", "Updated");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments/c1", {
headers: { "Content-Type": "application/json" },
method: "PATCH",
body: JSON.stringify({ text: "Updated" }),
});
});
it("deletes a task comment", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, FAKE_TASK));
await deleteTaskComment("FN-001", "c1");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments/c1", {
headers: { "Content-Type": "application/json" },
method: "DELETE",
});
});
});
describe("fetchModels", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("returns available models with favorites", async () => {
const response = {
models: [
{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 },
],
favoriteProviders: ["anthropic"],
favoriteModels: ["anthropic/claude-sonnet-4-5"],
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, response));
const result = await fetchModels();
expect(result).toEqual(response);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/models", {
headers: { "Content-Type": "application/json" },
});
});
it("throws on error", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "Server error" }));
await expect(fetchModels()).rejects.toThrow("Server error");
});
});
describe("fetchBatchStatus", () => {
const originalFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("posts task ids and unwraps the results envelope", async () => {
const response: BatchStatusResponse = {
results: {
"FN-001": {
issueInfo: {
url: "https://github.com/owner/repo/issues/101",
number: 101,
state: "closed",
title: "Issue 101",
stateReason: "completed",
lastCheckedAt: "2026-03-30T12:00:00.000Z",
},
stale: false,
},
},
};
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, response));
const result = await fetchBatchStatus(["FN-001"]);
expect(result).toEqual(response.results);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/github/batch/status", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ taskIds: ["FN-001"] }),
});
});
it("propagates API errors", async () => {
globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(false, { error: "rate limit exceeded" }, 429));
await expect(fetchBatchStatus(["FN-001"])).rejects.toThrow("rate limit exceeded");
});
});
describe("batchUpdateTaskModels", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
globalThis.fetch = vi.fn();
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
it("calls API with correct parameters for executor model update", async () => {
const mockResponse = {
updated: [{ id: "FN-001", modelProvider: "openai", modelId: "gpt-4o" }],
count: 1,
};
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
mockFetchResponse(true, mockResponse)
);
const { batchUpdateTaskModels } = await import("../api");
const result = await batchUpdateTaskModels(["FN-001"], "openai", "gpt-4o");
expect(result.count).toBe(1);
expect(result.updated).toHaveLength(1);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/tasks/batch-update-models",
expect.objectContaining({
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
taskIds: ["FN-001"],
modelProvider: "openai",
modelId: "gpt-4o",
}),
})
);
});
it("calls API with correct parameters for validator model update", async () => {
const mockResponse = { updated: [], count: 0 };
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
mockFetchResponse(true, mockResponse)
);
const { batchUpdateTaskModels } = await import("../api");
await batchUpdateTaskModels(
["FN-001", "FN-002"],
undefined,
undefined,
"anthropic",
"claude-sonnet-4-5"
);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/tasks/batch-update-models",
expect.objectContaining({
body: JSON.stringify({
taskIds: ["FN-001", "FN-002"],
validatorModelProvider: "anthropic",
validatorModelId: "claude-sonnet-4-5",
}),
})
);
});
it("calls API with null values to clear models", async () => {
const mockResponse = { updated: [{ id: "FN-001" }], count: 1 };
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
mockFetchResponse(true, mockResponse)
);
const { batchUpdateTaskModels } = await import("../api");
await batchUpdateTaskModels(["FN-001"], null, null);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/tasks/batch-update-models",
expect.objectContaining({
body: JSON.stringify({
taskIds: ["FN-001"],
modelProvider: null,
modelId: null,
}),
})
);
});
it("includes nodeId when provided", async () => {
const mockResponse = { updated: [{ id: "FN-001", nodeId: "node-abc" }], count: 1 };
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
mockFetchResponse(true, mockResponse)
);
const { batchUpdateTaskModels } = await import("../api");
await batchUpdateTaskModels(
["FN-001"],
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
"node-abc",
"proj-123"
);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/tasks/batch-update-models?projectId=proj-123",
expect.objectContaining({
body: JSON.stringify({
taskIds: ["FN-001"],
nodeId: "node-abc",
}),
})
);
});
it("includes null nodeId when clearing override", async () => {
const mockResponse = { updated: [{ id: "FN-001" }], count: 1 };
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
mockFetchResponse(true, mockResponse)
);
const { batchUpdateTaskModels } = await import("../api");
await batchUpdateTaskModels(["FN-001"], undefined, undefined, undefined, undefined, undefined, undefined, null);
expect(globalThis.fetch).toHaveBeenCalledWith(
"/api/tasks/batch-update-models",
expect.objectContaining({
body: JSON.stringify({
taskIds: ["FN-001"],
nodeId: null,
}),
})
);
});
it("throws on 400 validation error", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
mockFetchResponse(false, { error: "taskIds must be an array" }, 400)
);
const { batchUpdateTaskModels } = await import("../api");
await expect(batchUpdateTaskModels([], "openai", "gpt-4o")).rejects.toThrow(
"taskIds must be an array"
);
});
it("throws on 404 when task not found", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue(
mockFetchResponse(false, { error: "Task KB-999 not found" }, 404)
);
const { batchUpdateTaskModels } = await import("../api");
await expect(batchUpdateTaskModels(["KB-999"], "openai", "gpt-4o")).rejects.toThrow(
"Task KB-999 not found"
);
});
it("throws on network error", async () => {
(globalThis.fetch as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("Network failed"));
const { batchUpdateTaskModels } = await import("../api");
await expect(batchUpdateTaskModels(["FN-001"], "openai", "gpt-4o")).rejects.toThrow(
"Network failed"
);
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,697 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from "vitest";
import express from "express";
import http from "node:http";
import { EventEmitter } from "node:events";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { createHmac } from "node:crypto";
import { createApiRoutes } from "../routes.js";
import {
getProjectIdFromRequest as getProjectIdFromRouteRequest,
getProjectContext as resolveRouteProjectContext,
getScopedStore as resolveRouteScopedStore,
} from "../routes/context.js";
import { GitHubClient } from "../github.js";
import * as resolveDiffBaseModule from "../routes/resolve-diff-base.js";
import { githubRateLimiter } from "../github-poll.js";
import type { TaskStore, TaskAttachment, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult, ChatSession, ChatMessage } from "@fusion/core";
import type { TaskDetail } from "@fusion/core";
import type { AuthStorageLike, ModelRegistryLike } from "../routes.js";
import { __resetBatchImportRateLimiter, __setCreateFnAgentForRefine } from "../routes.js";
import * as agentGenerationModule from "../agent-generation.js";
import { __resetPlanningState, __setCreateFnAgent, planningStreamManager } from "../planning.js";
import * as planningModule from "../planning.js";
import { __resetSubtaskBreakdownState, subtaskStreamManager } from "../subtask-breakdown.js";
import * as subtaskBreakdownModule from "../subtask-breakdown.js";
import { SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "../ai-session-store.js";
import * as usageModule from "../usage.js";
import * as claudeCliProbeModule from "../claude-cli-probe.js";
import * as droidCliProbeModule from "../droid-cli-probe.js";
import * as projectStoreResolver from "../project-store-resolver.js";
import * as terminalServiceModule from "../terminal-service.js";
import { get as performGet, request as performRequest } from "../test-request.js";
import { resetRuntimeLogSink, setRuntimeLogSink } from "../runtime-logger.js";
import { resetDiagnosticsSink, setDiagnosticsSink, type LogEntry } from "../ai-session-diagnostics.js";
import * as updateCheckModule from "../update-check.js";
import { __setAgentReflectionServiceForTests } from "../routes/register-agent-reflection-rating-routes.js";
// Mock @fusion/core for gh CLI auth checks
const mockCentralListProjects = vi.fn().mockResolvedValue([]);
const mockCentralInit = vi.fn().mockResolvedValue(undefined);
const mockCentralClose = vi.fn().mockResolvedValue(undefined);
const mockCentralReconcileProjectStatuses = vi.fn().mockResolvedValue(undefined);
const { mockPerformUpdateCheck, mockClearUpdateCheckCache, mockExecSync, mockExecFile } = vi.hoisted(() => ({
mockPerformUpdateCheck: vi.fn(),
mockClearUpdateCheckCache: vi.fn(),
mockExecSync: vi.fn(),
mockExecFile: vi.fn(),
}));
vi.mock("../update-check.js", async () => {
const actual = await vi.importActual<typeof import("../update-check.js")>("../update-check.js");
return {
...actual,
performUpdateCheck: mockPerformUpdateCheck,
clearUpdateCheckCache: mockClearUpdateCheckCache,
};
});
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
mockExecSync.mockImplementation(((...args: Parameters<typeof actual.execSync>) => actual.execSync(...args)) as typeof actual.execSync);
// Default execFile mock blocks host-process pgrep calls used by /kill-vitest
// but passes through all other commands (including git) to preserve route
// behavior for integration-style API tests in this file.
mockExecFile.mockImplementation((...callArgs: unknown[]) => {
const [file, argsOrCb, maybeOptions, maybeCb] = callArgs as [string, unknown, unknown, unknown];
const args = Array.isArray(argsOrCb) ? argsOrCb : [];
const cb =
typeof maybeCb === "function"
? (maybeCb as (err: unknown, stdout?: string, stderr?: string) => void)
: typeof maybeOptions === "function"
? (maybeOptions as (err: unknown, stdout?: string, stderr?: string) => void)
: typeof argsOrCb === "function"
? (argsOrCb as (err: unknown, stdout?: string, stderr?: string) => void)
: null;
if (file === "pgrep" && args[0] === "-f" && args[1] === "vitest") {
if (cb) queueMicrotask(() => cb(null, "", ""));
return;
}
return (actual.execFile as (...innerArgs: unknown[]) => unknown)(...callArgs);
});
return {
...actual,
execSync: mockExecSync,
execFile: mockExecFile,
};
});
vi.mock("@fusion/core", async (importOriginal) => {
const { createCoreMock } = await import("../test/mockCoreEngine.js");
return createCoreMock(() => importOriginal<typeof import("@fusion/core")>(), {
resolveGlobalDir: vi.fn().mockReturnValue("/tmp/fusion-test"),
isGhAvailable: vi.fn(),
isGhAuthenticated: vi.fn(),
isQmdAvailable: vi.fn().mockResolvedValue(false),
CentralCore: vi.fn().mockImplementation(() => ({
init: mockCentralInit,
close: mockCentralClose,
listProjects: mockCentralListProjects,
reconcileProjectStatuses: mockCentralReconcileProjectStatuses,
})),
});
});
vi.mock("@fusion/engine", async () => {
const { createEngineMock } = await import("../test/mockCoreEngine.js");
return createEngineMock({
createFnAgent: vi.fn(async (options?: { onText?: (delta: string) => void }) => ({
session: {
state: {
messages: [] as Array<{ role: string; content: string }>,
},
prompt: vi.fn(async function (this: { state?: { messages?: Array<{ role: string; content: string }> } }, message: string) {
options?.onText?.("mock-ai-output");
const messages = this.state?.messages ?? [];
messages.push({ role: "user", content: message });
messages.push({
role: "assistant",
content: JSON.stringify({
subtasks: [
{
id: "subtask-1",
title: "Mock subtask",
description: "Generated by the route test engine mock",
suggestedSize: "S",
dependsOn: [],
},
],
}),
});
}),
dispose: vi.fn(),
},
})),
promptWithFallback: vi.fn(async (session: { prompt: (message: string) => Promise<void> }, prompt: string) => {
await session.prompt(prompt);
}),
AgentReflectionService: class MockAgentReflectionService {
async generateReflection(): Promise<import("@fusion/core").AgentReflection | null> {
throw new Error("Reflection service unavailable in route tests");
}
async buildReflectionContext(): Promise<never> {
throw new Error("Reflection service unavailable in route tests");
}
},
});
});
import { AgentStore, Database, RoutineStore, isGhAvailable, isGhAuthenticated } from "@fusion/core";
import { createFnAgent } from "@fusion/engine";
const mockIsGhAvailable = vi.mocked(isGhAvailable);
const mockIsGhAuthenticated = vi.mocked(isGhAuthenticated);
function createMockGlobalSettingsStore() {
return {
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn().mockResolvedValue({}),
getSettingsPath: vi.fn().mockReturnValue("/fake/home/.fusion/settings.json"),
init: vi.fn().mockResolvedValue(false),
invalidateCache: vi.fn(),
};
}
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getTask: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
searchTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
getSettingsFast: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getSettingsByScopeFast: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getGlobalSettingsStore: vi.fn().mockReturnValue(createMockGlobalSettingsStore()),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
getAgentLogCount: vi.fn().mockResolvedValue(0),
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
addTaskComment: vi.fn(),
updateTaskComment: vi.fn(),
deleteTaskComment: vi.fn(),
getTaskDocuments: vi.fn().mockResolvedValue([]),
getTaskDocument: vi.fn().mockResolvedValue(null),
getTaskDocumentRevisions: vi.fn().mockResolvedValue([]),
getAllDocuments: vi.fn().mockResolvedValue([]),
upsertTaskDocument: vi.fn(),
deleteTaskDocument: vi.fn().mockResolvedValue(undefined),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
listWorkflowSteps: vi.fn().mockResolvedValue([]),
createWorkflowStep: vi.fn(),
getWorkflowStep: vi.fn(),
updateWorkflowStep: vi.fn(),
deleteWorkflowStep: vi.fn(),
getMissionStore: vi.fn().mockReturnValue({
listMissions: vi.fn().mockReturnValue([]),
createMission: vi.fn(),
getMissionWithHierarchy: vi.fn(),
updateMission: vi.fn(),
getMission: vi.fn(),
deleteMission: vi.fn(),
listMilestonesByMission: vi.fn().mockReturnValue([]),
createMilestone: vi.fn(),
updateMilestone: vi.fn(),
getMilestone: vi.fn(),
deleteMilestone: vi.fn(),
listTasksByMilestone: vi.fn().mockReturnValue([]),
createMissionTask: vi.fn(),
updateMissionTask: vi.fn(),
getMissionTask: vi.fn(),
deleteMissionTask: vi.fn(),
}),
...overrides,
} as unknown as TaskStore;
}
const TASK_TOKEN_USAGE_FIXTURE = {
inputTokens: 1200,
outputTokens: 450,
cachedTokens: 210,
totalTokens: 1860,
firstUsedAt: "2026-04-24T09:00:00.000Z",
lastUsedAt: "2026-04-24T10:15:00.000Z",
};
const FAKE_TASK_DETAIL: TaskDetail = {
id: "FN-001",
description: "Test task",
column: "in-progress",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
tokenUsage: TASK_TOKEN_USAGE_FIXTURE,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# KB-001\n\nTest task",
};
async function GET(app: express.Express, path: string): Promise<{ status: number; body: any }> {
const res = await performGet(app, path);
return { status: res.status, body: res.body };
}
async function REQUEST(
app: express.Express,
method: string,
path: string,
body?: Buffer | string,
headers?: Record<string, string>,
): Promise<{ status: number; body: any }> {
const res = await performRequest(app, method, path, body, headers);
return { status: res.status, body: res.body };
}
function collectOrderedRouteKeys(router: express.Router): string[] {
const stack = (router as unknown as {
stack?: Array<{ route?: { path?: string; methods?: Record<string, boolean> } }>;
}).stack ?? [];
const orderedKeys: string[] = [];
for (const layer of stack) {
const route = layer.route;
if (!route?.path || !route.methods) continue;
const method = Object.keys(route.methods).find((name) => route.methods?.[name]);
if (!method) continue;
orderedKeys.push(`${method.toUpperCase()} ${route.path}`);
}
return orderedKeys;
}
describe("route registrar ordering invariants", () => {
it("keeps project, node settings, and mesh/discovery precedence-sensitive routes ordered", () => {
const router = createApiRoutes(createMockStore());
const orderedKeys = collectOrderedRouteKeys(router);
const indexOf = (routeKey: string): number => orderedKeys.indexOf(routeKey);
expect(indexOf("GET /projects/across-nodes")).toBeGreaterThan(-1);
expect(indexOf("POST /projects/detect")).toBeGreaterThan(-1);
expect(indexOf("GET /projects/:id")).toBeGreaterThan(-1);
expect(indexOf("GET /projects/across-nodes")).toBeLessThan(indexOf("GET /projects/:id"));
expect(indexOf("POST /projects/detect")).toBeLessThan(indexOf("GET /projects/:id"));
expect(indexOf("GET /nodes/:id/settings")).toBeLessThan(indexOf("POST /nodes/:id/settings/push"));
expect(indexOf("GET /nodes/:id/settings")).toBeLessThan(indexOf("POST /nodes/:id/settings/pull"));
expect(indexOf("GET /nodes/:id/settings")).toBeLessThan(indexOf("GET /nodes/:id/settings/sync-status"));
expect(indexOf("GET /nodes/:id/settings")).toBeLessThan(indexOf("POST /nodes/:id/auth/sync"));
expect(indexOf("GET /mesh/state")).toBeLessThan(indexOf("POST /mesh/sync"));
expect(indexOf("GET /discovery/status")).toBeGreaterThan(indexOf("POST /mesh/sync"));
});
});
describe("GET /api/system-stats", () => {
const projectId = "proj-system-stats";
function buildApp(store: TaskStore, options?: Parameters<typeof createApiRoutes>[1]) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, options));
return app;
}
it("returns process/system metrics with task and agent aggregates", async () => {
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([
{ id: "FN-1", column: "triage" },
{ id: "FN-2", column: "in-progress" },
{ id: "FN-3", column: "in-review" },
]),
getFusionDir: vi.fn().mockReturnValue("/fake/default"),
});
mockExecFile.mockImplementationOnce((...callArgs: unknown[]) => {
const cb = callArgs[callArgs.length - 1] as (err: unknown, stdout?: string, stderr?: string) => void;
cb(null, `${process.pid}\n111\n222\n`, "");
});
vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined);
vi.spyOn(AgentStore.prototype, "listAgents").mockResolvedValue([
{ id: "agent-1", state: "idle" },
{ id: "agent-2", state: "active" },
{ id: "agent-3", state: "running" },
{ id: "agent-4", state: "error" },
] as Array<Awaited<ReturnType<AgentStore["listAgents"]>>[number]>);
const res = await GET(buildApp(store), "/api/system-stats");
expect(res.status).toBe(200);
expect(res.body.systemStats).toEqual(
expect.objectContaining({
rss: expect.any(Number),
heapUsed: expect.any(Number),
heapTotal: expect.any(Number),
heapLimit: expect.any(Number),
external: expect.any(Number),
arrayBuffers: expect.any(Number),
cpuPercent: null,
loadAvg: expect.arrayContaining([expect.any(Number)]),
cpuCount: expect.any(Number),
systemTotalMem: expect.any(Number),
systemFreeMem: expect.any(Number),
pid: expect.any(Number),
nodeVersion: expect.stringMatching(/^v/),
platform: expect.stringContaining("/"),
}),
);
expect(res.body.taskStats).toEqual({
total: 3,
byColumn: {
triage: 1,
todo: 0,
"in-progress": 1,
"in-review": 1,
done: 0,
archived: 0,
},
active: 2,
agents: {
idle: 1,
active: 1,
running: 1,
error: 1,
},
});
expect(res.body.vitestProcessCount).toBe(2);
expect(res.body.vitestLastAutoKillAt).toBeNull();
mockExecFile.mockClear();
});
it("includes last auto-kill timestamp when available in global settings", async () => {
const store = createMockStore({
listTasks: vi.fn().mockResolvedValue([]),
getFusionDir: vi.fn().mockReturnValue("/fake/default"),
getGlobalSettingsStore: vi.fn().mockReturnValue({
getSettings: vi.fn().mockResolvedValue({ vitestLastAutoKillAt: "2026-04-27T12:00:00.000Z" }),
}),
});
vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined);
vi.spyOn(AgentStore.prototype, "listAgents").mockResolvedValue([]);
const res = await GET(buildApp(store), "/api/system-stats");
expect(res.status).toBe(200);
expect(res.body.vitestLastAutoKillAt).toBe("2026-04-27T12:00:00.000Z");
});
it("uses project-scoped store when projectId query param is provided", async () => {
const defaultStore = createMockStore({
listTasks: vi.fn().mockResolvedValue([{ id: "FN-default", column: "triage" }]),
getFusionDir: vi.fn().mockReturnValue("/fake/default"),
});
const scopedStore = createMockStore({
listTasks: vi.fn().mockResolvedValue([{ id: "FN-scoped", column: "todo" }]),
getFusionDir: vi.fn().mockReturnValue("/fake/scoped"),
});
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValue(scopedStore);
vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined);
vi.spyOn(AgentStore.prototype, "listAgents").mockResolvedValue([]);
const res = await GET(buildApp(defaultStore), `/api/system-stats?projectId=${projectId}`);
expect(res.status).toBe(200);
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
expect(scopedStore.listTasks).toHaveBeenCalledTimes(1);
expect(defaultStore.listTasks).not.toHaveBeenCalled();
expect(res.body.taskStats.byColumn.todo).toBe(1);
});
it("returns system stats with zeroed task stats when scoped project resolution fails", async () => {
const defaultStore = createMockStore({
listTasks: vi.fn().mockResolvedValue([{ id: "FN-default", column: "triage" }]),
getFusionDir: vi.fn().mockReturnValue("/fake/default"),
});
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockRejectedValue(
new Error(`Project "${projectId}" not found`),
);
const initSpy = vi.spyOn(AgentStore.prototype, "init").mockResolvedValue(undefined);
const listAgentsSpy = vi.spyOn(AgentStore.prototype, "listAgents").mockResolvedValue([]);
const res = await GET(buildApp(defaultStore), `/api/system-stats?projectId=${projectId}`);
expect(res.status).toBe(200);
expect(projectStoreResolver.getOrCreateProjectStore).toHaveBeenCalledWith(projectId);
expect(defaultStore.listTasks).not.toHaveBeenCalled();
expect(initSpy).not.toHaveBeenCalled();
expect(listAgentsSpy).not.toHaveBeenCalled();
expect(res.body.systemStats).toEqual(
expect.objectContaining({
rss: expect.any(Number),
heapUsed: expect.any(Number),
}),
);
expect(res.body.taskStats).toEqual({
total: 0,
byColumn: {
triage: 0,
todo: 0,
"in-progress": 0,
"in-review": 0,
done: 0,
archived: 0,
},
active: 0,
agents: {
idle: 0,
active: 0,
running: 0,
error: 0,
},
});
expect(res.body.vitestLastAutoKillAt).toBeNull();
});
});
describe("POST /api/kill-vitest", () => {
function buildApp(store: TaskStore) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store));
return app;
}
it("returns killed: 0 when no vitest processes are found", async () => {
const store = createMockStore();
mockExecFile.mockImplementationOnce((...callArgs: unknown[]) => {
const cb = callArgs[callArgs.length - 1] as (err: unknown, stdout?: string, stderr?: string) => void;
cb(null, "", "");
});
const res = await REQUEST(buildApp(store), "POST", "/api/kill-vitest");
expect(res.status).toBe(200);
expect(res.body).toEqual({ killed: 0, pids: [] });
mockExecFile.mockClear();
});
it("kills all matched vitest pids except the current dashboard process", async () => {
const store = createMockStore();
mockExecFile.mockImplementationOnce((...callArgs: unknown[]) => {
const cb = callArgs[callArgs.length - 1] as (err: unknown, stdout?: string, stderr?: string) => void;
cb(null, `${process.pid}\n1001\n1002\nnot-a-pid\n`, "");
});
const killSpy = vi.spyOn(process, "kill").mockImplementation(() => true);
const res = await REQUEST(buildApp(store), "POST", "/api/kill-vitest");
expect(res.status).toBe(200);
expect(killSpy).toHaveBeenCalledTimes(2);
expect(killSpy).toHaveBeenNthCalledWith(1, 1001, "SIGKILL");
expect(killSpy).toHaveBeenNthCalledWith(2, 1002, "SIGKILL");
expect(res.body).toEqual({ killed: 2, pids: [1001, 1002] });
killSpy.mockRestore();
mockExecFile.mockClear();
});
it("returns killed: 0 when pgrep exits with no matches", async () => {
const store = createMockStore();
mockExecFile.mockImplementationOnce((...callArgs: unknown[]) => {
const cb = callArgs[callArgs.length - 1] as (err: unknown, stdout?: string, stderr?: string) => void;
const err = Object.assign(new Error("pgrep exited 1"), { code: 1 });
cb(err);
});
const res = await REQUEST(buildApp(store), "POST", "/api/kill-vitest");
expect(res.status).toBe(200);
expect(res.body).toEqual({ killed: 0, pids: [] });
mockExecFile.mockClear();
});
});
describe("GET /api/plugins/runtimes", () => {
function buildApp(pluginLoader?: { getPluginRuntimes?: () => Array<{ pluginId: string; runtime: { metadata: { runtimeId: string; name: string; description?: string; version?: string }; factory: () => unknown } }> }) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(createMockStore(), { pluginLoader }));
return app;
}
it("returns plugin runtime metadata with 200 status, with installed entries overriding bundled fallbacks by runtimeId", async () => {
const pluginLoader = {
getPluginRuntimes: () => [
{
pluginId: "plugin-openclaw",
runtime: {
metadata: {
runtimeId: "openclaw",
name: "OpenClaw Runtime",
description: "Executes OpenClaw prompts",
version: "1.2.3",
},
factory: () => ({ run: async () => undefined }),
},
},
],
};
const res = await GET(buildApp(pluginLoader), "/api/plugins/runtimes");
expect(res.status).toBe(200);
const body = res.body as Array<{ pluginId: string; runtimeId: string }>;
// Installed runtime appears first and shadows the bundled openclaw entry.
expect(body[0]).toEqual({
pluginId: "plugin-openclaw",
runtimeId: "openclaw",
name: "OpenClaw Runtime",
description: "Executes OpenClaw prompts",
version: "1.2.3",
});
const ids = body.map((r) => r.runtimeId);
expect(ids).toContain("hermes");
expect(ids).toContain("paperclip");
// Only one openclaw entry (installed wins over bundled).
expect(ids.filter((id) => id === "openclaw")).toHaveLength(1);
});
it("returns the bundled plugin runtime fallbacks when no plugins are installed", async () => {
const res = await GET(buildApp(), "/api/plugins/runtimes");
expect(res.status).toBe(200);
const body = res.body as Array<{ pluginId: string; runtimeId: string }>;
const ids = body.map((r) => r.runtimeId).sort();
expect(ids).toEqual(["hermes", "openclaw", "paperclip"]);
});
});
describe("routes/context project scoping helpers", () => {
it("prefers query.projectId over body.projectId", () => {
const req = {
query: { projectId: "query-project" },
body: { projectId: "body-project" },
} as unknown as express.Request;
expect(getProjectIdFromRouteRequest(req)).toBe("query-project");
});
it("falls back to body.projectId when query.projectId is absent", () => {
const req = {
query: {},
body: { projectId: "body-project" },
} as unknown as express.Request;
expect(getProjectIdFromRouteRequest(req)).toBe("body-project");
});
it("getScopedStore returns root store when projectId is missing", async () => {
const store = createMockStore();
const req = { query: {}, body: {} } as unknown as express.Request;
const getOrCreateSpy = vi.spyOn(projectStoreResolver, "getOrCreateProjectStore");
const scopedStore = await resolveRouteScopedStore(req, store);
expect(scopedStore).toBe(store);
expect(getOrCreateSpy).not.toHaveBeenCalled();
});
it("getProjectContext falls back to scoped store when ensureEngine throws", async () => {
const store = createMockStore();
const fallbackStore = createMockStore();
const getOrCreateSpy = vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockResolvedValueOnce(fallbackStore);
const req = { query: { projectId: "proj-123" }, body: {} } as unknown as express.Request;
const options = {
engineManager: {
getEngine: vi.fn().mockReturnValue(undefined),
ensureEngine: vi.fn().mockRejectedValue(new Error("startup failed")),
},
} as any;
const context = await resolveRouteProjectContext(req, store, options);
expect(context.projectId).toBe("proj-123");
expect(context.engine).toBeUndefined();
expect(context.store).toBe(fallbackStore);
expect(options.engineManager.ensureEngine).toHaveBeenCalledWith("proj-123");
expect(getOrCreateSpy).toHaveBeenCalledWith("proj-123");
});
});
/** Build a minimal multipart/form-data body */
function buildMultipart(fieldName: string, filename: string, contentType: string, content: Buffer): { body: Buffer; boundary: string } {
const boundary = "----TestBoundary" + Date.now();
const header = `--${boundary}\r\nContent-Disposition: form-data; name="${fieldName}"; filename="${filename}"\r\nContent-Type: ${contentType}\r\n\r\n`;
const footer = `\r\n--${boundary}--\r\n`;
const body = Buffer.concat([Buffer.from(header), content, Buffer.from(footer)]);
return { body, boundary };
}
type GitTestRepo = {
root: string;
repoDir: string;
headSha: string;
};
let sharedGitTestRepo: GitTestRepo | null = null;
function getSharedGitTestRepo(): GitTestRepo {
if (sharedGitTestRepo) {
return sharedGitTestRepo;
}
const root = mkdtempSync(join(tmpdir(), "kb-dashboard-git-"));
const remoteDir = join(root, "remote.git");
const repoDir = join(root, "repo");
mkdirSync(repoDir, { recursive: true });
execFileSync("git", ["init", "--bare", remoteDir], { stdio: "pipe" });
execFileSync("git", ["init", repoDir], { stdio: "pipe" });
execFileSync("git", ["-C", repoDir, "config", "user.email", "kb-tests@example.com"], { stdio: "pipe" });
execFileSync("git", ["-C", repoDir, "config", "user.name", "KB Tests"], { stdio: "pipe" });
writeFileSync(join(repoDir, "README.md"), "# Test Repo\n");
execFileSync("git", ["-C", repoDir, "add", "README.md"], { stdio: "pipe" });
execFileSync("git", ["-C", repoDir, "commit", "-m", "Initial commit"], { stdio: "pipe" });
execFileSync("git", ["-C", repoDir, "branch", "-M", "main"], { stdio: "pipe" });
execFileSync("git", ["-C", repoDir, "remote", "add", "origin", remoteDir], { stdio: "pipe" });
execFileSync("git", ["-C", repoDir, "push", "-u", "origin", "HEAD"], { stdio: "pipe" });
const headSha = execFileSync("git", ["-C", repoDir, "rev-parse", "HEAD"], { encoding: "utf-8", stdio: "pipe" }).trim();
sharedGitTestRepo = { root, repoDir, headSha };
return sharedGitTestRepo;
}
afterAll(() => {
if (sharedGitTestRepo) {
rmSync(sharedGitTestRepo.root, { recursive: true, force: true });
sharedGitTestRepo = null;
}
});
afterEach(() => {
resetDiagnosticsSink();
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff