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: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
prompt: "# FN-001",
prompt: "# KB-001",
};
function mockFetchResponse(
@@ -141,7 +141,7 @@ describe("updateTask", () => {
const result = await updateTask("FN-001", { dependencies: ["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" },
method: "PATCH",
body: JSON.stringify({ dependencies: ["FN-002"] }),
@@ -182,7 +182,7 @@ describe("task comments api", () => {
const result = await fetchTaskComments("FN-001");
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" },
});
});
@@ -193,7 +193,7 @@ describe("task comments api", () => {
const result = await addTaskComment("FN-001", "Hello", "user");
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" },
method: "POST",
body: JSON.stringify({ text: "Hello", author: "user" }),
@@ -205,7 +205,7 @@ describe("task comments api", () => {
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" },
method: "PATCH",
body: JSON.stringify({ text: "Updated" }),
@@ -217,7 +217,7 @@ describe("task comments api", () => {
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" },
method: "DELETE",
});
@@ -529,7 +529,7 @@ describe("addSteeringComment", () => {
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", {
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/steer", {
headers: { "Content-Type": "application/json" },
method: "POST",
body: JSON.stringify({ text: "Please handle the edge case" }),
@@ -794,7 +794,7 @@ describe("approvePlan", () => {
expect(result.column).toBe("todo");
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" },
method: "POST",
});
@@ -828,7 +828,7 @@ describe("rejectPlan", () => {
expect(result.column).toBe("triage");
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" },
method: "POST",
});
@@ -854,7 +854,7 @@ describe("refineTask", () => {
const FAKE_REFINED_TASK: Task = {
id: "FN-002",
description: "Refinement of FN-001",
description: "Refinement of KB-001",
column: "triage",
dependencies: ["FN-001"],
steps: [],
@@ -872,7 +872,7 @@ describe("refineTask", () => {
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", {
expect(globalThis.fetch).toHaveBeenCalledWith("/api/tasks/KB-001/refine", {
headers: { "Content-Type": "application/json" },
method: "POST",
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");
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" },
method: "POST",
});
@@ -1192,7 +1192,7 @@ describe("Git Management API", () => {
const response = await unarchiveTask("FN-001");
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" },
method: "POST",
});
@@ -1228,7 +1228,7 @@ describe("Git Management API", () => {
const response = await fetchWorkspaceFileList("FN-001", "src");
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" },
});
});
@@ -1252,7 +1252,7 @@ describe("Git Management API", () => {
const response = await saveWorkspaceFileContent("FN-001", "src/index.ts", "hello");
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" },
method: "POST",
body: JSON.stringify({ content: "hello" }),
@@ -2155,3 +2155,4 @@ describe("fetchProjectConfig", () => {
expect(result.rootDir).toBe("/path/to/project");
});
});

View File

@@ -25,8 +25,6 @@ import type {
FeatureCreateInput,
MissionStatus,
MilestoneStatus,
SliceStatus,
FeatureStatus,
InterviewState,
} from "@fusion/core";
import {
@@ -119,11 +117,9 @@ function validateOrderedIds(body: unknown): string[] {
// ── Async Handler Wrapper ───────────────────────────────────────────────────
type TypedRequest = Request<Record<string, string>>;
function asyncHandler(fn: (req: TypedRequest, res: Response, next: NextFunction) => Promise<void>) {
function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<void>) {
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, beforeAll, beforeEach, afterAll, afterEach } from "vitest";
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import express from "express";
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 { GitHubClient } from "./github.js";
import { githubRateLimiter } from "./github-poll.js";
@@ -17,7 +10,6 @@ import type { AuthStorageLike, ModelRegistryLike } from "./routes.js";
import { __resetPlanningState } from "./planning.js";
import { __resetSubtaskBreakdownState } from "./subtask-breakdown.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
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 {
return {
getTask: vi.fn(),
@@ -83,7 +63,6 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
getWorkflowStep: vi.fn(),
updateWorkflowStep: vi.fn(),
deleteWorkflowStep: vi.fn(),
getMissionStore: vi.fn().mockReturnValue(createMockMissionStore()),
...overrides,
} as unknown as TaskStore;
}
@@ -101,11 +80,28 @@ const FAKE_TASK_DETAIL: TaskDetail = {
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 }> {
const res = await performGet(app, path);
return { status: res.status, body: res.body };
return new Promise((resolve, reject) => {
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(
app: express.Express,
method: string,
@@ -113,8 +109,30 @@ async function REQUEST(
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 };
return new Promise((resolve, reject) => {
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 */
@@ -238,19 +256,13 @@ describe("POST /tasks", () => {
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: undefined,
description: "Big initiative",
column: undefined,
dependencies: undefined,
breakIntoSubtasks: true,
summarize: false,
}),
expect.objectContaining({
settings: { autoSummarizeTitles: undefined },
}),
);
expect(store.createTask).toHaveBeenCalledWith({
title: undefined,
description: "Big initiative",
column: undefined,
dependencies: undefined,
breakIntoSubtasks: true,
});
});
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(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: undefined,
description: "Use explicit models",
column: undefined,
dependencies: undefined,
breakIntoSubtasks: undefined,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
summarize: false,
}),
expect.objectContaining({
settings: { autoSummarizeTitles: undefined },
}),
);
expect(store.createTask).toHaveBeenCalledWith({
title: undefined,
description: "Use explicit models",
column: undefined,
dependencies: undefined,
breakIntoSubtasks: undefined,
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
validatorModelProvider: "openai",
validatorModelId: "gpt-4o",
});
});
it("normalizes partial model overrides back to defaults", async () => {
@@ -318,23 +324,17 @@ describe("POST /tasks", () => {
);
expect(res.status).toBe(201);
expect(store.createTask).toHaveBeenCalledWith(
expect.objectContaining({
title: undefined,
description: "Ignore partial model selection",
column: undefined,
dependencies: undefined,
breakIntoSubtasks: undefined,
modelProvider: undefined,
modelId: undefined,
validatorModelProvider: undefined,
validatorModelId: undefined,
summarize: false,
}),
expect.objectContaining({
settings: { autoSummarizeTitles: undefined },
}),
);
expect(store.createTask).toHaveBeenCalledWith({
title: undefined,
description: "Ignore partial model selection",
column: undefined,
dependencies: undefined,
breakIntoSubtasks: undefined,
modelProvider: undefined,
modelId: undefined,
validatorModelProvider: undefined,
validatorModelId: undefined,
});
});
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(store.updateTask).toHaveBeenCalledWith("KB-001", { status: undefined, error: undefined });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined });
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
});
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(store.updateTask).toHaveBeenCalledWith("KB-001", { status: undefined, error: undefined });
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined });
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
});
});
@@ -655,7 +655,7 @@ describe("POST /tasks/:id/duplicate", () => {
expect(res.status).toBe(201);
expect(res.body.id).toBe("FN-002");
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 () => {
@@ -712,8 +712,8 @@ describe("POST /tasks/:id/refine", () => {
expect(res.status).toBe(201);
expect(res.body.id).toBe("FN-002");
expect(res.body.column).toBe("triage");
expect(store.refineTask).toHaveBeenCalledWith("KB-001", "Need improvements");
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Refinement requested", "Need improvements");
expect(store.refineTask).toHaveBeenCalledWith("FN-001", "Need improvements");
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Refinement requested", "Need improvements");
});
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.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 () => {
@@ -834,7 +834,7 @@ describe("POST /tasks/:id/archive", () => {
expect(res.status).toBe(200);
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 () => {
@@ -886,7 +886,7 @@ describe("POST /tasks/:id/unarchive", () => {
expect(res.status).toBe(200);
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 () => {
@@ -1260,7 +1260,7 @@ describe("PATCH /tasks/:id", () => {
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
title: undefined,
description: undefined,
prompt: undefined,
@@ -1281,7 +1281,7 @@ describe("PATCH /tasks/:id", () => {
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
title: "New",
description: undefined,
prompt: undefined,
@@ -1312,7 +1312,7 @@ describe("PATCH /tasks/:id", () => {
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
title: undefined,
description: undefined,
prompt: undefined,
@@ -1361,7 +1361,7 @@ describe("PATCH /tasks/:id", () => {
});
expect(res.status).toBe(200);
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
title: undefined,
description: undefined,
prompt: undefined,
@@ -1411,7 +1411,7 @@ describe("Attachment routes", () => {
expect(res.status).toBe(201);
expect(res.body.filename).toBe("1234-screenshot.png");
expect((store.addAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith(
"KB-001",
"FN-001",
"screenshot.png",
expect.any(Buffer),
"image/png",
@@ -1454,7 +1454,7 @@ describe("Attachment routes", () => {
const res = await REQUEST(buildApp(), "DELETE", "/api/tasks/KB-001/attachments/1234-screenshot.png");
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 () => {
@@ -1478,7 +1478,7 @@ describe("Attachment routes", () => {
expect(res.status).toBe(200);
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 () => {
@@ -1763,14 +1763,14 @@ describe("Pause/Unpause endpoints", () => {
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/pause");
expect(res.status).toBe(200);
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 () => {
(store.pauseTask as ReturnType<typeof vi.fn>).mockResolvedValue({ id: "FN-001" });
const res = await REQUEST(buildApp(), "POST", "/api/tasks/KB-001/unpause");
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 () => {
@@ -1807,7 +1807,7 @@ describe("Pause/Unpause endpoints", () => {
"Content-Type": "application/json",
});
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 () => {
@@ -1821,7 +1821,7 @@ describe("Pause/Unpause endpoints", () => {
"Content-Type": "application/json",
});
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 () => {
@@ -1833,7 +1833,7 @@ describe("Pause/Unpause endpoints", () => {
const res = await REQUEST(app, "DELETE", "/api/tasks/KB-001/comments/c1");
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.body).toEqual(mockComment);
expect(store.addSteeringComment).toHaveBeenCalledWith(
"KB-001",
"FN-001",
"Please handle the edge case",
"user"
);
@@ -3160,10 +3160,19 @@ describe("POST /github/issues/batch-import", () => {
});
it("handles rate limit (429) with retry and eventual success", async () => {
const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled").mockResolvedValueOnce({
success: true,
data: mockGitHubIssue(1, "Issue After Rate Limit"),
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>);
fetchSpy
.mockResolvedValueOnce({
ok: false,
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(
buildApp(),
@@ -3177,7 +3186,7 @@ describe("POST /github/issues/batch-import", () => {
expect(res.body.results).toHaveLength(1);
expect(res.body.results[0].success).toBe(true);
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
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].error).toContain("rate limit");
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
it("processes issues sequentially (not parallel)", async () => {
@@ -3653,31 +3663,9 @@ describe("POST /tasks/:id/reject-plan", () => {
describe("Git Management endpoints", () => {
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(() => {
// Use the actual project root so git commands work
store = createMockStore({
getRootDir: vi.fn().mockReturnValue(process.cwd()),
});
store = createMockStore();
});
function buildApp() {
@@ -4783,25 +4771,39 @@ describe("Terminal WebSocket close handler", () => {
const server = http.createServer(app);
setupTerminalWebSocket(app, server);
class FakeWebSocket extends EventEmitter {
send = vi.fn();
close = vi.fn(() => this.emit("close"));
terminate = vi.fn();
}
const ws = new FakeWebSocket();
const wss = (app as express.Express & { terminalWsServer?: EventEmitter }).terminalWsServer;
expect(wss).toBeTruthy();
await new Promise<void>((resolve, reject) => {
server.listen(0, () => {
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, {
url: "/api/terminal/ws?sessionId=term-ws-test",
headers: { host: "127.0.0.1" },
ws.on("open", () => {
// Close the WebSocket - this should trigger killSession
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();
});
@@ -4834,25 +4836,32 @@ describe("Terminal WebSocket close handler", () => {
const server = http.createServer(app);
setupTerminalWebSocket(app, server);
class FakeWebSocket extends EventEmitter {
send = vi.fn();
close = vi.fn(() => this.emit("close"));
terminate = vi.fn();
}
const ws = new FakeWebSocket();
const wss = (app as express.Express & { terminalWsServer?: EventEmitter }).terminalWsServer;
expect(wss).toBeTruthy();
await new Promise<void>((resolve, reject) => {
server.listen(0, () => {
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, {
url: "/api/terminal/ws?sessionId=term-ws-err",
headers: { host: "127.0.0.1" },
ws.on("open", () => {
// Force-terminate the connection to trigger error/close
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();
});
});