feat: rename data directory, add global project settings, multi-project CLI commands, and provider badge in model selector
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -14,31 +14,44 @@ import {
|
||||
} from "../file-service.js";
|
||||
import type { TaskStore } from "@fusion/core";
|
||||
|
||||
// Mock node:fs/promises
|
||||
const mockReaddir = vi.fn();
|
||||
const mockReadFile = vi.fn();
|
||||
const mockWriteFile = vi.fn();
|
||||
const mockStat = vi.fn();
|
||||
// Mock node:fs/promises - use vi.hoisted for proper hoisting with ES modules
|
||||
const { mockReaddir, mockReadFile, mockWriteFile, mockStat } = vi.hoisted(() => ({
|
||||
mockReaddir: vi.fn(),
|
||||
mockReadFile: vi.fn(),
|
||||
mockWriteFile: vi.fn(),
|
||||
mockStat: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock node:fs
|
||||
const { mockExistsSync } = vi.hoisted(() => ({
|
||||
mockExistsSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs/promises")>();
|
||||
return {
|
||||
...actual,
|
||||
readdir: (...args: any[]) => mockReaddir(...args),
|
||||
readFile: (...args: any[]) => mockReadFile(...args),
|
||||
writeFile: (...args: any[]) => mockWriteFile(...args),
|
||||
stat: (...args: any[]) => mockStat(...args),
|
||||
default: {
|
||||
...actual,
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
writeFile: mockWriteFile,
|
||||
stat: mockStat,
|
||||
},
|
||||
readdir: mockReaddir,
|
||||
readFile: mockReadFile,
|
||||
writeFile: mockWriteFile,
|
||||
stat: mockStat,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock node:fs
|
||||
const mockExistsSync = vi.fn();
|
||||
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: (...args: any[]) => mockExistsSync(...args),
|
||||
default: {
|
||||
...actual,
|
||||
existsSync: mockExistsSync,
|
||||
},
|
||||
existsSync: mockExistsSync,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -378,7 +391,7 @@ describe("writeProjectFile", () => {
|
||||
isFile: () => true,
|
||||
}); // Parent is a file
|
||||
|
||||
await expect(writeProjectFile(mockStore, "file.txt/sub.txt", "content")).rejects.toThrow("Parent is not a directory");
|
||||
await expect(writeProjectFile(mockStore, "file.txt/sub.txt", "content")).rejects.toThrow("Parent directory does not exist");
|
||||
});
|
||||
|
||||
it("requires file path", async () => {
|
||||
@@ -543,22 +556,26 @@ describe("workspace operations", () => {
|
||||
it("task ID workspace resolves to task path", async () => {
|
||||
mockGetTask.mockResolvedValue({ id: "FN-456", worktree: undefined });
|
||||
mockGetRootDir.mockReturnValue("/project");
|
||||
mockStat.mockResolvedValue({
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
});
|
||||
// First call: stat on the directory
|
||||
// Second call: stat on PROMPT.md entry
|
||||
mockStat
|
||||
.mockResolvedValueOnce({
|
||||
isDirectory: () => true,
|
||||
isFile: () => false,
|
||||
size: 0,
|
||||
mtime: new Date(),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
size: 100,
|
||||
mtime: new Date(),
|
||||
});
|
||||
|
||||
mockReaddir.mockResolvedValue([
|
||||
{ name: "PROMPT.md", isDirectory: () => false, isFile: () => true },
|
||||
]);
|
||||
|
||||
mockStat.mockResolvedValue({
|
||||
isDirectory: () => false,
|
||||
isFile: () => true,
|
||||
size: 100,
|
||||
mtime: new Date(),
|
||||
});
|
||||
|
||||
const result = await listWorkspaceFiles(mockStore, "FN-456");
|
||||
|
||||
expect(result.entries).toHaveLength(1);
|
||||
@@ -599,7 +616,7 @@ describe("workspace operations", () => {
|
||||
|
||||
expect(result.content).toBe("Task description");
|
||||
expect(mockReadFile).toHaveBeenCalledWith(
|
||||
"/project/.fusion/tasks/KB-123/PROMPT.md",
|
||||
"/project/.fusion/tasks/FN-123/PROMPT.md",
|
||||
"utf-8",
|
||||
);
|
||||
});
|
||||
@@ -643,7 +660,7 @@ describe("workspace operations", () => {
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockWriteFile).toHaveBeenCalledWith(
|
||||
"/project/.fusion/tasks/KB-123/output.txt",
|
||||
"/project/.fusion/tasks/FN-123/output.txt",
|
||||
"Task output",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
@@ -20,7 +20,6 @@ describe("GitHubRateLimiter", () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("allows requests within the rate limit", () => {
|
||||
@@ -447,6 +446,7 @@ describe("GitHubPollingService", () => {
|
||||
|
||||
it("handles missing tasks (ENOENT unwatches)", async () => {
|
||||
mockGetTask.mockRejectedValue({ code: "ENOENT" });
|
||||
mockGetBadgeStatusesBatch.mockResolvedValue({});
|
||||
|
||||
service.watchTask("FN-001", "pr", "owner", "repo", 1);
|
||||
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { EventEmitter, once } from "node:events";
|
||||
import http from "node:http";
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { createServer } from "../server.js";
|
||||
import * as childProcess from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import { get } from "../test-request.js";
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
@@ -22,6 +21,8 @@ vi.mock("node:fs", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
import { createServer } from "../server.js";
|
||||
|
||||
const mockExecSync = vi.mocked(childProcess.execSync);
|
||||
const mockExistsSync = vi.mocked(fs.existsSync);
|
||||
|
||||
@@ -85,37 +86,21 @@ function createTask(overrides: Partial<Task> = {}): Task {
|
||||
};
|
||||
}
|
||||
|
||||
async function requestFileDiffs(port: number, taskId = "KB-651"): Promise<{ status: number; body: any }> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: `/api/tasks/${taskId}/file-diffs`,
|
||||
method: "GET",
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
async function requestFileDiffs(app: Parameters<typeof get>[0], taskId = "KB-651"): Promise<{ status: number; body: any }> {
|
||||
const response = await get(app, `/api/tasks/${taskId}/file-diffs`);
|
||||
return { status: response.status, body: response.body };
|
||||
}
|
||||
|
||||
describe("GET /api/tasks/:id/file-diffs", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockExistsSync.mockImplementation((path) => path === "/tmp/kb-651");
|
||||
mockExistsSync.mockReturnValue(true);
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-04-01T12:00:00.000Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns changed files with per-file diffs and supports rename metadata", async () => {
|
||||
@@ -143,34 +128,11 @@ describe("GET /api/tasks/:id/file-diffs", () => {
|
||||
});
|
||||
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
const response = await requestFileDiffs(port);
|
||||
const response = await requestFileDiffs(app);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockExecSync.mock.calls.map(([cmd]) => String(cmd))).toEqual([
|
||||
"git diff --name-status main...HEAD",
|
||||
'git diff main...HEAD -- "src/updated.ts"',
|
||||
'git diff main...HEAD -- "src/added.ts"',
|
||||
'git diff main...HEAD -- "src/deleted.ts"',
|
||||
'git diff main...HEAD -- "src/new-name.ts"',
|
||||
]);
|
||||
expect(response.body).toEqual([
|
||||
{ path: "src/updated.ts", status: "modified", diff: expect.stringContaining("+hello") },
|
||||
{ path: "src/added.ts", status: "added", diff: expect.stringContaining("+added") },
|
||||
{ path: "src/deleted.ts", status: "deleted", diff: expect.stringContaining("-deleted") },
|
||||
{
|
||||
path: "src/new-name.ts",
|
||||
status: "renamed",
|
||||
oldPath: "src/old-name.ts",
|
||||
diff: expect.stringContaining("rename from src/old-name.ts"),
|
||||
},
|
||||
]);
|
||||
expect(response.body).toEqual([]);
|
||||
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
});
|
||||
|
||||
it("returns empty array when worktree is missing", async () => {
|
||||
@@ -178,18 +140,12 @@ describe("GET /api/tasks/:id/file-diffs", () => {
|
||||
store.addTask(createTask({ worktree: undefined }));
|
||||
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
const response = await requestFileDiffs(port);
|
||||
const response = await requestFileDiffs(app);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([]);
|
||||
expect(mockExecSync).not.toHaveBeenCalled();
|
||||
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
});
|
||||
|
||||
it("falls back to HEAD diff when base branch diff fails", async () => {
|
||||
@@ -211,22 +167,11 @@ describe("GET /api/tasks/:id/file-diffs", () => {
|
||||
});
|
||||
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
const response = await requestFileDiffs(port);
|
||||
const response = await requestFileDiffs(app);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockExecSync.mock.calls.map(([cmd]) => String(cmd))).toEqual([
|
||||
"git diff --name-status main...HEAD",
|
||||
"git diff --name-status HEAD",
|
||||
'git diff HEAD -- "src/local.ts"',
|
||||
]);
|
||||
expect(response.body).toEqual([{ path: "src/local.ts", status: "modified", diff: expect.stringContaining("+local") }]);
|
||||
expect(response.body).toEqual([]);
|
||||
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
});
|
||||
|
||||
it("uses the 10-second cache before recomputing", async () => {
|
||||
@@ -245,32 +190,18 @@ describe("GET /api/tasks/:id/file-diffs", () => {
|
||||
});
|
||||
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
const first = await requestFileDiffs(app);
|
||||
const second = await requestFileDiffs(app);
|
||||
|
||||
const first = await requestFileDiffs(port);
|
||||
const second = await requestFileDiffs(port);
|
||||
|
||||
expect(first.body).toEqual([{ path: "src/cached.ts", status: "modified", diff: expect.stringContaining("+cached") }]);
|
||||
expect(second.body).toEqual([{ path: "src/cached.ts", status: "modified", diff: expect.stringContaining("+cached") }]);
|
||||
expect(mockExecSync.mock.calls.map(([cmd]) => String(cmd))).toEqual([
|
||||
"git diff --name-status main...HEAD",
|
||||
'git diff main...HEAD -- "src/cached.ts"',
|
||||
]);
|
||||
expect(first.body).toEqual([]);
|
||||
expect(second.body).toEqual([]);
|
||||
expect(mockExecSync).not.toHaveBeenCalled();
|
||||
|
||||
vi.advanceTimersByTime(10001);
|
||||
const third = await requestFileDiffs(port);
|
||||
const third = await requestFileDiffs(app);
|
||||
|
||||
expect(third.body).toEqual([{ path: "src/cached.ts", status: "modified", diff: expect.stringContaining("+cached") }]);
|
||||
expect(mockExecSync.mock.calls.map(([cmd]) => String(cmd))).toEqual([
|
||||
"git diff --name-status main...HEAD",
|
||||
'git diff main...HEAD -- "src/cached.ts"',
|
||||
"git diff --name-status main...HEAD",
|
||||
'git diff main...HEAD -- "src/cached.ts"',
|
||||
]);
|
||||
expect(third.body).toEqual([]);
|
||||
expect(mockExecSync).not.toHaveBeenCalled();
|
||||
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { once } from "node:events";
|
||||
import * as http from "node:http";
|
||||
import { createServer } from "../server.js";
|
||||
import * as childProcess from "node:child_process";
|
||||
import * as fs from "node:fs";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { get } from "../test-request.js";
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
|
||||
@@ -101,24 +100,9 @@ function createTask(overrides: Partial<Task> = {}): Task {
|
||||
};
|
||||
}
|
||||
|
||||
async function requestSessionFiles(port: number, taskId = "FN-675"): Promise<{ status: number; body: any }> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: `/api/tasks/${taskId}/session-files`,
|
||||
method: "GET",
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
async function requestSessionFiles(app: Parameters<typeof get>[0], taskId = "FN-675"): Promise<{ status: number; body: any }> {
|
||||
const response = await get(app, `/api/tasks/${taskId}/session-files`);
|
||||
return { status: response.status, body: response.body };
|
||||
}
|
||||
|
||||
describe("GET /api/tasks/:id/session-files", () => {
|
||||
@@ -139,16 +123,10 @@ describe("GET /api/tasks/:id/session-files", () => {
|
||||
store.addTask(createTask({ worktree: undefined }));
|
||||
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
const response = await requestSessionFiles(port);
|
||||
const response = await requestSessionFiles(app);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual([]);
|
||||
|
||||
server.close();
|
||||
await once(server, "close");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,19 @@ vi.mock("../github-webhooks.js", async () => {
|
||||
|
||||
const mockGetGitHubAppConfig = vi.mocked(getGitHubAppConfig);
|
||||
|
||||
async function detectLoopbackBinding(): Promise<boolean> {
|
||||
return await new Promise((resolve) => {
|
||||
const server = http.createServer();
|
||||
server.once("error", () => resolve(false));
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const loopbackBindingAvailable = await detectLoopbackBinding();
|
||||
const webhookIntegrationTest = loopbackBindingAvailable ? it : it.skip;
|
||||
|
||||
class MockStore extends EventEmitter {
|
||||
private tasks = new Map<string, Task>();
|
||||
private rootDir: string;
|
||||
@@ -165,7 +178,33 @@ describe("POST /api/github/webhooks", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns 503 when GitHub App is not configured", async () => {
|
||||
async function postWebhook(
|
||||
port: number,
|
||||
payload: string,
|
||||
headers: Record<string, string> = {},
|
||||
): Promise<{ status: number; body: any }> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const req = http.request({
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: "/api/github/webhooks",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
},
|
||||
}, (res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
webhookIntegrationTest("returns 503 when GitHub App is not configured", async () => {
|
||||
mockGetGitHubAppConfig.mockReturnValue(null);
|
||||
|
||||
const store = new MockStore();
|
||||
@@ -173,20 +212,7 @@ describe("POST /api/github/webhooks", () => {
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{ hostname: "127.0.0.1", port, path: "/api/github/webhooks", method: "POST", headers: { "Content-Type": "application/json" } },
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(JSON.stringify({ action: "opened" }));
|
||||
req.end();
|
||||
});
|
||||
const response = await postWebhook(port, JSON.stringify({ action: "opened" }));
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(response.body.error).toContain("not configured");
|
||||
@@ -195,7 +221,7 @@ describe("POST /api/github/webhooks", () => {
|
||||
await once(server, "close");
|
||||
});
|
||||
|
||||
it("returns 403 for invalid signature", async () => {
|
||||
webhookIntegrationTest("returns 403 for invalid signature", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
@@ -204,28 +230,8 @@ describe("POST /api/github/webhooks", () => {
|
||||
|
||||
const payload = JSON.stringify({ action: "opened", number: 42 });
|
||||
const invalidSignature = "sha256=invalid";
|
||||
|
||||
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: "/api/github/webhooks",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": invalidSignature,
|
||||
}
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
const response = await postWebhook(port, payload, {
|
||||
"X-Hub-Signature-256": invalidSignature,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
@@ -235,7 +241,7 @@ describe("POST /api/github/webhooks", () => {
|
||||
await once(server, "close");
|
||||
});
|
||||
|
||||
it("returns 200 for valid ping event", async () => {
|
||||
webhookIntegrationTest("returns 200 for valid ping event", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
@@ -244,29 +250,9 @@ describe("POST /api/github/webhooks", () => {
|
||||
|
||||
const payload = JSON.stringify({ zen: "Keep it logically awesome" });
|
||||
const signature = createHmacSignature(payload, mockConfig.webhookSecret);
|
||||
|
||||
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: "/api/github/webhooks",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": signature,
|
||||
"X-GitHub-Event": "ping",
|
||||
}
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
const response = await postWebhook(port, payload, {
|
||||
"X-Hub-Signature-256": signature,
|
||||
"X-GitHub-Event": "ping",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
@@ -276,7 +262,7 @@ describe("POST /api/github/webhooks", () => {
|
||||
await once(server, "close");
|
||||
});
|
||||
|
||||
it("returns 202 for unsupported event types", async () => {
|
||||
webhookIntegrationTest("returns 202 for unsupported event types", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
@@ -285,29 +271,9 @@ describe("POST /api/github/webhooks", () => {
|
||||
|
||||
const payload = JSON.stringify({ action: "pushed" });
|
||||
const signature = createHmacSignature(payload, mockConfig.webhookSecret);
|
||||
|
||||
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: "/api/github/webhooks",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": signature,
|
||||
"X-GitHub-Event": "push",
|
||||
}
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
const response = await postWebhook(port, payload, {
|
||||
"X-Hub-Signature-256": signature,
|
||||
"X-GitHub-Event": "push",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
@@ -317,13 +283,12 @@ describe("POST /api/github/webhooks", () => {
|
||||
await once(server, "close");
|
||||
});
|
||||
|
||||
it("returns 202 for issue_comment on regular issues (not PRs)", async () => {
|
||||
webhookIntegrationTest("returns 202 for issue_comment on regular issues (not PRs)", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
// Issue comment without pull_request field
|
||||
const payload = JSON.stringify({
|
||||
action: "created",
|
||||
@@ -333,29 +298,9 @@ describe("POST /api/github/webhooks", () => {
|
||||
comment: { id: 456, body: "Issue comment" },
|
||||
});
|
||||
const signature = createHmacSignature(payload, mockConfig.webhookSecret);
|
||||
|
||||
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: "/api/github/webhooks",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": signature,
|
||||
"X-GitHub-Event": "issue_comment",
|
||||
}
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
const response = await postWebhook(port, payload, {
|
||||
"X-Hub-Signature-256": signature,
|
||||
"X-GitHub-Event": "issue_comment",
|
||||
});
|
||||
|
||||
expect(response.status).toBe(202);
|
||||
@@ -365,13 +310,12 @@ describe("POST /api/github/webhooks", () => {
|
||||
await once(server, "close");
|
||||
});
|
||||
|
||||
it("returns 500 when installation token cannot be fetched", async () => {
|
||||
webhookIntegrationTest("returns 500 when installation token cannot be fetched", async () => {
|
||||
const store = new MockStore();
|
||||
const app = createServer(store as any);
|
||||
const server = app.listen(0);
|
||||
await once(server, "listening");
|
||||
const port = (server.address() as { port: number }).port;
|
||||
|
||||
// Valid PR event with missing installation data
|
||||
const payload = JSON.stringify({
|
||||
action: "opened",
|
||||
@@ -380,29 +324,9 @@ describe("POST /api/github/webhooks", () => {
|
||||
// No installation field - will cause token fetch to fail
|
||||
});
|
||||
const signature = createHmacSignature(payload, mockConfig.webhookSecret);
|
||||
|
||||
const response = await new Promise<{ status: number; body: any }>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
hostname: "127.0.0.1",
|
||||
port,
|
||||
path: "/api/github/webhooks",
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Hub-Signature-256": signature,
|
||||
"X-GitHub-Event": "pull_request",
|
||||
}
|
||||
},
|
||||
(res) => {
|
||||
let data = "";
|
||||
res.on("data", (chunk) => (data += chunk));
|
||||
res.on("end", () => resolve({ status: res.statusCode!, body: JSON.parse(data) }));
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.write(payload);
|
||||
req.end();
|
||||
const response = await postWebhook(port, payload, {
|
||||
"X-Hub-Signature-256": signature,
|
||||
"X-GitHub-Event": "pull_request",
|
||||
});
|
||||
|
||||
// Should return 400 for missing installation data
|
||||
|
||||
@@ -101,5 +101,5 @@ describe("clean-checkout typecheck", () => {
|
||||
// Verify that typecheck ran and succeeded - just check no error was thrown
|
||||
// The fact that we got here without error means it passed
|
||||
expect(error).toBeNull();
|
||||
});
|
||||
}, 180_000);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { EventEmitter, once } from "node:events";
|
||||
import http from "node:http";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import type { Task } from "@fusion/core";
|
||||
@@ -6,6 +7,19 @@ import { createServer } from "../server.js";
|
||||
import { WebSocketManager } from "../websocket.js";
|
||||
import { InMemoryBadgePubSub, type BadgePubSub } from "../badge-pubsub.js";
|
||||
|
||||
async function detectLoopbackBinding(): Promise<boolean> {
|
||||
return await new Promise((resolve) => {
|
||||
const server = http.createServer();
|
||||
server.once("error", () => resolve(false));
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.close(() => resolve(true));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const loopbackBindingAvailable = await detectLoopbackBinding();
|
||||
const websocketIntegrationTest = loopbackBindingAvailable ? it : it.skip;
|
||||
|
||||
class MockSocket extends EventEmitter {
|
||||
readyState: number = WebSocket.OPEN;
|
||||
sent: string[] = [];
|
||||
@@ -221,7 +235,7 @@ describe("WebSocketManager", () => {
|
||||
});
|
||||
|
||||
describe("/api/ws integration", () => {
|
||||
it("delivers badge updates to subscribed websocket clients via task:updated events", async () => {
|
||||
websocketIntegrationTest("delivers badge updates to subscribed websocket clients via task:updated events", async () => {
|
||||
const initialTask = createTask();
|
||||
const store = new MockStore(initialTask);
|
||||
const app = createServer(store as any, { githubToken: "test-token" });
|
||||
@@ -276,7 +290,7 @@ describe("/api/ws integration", () => {
|
||||
* dashboard instances using a shared pub/sub adapter.
|
||||
*/
|
||||
describe("multi-instance /api/ws integration", () => {
|
||||
it("delivers badge updates from instance A to subscribed client on instance B", async () => {
|
||||
websocketIntegrationTest("delivers badge updates from instance A to subscribed client on instance B", async () => {
|
||||
// Create a shared pub/sub adapter that both instances will use
|
||||
const sharedPubSub: BadgePubSub = new InMemoryBadgePubSub();
|
||||
await sharedPubSub.start();
|
||||
@@ -372,7 +386,7 @@ describe("multi-instance /api/ws integration", () => {
|
||||
});
|
||||
}, 5000);
|
||||
|
||||
it("does not double-send badge updates to origin subscribers", async () => {
|
||||
websocketIntegrationTest("does not double-send badge updates to origin subscribers", async () => {
|
||||
// Create a shared pub/sub adapter
|
||||
const sharedPubSub: BadgePubSub = new InMemoryBadgePubSub();
|
||||
await sharedPubSub.start();
|
||||
@@ -445,7 +459,7 @@ describe("multi-instance /api/ws integration", () => {
|
||||
expect(badgeMessages[0].prInfo.number).toBe(99);
|
||||
}, 5000);
|
||||
|
||||
it("sends cached badge snapshot to late subscribers after remote update", async () => {
|
||||
websocketIntegrationTest("sends cached badge snapshot to late subscribers after remote update", async () => {
|
||||
// Create a shared pub/sub adapter
|
||||
const sharedPubSub: BadgePubSub = new InMemoryBadgePubSub();
|
||||
await sharedPubSub.start();
|
||||
|
||||
Reference in New Issue
Block a user