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:
@@ -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 } },
|
||||
|
||||
812
packages/dashboard/app/__tests__/api-auth.test.ts
Normal file
812
packages/dashboard/app/__tests__/api-auth.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
|
||||
1015
packages/dashboard/app/__tests__/api-git.test.ts
Normal file
1015
packages/dashboard/app/__tests__/api-git.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1577
packages/dashboard/app/__tests__/api-missions.test.ts
Normal file
1577
packages/dashboard/app/__tests__/api-missions.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1122
packages/dashboard/app/__tests__/api-projects.test.ts
Normal file
1122
packages/dashboard/app/__tests__/api-projects.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1816
packages/dashboard/app/__tests__/api-settings.test.ts
Normal file
1816
packages/dashboard/app/__tests__/api-settings.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
938
packages/dashboard/app/__tests__/api-tasks.test.ts
Normal file
938
packages/dashboard/app/__tests__/api-tasks.test.ts
Normal 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
3900
packages/dashboard/src/__tests__/routes-agents.test.ts
Normal file
3900
packages/dashboard/src/__tests__/routes-agents.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
2992
packages/dashboard/src/__tests__/routes-auth.test.ts
Normal file
2992
packages/dashboard/src/__tests__/routes-auth.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
2268
packages/dashboard/src/__tests__/routes-automation.test.ts
Normal file
2268
packages/dashboard/src/__tests__/routes-automation.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1432
packages/dashboard/src/__tests__/routes-git.test.ts
Normal file
1432
packages/dashboard/src/__tests__/routes-git.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
2032
packages/dashboard/src/__tests__/routes-github.test.ts
Normal file
2032
packages/dashboard/src/__tests__/routes-github.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
2797
packages/dashboard/src/__tests__/routes-planning.test.ts
Normal file
2797
packages/dashboard/src/__tests__/routes-planning.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
2602
packages/dashboard/src/__tests__/routes-settings.test.ts
Normal file
2602
packages/dashboard/src/__tests__/routes-settings.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
697
packages/dashboard/src/__tests__/routes-system.test.ts
Normal file
697
packages/dashboard/src/__tests__/routes-system.test.ts
Normal 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();
|
||||
});
|
||||
|
||||
2639
packages/dashboard/src/__tests__/routes-tasks-ops.test.ts
Normal file
2639
packages/dashboard/src/__tests__/routes-tasks-ops.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
1858
packages/dashboard/src/__tests__/routes-tasks.test.ts
Normal file
1858
packages/dashboard/src/__tests__/routes-tasks.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user