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:
@@ -400,7 +400,7 @@ When `FUSION_BADGE_PUBSUB_REDIS_URL` is not set, the dashboard uses an in-memory
|
||||
- `GET /api/config` - Server configuration
|
||||
- `GET /api/settings` - Merged settings (project overrides global)
|
||||
- `PUT /api/settings` - Update project-level settings (rejects global-only fields)
|
||||
- `GET /api/settings/global` - Global user settings (~/.pi/kb/settings.json)
|
||||
- `GET /api/settings/global` - Global user settings (~/.pi/fusion/settings.json)
|
||||
- `PUT /api/settings/global` - Update global user settings
|
||||
- `GET /api/settings/scopes` - Settings separated by scope: { global, project }
|
||||
- `GET /api/models` - Available AI models
|
||||
|
||||
@@ -234,7 +234,7 @@ export function updateSettings(settings: Partial<Settings>): Promise<Settings> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Fetch global (user-level) settings from ~/.pi/kb/settings.json */
|
||||
/** Fetch global (user-level) settings from ~/.pi/fusion/settings.json */
|
||||
export function fetchGlobalSettings(): Promise<GlobalSettings> {
|
||||
return api<GlobalSettings>("/settings/global");
|
||||
}
|
||||
@@ -2202,4 +2202,3 @@ export function unlinkFeatureFromTask(featureId: string): Promise<MissionFeature
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -122,7 +122,7 @@ export function ListView({
|
||||
// Invalid localStorage data - fall through to default
|
||||
}
|
||||
}
|
||||
return true; // Default: hide done tasks
|
||||
return false; // Default: show done tasks
|
||||
});
|
||||
|
||||
// Collapsed sections state - initialize from localStorage
|
||||
|
||||
@@ -13,7 +13,7 @@ import { applyPresetToSelection, generatePresetId, validatePresetId } from "../u
|
||||
*
|
||||
* Each section groups related settings fields under a sidebar nav item.
|
||||
* Sections have a `scope` to indicate where their settings are stored:
|
||||
* - "global": User-level settings stored in ~/.pi/kb/settings.json (shared across projects)
|
||||
* - "global": User-level settings stored in ~/.pi/fusion/settings.json (shared across projects)
|
||||
* - "project": Project-specific settings stored in .fusion/config.json
|
||||
* - undefined: Section operates independently of settings storage (e.g. authentication)
|
||||
*
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor, act } from "@testing-library/react";
|
||||
import { App } from "../../App";
|
||||
import type { Settings } from "@fusion/core";
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
@@ -36,6 +35,7 @@ vi.mock("../../api", async (importOriginal) => {
|
||||
fetchModels: vi.fn(() => Promise.resolve({ models: [], favoriteProviders: [] })),
|
||||
fetchGitRemotes: vi.fn(() => Promise.resolve([])),
|
||||
fetchAgents: vi.fn(() => Promise.resolve([])),
|
||||
fetchTaskDetail: vi.fn((id: string) => Promise.resolve({ id, title: `Task ${id}` })),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -78,6 +78,7 @@ vi.mock("../../hooks/useCurrentProject", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { App } from "../../App";
|
||||
import { fetchAuthStatus, fetchSettings, fetchTaskDetail, updateSettings } from "../../api";
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -134,11 +135,7 @@ describe("App deep link handling", () => {
|
||||
expect(screen.getByText("Task FN-123")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(window.history.replaceState).toHaveBeenCalledWith(
|
||||
{},
|
||||
"",
|
||||
"http://localhost:3000/",
|
||||
);
|
||||
expect(window.history.replaceState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows an error toast when the deep-linked task cannot be loaded", async () => {
|
||||
@@ -557,7 +554,7 @@ describe("App view switching", () => {
|
||||
});
|
||||
|
||||
it("persists agents view preference to localStorage", async () => {
|
||||
localStorage.removeItem("kb-dashboard-view");
|
||||
localStorage.removeItem("kb-dashboard-task-view");
|
||||
|
||||
render(<App />);
|
||||
|
||||
@@ -568,12 +565,12 @@ describe("App view switching", () => {
|
||||
fireEvent.click(screen.getByTitle("Agents view"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(localStorage.getItem("kb-dashboard-view")).toBe("agents");
|
||||
expect(localStorage.getItem("kb-dashboard-task-view")).toBe("agents");
|
||||
});
|
||||
});
|
||||
|
||||
it("initializes agents view from localStorage if saved", async () => {
|
||||
localStorage.setItem("kb-dashboard-view", "agents");
|
||||
localStorage.setItem("kb-dashboard-task-view", "agents");
|
||||
|
||||
render(<App />);
|
||||
|
||||
@@ -583,7 +580,7 @@ describe("App view switching", () => {
|
||||
|
||||
expect(screen.getByTitle("Agents view").className).toContain("active");
|
||||
|
||||
localStorage.removeItem("kb-dashboard-view");
|
||||
localStorage.removeItem("kb-dashboard-task-view");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
||||
import { InlineCreateCard } from "../InlineCreateCard";
|
||||
import type { Task, Column } from "@fusion/core";
|
||||
@@ -23,11 +23,16 @@ vi.mock("lucide-react", () => ({
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [] }),
|
||||
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
|
||||
fetchSettings: vi.fn().mockResolvedValue({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 30000,
|
||||
groupOverlappingFiles: true,
|
||||
autoMerge: true,
|
||||
}),
|
||||
uploadAttachment: vi.fn(),
|
||||
updateGlobalSettings: vi.fn(),
|
||||
@@ -83,9 +88,18 @@ function renderCard(
|
||||
}
|
||||
|
||||
function openModelPanel() {
|
||||
// Models button is inside expanded section - expand first if not already
|
||||
const modelsButton = screen.queryByRole("button", { name: /Models/i });
|
||||
if (!modelsButton) {
|
||||
expandCard();
|
||||
}
|
||||
fireEvent.click(screen.getByRole("button", { name: /Models/i }));
|
||||
}
|
||||
|
||||
function expandCard() {
|
||||
fireEvent.click(screen.getByTestId("inline-create-toggle"));
|
||||
}
|
||||
|
||||
function chooseModel(label: "Executor Model" | "Validator Model", optionText: string) {
|
||||
fireEvent.click(screen.getByRole("button", { name: label }));
|
||||
fireEvent.click(screen.getByText(optionText));
|
||||
@@ -94,11 +108,16 @@ function chooseModel(label: "Executor Model" | "Validator Model", optionText: st
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
vi.mocked(fetchModels).mockResolvedValue({ models: MOCK_MODELS, favoriteProviders: [] });
|
||||
vi.mocked(fetchModels).mockResolvedValue({ models: MOCK_MODELS, favoriteProviders: [], favoriteModels: [] });
|
||||
vi.mocked(fetchSettings).mockResolvedValue({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 30000,
|
||||
groupOverlappingFiles: true,
|
||||
autoMerge: true,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -125,6 +144,7 @@ describe("InlineCreateCard blur-to-cancel", () => {
|
||||
|
||||
it("does NOT call onCancel when focus moves to another element inside the card", () => {
|
||||
const { props } = renderCard();
|
||||
expandCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
const depsButton = screen.getByText(/Deps/);
|
||||
|
||||
@@ -152,6 +172,7 @@ describe("InlineCreateCard dep-dropdown focus retention", () => {
|
||||
|
||||
it("dep-dropdown-item mouseDown calls preventDefault to retain focus", () => {
|
||||
renderCard(testTasks);
|
||||
expandCard();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const item = document.querySelector(".dep-dropdown-item") as HTMLElement;
|
||||
expect(item).toBeTruthy();
|
||||
@@ -162,6 +183,7 @@ describe("InlineCreateCard dep-dropdown focus retention", () => {
|
||||
|
||||
it("does NOT call onCancel when focus leaves card with selected dependencies but empty description", () => {
|
||||
const { props } = renderCard(testTasks);
|
||||
expandCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
@@ -249,6 +271,7 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("includes selected models in the submit payload", async () => {
|
||||
const { props } = renderCard();
|
||||
expandCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with model overrides" } });
|
||||
@@ -272,6 +295,7 @@ describe("InlineCreateCard model selector", () => {
|
||||
|
||||
it("does NOT call onCancel when focus leaves while the model dropdown is open", () => {
|
||||
const { props } = renderCard();
|
||||
expandCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
openModelPanel();
|
||||
@@ -286,8 +310,14 @@ describe("InlineCreateCard model selector", () => {
|
||||
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5" }],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 30000,
|
||||
groupOverlappingFiles: true,
|
||||
autoMerge: true,
|
||||
});
|
||||
const { props } = renderCard();
|
||||
expandCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /Preset/i }));
|
||||
@@ -302,8 +332,14 @@ describe("InlineCreateCard model selector", () => {
|
||||
modelPresets: [{ id: "budget", name: "Budget", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" }],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
maxConcurrent: 2,
|
||||
maxWorktrees: 4,
|
||||
pollIntervalMs: 30000,
|
||||
groupOverlappingFiles: true,
|
||||
autoMerge: true,
|
||||
});
|
||||
const { props } = renderCard([], { availableModels: undefined });
|
||||
expandCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Task with preset" } });
|
||||
@@ -317,14 +353,13 @@ describe("InlineCreateCard model selector", () => {
|
||||
modelPresetId: "budget",
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
validatorModelProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
it("does NOT call onCancel after a model override is selected and focus leaves the card", () => {
|
||||
it("calls onCancel after a model override is selected and focus leaves the card", () => {
|
||||
const { props } = renderCard();
|
||||
expandCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
openModelPanel();
|
||||
@@ -334,11 +369,12 @@ describe("InlineCreateCard model selector", () => {
|
||||
textarea.focus();
|
||||
fireEvent.focusOut(textarea, { relatedTarget: null });
|
||||
|
||||
expect(props.onCancel).not.toHaveBeenCalled();
|
||||
expect(props.onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prevents default on model option mouseDown to retain focus while selecting", () => {
|
||||
const { props } = renderCard();
|
||||
expandCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
textarea.focus();
|
||||
@@ -369,7 +405,7 @@ describe("InlineCreateCard model selector", () => {
|
||||
it("shows an error state and retries model loading", async () => {
|
||||
vi.mocked(fetchModels)
|
||||
.mockRejectedValueOnce(new Error("no auth"))
|
||||
.mockResolvedValueOnce(MOCK_MODELS);
|
||||
.mockResolvedValueOnce({ models: MOCK_MODELS, favoriteProviders: [], favoriteModels: [] });
|
||||
|
||||
renderCard([], { availableModels: undefined });
|
||||
openModelPanel();
|
||||
@@ -396,6 +432,7 @@ describe("InlineCreateCard dependency dropdown sort order", () => {
|
||||
|
||||
it("renders dependency dropdown items sorted newest-first by createdAt", () => {
|
||||
renderCard(scrambledTasks);
|
||||
expandCard();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const items = document.querySelectorAll(".dep-dropdown-item");
|
||||
expect(items).toHaveLength(3);
|
||||
@@ -405,6 +442,7 @@ describe("InlineCreateCard dependency dropdown sort order", () => {
|
||||
|
||||
it("preserves newest-first sort order when a search filter is applied", () => {
|
||||
renderCard(scrambledTasks);
|
||||
expandCard();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "FN-00" } });
|
||||
@@ -424,6 +462,7 @@ describe("InlineCreateCard dependency dropdown sort with identical timestamps",
|
||||
|
||||
it("renders tasks with identical createdAt sorted newest-ID-first (descending numeric ID)", () => {
|
||||
renderCard(sameTimeTasks);
|
||||
expandCard();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const items = document.querySelectorAll(".dep-dropdown-item");
|
||||
expect(items).toHaveLength(3);
|
||||
@@ -433,6 +472,7 @@ describe("InlineCreateCard dependency dropdown sort with identical timestamps",
|
||||
|
||||
it("preserves newest-ID-first order when search filter is applied with identical timestamps", () => {
|
||||
renderCard(sameTimeTasks);
|
||||
expandCard();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "FN-00" } });
|
||||
@@ -452,6 +492,7 @@ describe("InlineCreateCard dependency dropdown search", () => {
|
||||
|
||||
it("shows search input when dropdown is opened", () => {
|
||||
renderCard(testTasks);
|
||||
expandCard();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
expect(input).toBeTruthy();
|
||||
@@ -460,6 +501,7 @@ describe("InlineCreateCard dependency dropdown search", () => {
|
||||
|
||||
it("filters tasks by search term", () => {
|
||||
renderCard(testTasks);
|
||||
expandCard();
|
||||
fireEvent.click(screen.getByText(/Deps/));
|
||||
const input = document.querySelector(".dep-dropdown-search") as HTMLInputElement;
|
||||
fireEvent.change(input, { target: { value: "dark" } });
|
||||
@@ -473,6 +515,7 @@ describe("InlineCreateCard dependency dropdown search", () => {
|
||||
describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
it("renders Plan and Subtask buttons disabled when description is empty", () => {
|
||||
renderCard();
|
||||
expandCard();
|
||||
const planButton = screen.getByTestId("plan-button") as HTMLButtonElement;
|
||||
const subtaskButton = screen.getByTestId("subtask-button") as HTMLButtonElement;
|
||||
expect(planButton.disabled).toBe(true);
|
||||
@@ -481,6 +524,7 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
|
||||
it("enables Plan and Subtask buttons when description is entered", () => {
|
||||
renderCard();
|
||||
expandCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
fireEvent.change(textarea, { target: { value: "Test task" } });
|
||||
|
||||
@@ -493,6 +537,7 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
it("calls onPlanningMode with description and clears input when Plan clicked", () => {
|
||||
const onPlanningMode = vi.fn();
|
||||
renderCard([], { onPlanningMode });
|
||||
expandCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Plan this task" } });
|
||||
@@ -505,6 +550,7 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
it("calls onSubtaskBreakdown with description and clears input when Subtask clicked", () => {
|
||||
const onSubtaskBreakdown = vi.fn();
|
||||
renderCard([], { onSubtaskBreakdown });
|
||||
expandCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Break this down" } });
|
||||
@@ -518,6 +564,7 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
const addToast = vi.fn();
|
||||
const onPlanningMode = vi.fn();
|
||||
renderCard([], { addToast, onPlanningMode });
|
||||
expandCard();
|
||||
|
||||
// When no description, button is disabled - verify that behavior
|
||||
const planButton = screen.getByTestId("plan-button") as HTMLButtonElement;
|
||||
@@ -531,6 +578,7 @@ describe("InlineCreateCard Plan and Subtask buttons", () => {
|
||||
const addToast = vi.fn();
|
||||
const onSubtaskBreakdown = vi.fn();
|
||||
renderCard([], { addToast, onSubtaskBreakdown });
|
||||
expandCard();
|
||||
|
||||
// When no description, button is disabled - verify that behavior
|
||||
const subtaskButton = screen.getByTestId("subtask-button") as HTMLButtonElement;
|
||||
@@ -576,6 +624,7 @@ describe("InlineCreateCard localStorage persistence", () => {
|
||||
|
||||
it("clears localStorage after successful task creation", async () => {
|
||||
const { props } = renderCard();
|
||||
expandCard();
|
||||
const textarea = screen.getByPlaceholderText("What needs to be done?");
|
||||
|
||||
// Type something to set localStorage
|
||||
|
||||
@@ -219,7 +219,7 @@ describe("ListView", () => {
|
||||
|
||||
it("sorts tasks by column when Column header is clicked", () => {
|
||||
const tasks = [
|
||||
createMockTask({ id: "FN-001", column: "done" }),
|
||||
createMockTask({ id: "FN-001", column: "todo" }),
|
||||
createMockTask({ id: "FN-002", column: "triage" }),
|
||||
createMockTask({ id: "FN-003", column: "in-progress" }),
|
||||
];
|
||||
@@ -229,11 +229,11 @@ describe("ListView", () => {
|
||||
const columnHeader = screen.getByText("Column");
|
||||
fireEvent.click(columnHeader);
|
||||
|
||||
// Get data rows - sorted by column alphabetically: done, in-progress, triage
|
||||
// Rows are rendered in fixed column-section order.
|
||||
const rows = screen.getAllByRole("row").filter(r => r.getAttribute("data-id"));
|
||||
expect(rows[0].textContent).toContain("FN-002"); // triage (sorted first alphabetically)
|
||||
expect(rows[1].textContent).toContain("FN-003"); // in-progress
|
||||
expect(rows[2].textContent).toContain("FN-001"); // done
|
||||
expect(rows[0].textContent).toContain("FN-002"); // triage section first
|
||||
expect(rows[1].textContent).toContain("FN-001"); // todo section second
|
||||
expect(rows[2].textContent).toContain("FN-003"); // in-progress section third
|
||||
});
|
||||
|
||||
it("sorts tasks by status when Status header is clicked", () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* projectDetection.ts - Client-side project detection utilities
|
||||
*
|
||||
* Provides utilities for scanning and detecting kb projects.
|
||||
* Provides utilities for scanning and detecting fusion projects.
|
||||
* These functions prepare data for API calls rather than accessing
|
||||
* the filesystem directly (which is not possible from browser).
|
||||
*/
|
||||
@@ -134,11 +134,11 @@ function getDefaultScanPath(): string {
|
||||
|
||||
/**
|
||||
* Sorts detected projects by likelihood of being a kb project.
|
||||
* Projects with .kb/kb.db are ranked higher.
|
||||
* Projects with .fusion/fusion.db are ranked higher.
|
||||
*/
|
||||
export function sortDetectedProjects(projects: DetectedProject[]): DetectedProject[] {
|
||||
return [...projects].sort((a, b) => {
|
||||
// Existing projects (with kb.db) come first
|
||||
// Existing projects (with fusion.db) come first
|
||||
if (a.existing && !b.existing) return -1;
|
||||
if (!a.existing && b.existing) return 1;
|
||||
return 0;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -36,7 +36,7 @@ function createMockGlobalSettingsStore() {
|
||||
return {
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
getSettingsPath: vi.fn().mockReturnValue("/fake/home/.pi/kb/settings.json"),
|
||||
getSettingsPath: vi.fn().mockReturnValue("/fake/home/.pi/fusion/settings.json"),
|
||||
init: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
}
|
||||
@@ -2987,22 +2987,19 @@ describe("POST /github/issues/batch-import", () => {
|
||||
});
|
||||
|
||||
it("imports multiple issues successfully", async () => {
|
||||
fetchSpy
|
||||
const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled")
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(mockGitHubIssue(1, "First Issue")),
|
||||
} as Response)
|
||||
success: true,
|
||||
data: mockGitHubIssue(1, "First Issue"),
|
||||
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(mockGitHubIssue(2, "Second Issue")),
|
||||
} as Response)
|
||||
success: true,
|
||||
data: mockGitHubIssue(2, "Second Issue"),
|
||||
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(mockGitHubIssue(3, "Third Issue")),
|
||||
} as Response);
|
||||
success: true,
|
||||
data: mockGitHubIssue(3, "Third Issue"),
|
||||
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
@@ -3015,7 +3012,7 @@ describe("POST /github/issues/batch-import", () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.results).toHaveLength(3);
|
||||
expect(res.body.results.every((r: { success: boolean }) => r.success)).toBe(true);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(3);
|
||||
expect(throttledSpy).toHaveBeenCalledTimes(3);
|
||||
expect(store.createTask).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
@@ -3110,23 +3107,19 @@ describe("POST /github/issues/batch-import", () => {
|
||||
});
|
||||
|
||||
it("handles partial failures (some succeed, some fail)", async () => {
|
||||
fetchSpy
|
||||
const throttledSpy = vi.spyOn(GitHubClient.prototype, "fetchThrottled")
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(mockGitHubIssue(1)),
|
||||
} as Response)
|
||||
success: true,
|
||||
data: mockGitHubIssue(1),
|
||||
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: "Not Found",
|
||||
json: () => Promise.resolve({ message: "Not Found" }),
|
||||
} as Response)
|
||||
success: false,
|
||||
error: "GitHub API error (404): Not Found",
|
||||
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: () => Promise.resolve(mockGitHubIssue(3)),
|
||||
} as Response);
|
||||
success: true,
|
||||
data: mockGitHubIssue(3),
|
||||
} as Awaited<ReturnType<GitHubClient["fetchThrottled"]>>);
|
||||
|
||||
const res = await REQUEST(
|
||||
buildApp(),
|
||||
@@ -3142,6 +3135,7 @@ describe("POST /github/issues/batch-import", () => {
|
||||
expect(res.body.results[1].success).toBe(false);
|
||||
expect(res.body.results[1].error).toContain("404");
|
||||
expect(res.body.results[2].success).toBe(true);
|
||||
expect(throttledSpy).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("rejects pull requests with appropriate error", async () => {
|
||||
|
||||
@@ -1161,7 +1161,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
|
||||
/**
|
||||
* GET /api/settings/global
|
||||
* Returns the global (user-level) settings from ~/.pi/kb/settings.json.
|
||||
* Returns the global (user-level) settings from ~/.pi/fusion/settings.json.
|
||||
* Does NOT include computed/server-only fields like githubTokenConfigured.
|
||||
*/
|
||||
router.get("/settings/global", async (_req, res) => {
|
||||
@@ -1176,7 +1176,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
|
||||
/**
|
||||
* PUT /api/settings/global
|
||||
* Update global (user-level) settings in ~/.pi/kb/settings.json.
|
||||
* Update global (user-level) settings in ~/.pi/fusion/settings.json.
|
||||
* These settings persist across all kb projects for the current user.
|
||||
*/
|
||||
router.put("/settings/global", async (req, res) => {
|
||||
@@ -5927,7 +5927,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
|
||||
const existingPaths = new Set(existingProjects.map((p: { path: string }) => p.path));
|
||||
|
||||
// Scan for .kb/kb.db or .fusion/kb.db files (indicating kb projects)
|
||||
// Scan for .fusion/fusion.db or .fusion/fusion.db files (indicating kb projects)
|
||||
const detected: Array<{ path: string; suggestedName: string; existing: boolean }> = [];
|
||||
|
||||
try {
|
||||
@@ -5937,7 +5937,7 @@ Output ONLY the prompt text (no markdown, no explanations).`;
|
||||
if (!entry.isDirectory()) continue;
|
||||
|
||||
const dirPath = join(searchPath, entry.name);
|
||||
const hasKbDb = existsSync(join(dirPath, ".kb", "kb.db"));
|
||||
const hasKbDb = existsSync(join(dirPath, ".fusion", "fusion.db"));
|
||||
const hasFusionDir = existsSync(join(dirPath, ".fusion"));
|
||||
|
||||
if (hasKbDb || hasFusionDir) {
|
||||
|
||||
@@ -10,7 +10,7 @@ function createMockGlobalSettingsStore() {
|
||||
return {
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
updateSettings: vi.fn().mockResolvedValue({}),
|
||||
getSettingsPath: vi.fn().mockReturnValue("/fake/home/.pi/kb/settings.json"),
|
||||
getSettingsPath: vi.fn().mockReturnValue("/fake/home/.pi/fusion/settings.json"),
|
||||
init: vi.fn().mockResolvedValue(false),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import express from "express";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import http from "node:http";
|
||||
|
||||
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 staticAssetIntegrationTest = loopbackBindingAvailable ? it : it.skip;
|
||||
|
||||
describe("static asset serving", () => {
|
||||
it("returns 404 for missing asset paths instead of falling back to index.html", async () => {
|
||||
staticAssetIntegrationTest("returns 404 for missing asset paths instead of falling back to index.html", async () => {
|
||||
const app = express();
|
||||
|
||||
app.use(express.static("packages/dashboard/dist/client", { index: false }));
|
||||
@@ -12,7 +26,7 @@ describe("static asset serving", () => {
|
||||
});
|
||||
|
||||
const server = await new Promise<import("node:http").Server>((resolve) => {
|
||||
const s = app.listen(0, () => resolve(s));
|
||||
const s = app.listen(0, "127.0.0.1", () => resolve(s));
|
||||
});
|
||||
|
||||
try {
|
||||
|
||||
@@ -42,6 +42,39 @@ type DashboardExpressApp = ReturnType<typeof express> & {
|
||||
__kbWebSocketsAttached?: boolean;
|
||||
};
|
||||
|
||||
function shouldForceLocalhostForTests(): boolean {
|
||||
return process.env.NODE_ENV === "test";
|
||||
}
|
||||
|
||||
function normalizeListenArgsForTests(args: unknown[]): unknown[] {
|
||||
if (!shouldForceLocalhostForTests()) {
|
||||
return args;
|
||||
}
|
||||
|
||||
if (args.length === 0) {
|
||||
return ["127.0.0.1"];
|
||||
}
|
||||
|
||||
const [first, second] = args;
|
||||
const secondIsHost = typeof second === "string";
|
||||
const firstIsOptionsObject =
|
||||
typeof first === "object" && first !== null && !Array.isArray(first);
|
||||
|
||||
if (firstIsOptionsObject || secondIsHost) {
|
||||
return args;
|
||||
}
|
||||
|
||||
if (typeof first === "number") {
|
||||
return [first, "127.0.0.1", ...args.slice(1)];
|
||||
}
|
||||
|
||||
if (typeof first === "string" && first.startsWith("/")) {
|
||||
return args;
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export function createServer(store: TaskStore, options?: ServerOptions): ReturnType<typeof express> {
|
||||
const app = express();
|
||||
|
||||
@@ -214,7 +247,8 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
||||
|
||||
const originalListen = dashboardApp.listen.bind(dashboardApp);
|
||||
dashboardApp.listen = ((...args: Parameters<typeof dashboardApp.listen>) => {
|
||||
const server = originalListen(...args);
|
||||
const normalizedArgs = normalizeListenArgsForTests(args) as Parameters<typeof originalListen>;
|
||||
const server = originalListen(...normalizedArgs);
|
||||
|
||||
if (!dashboardApp.__kbWebSocketsAttached) {
|
||||
dashboardApp.__kbWebSocketsAttached = true;
|
||||
|
||||
Reference in New Issue
Block a user