Files
fusion/packages/dashboard/src/__tests__/routes-system.test.ts
Fusion 95d368e7d7 feat(FN-3211): document app CPU metric in system stats
Updated the architecture documentation to document the app CPU metric in the system stats section.

Fusion-Task-Id: FN-3211
2026-05-04 15:16:53 -07:00

724 lines
28 KiB
TypeScript

// @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 cpuUsageSpy = vi.spyOn(process, "cpuUsage");
const dateNowSpy = vi.spyOn(Date, "now");
cpuUsageSpy
.mockReturnValueOnce({ user: 1_000_000, system: 500_000 })
.mockImplementation((previousValue?: NodeJS.CpuUsage) => {
if (previousValue) {
return { user: 200_000, system: 100_000 };
}
return { user: 1_200_000, system: 600_000 };
});
let now = 1_000;
dateNowSpy.mockImplementation(() => {
now += 1_000;
return now;
});
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.mockImplementation((...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 app = buildApp(store);
const res = await GET(app, "/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("/"),
}),
);
const secondRes = await GET(app, "/api/system-stats");
expect(secondRes.status).toBe(200);
expect(secondRes.body.systemStats.cpuPercent).toBe(30);
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();
cpuUsageSpy.mockRestore();
dateNowSpy.mockRestore();
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),
cpuPercent: expect.toSatisfy((value: unknown) => value === null || typeof value === "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();
});