Files
fusion/packages/dashboard/src/__tests__/plugin-routes.routes.test.ts
Fusion d910c0c0f3 feat(FN-3637): establish canonical roadmap plugin ID and compatibility rout
Merged commits stabilize the Fusion roadmap plugin's identity and routing surface, establishing a canonical plugin ID and compatibility routes so the roadmap plugin integrates cleanly with the dashboard's plugin system. Added new roadmap-routes and roadmap-suggestions modules in the dashboard packag

Fusion-Task-Id: FN-3637
2026-05-11 06:47:43 -07:00

1326 lines
44 KiB
TypeScript

// @vitest-environment node
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import express from "express";
import type { TaskStore } from "@fusion/core";
import type { PluginInstallation } from "@fusion/core";
import type { PluginStore } from "@fusion/core";
import type { PluginLoader } from "@fusion/core";
import { createApiRoutes } from "../routes.js";
import { createPluginRouter } from "../plugin-routes.js";
import { get as performGet, request as performRequest } from "../test-request.js";
import * as projectStoreResolver from "../project-store-resolver.js";
// Mock @fusion/core
const mockCentralInit = vi.fn().mockResolvedValue(undefined);
const mockCentralClose = vi.fn().mockResolvedValue(undefined);
vi.mock("@fusion/core", async () => {
const actual = await vi.importActual<typeof import("@fusion/core")>("@fusion/core");
return {
...actual,
CentralCore: vi.fn().mockImplementation(() => ({
init: mockCentralInit,
close: mockCentralClose,
})),
};
});
// Mock project store resolver
const mockGetOrCreateProjectStore = vi.fn();
vi.spyOn(projectStoreResolver, "getOrCreateProjectStore").mockImplementation(mockGetOrCreateProjectStore);
function createMockPluginStore(overrides: Partial<PluginStore> = {}): PluginStore {
return {
listPlugins: vi.fn().mockResolvedValue([]),
getPlugin: vi.fn(),
registerPlugin: vi.fn(),
unregisterPlugin: vi.fn(),
enablePlugin: vi.fn(),
disablePlugin: vi.fn(),
updatePluginSettings: vi.fn(),
updatePluginState: vi.fn(),
updatePlugin: vi.fn(),
...overrides,
} as unknown as PluginStore;
}
function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): PluginLoader {
return {
loadPlugin: vi.fn().mockResolvedValue(undefined),
stopPlugin: vi.fn().mockResolvedValue(undefined),
getPlugin: vi.fn(),
getLoadedPlugins: vi.fn().mockReturnValue([]),
getPluginTools: vi.fn().mockReturnValue([]),
getPluginRoutes: vi.fn().mockReturnValue([]),
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
invokeHook: vi.fn().mockResolvedValue(undefined),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
createRouteContext: vi.fn().mockImplementation(async (pluginId: string, ctx: Record<string, unknown>) => ({
pluginId,
taskStore: ctx.taskStore,
settings: ctx.settings ?? {},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
createAiSession: (ctx as { createAiSession?: unknown }).createAiSession,
resolveProjectTaskStore: ctx.resolveProjectTaskStore,
})),
...overrides,
} as unknown as PluginLoader;
}
function createMockTaskStore(overrides: Partial<TaskStore> = {}): TaskStore {
return {
getTask: vi.fn(),
listTasks: vi.fn().mockResolvedValue([]),
searchTasks: vi.fn().mockResolvedValue([]),
createTask: vi.fn(),
moveTask: vi.fn(),
updateTask: vi.fn(),
deleteTask: vi.fn(),
mergeTask: vi.fn(),
archiveTask: vi.fn(),
unarchiveTask: vi.fn(),
getSettings: vi.fn().mockResolvedValue({}),
getSettingsFast: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn(),
updateGlobalSettings: vi.fn(),
getSettingsByScope: vi.fn().mockResolvedValue({ global: {}, project: {} }),
getGlobalSettingsStore: vi.fn().mockReturnValue({
getSettings: vi.fn().mockResolvedValue({}),
updateSettings: vi.fn().mockResolvedValue({}),
}),
logEntry: vi.fn().mockResolvedValue(undefined),
getAgentLogs: vi.fn().mockResolvedValue([]),
getAgentLogsByTimeRange: vi.fn().mockResolvedValue([]),
addSteeringComment: vi.fn(),
addTaskComment: vi.fn(),
updateTaskComment: vi.fn(),
deleteTaskComment: vi.fn(),
updatePrInfo: vi.fn().mockResolvedValue(undefined),
updateIssueInfo: vi.fn().mockResolvedValue(undefined),
getRootDir: vi.fn().mockReturnValue("/fake/root"),
getFusionDir: vi.fn().mockReturnValue("/fake/root/.fusion"),
listWorkflowSteps: vi.fn().mockResolvedValue([]),
createWorkflowStep: vi.fn(),
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(),
}),
getPluginStore: vi.fn(),
...overrides,
} as unknown as TaskStore;
}
const FAKE_PLUGIN: PluginInstallation = {
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
description: "A test plugin",
path: "/path/to/plugin",
enabled: true,
state: "installed",
settings: {},
dependencies: [],
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
async function GET(app: express.Express, path: string): Promise<{ status: number; body: any }> {
const res = await performGet(app, path);
return { status: res.status, body: res.body };
}
async function REQUEST(
app: express.Express,
method: string,
path: string,
body?: unknown,
): Promise<{ status: number; body: any }> {
const res = await performRequest(
app,
method,
path,
body ? JSON.stringify(body) : undefined,
body ? { "content-type": "application/json" } : undefined,
);
return { status: res.status, body: res.body };
}
describe("GET /plugins", () => {
let store: TaskStore;
let pluginStore: PluginStore;
beforeEach(() => {
pluginStore = createMockPluginStore();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
}));
return app;
}
it("returns empty array when no plugins", async () => {
const res = await GET(buildApp(), "/api/plugins");
expect(res.status).toBe(200);
expect(res.body).toEqual([]);
expect(pluginStore.listPlugins).toHaveBeenCalledWith({});
});
it("returns list of plugins", async () => {
(pluginStore.listPlugins as ReturnType<typeof vi.fn>).mockResolvedValueOnce([FAKE_PLUGIN]);
const res = await GET(buildApp(), "/api/plugins");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0]).toMatchObject({
id: "test-plugin",
name: "Test Plugin",
});
});
it("includes the Droid plugin in installed plugin listings", async () => {
(pluginStore.listPlugins as ReturnType<typeof vi.fn>).mockResolvedValueOnce([
{
...FAKE_PLUGIN,
id: "fusion-plugin-droid-runtime",
name: "Droid Runtime Plugin",
version: "0.1.0",
},
]);
const res = await GET(buildApp(), "/api/plugins");
expect(res.status).toBe(200);
expect(pluginStore.listPlugins).toHaveBeenCalledWith({});
expect(res.body).toEqual([
expect.objectContaining({
id: "fusion-plugin-droid-runtime",
name: "Droid Runtime Plugin",
version: "0.1.0",
}),
]);
});
it("filters plugins by enabled status", async () => {
const res = await GET(buildApp(), "/api/plugins?enabled=true");
expect(res.status).toBe(200);
expect(pluginStore.listPlugins).toHaveBeenCalledWith({ enabled: true });
});
it("filters plugins by disabled status", async () => {
const res = await GET(buildApp(), "/api/plugins?enabled=false");
expect(res.status).toBe(200);
expect(pluginStore.listPlugins).toHaveBeenCalledWith({ enabled: false });
});
});
describe("GET /plugins/:id", () => {
let store: TaskStore;
let pluginStore: PluginStore;
beforeEach(() => {
pluginStore = createMockPluginStore();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
}));
return app;
}
it("returns plugin by id", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const res = await GET(buildApp(), "/api/plugins/test-plugin");
expect(res.status).toBe(200);
expect(res.body).toMatchObject({
id: "test-plugin",
name: "Test Plugin",
});
});
it("returns 404 for non-existent plugin", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
Object.assign(new Error('Plugin "nonexistent" not found'), { code: "ENOENT" }),
);
const res = await GET(buildApp(), "/api/plugins/nonexistent");
expect(res.status).toBe(404);
expect(res.body).toHaveProperty("error");
});
it("supports projectId query param scoping", async () => {
// Set up mock for scoped store with projectId
const scopedPluginStore = createMockPluginStore();
(scopedPluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await GET(buildApp(), "/api/plugins/test-plugin?projectId=proj_123");
expect(res.status).toBe(200);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
});
});
describe("GET /plugins/:id/settings", () => {
let store: TaskStore;
let pluginStore: PluginStore;
beforeEach(() => {
pluginStore = createMockPluginStore();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
}));
return app;
}
it("returns plugin settings by id", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
settings: { apiKey: "secret", enabled: true },
});
const res = await GET(buildApp(), "/api/plugins/test-plugin/settings");
expect(res.status).toBe(200);
expect(res.body).toEqual({ apiKey: "secret", enabled: true });
});
it("returns 404 for non-existent non-bundled plugin", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
Object.assign(new Error('Plugin "nonexistent" not found'), { code: "ENOENT" }),
);
const res = await GET(buildApp(), "/api/plugins/nonexistent/settings");
expect(res.status).toBe(404);
expect(res.body).toHaveProperty("error");
});
it("returns empty settings for bundled runtime plugin before first install", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
Object.assign(new Error('Plugin "fusion-plugin-hermes-runtime" not found'), { code: "ENOENT" }),
);
const res = await GET(buildApp(), "/api/plugins/fusion-plugin-hermes-runtime/settings");
expect(res.status).toBe(200);
expect(res.body).toEqual({});
});
it("supports projectId query param scoping", async () => {
const scopedPluginStore = createMockPluginStore();
(scopedPluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
settings: { scoped: true },
});
const scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await GET(buildApp(), "/api/plugins/test-plugin/settings?projectId=proj_123");
expect(res.status).toBe(200);
expect(res.body).toEqual({ scoped: true });
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
});
});
describe("POST /plugins", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
beforeEach(() => {
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader,
}));
return app;
}
describe("mode: register", () => {
it("registers a plugin with required fields", async () => {
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
path: "/path/to/plugin",
});
expect(res.status).toBe(201);
expect(res.body).toMatchObject({
id: "test-plugin",
name: "Test Plugin",
});
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
}),
path: "/path/to/plugin",
}),
);
});
it("registers a plugin with optional fields", async () => {
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
description: "A test plugin",
author: "Test Author",
});
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
path: "/path/to/plugin",
description: "A test plugin",
author: "Test Author",
settings: { apiKey: "secret" },
});
expect(res.status).toBe(201);
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({
description: "A test plugin",
author: "Test Author",
}),
settings: { apiKey: "secret" },
}),
);
});
it("loads plugin after registration when enabled", async () => {
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
path: "/path/to/plugin",
enabled: true,
});
// Plugin should be loaded after registration
expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("test-plugin");
});
it("returns 400 when mode is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
path: "/path/to/plugin",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("mode");
});
it("returns 400 when id is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
name: "Test Plugin",
version: "1.0.0",
path: "/path/to/plugin",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("'id' is required");
});
it("returns 400 when name is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
version: "1.0.0",
path: "/path/to/plugin",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("'name' is required");
});
it("returns 400 when version is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
name: "Test Plugin",
path: "/path/to/plugin",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("'version' is required");
});
it("returns 400 when path is missing", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("'path' is required");
});
it("returns 409 when plugin is already registered", async () => {
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
Object.assign(new Error('Plugin "test-plugin" is already registered'), { code: "EEXISTS" }),
);
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "test-plugin",
name: "Test Plugin",
version: "1.0.0",
path: "/path/to/plugin",
});
expect(res.status).toBe(409);
});
});
describe("mode: install", () => {
it("returns 400 when plugin loader is not available", async () => {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
// No pluginLoader
}));
const res = await REQUEST(app, "POST", "/api/plugins", {
mode: "install",
path: "/path/to/plugin",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("not supported");
});
});
describe("invalid mode", () => {
it("returns 400 for unknown mode", async () => {
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "unknown",
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Invalid mode");
});
});
});
describe("POST /plugins/:id/enable", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
beforeEach(() => {
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader,
}));
return app;
}
it("enables a plugin and loads it", async () => {
(pluginStore.enablePlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/enable", {});
expect(res.status).toBe(200);
expect(pluginStore.enablePlugin).toHaveBeenCalledWith("test-plugin");
expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("test-plugin");
});
it("supports body-based projectId scoping", async () => {
// Set up mock for scoped store with projectId
const scopedPluginStore = createMockPluginStore();
(scopedPluginStore.enablePlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/enable", {
projectId: "proj_123",
});
expect(res.status).toBe(200);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
});
});
describe("POST /plugins/:id/disable", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
beforeEach(() => {
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader,
}));
return app;
}
it("disables a plugin and stops it", async () => {
(pluginStore.disablePlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
enabled: false,
});
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/disable", {});
expect(res.status).toBe(200);
expect(pluginStore.disablePlugin).toHaveBeenCalledWith("test-plugin");
expect(pluginLoader.stopPlugin).toHaveBeenCalledWith("test-plugin");
});
it("supports body-based projectId scoping", async () => {
// Set up mock for scoped store with projectId
const scopedPluginStore = createMockPluginStore();
(scopedPluginStore.disablePlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
enabled: false,
});
const scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/disable", {
projectId: "proj_123",
});
expect(res.status).toBe(200);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
});
});
describe("POST /plugins/:id/reload", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginRunner: {
getPluginRoutes: ReturnType<typeof vi.fn>;
reloadPlugin: ReturnType<typeof vi.fn>;
checkPluginSetup: ReturnType<typeof vi.fn>;
installPluginSetup: ReturnType<typeof vi.fn>;
uninstallPluginSetup: ReturnType<typeof vi.fn>;
getPluginSetupInfo: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
pluginStore = createMockPluginStore();
pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([]),
reloadPlugin: vi.fn().mockResolvedValue(undefined),
checkPluginSetup: vi.fn().mockResolvedValue({ status: "installed" }),
installPluginSetup: vi.fn().mockResolvedValue({ success: true }),
uninstallPluginSetup: vi.fn().mockResolvedValue({ success: true }),
getPluginSetupInfo: vi.fn().mockReturnValue([]),
};
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp(includeRunner = true) {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
pluginRunner: includeRunner ? pluginRunner : undefined,
}));
return app;
}
it("reloads a started plugin", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({ ...FAKE_PLUGIN, state: "started" })
.mockResolvedValueOnce({ ...FAKE_PLUGIN, state: "started", updatedAt: "2026-02-01T00:00:00.000Z" });
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/reload", {});
expect(res.status).toBe(200);
expect(pluginRunner.reloadPlugin).toHaveBeenCalledWith("test-plugin");
expect(res.body.id).toBe("test-plugin");
});
it("returns 404 when plugin is not found", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
Object.assign(new Error('Plugin "nonexistent" not found'), { code: "ENOENT" }),
);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/nonexistent/reload", {});
expect(res.status).toBe(404);
});
it("returns 400 when plugin is not started", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
state: "installed",
});
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/reload", {});
expect(res.status).toBe(400);
expect(res.body.error).toContain("Use enable instead");
});
it("returns 500 when plugin runner is unavailable", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
state: "started",
});
const res = await REQUEST(buildApp(false), "POST", "/api/plugins/test-plugin/reload", {});
expect(res.status).toBe(500);
expect(res.body.error).toContain("Plugin runner not available");
});
it("returns 500 when reload operation fails", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
state: "started",
});
pluginRunner.reloadPlugin.mockRejectedValueOnce(new Error("boom"));
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/reload", {});
expect(res.status).toBe(500);
expect(res.body.error).toContain("Reload failed: boom");
});
});
describe("plugin setup routes", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginRunner: {
getPluginRoutes: ReturnType<typeof vi.fn>;
checkPluginSetup: ReturnType<typeof vi.fn>;
installPluginSetup: ReturnType<typeof vi.fn>;
uninstallPluginSetup: ReturnType<typeof vi.fn>;
getPluginSetupInfo: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
pluginStore = createMockPluginStore();
pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([]),
checkPluginSetup: vi.fn().mockResolvedValue({ status: "installed", version: "1.0.0" }),
installPluginSetup: vi.fn().mockResolvedValue({ success: true }),
uninstallPluginSetup: vi.fn().mockResolvedValue({ success: true }),
getPluginSetupInfo: vi.fn().mockReturnValue([]),
};
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
pluginRunner,
}));
return app;
}
it("GET /plugins/:id/setup-status returns hasSetup true result", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ...FAKE_PLUGIN, state: "started" });
pluginRunner.getPluginSetupInfo.mockReturnValueOnce([
{
pluginId: "test-plugin",
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup: vi.fn() },
},
]);
const res = await REQUEST(buildApp(), "GET", "/api/plugins/test-plugin/setup-status");
expect(res.status).toBe(200);
expect(res.body).toEqual({ hasSetup: true, status: "installed", version: "1.0.0" });
});
it("GET /plugins/:id/setup-status returns hasSetup false when no setup", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ...FAKE_PLUGIN, state: "started" });
pluginRunner.getPluginSetupInfo.mockReturnValueOnce([]);
const res = await REQUEST(buildApp(), "GET", "/api/plugins/test-plugin/setup-status");
expect(res.status).toBe(200);
expect(res.body).toEqual({ hasSetup: false });
});
it("GET /plugins/:id/setup-status returns deferred status when plugin is not started", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ...FAKE_PLUGIN, state: "installed" });
pluginRunner.getPluginSetupInfo.mockReturnValueOnce([
{
pluginId: "test-plugin",
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup: vi.fn() },
},
]);
const res = await REQUEST(buildApp(), "GET", "/api/plugins/test-plugin/setup-status");
expect(res.status).toBe(200);
expect(res.body).toEqual({
hasSetup: true,
setupCheckDeferred: true,
deferredReason: "plugin-not-started",
pluginState: "installed",
});
expect(pluginRunner.checkPluginSetup).not.toHaveBeenCalled();
});
it("GET /plugins/:id/setup-status returns 404 for nonexistent plugin", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new Error('Plugin "missing" not found'));
const res = await REQUEST(buildApp(), "GET", "/api/plugins/missing/setup-status");
expect(res.status).toBe(404);
});
it("POST /plugins/:id/setup/install returns success true", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ...FAKE_PLUGIN, enabled: true });
pluginRunner.getPluginSetupInfo.mockReturnValueOnce([
{
pluginId: "test-plugin",
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup: vi.fn(), install: vi.fn() },
},
]);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/setup/install", {});
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: true });
});
it("POST /plugins/:id/setup/install returns setup failure result", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ...FAKE_PLUGIN, enabled: true });
pluginRunner.getPluginSetupInfo.mockReturnValueOnce([
{
pluginId: "test-plugin",
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup: vi.fn(), install: vi.fn() },
},
]);
pluginRunner.installPluginSetup.mockResolvedValueOnce({ success: false, error: "install failed" });
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/setup/install", {});
expect(res.status).toBe(200);
expect(res.body).toEqual({ success: false, error: "install failed" });
});
it("POST /plugins/:id/setup/install returns 400 when no install hook", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ ...FAKE_PLUGIN, enabled: true });
pluginRunner.getPluginSetupInfo.mockReturnValueOnce([
{
pluginId: "test-plugin",
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup: vi.fn() },
},
]);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/setup/install", {});
expect(res.status).toBe(400);
expect(res.body.error).toContain("no install hook");
});
it("POST /plugins/:id/setup/uninstall returns success and failure results", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValue({ ...FAKE_PLUGIN, enabled: true });
pluginRunner.getPluginSetupInfo.mockReturnValue([
{
pluginId: "test-plugin",
manifest: { binaryName: "agent-browser", description: "Binary" },
hooks: { checkSetup: vi.fn(), uninstall: vi.fn() },
},
]);
const successRes = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/setup/uninstall", {});
expect(successRes.status).toBe(200);
expect(successRes.body).toEqual({ success: true });
pluginRunner.uninstallPluginSetup.mockResolvedValueOnce({ success: false, error: "uninstall failed" });
const failureRes = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/setup/uninstall", {});
expect(failureRes.status).toBe(200);
expect(failureRes.body).toEqual({ success: false, error: "uninstall failed" });
});
});
describe("PUT /plugins/:id/settings", () => {
let store: TaskStore;
let pluginStore: PluginStore;
beforeEach(() => {
pluginStore = createMockPluginStore();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
}));
return app;
}
it("updates plugin settings", async () => {
(pluginStore.updatePluginSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
settings: { apiKey: "new-secret" },
});
const res = await REQUEST(buildApp(), "PUT", "/api/plugins/test-plugin/settings", {
settings: { apiKey: "new-secret" },
});
expect(res.status).toBe(200);
expect(pluginStore.updatePluginSettings).toHaveBeenCalledWith("test-plugin", { apiKey: "new-secret" });
});
it("returns 400 when settings is missing", async () => {
const res = await REQUEST(buildApp(), "PUT", "/api/plugins/test-plugin/settings", {});
expect(res.status).toBe(400);
expect(res.body.error).toContain("'settings'");
});
it("returns 404 when plugin not found", async () => {
(pluginStore.updatePluginSettings as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
Object.assign(new Error('Plugin "nonexistent" not found'), { code: "ENOENT" }),
);
const res = await REQUEST(buildApp(), "PUT", "/api/plugins/nonexistent/settings", {
settings: { key: "value" },
});
expect(res.status).toBe(404);
});
it("returns 400 when settings validation fails", async () => {
(pluginStore.updatePluginSettings as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("Settings validation failed: setting 'apiKey' is required"),
);
const res = await REQUEST(buildApp(), "PUT", "/api/plugins/test-plugin/settings", {
settings: {},
});
expect(res.status).toBe(400);
expect(res.body.error).toContain("validation failed");
});
});
describe("PUT /plugins/:id/settings auto-install for bundled runtime plugins", () => {
let store: TaskStore;
let pluginStore: PluginStore;
beforeEach(() => {
pluginStore = createMockPluginStore();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildAppWithBundleHook(
ensureBundledPluginInstalled: (id: string) => Promise<boolean>,
) {
const app = express();
app.use(express.json());
app.use(
"/api",
createApiRoutes(store, {
pluginStore,
pluginLoader: createMockPluginLoader(),
ensureBundledPluginInstalled,
}),
);
return app;
}
it("auto-installs a bundled runtime plugin on first save", async () => {
// Plugin not yet registered → first getPlugin throws.
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("Plugin not found"),
);
(pluginStore.updatePluginSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
...FAKE_PLUGIN,
id: "fusion-plugin-hermes-runtime",
settings: { apiKey: "k" },
});
const ensure = vi.fn().mockResolvedValue(true);
const res = await REQUEST(
buildAppWithBundleHook(ensure),
"PUT",
"/api/plugins/fusion-plugin-hermes-runtime/settings",
{ settings: { apiKey: "k" } },
);
expect(res.status).toBe(200);
expect(ensure).toHaveBeenCalledWith("fusion-plugin-hermes-runtime");
expect(pluginStore.updatePluginSettings).toHaveBeenCalled();
});
it("skips auto-install when the plugin is already registered", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
(pluginStore.updatePluginSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const ensure = vi.fn().mockResolvedValue(true);
const res = await REQUEST(
buildAppWithBundleHook(ensure),
"PUT",
"/api/plugins/fusion-plugin-hermes-runtime/settings",
{ settings: { apiKey: "k" } },
);
expect(res.status).toBe(200);
expect(ensure).not.toHaveBeenCalled();
});
it("does not invoke auto-install for non-bundled plugin ids", async () => {
(pluginStore.updatePluginSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const ensure = vi.fn().mockResolvedValue(true);
const res = await REQUEST(
buildAppWithBundleHook(ensure),
"PUT",
"/api/plugins/some-third-party-plugin/settings",
{ settings: { apiKey: "k" } },
);
expect(res.status).toBe(200);
expect(ensure).not.toHaveBeenCalled();
});
it("returns 500 with intentional message when bundled auto-install reports missing bundle", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("Plugin not found"),
);
const ensure = vi.fn().mockResolvedValue(false);
const res = await REQUEST(
buildAppWithBundleHook(ensure),
"PUT",
"/api/plugins/fusion-plugin-hermes-runtime/settings",
{ settings: { apiKey: "k" } },
);
expect(res.status).toBe(500);
expect(res.body.error).toContain("is unavailable in this build");
});
it("returns 500 when auto-install throws", async () => {
(pluginStore.getPlugin as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new Error("Plugin not found"),
);
const ensure = vi.fn().mockRejectedValue(new Error("bundle missing"));
const res = await REQUEST(
buildAppWithBundleHook(ensure),
"PUT",
"/api/plugins/fusion-plugin-hermes-runtime/settings",
{ settings: { apiKey: "k" } },
);
expect(res.status).toBe(500);
expect(res.body.error).toContain("Failed to auto-install");
});
});
describe("DELETE /plugins/:id", () => {
let store: TaskStore;
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;
beforeEach(() => {
pluginStore = createMockPluginStore();
pluginLoader = createMockPluginLoader();
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(pluginStore),
});
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore,
pluginLoader,
}));
return app;
}
it("unregisters a plugin", async () => {
(pluginStore.unregisterPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const res = await REQUEST(buildApp(), "DELETE", "/api/plugins/test-plugin");
expect(res.status).toBe(204);
expect(pluginStore.unregisterPlugin).toHaveBeenCalledWith("test-plugin");
});
it("stops plugin before unregistering", async () => {
(pluginStore.unregisterPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
await REQUEST(buildApp(), "DELETE", "/api/plugins/test-plugin");
// Should stop first, then unregister
expect(pluginLoader.stopPlugin).toHaveBeenCalledWith("test-plugin");
expect(pluginStore.unregisterPlugin).toHaveBeenCalledWith("test-plugin");
});
it("supports query-based projectId scoping", async () => {
// Set up mock for scoped store with projectId
const scopedPluginStore = createMockPluginStore();
(scopedPluginStore.unregisterPlugin as ReturnType<typeof vi.fn>).mockResolvedValueOnce(FAKE_PLUGIN);
const scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await REQUEST(buildApp(), "DELETE", "/api/plugins/test-plugin?projectId=proj_123");
expect(res.status).toBe(204);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
});
});
describe("plugin-defined route dispatch", () => {
it("registers PATCH routes from plugins", () => {
const pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([
{ pluginId: "fusion-plugin-roadmap", route: { method: "PATCH", path: "/roadmaps/x", handler: vi.fn() } },
]),
};
const pluginStore = createMockPluginStore();
const router = createPluginRouter(pluginStore, createMockPluginLoader({
createRouteContext: vi.fn().mockResolvedValue({
pluginId: "fusion-plugin-roadmap",
taskStore: createMockTaskStore(),
settings: {},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
}),
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "fusion-plugin-roadmap" } }),
} as any), pluginRunner as any, createMockTaskStore());
const stack = (router as any).stack as Array<{ route?: { path: string; methods: Record<string, boolean> } }>;
const patchRoute = stack.find((layer) => layer.route?.path === "/fusion-plugin-roadmap/roadmaps/x");
expect(patchRoute?.route?.methods.patch).toBe(true);
});
it("passes scoped taskStore and createAiSession through pluginLoader.createRouteContext", async () => {
const routeHandler = vi.fn().mockResolvedValue({ ok: true });
const pluginRunner = {
getPluginRoutes: vi.fn().mockReturnValue([
{ pluginId: "fusion-plugin-roadmap", route: { method: "POST", path: "/ctx-check", handler: routeHandler } },
]),
};
const scopedPluginStore = createMockPluginStore();
const scopedTaskStore = createMockTaskStore({ getPluginStore: vi.fn().mockReturnValue(scopedPluginStore) });
mockGetOrCreateProjectStore.mockResolvedValue(scopedTaskStore);
const createRouteContext = vi.fn().mockImplementation(async (_pluginId: string, overrides: any) => ({
pluginId: "fusion-plugin-roadmap",
taskStore: overrides.taskStore,
settings: overrides.settings,
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: vi.fn(),
createAiSession: vi.fn(),
resolveProjectTaskStore: overrides.resolveProjectTaskStore,
}));
const pluginLoader = createMockPluginLoader({
createRouteContext,
getPlugin: vi.fn().mockReturnValue({ manifest: { id: "fusion-plugin-roadmap" } }),
} as any);
const pluginStore = createMockPluginStore();
const app = express();
app.use(express.json());
app.use("/api/plugins", createPluginRouter(pluginStore, pluginLoader, pluginRunner as any, createMockTaskStore()));
const res = await REQUEST(app, "POST", "/api/plugins/fusion-plugin-roadmap/ctx-check", { projectId: "proj_123" });
expect(res.status).toBe(200);
expect(createRouteContext).toHaveBeenCalledWith("fusion-plugin-roadmap", expect.objectContaining({
taskStore: scopedTaskStore,
resolveProjectTaskStore: projectStoreResolver.getOrCreateProjectStore,
}));
expect(routeHandler).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ taskStore: scopedTaskStore, createAiSession: expect.any(Function) }),
);
});
});
describe("Project scoping", () => {
let defaultPluginStore: PluginStore;
let scopedPluginStore: PluginStore;
let store: TaskStore;
let scopedStore: TaskStore;
beforeEach(() => {
defaultPluginStore = createMockPluginStore({
listPlugins: vi.fn().mockResolvedValue([{ ...FAKE_PLUGIN, id: "default-plugin" }]),
});
scopedPluginStore = createMockPluginStore({
listPlugins: vi.fn().mockResolvedValue([{ ...FAKE_PLUGIN, id: "scoped-plugin" }]),
});
store = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(defaultPluginStore),
});
scopedStore = createMockTaskStore({
getPluginStore: vi.fn().mockReturnValue(scopedPluginStore),
});
// Reset the mock
mockGetOrCreateProjectStore.mockReset();
});
function buildApp() {
const app = express();
app.use(express.json());
app.use("/api", createApiRoutes(store, {
pluginStore: defaultPluginStore,
pluginLoader: createMockPluginLoader(),
}));
return app;
}
it("uses default store without projectId", async () => {
mockGetOrCreateProjectStore.mockResolvedValue(store);
const res = await GET(buildApp(), "/api/plugins");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].id).toBe("default-plugin");
});
it("uses scoped store with projectId query param", async () => {
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await GET(buildApp(), "/api/plugins?projectId=proj_123");
expect(res.status).toBe(200);
expect(res.body).toHaveLength(1);
expect(res.body[0].id).toBe("scoped-plugin");
});
it("uses scoped store with projectId in request body", async () => {
mockGetOrCreateProjectStore.mockResolvedValue(scopedStore);
const res = await REQUEST(buildApp(), "POST", "/api/plugins/test-plugin/disable", {
projectId: "proj_123",
});
expect(res.status).toBe(200);
expect(mockGetOrCreateProjectStore).toHaveBeenCalledWith("proj_123");
});
});