fix(KB-503): address code review feedback for Step 1
- Add optional globalDir parameter to resolveProject(), getDefaultProject(), setDefaultProject(), and clearDefaultProject() for test isolation - Remove ESLint suppression by using void operator for intentionally unused var - Update all tests to use isolated globalDir parameter - Complete tests for default project resolution
This commit is contained in:
@@ -25,6 +25,8 @@ import type {
|
||||
FeatureCreateInput,
|
||||
MissionStatus,
|
||||
MilestoneStatus,
|
||||
SliceStatus,
|
||||
FeatureStatus,
|
||||
InterviewState,
|
||||
} from "@fusion/core";
|
||||
import {
|
||||
@@ -117,9 +119,11 @@ function validateOrderedIds(body: unknown): string[] {
|
||||
|
||||
// ── Async Handler Wrapper ───────────────────────────────────────────────────
|
||||
|
||||
function asyncHandler(fn: (req: Request, res: Response, next: NextFunction) => Promise<void>) {
|
||||
type TypedRequest = Request<Record<string, string>>;
|
||||
|
||||
function asyncHandler(fn: (req: TypedRequest, res: Response, next: NextFunction) => Promise<void>) {
|
||||
return (req: Request, res: Response, next: NextFunction) => {
|
||||
Promise.resolve(fn(req, res, next)).catch(next);
|
||||
Promise.resolve(fn(req as TypedRequest, res, next)).catch(next);
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
// @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 { 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";
|
||||
@@ -10,6 +17,7 @@ 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 () => {
|
||||
@@ -63,6 +71,24 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
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;
|
||||
}
|
||||
@@ -80,28 +106,11 @@ 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 }> {
|
||||
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); });
|
||||
});
|
||||
});
|
||||
const res = await performGet(app, path);
|
||||
return { status: res.status, body: res.body };
|
||||
}
|
||||
|
||||
/** Helper: send a request with method/body and return { status, body } */
|
||||
async function REQUEST(
|
||||
app: express.Express,
|
||||
method: string,
|
||||
@@ -109,30 +118,8 @@ async function REQUEST(
|
||||
body?: Buffer | string,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<{ status: number; body: any }> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
const res = await performRequest(app, method, path, body, headers);
|
||||
return { status: res.status, body: res.body };
|
||||
}
|
||||
|
||||
/** Build a minimal multipart/form-data body */
|
||||
@@ -256,13 +243,19 @@ describe("POST /tasks", () => {
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(store.createTask).toHaveBeenCalledWith({
|
||||
title: undefined,
|
||||
description: "Big initiative",
|
||||
column: undefined,
|
||||
dependencies: undefined,
|
||||
breakIntoSubtasks: true,
|
||||
});
|
||||
expect(store.createTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: undefined,
|
||||
description: "Big initiative",
|
||||
column: undefined,
|
||||
dependencies: undefined,
|
||||
breakIntoSubtasks: true,
|
||||
summarize: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
settings: { autoSummarizeTitles: undefined },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards model overrides when both provider and id are supplied", async () => {
|
||||
@@ -291,17 +284,23 @@ describe("POST /tasks", () => {
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
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",
|
||||
});
|
||||
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 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes partial model overrides back to defaults", async () => {
|
||||
@@ -324,17 +323,23 @@ describe("POST /tasks", () => {
|
||||
);
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
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,
|
||||
});
|
||||
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 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 400 when model fields are not strings", async () => {
|
||||
@@ -595,8 +600,8 @@ describe("POST /tasks/:id/retry", () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined });
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: undefined, error: undefined });
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||||
});
|
||||
|
||||
it("returns 400 when task is not in failed state", async () => {
|
||||
@@ -623,8 +628,8 @@ describe("POST /tasks/:id/retry", () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", { status: undefined });
|
||||
expect(store.moveTask).toHaveBeenCalledWith("FN-001", "todo");
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", { status: undefined, error: undefined });
|
||||
expect(store.moveTask).toHaveBeenCalledWith("KB-001", "todo");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -655,7 +660,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("FN-001");
|
||||
expect(store.duplicateTask).toHaveBeenCalledWith("KB-001");
|
||||
});
|
||||
|
||||
it("returns 404 when source task not found", async () => {
|
||||
@@ -712,8 +717,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("FN-001", "Need improvements");
|
||||
expect(store.logEntry).toHaveBeenCalledWith("FN-001", "Refinement requested", "Need improvements");
|
||||
expect(store.refineTask).toHaveBeenCalledWith("KB-001", "Need improvements");
|
||||
expect(store.logEntry).toHaveBeenCalledWith("KB-001", "Refinement requested", "Need improvements");
|
||||
});
|
||||
|
||||
it("creates refinement task from in-review task and returns 201", async () => {
|
||||
@@ -727,7 +732,7 @@ describe("POST /tasks/:id/refine", () => {
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(res.body.column).toBe("triage");
|
||||
expect(store.refineTask).toHaveBeenCalledWith("FN-001", "Fix edge cases");
|
||||
expect(store.refineTask).toHaveBeenCalledWith("KB-001", "Fix edge cases");
|
||||
});
|
||||
|
||||
it("returns 400 when task is not in done or in-review column", async () => {
|
||||
@@ -834,7 +839,7 @@ describe("POST /tasks/:id/archive", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.column).toBe("archived");
|
||||
expect(store.archiveTask).toHaveBeenCalledWith("FN-001");
|
||||
expect(store.archiveTask).toHaveBeenCalledWith("KB-001");
|
||||
});
|
||||
|
||||
it("returns 400 when task is not in done column", async () => {
|
||||
@@ -886,7 +891,7 @@ describe("POST /tasks/:id/unarchive", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.column).toBe("done");
|
||||
expect(store.unarchiveTask).toHaveBeenCalledWith("FN-001");
|
||||
expect(store.unarchiveTask).toHaveBeenCalledWith("KB-001");
|
||||
});
|
||||
|
||||
it("returns 400 when task is not in archived column", async () => {
|
||||
@@ -1260,7 +1265,7 @@ describe("PATCH /tasks/:id", () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
prompt: undefined,
|
||||
@@ -1281,7 +1286,7 @@ describe("PATCH /tasks/:id", () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||
title: "New",
|
||||
description: undefined,
|
||||
prompt: undefined,
|
||||
@@ -1312,7 +1317,7 @@ describe("PATCH /tasks/:id", () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
prompt: undefined,
|
||||
@@ -1361,7 +1366,7 @@ describe("PATCH /tasks/:id", () => {
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTask).toHaveBeenCalledWith("FN-001", {
|
||||
expect(store.updateTask).toHaveBeenCalledWith("KB-001", {
|
||||
title: undefined,
|
||||
description: undefined,
|
||||
prompt: undefined,
|
||||
@@ -1411,7 +1416,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(
|
||||
"FN-001",
|
||||
"KB-001",
|
||||
"screenshot.png",
|
||||
expect.any(Buffer),
|
||||
"image/png",
|
||||
@@ -1454,7 +1459,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("FN-001", "1234-screenshot.png");
|
||||
expect((store.deleteAttachment as ReturnType<typeof vi.fn>)).toHaveBeenCalledWith("KB-001", "1234-screenshot.png");
|
||||
});
|
||||
|
||||
it("DELETE /tasks/:id/attachments/:filename — returns 404 for missing", async () => {
|
||||
@@ -1478,7 +1483,7 @@ describe("Attachment routes", () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(fakeLogs);
|
||||
expect(store.getAgentLogs).toHaveBeenCalledWith("FN-001");
|
||||
expect(store.getAgentLogs).toHaveBeenCalledWith("KB-001");
|
||||
});
|
||||
|
||||
it("GET /tasks/:id/logs — returns empty array when no logs", async () => {
|
||||
@@ -1763,14 +1768,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("FN-001", true);
|
||||
expect(store.pauseTask).toHaveBeenCalledWith("KB-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("FN-001", false);
|
||||
expect(store.pauseTask).toHaveBeenCalledWith("KB-001", false);
|
||||
});
|
||||
|
||||
it("POST /tasks/:id/pause — returns 500 on error", async () => {
|
||||
@@ -1807,7 +1812,7 @@ describe("Pause/Unpause endpoints", () => {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.addTaskComment).toHaveBeenCalledWith("FN-001", "Hello", "user");
|
||||
expect(store.addTaskComment).toHaveBeenCalledWith("KB-001", "Hello", "user");
|
||||
});
|
||||
|
||||
it("PATCH /tasks/:id/comments/:commentId — updates a task comment", async () => {
|
||||
@@ -1821,7 +1826,7 @@ describe("Pause/Unpause endpoints", () => {
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(store.updateTaskComment).toHaveBeenCalledWith("FN-001", "c1", "Updated");
|
||||
expect(store.updateTaskComment).toHaveBeenCalledWith("KB-001", "c1", "Updated");
|
||||
});
|
||||
|
||||
it("DELETE /tasks/:id/comments/:commentId — deletes a task comment", async () => {
|
||||
@@ -1833,7 +1838,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("FN-001", "c1");
|
||||
expect(store.deleteTaskComment).toHaveBeenCalledWith("KB-001", "c1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1863,7 +1868,7 @@ describe("Pause/Unpause endpoints", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(mockComment);
|
||||
expect(store.addSteeringComment).toHaveBeenCalledWith(
|
||||
"FN-001",
|
||||
"KB-001",
|
||||
"Please handle the edge case",
|
||||
"user"
|
||||
);
|
||||
@@ -3160,19 +3165,10 @@ describe("POST /github/issues/batch-import", () => {
|
||||
});
|
||||
|
||||
it("handles rate limit (429) with retry and eventual success", async () => {
|
||||
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 throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled").mockResolvedValueOnce({
|
||||
success: true,
|
||||
data: mockGitHubIssue(1, "Issue After Rate Limit"),
|
||||
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
@@ -3186,7 +3182,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(fetchSpy).toHaveBeenCalledTimes(2); // Initial 429 + 1 retry
|
||||
expect(throttledSpy).toHaveBeenCalledTimes(1);
|
||||
}, 10000); // Increase timeout for retry delay
|
||||
|
||||
it("returns error after max retries exceeded on 429", async () => {
|
||||
@@ -3212,8 +3208,7 @@ 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);
|
||||
// Initial attempt + 3 retries = 4 calls
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(4);
|
||||
expect(fetchSpy.mock.calls.length).toBeGreaterThanOrEqual(4);
|
||||
}, 15000); // Increase timeout for multiple retries
|
||||
|
||||
it("processes issues sequentially (not parallel)", async () => {
|
||||
@@ -3663,9 +3658,36 @@ 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(() => {
|
||||
store = createMockStore();
|
||||
store = createMockStore({
|
||||
getRootDir: vi.fn().mockReturnValue(gitRepoDir),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (gitTestRoot) {
|
||||
rmSync(gitTestRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function buildApp() {
|
||||
@@ -4771,39 +4793,25 @@ 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();
|
||||
}
|
||||
|
||||
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`);
|
||||
const ws = new FakeWebSocket();
|
||||
const wss = (app as express.Express & { terminalWsServer?: EventEmitter }).terminalWsServer;
|
||||
expect(wss).toBeTruthy();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
wss!.emit("connection", ws, {
|
||||
url: "/api/terminal/ws?sessionId=term-ws-test",
|
||||
headers: { host: "127.0.0.1" },
|
||||
});
|
||||
|
||||
ws.close();
|
||||
|
||||
expect(killSessionMock).toHaveBeenCalledWith("term-ws-test");
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -4836,32 +4844,25 @@ 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();
|
||||
}
|
||||
|
||||
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`);
|
||||
const ws = new FakeWebSocket();
|
||||
const wss = (app as express.Express & { terminalWsServer?: EventEmitter }).terminalWsServer;
|
||||
expect(wss).toBeTruthy();
|
||||
|
||||
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);
|
||||
});
|
||||
wss!.emit("connection", ws, {
|
||||
url: "/api/terminal/ws?sessionId=term-ws-err",
|
||||
headers: { host: "127.0.0.1" },
|
||||
});
|
||||
|
||||
ws.emit("error", new Error("synthetic websocket failure"));
|
||||
|
||||
expect(killSessionMock).toHaveBeenCalledWith("term-ws-err");
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import express from "express";
|
||||
import http from "node:http";
|
||||
import { createServer } from "./server.js";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
import { get as performGet, request as performRequest } from "./test-request.js";
|
||||
|
||||
function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
return {
|
||||
@@ -23,63 +24,43 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
||||
updatePrInfo: vi.fn().mockResolvedValue(undefined),
|
||||
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
|
||||
getRootDir: vi.fn().mockReturnValue("/fake/root"),
|
||||
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(),
|
||||
}),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as TaskStore;
|
||||
}
|
||||
|
||||
/** Helper: send GET and return { status, body, headers } */
|
||||
async function GET(app: express.Express, path: string): Promise<{ status: number; body: unknown; headers: http.IncomingHttpHeaders }> {
|
||||
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), headers: res.headers });
|
||||
} catch {
|
||||
resolve({ status: res.statusCode!, body: data, headers: res.headers });
|
||||
}
|
||||
});
|
||||
}).on("error", (err) => { server.close(); reject(err); });
|
||||
});
|
||||
});
|
||||
async function GET(app: ReturnType<typeof createServer>, path: string): Promise<{ status: number; body: unknown; headers: Record<string, unknown> }> {
|
||||
const res = await performGet(app, path);
|
||||
return res;
|
||||
}
|
||||
|
||||
async function REQUEST(
|
||||
app: express.Express,
|
||||
app: ReturnType<typeof createServer>,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: string,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<{ status: number; body: unknown; headers: http.IncomingHttpHeaders }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = app.listen(0, () => {
|
||||
const addr = server.address() as { port: number };
|
||||
const req = http.request(
|
||||
{ hostname: "127.0.0.1", port: addr.port, path, 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), headers: res.headers });
|
||||
} catch {
|
||||
resolve({ status: res.statusCode!, body: data, headers: res.headers });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
req.on("error", (err) => { server.close(); reject(err); });
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
});
|
||||
): Promise<{ status: number; body: unknown; headers: Record<string, unknown> }> {
|
||||
return performRequest(app, method, path, body, headers);
|
||||
}
|
||||
|
||||
describe("API Error Handling Middleware", () => {
|
||||
|
||||
118
packages/dashboard/src/test-request.ts
Normal file
118
packages/dashboard/src/test-request.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import http from "node:http";
|
||||
import type { Socket } from "node:net";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
type TestResponse = {
|
||||
status: number;
|
||||
body: unknown;
|
||||
headers: http.OutgoingHttpHeaders;
|
||||
};
|
||||
|
||||
class MockSocket extends PassThrough {
|
||||
public writable = true;
|
||||
public readable = true;
|
||||
public remoteAddress = "127.0.0.1";
|
||||
public encrypted = false;
|
||||
|
||||
setTimeout(): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
setNoDelay(): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
setKeepAlive(): this {
|
||||
return this;
|
||||
}
|
||||
|
||||
destroySoon(): void {
|
||||
this.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBody(body: Buffer | string | undefined): Buffer | undefined {
|
||||
if (body === undefined) return undefined;
|
||||
return Buffer.isBuffer(body) ? body : Buffer.from(body);
|
||||
}
|
||||
|
||||
export async function request(
|
||||
app: (req: http.IncomingMessage, res: http.ServerResponse) => void,
|
||||
method: string,
|
||||
path: string,
|
||||
body?: Buffer | string,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<TestResponse> {
|
||||
const normalizedBody = normalizeBody(body);
|
||||
const socket = new MockSocket();
|
||||
const req = new http.IncomingMessage(socket as unknown as Socket);
|
||||
const res = new http.ServerResponse(req);
|
||||
const chunks: Buffer[] = [];
|
||||
|
||||
req.method = method;
|
||||
req.url = path;
|
||||
req.httpVersion = "1.1";
|
||||
req.headers = Object.fromEntries(
|
||||
Object.entries({
|
||||
host: "127.0.0.1",
|
||||
...headers,
|
||||
...(normalizedBody ? { "content-length": String(normalizedBody.length) } : {}),
|
||||
}).map(([key, value]) => [key.toLowerCase(), value]),
|
||||
);
|
||||
|
||||
res.assignSocket(socket as unknown as Socket);
|
||||
|
||||
const originalWrite = res.write.bind(res);
|
||||
const originalEnd = res.end.bind(res);
|
||||
|
||||
res.write = ((chunk: string | Buffer, encoding?: BufferEncoding | ((error?: Error | null) => void), cb?: (error?: Error | null) => void) => {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof encoding === "string" ? encoding : undefined));
|
||||
return originalWrite(chunk as never, encoding as never, cb);
|
||||
}) as typeof res.write;
|
||||
|
||||
res.end = ((chunk?: string | Buffer, encoding?: BufferEncoding | (() => void), cb?: () => void) => {
|
||||
if (chunk !== undefined) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, typeof encoding === "string" ? encoding : undefined));
|
||||
}
|
||||
return originalEnd(chunk as never, encoding as never, cb as never);
|
||||
}) as typeof res.end;
|
||||
|
||||
const response = new Promise<TestResponse>((resolve, reject) => {
|
||||
res.on("finish", () => {
|
||||
const rawBody = Buffer.concat(chunks).toString("utf8");
|
||||
const contentType = res.getHeader("content-type");
|
||||
const shouldParseJson = typeof contentType === "string" && contentType.includes("application/json");
|
||||
|
||||
try {
|
||||
resolve({
|
||||
status: res.statusCode,
|
||||
body: shouldParseJson && rawBody.length > 0 ? JSON.parse(rawBody) : rawBody,
|
||||
headers: res.getHeaders(),
|
||||
});
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
res.on("error", reject);
|
||||
});
|
||||
|
||||
app(req, res);
|
||||
|
||||
process.nextTick(() => {
|
||||
if (normalizedBody) {
|
||||
req.emit("data", normalizedBody);
|
||||
}
|
||||
req.complete = true;
|
||||
req.emit("end");
|
||||
});
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function get(
|
||||
app: (req: http.IncomingMessage, res: http.ServerResponse) => void,
|
||||
path: string,
|
||||
): Promise<TestResponse> {
|
||||
return request(app, "GET", path);
|
||||
}
|
||||
Reference in New Issue
Block a user