feat(FN-1114): merge fusion/fn-1114 (auto-resolved)

- feat(FN-1114): complete all steps — plugin examples, docs, and scaffold command
This commit is contained in:
gsxdsm
2026-04-09 19:04:12 -07:00
parent 414bcad702
commit a3163f9935
24 changed files with 3078 additions and 1 deletions

View File

@@ -0,0 +1,324 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import plugin from "../index.js";
// ── Mock Context ───────────────────────────────────────────────────────────────
interface MockLogger {
info: ReturnType<typeof vi.fn>;
warn: ReturnType<typeof vi.fn>;
error: ReturnType<typeof vi.fn>;
debug: ReturnType<typeof vi.fn>;
}
interface MockContext {
pluginId: string;
settings: Record<string, unknown>;
logger: MockLogger;
emitEvent: ReturnType<typeof vi.fn>;
taskStore: {
getTask: ReturnType<typeof vi.fn>;
};
}
function createMockContext(overrides: Partial<MockContext> = {}): MockContext {
return {
pluginId: "fusion-plugin-ci-status",
settings: {
ciUrl: "https://ci.example.com/api",
pollIntervalMs: 60000,
branchPrefix: "fusion/",
},
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
emitEvent: vi.fn(),
taskStore: {
getTask: vi.fn(),
},
...overrides,
};
}
// ── Mock Request/Response ──────────────────────────────────────────────────────
function createMockRequest(overrides: Partial<{ params: Record<string, string>; method: string; url: string }> = {}): {
params: Record<string, string>;
method: string;
url: string;
} {
return {
params: {},
method: "GET",
url: "/status",
...overrides,
};
}
function createMockResponse() {
const json = vi.fn();
const status = vi.fn().mockReturnThis();
return { json, status };
}
// ── Test Suite ─────────────────────────────────────────────────────────────────
describe("ci-status plugin", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
describe("plugin export", () => {
it("should export a valid FusionPlugin with correct manifest fields", () => {
expect(plugin.manifest.id).toBe("fusion-plugin-ci-status");
expect(plugin.manifest.name).toBe("CI Status Plugin");
expect(plugin.manifest.version).toBe("0.1.0");
expect(plugin.state).toBe("installed");
});
it("should have routes array with 3 routes", () => {
expect(plugin.routes).toBeDefined();
expect(plugin.routes!.length).toBe(3);
});
it("should have GET /status route", () => {
const route = plugin.routes!.find(
(r) => r.method === "GET" && r.path === "/status",
);
expect(route).toBeDefined();
expect(route!.description).toBe("Get status of all tracked branches");
});
it("should have GET /status/:branch route", () => {
const route = plugin.routes!.find(
(r) => r.method === "GET" && r.path === "/status/:branch",
);
expect(route).toBeDefined();
expect(route!.description).toBe("Get status of a specific branch");
});
it("should have POST /refresh route", () => {
const route = plugin.routes!.find(
(r) => r.method === "POST" && r.path === "/refresh",
);
expect(route).toBeDefined();
expect(route!.description).toBe("Trigger an immediate CI status refresh");
});
it("should have onLoad, onUnload, and onTaskMoved hooks", () => {
expect(plugin.hooks.onLoad).toBeDefined();
expect(plugin.hooks.onUnload).toBeDefined();
expect(plugin.hooks.onTaskMoved).toBeDefined();
});
it("should have settings schema defined", () => {
expect(plugin.manifest.settingsSchema).toBeDefined();
expect(plugin.manifest.settingsSchema!.ciUrl).toBeDefined();
expect(plugin.manifest.settingsSchema!.pollIntervalMs).toBeDefined();
expect(plugin.manifest.settingsSchema!.branchPrefix).toBeDefined();
});
});
describe("hooks.onLoad", () => {
it("should log startup message", async () => {
const ctx = createMockContext();
await plugin.hooks.onLoad?.(ctx as any);
expect(ctx.logger.info).toHaveBeenCalledWith("CI Status plugin loaded");
});
it("should start an interval for polling", async () => {
const ctx = createMockContext();
await plugin.hooks.onLoad?.(ctx as any);
// The interval should be set
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.stringContaining("CI polling started"),
);
});
});
describe("hooks.onUnload", () => {
it("should log shutdown message", async () => {
const ctx = createMockContext();
await plugin.hooks.onLoad?.(ctx as any);
vi.clearAllMocks();
await plugin.hooks.onUnload?.();
// Should log that plugin is shutting down (logged as part of onUnload)
expect(ctx.logger.info).toHaveBeenCalled();
});
});
describe("hooks.onTaskMoved", () => {
const mockTask = {
id: "FN-001",
title: "Test Task",
description: "A test task",
column: "in-progress" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: "full" as const,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
};
it("should track branch when task moves to in-progress", async () => {
const ctx = createMockContext();
await plugin.hooks.onTaskMoved?.(
mockTask as any,
"todo",
"in-progress",
ctx as any,
);
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.stringContaining("Tracking branch for task FN-001"),
);
});
it("should stop tracking branch when task moves to done", async () => {
const ctx = createMockContext();
// First move to in-progress
await plugin.hooks.onTaskMoved?.(
mockTask as any,
"todo",
"in-progress",
ctx as any,
);
vi.clearAllMocks();
// Then move to done
await plugin.hooks.onTaskMoved?.(
mockTask as any,
"in-progress",
"done",
ctx as any,
);
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.stringContaining("Stopped tracking branch for task FN-001"),
);
});
it("should use configured branch prefix", async () => {
const ctx = createMockContext({
settings: {
ciUrl: "https://ci.example.com/api",
pollIntervalMs: 60000,
branchPrefix: "custom/",
},
});
await plugin.hooks.onTaskMoved?.(
mockTask as any,
"todo",
"in-progress",
ctx as any,
);
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.stringContaining("custom/fn-001"),
);
});
});
describe("route handlers", () => {
describe("GET /status", () => {
it("should return branches array", async () => {
const ctx = createMockContext();
const req = createMockRequest();
const res = createMockResponse();
const route = plugin.routes!.find(
(r) => r.method === "GET" && r.path === "/status",
)!;
const result = await route.handler(req as any, ctx as any);
expect(result).toHaveProperty("branches");
expect(Array.isArray(result.branches)).toBe(true);
});
});
describe("GET /status/:branch", () => {
it("should return specific branch data", async () => {
const ctx = createMockContext();
const req = createMockRequest({
params: { branch: "fusion/fn-001" },
});
// First add the branch via onTaskMoved
await plugin.hooks.onTaskMoved?.(
{
id: "FN-001",
title: "Test Task",
description: "A test task",
column: "in-progress" as const,
dependencies: [],
steps: [],
currentStep: 0,
size: "M" as const,
reviewLevel: "full" as const,
createdAt: "2024-01-01T00:00:00.000Z",
updatedAt: "2024-01-01T00:00:00.000Z",
} as any,
"todo",
"in-progress",
ctx as any,
);
const route = plugin.routes!.find(
(r) => r.method === "GET" && r.path === "/status/:branch",
)!;
const result = await route.handler(req as any, ctx as any);
expect(result).toHaveProperty("branch", "fusion/fn-001");
expect(result).toHaveProperty("status", "pending");
});
it("should throw 404 for unknown branch", async () => {
const ctx = createMockContext();
const req = createMockRequest({
params: { branch: "unknown/branch" },
});
const route = plugin.routes!.find(
(r) => r.method === "GET" && r.path === "/status/:branch",
)!;
await expect(
route.handler(req as any, ctx as any),
).rejects.toThrow("Branch not found");
});
});
describe("POST /refresh", () => {
it("should trigger refresh and return branches", async () => {
const ctx = createMockContext();
const req = createMockRequest({ method: "POST" });
const res = createMockResponse();
const route = plugin.routes!.find(
(r) => r.method === "POST" && r.path === "/refresh",
)!;
const result = await route.handler(req as any, ctx as any);
expect(result).toHaveProperty("refreshed", true);
expect(result).toHaveProperty("branches");
expect(Array.isArray(result.branches)).toBe(true);
});
});
});
});