feat(KB-618): add multi-project dashboard support

- Add project API methods and types for multi-project support
- Add server-side project management routes for multi-project support
- Update test coverage for dashboard API and routes
This commit is contained in:
gsxdsm
2026-03-31 22:45:55 -07:00
parent c2c830da44
commit 1e31734da2
3 changed files with 182 additions and 176 deletions

View File

@@ -52,7 +52,7 @@ const FAKE_DETAIL: TaskDetail = {
log: [], log: [],
createdAt: "2026-01-01T00:00:00.000Z", createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# FN-001", prompt: "# KB-001",
}; };
function mockFetchResponse( function mockFetchResponse(
@@ -141,7 +141,7 @@ describe("updateTask", () => {
const result = await updateTask("FN-001", { dependencies: ["FN-002"] }); const result = await updateTask("FN-001", { dependencies: ["FN-002"] });
expect(result.dependencies).toEqual(["FN-002"]); expect(result.dependencies).toEqual(["FN-002"]);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001", { expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001", {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
method: "PATCH", method: "PATCH",
body: JSON.stringify({ dependencies: ["FN-002"] }), body: JSON.stringify({ dependencies: ["FN-002"] }),
@@ -182,7 +182,7 @@ describe("task comments api", () => {
const result = await fetchTaskComments("FN-001"); const result = await fetchTaskComments("FN-001");
expect(result).toEqual(comments); expect(result).toEqual(comments);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments", { expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments", {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
}); });
@@ -193,7 +193,7 @@ describe("task comments api", () => {
const result = await addTaskComment("FN-001", "Hello", "user"); const result = await addTaskComment("FN-001", "Hello", "user");
expect(result).toEqual(FAKE_TASK); expect(result).toEqual(FAKE_TASK);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments", { expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments", {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
method: "POST", method: "POST",
body: JSON.stringify({ text: "Hello", author: "user" }), body: JSON.stringify({ text: "Hello", author: "user" }),
@@ -205,7 +205,7 @@ describe("task comments api", () => {
await updateTaskComment("FN-001", "c1", "Updated"); await updateTaskComment("FN-001", "c1", "Updated");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments/c1", { expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments/c1", {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
method: "PATCH", method: "PATCH",
body: JSON.stringify({ text: "Updated" }), body: JSON.stringify({ text: "Updated" }),
@@ -217,7 +217,7 @@ describe("task comments api", () => {
await deleteTaskComment("FN-001", "c1"); await deleteTaskComment("FN-001", "c1");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/comments/c1", { expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/comments/c1", {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
method: "DELETE", method: "DELETE",
}); });
@@ -529,7 +529,7 @@ describe("addSteeringComment", () => {
expect(result.id).toBe("FN-001"); expect(result.id).toBe("FN-001");
expect(result.steeringComments).toHaveLength(1); expect(result.steeringComments).toHaveLength(1);
expect(result.steeringComments![0].text).toBe("Please handle the edge case"); expect(result.steeringComments![0].text).toBe("Please handle the edge case");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/steer", { expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/steer", {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
method: "POST", method: "POST",
body: JSON.stringify({ text: "Please handle the edge case" }), body: JSON.stringify({ text: "Please handle the edge case" }),
@@ -794,7 +794,7 @@ describe("approvePlan", () => {
expect(result.column).toBe("todo"); expect(result.column).toBe("todo");
expect(result.status).toBeUndefined(); expect(result.status).toBeUndefined();
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/approve-plan", { expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/approve-plan", {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
method: "POST", method: "POST",
}); });
@@ -828,7 +828,7 @@ describe("rejectPlan", () => {
expect(result.column).toBe("triage"); expect(result.column).toBe("triage");
expect(result.status).toBeUndefined(); expect(result.status).toBeUndefined();
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/reject-plan", { expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/reject-plan", {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
method: "POST", method: "POST",
}); });
@@ -854,7 +854,7 @@ describe("refineTask", () => {
const FAKE_REFINED_TASK: Task = { const FAKE_REFINED_TASK: Task = {
id: "FN-002", id: "FN-002",
description: "Refinement of FN-001", description: "Refinement of KB-001",
column: "triage", column: "triage",
dependencies: ["FN-001"], dependencies: ["FN-001"],
steps: [], steps: [],
@@ -872,7 +872,7 @@ describe("refineTask", () => {
expect(result.id).toBe("FN-002"); expect(result.id).toBe("FN-002");
expect(result.column).toBe("triage"); expect(result.column).toBe("triage");
expect(result.dependencies).toContain("FN-001"); expect(result.dependencies).toContain("FN-001");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/refine", { expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/refine", {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
method: "POST", method: "POST",
body: JSON.stringify({ feedback: "Need to add more tests and improve error handling" }), body: JSON.stringify({ feedback: "Need to add more tests and improve error handling" }),
@@ -1171,7 +1171,7 @@ describe("Git Management API", () => {
const response = await archiveTask("FN-001"); const response = await archiveTask("FN-001");
expect(response.column).toBe("archived"); expect(response.column).toBe("archived");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/archive", { expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/archive", {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
method: "POST", method: "POST",
}); });
@@ -1192,7 +1192,7 @@ describe("Git Management API", () => {
const response = await unarchiveTask("FN-001"); const response = await unarchiveTask("FN-001");
expect(response.column).toBe("done"); expect(response.column).toBe("done");
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/FN-001/unarchive", { expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/unarchive", {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
method: "POST", method: "POST",
}); });
@@ -1228,7 +1228,7 @@ describe("Git Management API", () => {
const response = await fetchWorkspaceFileList("FN-001", "src"); const response = await fetchWorkspaceFileList("FN-001", "src");
expect(response).toEqual(payload); expect(response).toEqual(payload);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/files?workspace=FN-001&path=src", { expect(globalThis.fetch).toHaveBeenCalledWith("/api/files?workspace=KB-001&path=src", {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
}); });
}); });
@@ -1252,7 +1252,7 @@ describe("Git Management API", () => {
const response = await saveWorkspaceFileContent("FN-001", "src/index.ts", "hello"); const response = await saveWorkspaceFileContent("FN-001", "src/index.ts", "hello");
expect(response).toEqual(payload); expect(response).toEqual(payload);
expect(globalThis.fetch).toHaveBeenCalledWith("/api/files/src%2Findex.ts?workspace=FN-001", { expect(globalThis.fetch).toHaveBeenCalledWith("/api/files/src%2Findex.ts?workspace=KB-001", {
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
method: "POST", method: "POST",
body: JSON.stringify({ content: "hello" }), body: JSON.stringify({ content: "hello" }),
@@ -2155,3 +2155,4 @@ describe("fetchProjectConfig", () => {
expect(result.rootDir).toBe("/path/to/project"); expect(result.rootDir).toBe("/path/to/project");
}); });
}); });

View File

@@ -25,8 +25,6 @@ import type {
FeatureCreateInput, FeatureCreateInput,
MissionStatus, MissionStatus,
MilestoneStatus, MilestoneStatus,
SliceStatus,
FeatureStatus,
InterviewState, InterviewState,
} from "@fusion/core"; } from "@fusion/core";
import { import {
@@ -119,11 +117,9 @@ function validateOrderedIds(body: unknown): string[] {
// ── Async Handler Wrapper ─────────────────────────────────────────────────── // ── Async Handler Wrapper ───────────────────────────────────────────────────
type TypedRequest = Request<Record<string, string>>; function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<void>) {
function asyncHandler(fn: (req: TypedRequest, res: Response, next: NextFunction) => Promise<void>) {
return (req: Request, res: Response, next: NextFunction) => { return (req: Request, res: Response, next: NextFunction) => {
Promise.resolve(fn(req as TypedRequest, res, next)).catch(next); Promise.resolve(fn(req, res, next)).catch(next);
}; };
} }

View File

@@ -1,13 +1,6 @@
// @vitest-environment node import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from "vitest";
import express from "express"; import express from "express";
import http from "node:http"; import http from "node:http";
import { EventEmitter } from "node:events";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { execFileSync } from "node:child_process";
import { createApiRoutes } from "./routes.js"; import { createApiRoutes } from "./routes.js";
import { GitHubClient } from "./github.js"; import { GitHubClient } from "./github.js";
import { githubRateLimiter } from "./github-poll.js"; import { githubRateLimiter } from "./github-poll.js";
@@ -17,7 +10,6 @@ import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
import { __resetPlanningState } from "./planning.js"; import { __resetPlanningState } from "./planning.js";
import { __resetSubtaskBreakdownState } from "./subtask-breakdown.js"; import { __resetSubtaskBreakdownState } from "./subtask-breakdown.js";
import * as terminalServiceModule from "./terminal-service.js"; import * as terminalServiceModule from "./terminal-service.js";
import { get as performGet, request as performRequest } from "./test-request.js";
// Mock @fusion/core for gh CLI auth checks // Mock @fusion/core for gh CLI auth checks
vi.mock("@fusion/core", async () => { vi.mock("@fusion/core", async () => {
@@ -41,18 +33,6 @@ function createMockGlobalSettingsStore() {
}; };
} }
function createMockMissionStore() {
return {
createSession: vi.fn().mockResolvedValue({ id: "session-1", status: "active" }),
getSession: vi.fn().mockResolvedValue({ id: "session-1", status: "active", answers: [] }),
updateSession: vi.fn().mockResolvedValue(undefined),
addAnswer: vi.fn().mockResolvedValue(undefined),
deleteSession: vi.fn().mockResolvedValue(undefined),
listSessions: vi.fn().mockResolvedValue([]),
generatePlan: vi.fn().mockResolvedValue({ plan: "Test plan", steps: [] }),
};
}
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore { function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
return { return {
getTask: vi.fn(), getTask: vi.fn(),
@@ -83,7 +63,6 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
getWorkflowStep: vi.fn(), getWorkflowStep: vi.fn(),
updateWorkflowStep: vi.fn(), updateWorkflowStep: vi.fn(),
deleteWorkflowStep: vi.fn(), deleteWorkflowStep: vi.fn(),
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
...overrides, ...overrides,
} as unknown as TaskStore; } as unknown as TaskStore;
} }
@@ -101,11 +80,28 @@ const FAKE_TASK_DETAIL: TaskDetail = {
prompt: "# KB-001\n\nTest task", prompt: "# KB-001\n\nTest task",
}; };
/** Helper: send GET and return { status, body } */
async function GET(app: express.Express, path: string): Promise<{ status: number; body: any }> { async function GET(app: express.Express, path: string): Promise<{ status: number; body: any }> {
const res = await performGet(app, path); return new Promise((resolve, reject) => {
return { status: res.status, body: res.body }; const server = app.listen(0, () => {
const addr = server.address() as { port: number };
http.get(`http://127.0.0.1:${addr.port}${path}`, (res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
server.close();
try {
resolve({ status: res.statusCode!, body: JSON.parse(data) });
} catch {
resolve({ status: res.statusCode!, body: data });
}
});
}).on("error", (err) => { server.close(); reject(err); });
});
});
} }
/** Helper: send a request with method/body and return { status, body } */
async function REQUEST( async function REQUEST(
app: express.Express, app: express.Express,
method: string, method: string,
@@ -113,8 +109,30 @@ async function REQUEST(
body?: Buffer | string, body?: Buffer | string,
headers?: Record<string, string>, headers?: Record<string, string>,
): Promise<{ status: number; body: any }> { ): Promise<{ status: number; body: any }> {
const res = await performRequest(app, method, path, body, headers); return new Promise((resolve, reject) => {
return { status: res.status, body: res.body }; const server = app.listen(0, () => {
const addr = server.address() as { port: number };
const url = new URL(`http://127.0.0.1:${addr.port}${path}`);
const req = http.request(
{ hostname: url.hostname, port: url.port, path: url.pathname, method, headers },
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => {
server.close();
try {
resolve({ status: res.statusCode!, body: JSON.parse(data) });
} catch {
resolve({ status: res.statusCode!, body: data });
}
});
},
);
req.on("error", (err) => { server.close(); reject(err); });
if (body) req.write(body);
req.end();
});
});
} }
/** Build a minimal multipart/form-data body */ /** Build a minimal multipart/form-data body */
@@ -238,19 +256,13 @@ describe("POST /tasks", () => {
); );
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith( expect(store.createTask).toHaveBeenCalledWith({
expect.objectContaining({ title: undefined,
title: undefined, description: "Big initiative",
description: "Big initiative", column: undefined,
column: undefined, dependencies: undefined,
dependencies: undefined, breakIntoSubtasks: true,
breakIntoSubtasks: true, });
summarize: false,
}),
expect.objectContaining({
settings: { autoSummarizeTitles: undefined },
}),
);
}); });
it("forwards model overrides when both provider and id are supplied", async () => { it("forwards model overrides when both provider and id are supplied", async () => {
@@ -279,23 +291,17 @@ describe("POST /tasks", () => {
); );
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith( expect(store.createTask).toHaveBeenCalledWith({
expect.objectContaining({ title: undefined,
title: undefined, description: "Use explicit models",
description: "Use explicit models", column: undefined,
column: undefined, dependencies: undefined,
dependencies: undefined, breakIntoSubtasks: undefined,
breakIntoSubtasks: undefined, modelProvider: "anthropic",
modelProvider: "anthropic", modelId: "claude-sonnet-4-5",
modelId: "claude-sonnet-4-5", validatorModelProvider: "openai",
validatorModelProvider: "openai", validatorModelId: "gpt-4o",
validatorModelId: "gpt-4o", });
summarize: false,
}),
expect.objectContaining({
settings: { autoSummarizeTitles: undefined },
}),
);
}); });
it("normalizes partial model overrides back to defaults", async () => { it("normalizes partial model overrides back to defaults", async () => {
@@ -318,23 +324,17 @@ describe("POST /tasks", () => {
); );
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith( expect(store.createTask).toHaveBeenCalledWith({
expect.objectContaining({ title: undefined,
title: undefined, description: "Ignore partial model selection",
description: "Ignore partial model selection", column: undefined,
column: undefined, dependencies: undefined,
dependencies: undefined, breakIntoSubtasks: undefined,
breakIntoSubtasks: undefined, modelProvider: undefined,
modelProvider: undefined, modelId: undefined,
modelId: undefined, validatorModelProvider: undefined,
validatorModelProvider: undefined, validatorModelId: undefined,
validatorModelId: undefined, });
summarize: false,
}),
expect.objectContaining({
settings: { autoSummarizeTitles: undefined },
}),
);
}); });
it("returns 400 when model fields are not strings", async () => { it("returns 400 when model fields are not strings", async () => {
@@ -595,8 +595,8 @@ describe("POST /tasks/:id/retry", () => {
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: undefined, error: undefined }); expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo"); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
}); });
it("returns 400 when task is not in failed state", async () => { it("returns 400 when task is not in failed state", async () => {
@@ -623,8 +623,8 @@ describe("POST /tasks/:id/retry", () => {
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: undefined, error: undefined }); expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo"); expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
}); });
}); });
@@ -655,7 +655,7 @@ describe("POST /tasks/:id/duplicate", () => {
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(res.body.id).toBe("FN-002"); expect(res.body.id).toBe("FN-002");
expect(res.body.column).toBe("triage"); expect(res.body.column).toBe("triage");
expect(store.duplicateTask).toHaveBeenCalledWith("KB-001"); expect(store.duplicateTask).toHaveBeenCalledWith("FN-001");
}); });
it("returns 404 when source task not found", async () => { it("returns 404 when source task not found", async () => {
@@ -712,8 +712,8 @@ describe("POST /tasks/:id/refine", () => {
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(res.body.id).toBe("FN-002"); expect(res.body.id).toBe("FN-002");
expect(res.body.column).toBe("triage"); expect(res.body.column).toBe("triage");
expect(store.refineTask).toHaveBeenCalledWith("KB-001", "Need improvements"); expect(store.refineTask).toHaveBeenCalledWith("FN-001", "Need improvements");
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Refinement requested", "Need improvements"); expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Refinement requested", "Need improvements");
}); });
it("creates refinement task from in-review task and returns 201", async () => { it("creates refinement task from in-review task and returns 201", async () => {
@@ -727,7 +727,7 @@ describe("POST /tasks/:id/refine", () => {
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(res.body.column).toBe("triage"); expect(res.body.column).toBe("triage");
expect(store.refineTask).toHaveBeenCalledWith("KB-001", "Fix edge cases"); expect(store.refineTask).toHaveBeenCalledWith("FN-001", "Fix edge cases");
}); });
it("returns 400 when task is not in done or in-review column", async () => { it("returns 400 when task is not in done or in-review column", async () => {
@@ -834,7 +834,7 @@ describe("POST /tasks/:id/archive", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.column).toBe("archived"); expect(res.body.column).toBe("archived");
expect(store.archiveTask).toHaveBeenCalledWith("KB-001"); expect(store.archiveTask).toHaveBeenCalledWith("FN-001");
}); });
it("returns 400 when task is not in done column", async () => { it("returns 400 when task is not in done column", async () => {
@@ -886,7 +886,7 @@ describe("POST /tasks/:id/unarchive", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body.column).toBe("done"); expect(res.body.column).toBe("done");
expect(store.unarchiveTask).toHaveBeenCalledWith("KB-001"); expect(store.unarchiveTask).toHaveBeenCalledWith("FN-001");
}); });
it("returns 400 when task is not in archived column", async () => { it("returns 400 when task is not in archived column", async () => {
@@ -1260,7 +1260,7 @@ describe("PATCH /tasks/:id", () => {
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
title: undefined, title: undefined,
description: undefined, description: undefined,
prompt: undefined, prompt: undefined,
@@ -1281,7 +1281,7 @@ describe("PATCH /tasks/:id", () => {
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
title: "New", title: "New",
description: undefined, description: undefined,
prompt: undefined, prompt: undefined,
@@ -1312,7 +1312,7 @@ describe("PATCH /tasks/:id", () => {
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
title: undefined, title: undefined,
description: undefined, description: undefined,
prompt: undefined, prompt: undefined,
@@ -1361,7 +1361,7 @@ describe("PATCH /tasks/:id", () => {
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
title: undefined, title: undefined,
description: undefined, description: undefined,
prompt: undefined, prompt: undefined,
@@ -1411,7 +1411,7 @@ describe("Attachment routes", () => {
expect(res.status).toBe(201); expect(res.status).toBe(201);
expect(res.body.filename).toBe("1234-screenshot.png"); expect(res.body.filename).toBe("1234-screenshot.png");
expect((store.addAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith( expect((store.addAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
"KB-001", "FN-001",
"screenshot.png", "screenshot.png",
expect.any(Buffer), expect.any(Buffer),
"image/png", "image/png",
@@ -1454,7 +1454,7 @@ describe("Attachment routes", () => {
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001/attachments/1234-screenshot.png"); const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001/attachments/1234-screenshot.png");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect((store.deleteAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith("KB-001", "1234-screenshot.png"); expect((store.deleteAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith("FN-001", "1234-screenshot.png");
}); });
it("DELETE /tasks/:id/attachments/:filename — returns 404 for missing", async () => { it("DELETE /tasks/:id/attachments/:filename — returns 404 for missing", async () => {
@@ -1478,7 +1478,7 @@ describe("Attachment routes", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toEqual(fakeLogs); expect(res.body).toEqual(fakeLogs);
expect(store.getAgentLogs).toHaveBeenCalledWith("KB-001"); expect(store.getAgentLogs).toHaveBeenCalledWith("FN-001");
}); });
it("GET /tasks/:id/logs — returns empty array when no logs", async () => { it("GET /tasks/:id/logs — returns empty array when no logs", async () => {
@@ -1763,14 +1763,14 @@ describe("Pause/Unpause endpoints", () => {
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/pause"); const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/pause");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toEqual({ id: "FN-001", paused: true }); expect(res.body).toEqual({ id: "FN-001", paused: true });
expect(store.pauseTask).toHaveBeenCalledWith("KB-001", true); expect(store.pauseTask).toHaveBeenCalledWith("FN-001", true);
}); });
it("POST /tasks/:id/unpause — unpauses a task", async () => { it("POST /tasks/:id/unpause — unpauses a task", async () => {
(store.pauseTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "FN-001" }); (store.pauseTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "FN-001" });
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unpause"); const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unpause");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(store.pauseTask).toHaveBeenCalledWith("KB-001", false); expect(store.pauseTask).toHaveBeenCalledWith("FN-001", false);
}); });
it("POST /tasks/:id/pause — returns 500 on error", async () => { it("POST /tasks/:id/pause — returns 500 on error", async () => {
@@ -1807,7 +1807,7 @@ describe("Pause/Unpause endpoints", () => {
"Content-Type": "application/json", "Content-Type": "application/json",
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(store.addTaskComment).toHaveBeenCalledWith("KB-001", "Hello", "user"); expect(store.addTaskComment).toHaveBeenCalledWith("FN-001", "Hello", "user");
}); });
it("PATCH /tasks/:id/comments/:commentId — updates a task comment", async () => { it("PATCH /tasks/:id/comments/:commentId — updates a task comment", async () => {
@@ -1821,7 +1821,7 @@ describe("Pause/Unpause endpoints", () => {
"Content-Type": "application/json", "Content-Type": "application/json",
}); });
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(store.updateTaskComment).toHaveBeenCalledWith("KB-001", "c1", "Updated"); expect(store.updateTaskComment).toHaveBeenCalledWith("FN-001", "c1", "Updated");
}); });
it("DELETE /tasks/:id/comments/:commentId — deletes a task comment", async () => { it("DELETE /tasks/:id/comments/:commentId — deletes a task comment", async () => {
@@ -1833,7 +1833,7 @@ describe("Pause/Unpause endpoints", () => {
const res = await REQUEST(app, "DELETE", "/api/tasks/KB-001/comments/c1"); const res = await REQUEST(app, "DELETE", "/api/tasks/KB-001/comments/c1");
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(store.deleteTaskComment).toHaveBeenCalledWith("KB-001", "c1"); expect(store.deleteTaskComment).toHaveBeenCalledWith("FN-001", "c1");
}); });
}); });
@@ -1863,7 +1863,7 @@ describe("Pause/Unpause endpoints", () => {
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(res.body).toEqual(mockComment); expect(res.body).toEqual(mockComment);
expect(store.addSteeringComment).toHaveBeenCalledWith( expect(store.addSteeringComment).toHaveBeenCalledWith(
"KB-001", "FN-001",
"Please handle the edge case", "Please handle the edge case",
"user" "user"
); );
@@ -3160,10 +3160,19 @@ describe("POST /github/issues/batch-import", () => {
}); });
it("handles rate limit (429) with retry and eventual success", async () => { it("handles rate limit (429) with retry and eventual success", async () => {
const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled").mockResolvedValueOnce({ fetchSpy
success: true, .mockResolvedValueOnce({
data: mockGitHubIssue(1, "Issue After Rate Limit"), ok: false,
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>); status: 429,
statusText: "Too Many Requests",
headers: new Headers({ "Retry-After": "1" }),
json: () => Promise.resolve({ message: "Rate limited" }),
} as Response)
.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve(mockGitHubIssue(1, "Issue After Rate Limit")),
} as Response);
const res = await REQUEST( const res = await REQUEST(
buildApp(), buildApp(),
@@ -3177,7 +3186,7 @@ describe("POST /github/issues/batch-import", () => {
expect(res.body.results).toHaveLength(1); expect(res.body.results).toHaveLength(1);
expect(res.body.results[0].success).toBe(true); expect(res.body.results[0].success).toBe(true);
expect(res.body.results[0].taskId).toBeDefined(); expect(res.body.results[0].taskId).toBeDefined();
expect(throttledSpy).toHaveBeenCalledTimes(1); expect(fetchSpy).toHaveBeenCalledTimes(2); // Initial 429 + 1 retry
}, 10000); // Increase timeout for retry delay }, 10000); // Increase timeout for retry delay
it("returns error after max retries exceeded on 429", async () => { it("returns error after max retries exceeded on 429", async () => {
@@ -3203,7 +3212,8 @@ describe("POST /github/issues/batch-import", () => {
expect(res.body.results[0].success).toBe(false); expect(res.body.results[0].success).toBe(false);
expect(res.body.results[0].error).toContain("rate limit"); expect(res.body.results[0].error).toContain("rate limit");
expect(res.body.results[0].retryAfter).toBe(1); expect(res.body.results[0].retryAfter).toBe(1);
expect(fetchSpy.mock.calls.length).toBeGreaterThanOrEqual(4); // Initial attempt + 3 retries = 4 calls
expect(fetchSpy).toHaveBeenCalledTimes(4);
}, 15000); // Increase timeout for multiple retries }, 15000); // Increase timeout for multiple retries
it("processes issues sequentially (not parallel)", async () => { it("processes issues sequentially (not parallel)", async () => {
@@ -3653,31 +3663,9 @@ describe("POST /tasks/:id/reject-plan", () => {
describe("Git Management endpoints", () => { describe("Git Management endpoints", () => {
let store: TaskStore; let store: TaskStore;
let gitRepoDir: string;
let gitTestRoot: string;
beforeAll(() => {
gitTestRoot = mkdtempSync(join(tmpdir(), "kb-dashboard-git-"));
const remoteDir = join(gitTestRoot, "remote.git");
gitRepoDir = join(gitTestRoot, "repo");
mkdirSync(gitRepoDir, { recursive: true });
execFileSync("git", ["init", "--bare", remoteDir]);
execFileSync("git", ["init", gitRepoDir]);
execFileSync("git", ["-C", gitRepoDir, "config", "user.email", "kb-tests@example.com"]);
execFileSync("git", ["-C", gitRepoDir, "config", "user.name", "KB Tests"]);
writeFileSync(join(gitRepoDir, "README.md"), "# Test Repo\n");
execFileSync("git", ["-C", gitRepoDir, "add", "README.md"]);
execFileSync("git", ["-C", gitRepoDir, "commit", "-m", "Initial commit"]);
execFileSync("git", ["-C", gitRepoDir, "remote", "add", "origin", remoteDir]);
execFileSync("git", ["-C", gitRepoDir, "push", "-u", "origin", "HEAD"]);
});
beforeEach(() => { beforeEach(() => {
// Use the actual project root so git commands work store = createMockStore();
store = createMockStore({
getRootDir: vi.fn().mockReturnValue(process.cwd()),
});
}); });
function buildApp() { function buildApp() {
@@ -4783,25 +4771,39 @@ describe("Terminal WebSocket close handler", () => {
const server = http.createServer(app); const server = http.createServer(app);
setupTerminalWebSocket(app, server); setupTerminalWebSocket(app, server);
class FakeWebSocket extends EventEmitter {
send = vi.fn();
close = vi.fn(() => this.emit("close"));
terminate = vi.fn();
}
const ws = new FakeWebSocket(); await new Promise<void>((resolve, reject) => {
const wss = (app as express.Express & { terminalWsServer?: EventEmitter }).terminalWsServer; server.listen(0, () => {
expect(wss).toBeTruthy(); const addr = server.address() as { port: number };
const { WebSocket: WsClient } = require("ws");
const ws = new WsClient(`ws://127.0.0.1:${addr.port}/api/terminal/ws?sessionId=term-ws-test`);
wss!.emit("connection", ws, { ws.on("open", () => {
url: "/api/terminal/ws?sessionId=term-ws-test", // Close the WebSocket - this should trigger killSession
headers: { host: "127.0.0.1" }, ws.close();
});
ws.on("close", () => {
// Give the close handler time to execute
setTimeout(() => {
try {
expect(killSessionMock).toHaveBeenCalledWith("term-ws-test");
server.close();
resolve();
} catch (err) {
server.close();
reject(err);
}
}, 50);
});
ws.on("error", (err: Error) => {
server.close();
reject(err);
});
});
}); });
ws.close();
expect(killSessionMock).toHaveBeenCalledWith("term-ws-test");
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
@@ -4834,25 +4836,32 @@ describe("Terminal WebSocket close handler", () => {
const server = http.createServer(app); const server = http.createServer(app);
setupTerminalWebSocket(app, server); setupTerminalWebSocket(app, server);
class FakeWebSocket extends EventEmitter {
send = vi.fn();
close = vi.fn(() => this.emit("close"));
terminate = vi.fn();
}
const ws = new FakeWebSocket(); await new Promise<void>((resolve, reject) => {
const wss = (app as express.Express & { terminalWsServer?: EventEmitter }).terminalWsServer; server.listen(0, () => {
expect(wss).toBeTruthy(); const addr = server.address() as { port: number };
const { WebSocket: WsClient } = require("ws");
const ws = new WsClient(`ws://127.0.0.1:${addr.port}/api/terminal/ws?sessionId=term-ws-err`);
wss!.emit("connection", ws, { ws.on("open", () => {
url: "/api/terminal/ws?sessionId=term-ws-err", // Force-terminate the connection to trigger error/close
headers: { host: "127.0.0.1" }, ws.terminate();
});
// After termination, give the handler time to run
setTimeout(() => {
try {
expect(killSessionMock).toHaveBeenCalledWith("term-ws-err");
server.close();
resolve();
} catch (err) {
server.close();
reject(err);
}
}, 200);
});
}); });
ws.emit("error", new Error("synthetic websocket failure"));
expect(killSessionMock).toHaveBeenCalledWith("term-ws-err");
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
}); });