feat(FN-1914): merge fusion/fn-1914
This commit is contained in:
@@ -107,6 +107,7 @@ export type {
|
||||
PluginToolResult,
|
||||
PluginRouteDefinition,
|
||||
PluginRouteMethod,
|
||||
PluginUiSlotDefinition,
|
||||
PluginContext,
|
||||
PluginLogger,
|
||||
FusionPlugin,
|
||||
|
||||
@@ -813,6 +813,182 @@ describe("PluginLoader", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// ── getPluginUiSlots ───────────────────────────────────────────────
|
||||
|
||||
describe("getPluginUiSlots", () => {
|
||||
it("returns empty array when no plugins loaded", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
const slots = loader.getPluginUiSlots();
|
||||
expect(slots).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns empty array when plugins have no uiSlots", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
// Manually add plugin without uiSlots
|
||||
(loader as any).plugins.set("no-ui-slots", {
|
||||
manifest: makeManifest({ id: "no-ui-slots" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
} as FusionPlugin);
|
||||
|
||||
const slots = loader.getPluginUiSlots();
|
||||
expect(slots).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns aggregated slots from single plugin", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
// Manually add plugin with uiSlots
|
||||
(loader as any).plugins.set("slots-a", {
|
||||
manifest: makeManifest({ id: "slots-a" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
uiSlots: [
|
||||
{
|
||||
slotId: "task-detail-tab",
|
||||
label: "Task Details",
|
||||
componentPath: "./components/TaskDetailTab.js",
|
||||
},
|
||||
],
|
||||
} as FusionPlugin);
|
||||
|
||||
const slots = loader.getPluginUiSlots();
|
||||
|
||||
expect(slots).toHaveLength(1);
|
||||
expect(slots[0].pluginId).toBe("slots-a");
|
||||
expect(slots[0].slot.slotId).toBe("task-detail-tab");
|
||||
expect(slots[0].slot.label).toBe("Task Details");
|
||||
expect(slots[0].slot.componentPath).toBe("./components/TaskDetailTab.js");
|
||||
});
|
||||
|
||||
it("returns aggregated slots from multiple plugins", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
// Manually add plugins with uiSlots
|
||||
(loader as any).plugins.set("slots-a", {
|
||||
manifest: makeManifest({ id: "slots-a" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
uiSlots: [
|
||||
{
|
||||
slotId: "task-detail-tab",
|
||||
label: "Task Details",
|
||||
componentPath: "./components/TaskDetailTab.js",
|
||||
},
|
||||
],
|
||||
} as FusionPlugin);
|
||||
(loader as any).plugins.set("slots-b", {
|
||||
manifest: makeManifest({ id: "slots-b" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
uiSlots: [
|
||||
{
|
||||
slotId: "header-action",
|
||||
label: "Header Action",
|
||||
icon: "Plus",
|
||||
componentPath: "./components/HeaderAction.js",
|
||||
},
|
||||
{
|
||||
slotId: "settings-section",
|
||||
label: "Settings",
|
||||
componentPath: "./components/SettingsSection.js",
|
||||
},
|
||||
],
|
||||
} as FusionPlugin);
|
||||
|
||||
const slots = loader.getPluginUiSlots();
|
||||
|
||||
expect(slots).toHaveLength(3);
|
||||
expect(slots.find((s) => s.pluginId === "slots-a")?.slot.slotId).toBe(
|
||||
"task-detail-tab",
|
||||
);
|
||||
expect(slots.find((s) => s.pluginId === "slots-b")?.slot.slotId).toBe(
|
||||
"header-action",
|
||||
);
|
||||
expect(slots.filter((s) => s.pluginId === "slots-b")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("each slot includes correct pluginId", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
// Manually add plugins with overlapping slotIds (different plugins)
|
||||
(loader as any).plugins.set("plugin-x", {
|
||||
manifest: makeManifest({ id: "plugin-x" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
uiSlots: [
|
||||
{
|
||||
slotId: "custom-tab",
|
||||
label: "Custom Tab",
|
||||
componentPath: "./components/CustomTab.js",
|
||||
},
|
||||
],
|
||||
} as FusionPlugin);
|
||||
(loader as any).plugins.set("plugin-y", {
|
||||
manifest: makeManifest({ id: "plugin-y" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
uiSlots: [
|
||||
{
|
||||
slotId: "custom-tab",
|
||||
label: "Custom Tab Y",
|
||||
componentPath: "./components/CustomTabY.js",
|
||||
},
|
||||
],
|
||||
} as FusionPlugin);
|
||||
|
||||
const slots = loader.getPluginUiSlots();
|
||||
|
||||
// Both plugins can have slots with the same slotId
|
||||
const pluginXSlot = slots.find((s) => s.pluginId === "plugin-x");
|
||||
const pluginYSlot = slots.find((s) => s.pluginId === "plugin-y");
|
||||
|
||||
expect(pluginXSlot?.slot.slotId).toBe("custom-tab");
|
||||
expect(pluginXSlot?.slot.label).toBe("Custom Tab");
|
||||
expect(pluginYSlot?.slot.slotId).toBe("custom-tab");
|
||||
expect(pluginYSlot?.slot.label).toBe("Custom Tab Y");
|
||||
});
|
||||
});
|
||||
|
||||
// ── getLoadedPlugins ───────────────────────────────────────────────
|
||||
|
||||
describe("getLoadedPlugins", () => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
PluginLogger,
|
||||
PluginToolDefinition,
|
||||
PluginRouteDefinition,
|
||||
PluginUiSlotDefinition,
|
||||
PluginState,
|
||||
PluginInstallation,
|
||||
} from "./plugin-types.js";
|
||||
@@ -729,6 +730,21 @@ export class PluginLoader extends EventEmitter<{
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all UI slot definitions from loaded plugins.
|
||||
*/
|
||||
getPluginUiSlots(): Array<{ pluginId: string; slot: PluginUiSlotDefinition }> {
|
||||
const slots: Array<{ pluginId: string; slot: PluginUiSlotDefinition }> = [];
|
||||
for (const [pluginId, plugin] of this.plugins) {
|
||||
if (plugin.uiSlots) {
|
||||
for (const slot of plugin.uiSlots) {
|
||||
slots.push({ pluginId, slot });
|
||||
}
|
||||
}
|
||||
}
|
||||
return slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all loaded plugin instances.
|
||||
*/
|
||||
|
||||
@@ -463,3 +463,106 @@ describe("validatePluginManifest", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ── PluginUiSlotDefinition ─────────────────────────────────────────────
|
||||
|
||||
describe("PluginUiSlotDefinition", () => {
|
||||
it("accepts a valid PluginUiSlotDefinition with all fields", () => {
|
||||
const slot = {
|
||||
slotId: "task-detail-tab",
|
||||
label: "Task Details",
|
||||
icon: "FileText",
|
||||
componentPath: "./components/TaskDetailTab.js",
|
||||
};
|
||||
|
||||
expect(slot.slotId).toBe("task-detail-tab");
|
||||
expect(slot.label).toBe("Task Details");
|
||||
expect(slot.icon).toBe("FileText");
|
||||
expect(slot.componentPath).toBe("./components/TaskDetailTab.js");
|
||||
});
|
||||
|
||||
it("accepts a valid PluginUiSlotDefinition without optional icon field", () => {
|
||||
const slot = {
|
||||
slotId: "header-action",
|
||||
label: "Header Action",
|
||||
componentPath: "./components/HeaderAction.js",
|
||||
};
|
||||
|
||||
expect(slot.slotId).toBe("header-action");
|
||||
expect(slot.label).toBe("Header Action");
|
||||
expect(slot.componentPath).toBe("./components/HeaderAction.js");
|
||||
// icon is optional, so it should be undefined
|
||||
expect((slot as any).icon).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires slotId field", () => {
|
||||
const slot = {
|
||||
label: "Some Label",
|
||||
componentPath: "./components/Test.js",
|
||||
};
|
||||
|
||||
// TypeScript would catch this at compile time, but at runtime we verify the structure
|
||||
expect((slot as any).slotId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires label field", () => {
|
||||
const slot = {
|
||||
slotId: "some-slot",
|
||||
componentPath: "./components/Test.js",
|
||||
};
|
||||
|
||||
expect((slot as any).label).toBeUndefined();
|
||||
});
|
||||
|
||||
it("requires componentPath field", () => {
|
||||
const slot = {
|
||||
slotId: "some-slot",
|
||||
label: "Some Label",
|
||||
};
|
||||
|
||||
expect((slot as any).componentPath).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── FusionPlugin with uiSlots ──────────────────────────────────────────
|
||||
|
||||
describe("FusionPlugin with uiSlots", () => {
|
||||
it("accepts a FusionPlugin with uiSlots array", () => {
|
||||
const plugin = {
|
||||
manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" },
|
||||
state: "started" as const,
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
uiSlots: [
|
||||
{
|
||||
slotId: "task-detail-tab",
|
||||
label: "Task Details",
|
||||
componentPath: "./components/TaskDetailTab.js",
|
||||
},
|
||||
{
|
||||
slotId: "header-action",
|
||||
label: "Header Action",
|
||||
icon: "Plus",
|
||||
componentPath: "./components/HeaderAction.js",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(plugin.uiSlots).toHaveLength(2);
|
||||
expect(plugin.uiSlots![0].slotId).toBe("task-detail-tab");
|
||||
expect(plugin.uiSlots![1].icon).toBe("Plus");
|
||||
});
|
||||
|
||||
it("accepts a FusionPlugin without uiSlots field", () => {
|
||||
const plugin = {
|
||||
manifest: { id: "test-plugin", name: "Test Plugin", version: "1.0.0" },
|
||||
state: "started" as const,
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
};
|
||||
|
||||
expect((plugin as any).uiSlots).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -142,6 +142,28 @@ export interface PluginRouteDefinition {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// ── Plugin UI Slots ─────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* UI slot definition for plugin-provided dashboard components.
|
||||
* Each slot represents a mount point where a plugin can render UI.
|
||||
*/
|
||||
export interface PluginUiSlotDefinition {
|
||||
/** Unique slot identifier (e.g., "task-detail-tab", "header-action", "settings-section") */
|
||||
slotId: string;
|
||||
/** Human-readable label for the UI slot */
|
||||
label: string;
|
||||
/** Optional icon name (lucide-react icon name or custom icon identifier) */
|
||||
icon?: string;
|
||||
/**
|
||||
* Path to the JS module that exports the component.
|
||||
* This should be a web component or a function component descriptor
|
||||
* that the dashboard can render in the slot.
|
||||
* Path is relative to the plugin's root directory.
|
||||
*/
|
||||
componentPath: string;
|
||||
}
|
||||
|
||||
// ── Fusion Plugin ────────────────────────────────────────────────────
|
||||
|
||||
export type PluginState = "installed" | "started" | "stopped" | "error";
|
||||
@@ -162,6 +184,7 @@ export interface FusionPlugin {
|
||||
};
|
||||
tools?: PluginToolDefinition[];
|
||||
routes?: PluginRouteDefinition[];
|
||||
uiSlots?: PluginUiSlotDefinition[];
|
||||
}
|
||||
|
||||
// ── Plugin Installation ───────────────────────────────────────────────
|
||||
|
||||
@@ -87,6 +87,7 @@ function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): PluginLo
|
||||
getLoadedPlugins: vi.fn().mockReturnValue([]),
|
||||
getPluginTools: vi.fn().mockReturnValue([]),
|
||||
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||
getPluginUiSlots: vi.fn().mockReturnValue([]),
|
||||
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
|
||||
stopAllPlugins: vi.fn().mockResolvedValue(undefined),
|
||||
invokeHook: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -657,3 +658,115 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", ()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════
|
||||
describe("GET /api/plugins/ui-slots", () => {
|
||||
let pluginStore: PluginStore;
|
||||
let pluginLoader: PluginLoader;
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
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("returns 200 with empty array when no plugins have uiSlots", async () => {
|
||||
(pluginLoader.getPluginUiSlots as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
||||
|
||||
const res = await performGet(buildApp(), "/api/plugins/ui-slots");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns 200 with aggregated slots when plugins have uiSlots", async () => {
|
||||
const mockSlots = [
|
||||
{
|
||||
pluginId: "test-plugin",
|
||||
slot: {
|
||||
slotId: "task-detail-tab",
|
||||
label: "Task Details",
|
||||
componentPath: "./components/TaskDetailTab.js",
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "test-plugin",
|
||||
slot: {
|
||||
slotId: "header-action",
|
||||
label: "Header Action",
|
||||
icon: "Plus",
|
||||
componentPath: "./components/HeaderAction.js",
|
||||
},
|
||||
},
|
||||
];
|
||||
(pluginLoader.getPluginUiSlots as ReturnType<typeof vi.fn>).mockReturnValue(mockSlots);
|
||||
|
||||
const res = await performGet(buildApp(), "/api/plugins/ui-slots");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual(mockSlots);
|
||||
expect(res.body).toHaveLength(2);
|
||||
expect(res.body[0].pluginId).toBe("test-plugin");
|
||||
expect(res.body[0].slot.slotId).toBe("task-detail-tab");
|
||||
expect(res.body[1].slot.icon).toBe("Plus");
|
||||
});
|
||||
|
||||
it("response shape is Array<{ pluginId: string; slot: PluginUiSlotDefinition }>", async () => {
|
||||
const mockSlots = [
|
||||
{
|
||||
pluginId: "plugin-a",
|
||||
slot: {
|
||||
slotId: "custom-slot",
|
||||
label: "Custom Slot",
|
||||
componentPath: "./components/CustomSlot.js",
|
||||
},
|
||||
},
|
||||
];
|
||||
(pluginLoader.getPluginUiSlots as ReturnType<typeof vi.fn>).mockReturnValue(mockSlots);
|
||||
|
||||
const res = await performGet(buildApp(), "/api/plugins/ui-slots");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Verify response shape
|
||||
expect(Array.isArray(res.body)).toBe(true);
|
||||
expect(res.body[0]).toHaveProperty("pluginId");
|
||||
expect(typeof res.body[0].pluginId).toBe("string");
|
||||
expect(res.body[0]).toHaveProperty("slot");
|
||||
expect(res.body[0].slot).toHaveProperty("slotId");
|
||||
expect(res.body[0].slot).toHaveProperty("label");
|
||||
expect(res.body[0].slot).toHaveProperty("componentPath");
|
||||
});
|
||||
|
||||
it("returns empty array when pluginLoader is not available", async () => {
|
||||
// Build app without pluginLoader
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { pluginStore }));
|
||||
const res = await performGet(app, "/api/plugins/ui-slots");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not conflict with /plugins/:id route", async () => {
|
||||
// Verify that /plugins/ui-slots doesn't get matched by /plugins/:id
|
||||
(pluginLoader.getPluginUiSlots as ReturnType<typeof vi.fn>).mockReturnValue([]);
|
||||
|
||||
const res = await performGet(buildApp(), "/api/plugins/ui-slots");
|
||||
|
||||
// Should return 200, not 404 (which would happen if :id = "ui-slots" matched)
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12969,6 +12969,16 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
res.json(plugins);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/ui-slots
|
||||
* Get all UI slot definitions from active plugins.
|
||||
* Returns aggregated array of { pluginId, slot } objects.
|
||||
*/
|
||||
router.get("/plugins/ui-slots", async (_req: Request, res: Response) => {
|
||||
const slots = options?.pluginLoader?.getPluginUiSlots() ?? [];
|
||||
res.json(slots);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/:id
|
||||
* Get a single plugin by ID.
|
||||
|
||||
@@ -31,6 +31,7 @@ describe("PluginRunner", () => {
|
||||
invokeHook: ReturnType<typeof vi.fn>;
|
||||
getPluginTools: ReturnType<typeof vi.fn>;
|
||||
getPluginRoutes: ReturnType<typeof vi.fn>;
|
||||
getPluginUiSlots: ReturnType<typeof vi.fn>;
|
||||
getLoadedPlugins: ReturnType<typeof vi.fn>;
|
||||
getPlugin: ReturnType<typeof vi.fn>;
|
||||
loadPlugin: ReturnType<typeof vi.fn>;
|
||||
@@ -70,6 +71,7 @@ describe("PluginRunner", () => {
|
||||
invokeHook: vi.fn().mockResolvedValue(undefined),
|
||||
getPluginTools: vi.fn().mockReturnValue([]),
|
||||
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||
getPluginUiSlots: vi.fn().mockReturnValue([]),
|
||||
getLoadedPlugins: vi.fn().mockReturnValue([]),
|
||||
getPlugin: vi.fn(),
|
||||
loadPlugin: vi.fn().mockResolvedValue({}),
|
||||
@@ -280,6 +282,202 @@ describe("PluginRunner", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPluginUiSlots()", () => {
|
||||
it("should return empty array when no plugins have uiSlots", async () => {
|
||||
mockPluginLoader.getPluginUiSlots.mockReturnValue([]);
|
||||
await pluginRunner.init();
|
||||
const slots = pluginRunner.getPluginUiSlots();
|
||||
expect(slots).toEqual([]);
|
||||
});
|
||||
|
||||
it("should return cached slots after plugins load", async () => {
|
||||
const mockSlots = [
|
||||
{
|
||||
pluginId: "test-plugin",
|
||||
slot: {
|
||||
slotId: "task-detail-tab",
|
||||
label: "Task Details",
|
||||
componentPath: "./components/TaskDetailTab.js",
|
||||
},
|
||||
},
|
||||
];
|
||||
mockPluginLoader.getPluginUiSlots.mockReturnValue(mockSlots);
|
||||
|
||||
await pluginRunner.init();
|
||||
const slots1 = pluginRunner.getPluginUiSlots();
|
||||
const slots2 = pluginRunner.getPluginUiSlots();
|
||||
|
||||
expect(slots1).toEqual(mockSlots);
|
||||
expect(slots2).toBe(slots1); // Same reference (cached)
|
||||
});
|
||||
|
||||
it("should invalidate cache on plugin:reloaded event", async () => {
|
||||
const mockSlots = [
|
||||
{
|
||||
pluginId: "test-plugin",
|
||||
slot: {
|
||||
slotId: "custom-tab",
|
||||
label: "Custom Tab",
|
||||
componentPath: "./components/CustomTab.js",
|
||||
},
|
||||
},
|
||||
];
|
||||
mockPluginLoader.getPluginUiSlots.mockReturnValue(mockSlots);
|
||||
|
||||
await pluginRunner.init();
|
||||
const slots1 = pluginRunner.getPluginUiSlots();
|
||||
expect(slots1).toEqual(mockSlots);
|
||||
|
||||
// Simulate plugin:reloaded event that invalidates cache
|
||||
const reloadHandler = mockPluginLoader.on.mock.calls.find(
|
||||
call => call[0] === "plugin:reloaded"
|
||||
)?.[1];
|
||||
if (reloadHandler) {
|
||||
reloadHandler({ pluginId: "test-plugin" });
|
||||
}
|
||||
|
||||
// Next call should rebuild cache
|
||||
const newSlots = [
|
||||
{
|
||||
pluginId: "test-plugin",
|
||||
slot: {
|
||||
slotId: "updated-tab",
|
||||
label: "Updated Tab",
|
||||
componentPath: "./components/UpdatedTab.js",
|
||||
},
|
||||
},
|
||||
];
|
||||
mockPluginLoader.getPluginUiSlots.mockReturnValue(newSlots);
|
||||
|
||||
const slots2 = pluginRunner.getPluginUiSlots();
|
||||
expect(slots2).toEqual(newSlots);
|
||||
expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should invalidate cache on plugin:enabled event", async () => {
|
||||
mockPluginLoader.getPluginUiSlots.mockReturnValue([]);
|
||||
await pluginRunner.init();
|
||||
|
||||
// Get initial slots
|
||||
pluginRunner.getPluginUiSlots();
|
||||
|
||||
// Simulate plugin:enabled event
|
||||
const enabledHandler = mockPluginStore.on.mock.calls.find(
|
||||
call => call[0] === "plugin:enabled"
|
||||
)?.[1];
|
||||
|
||||
const newPlugin = {
|
||||
id: "new-plugin",
|
||||
name: "New Plugin",
|
||||
version: "1.0.0",
|
||||
path: "/test/path",
|
||||
enabled: true,
|
||||
state: "stopped" as const,
|
||||
settings: {},
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
if (enabledHandler) {
|
||||
enabledHandler(newPlugin);
|
||||
}
|
||||
|
||||
// Next call should rebuild cache
|
||||
pluginRunner.getPluginUiSlots();
|
||||
expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should invalidate cache on plugin:disabled event", async () => {
|
||||
mockPluginLoader.getPluginUiSlots.mockReturnValue([]);
|
||||
await pluginRunner.init();
|
||||
|
||||
// Get initial slots
|
||||
pluginRunner.getPluginUiSlots();
|
||||
|
||||
// Simulate plugin:disabled event
|
||||
const disabledHandler = mockPluginStore.on.mock.calls.find(
|
||||
call => call[0] === "plugin:disabled"
|
||||
)?.[1];
|
||||
|
||||
const plugin = {
|
||||
id: "test-plugin",
|
||||
name: "Test Plugin",
|
||||
version: "1.0.0",
|
||||
path: "/test/path",
|
||||
enabled: true,
|
||||
state: "stopped" as const,
|
||||
settings: {},
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
updatedAt: "2024-01-01T00:00:00.000Z",
|
||||
};
|
||||
|
||||
if (disabledHandler) {
|
||||
disabledHandler(plugin);
|
||||
}
|
||||
|
||||
// Next call should rebuild cache
|
||||
pluginRunner.getPluginUiSlots();
|
||||
expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should invalidate cache on plugin:stateChanged event", async () => {
|
||||
mockPluginLoader.getPluginUiSlots.mockReturnValue([]);
|
||||
await pluginRunner.init();
|
||||
|
||||
// Get initial slots
|
||||
pluginRunner.getPluginUiSlots();
|
||||
|
||||
// Simulate plugin:stateChanged event
|
||||
const stateHandler = mockPluginStore.on.mock.calls.find(
|
||||
call => call[0] === "plugin:stateChanged"
|
||||
)?.[1];
|
||||
|
||||
if (stateHandler) {
|
||||
stateHandler();
|
||||
}
|
||||
|
||||
// Next call should rebuild cache
|
||||
pluginRunner.getPluginUiSlots();
|
||||
expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should invalidate cache on plugin:updated event", async () => {
|
||||
mockPluginLoader.getPluginUiSlots.mockReturnValue([]);
|
||||
await pluginRunner.init();
|
||||
|
||||
// Get initial slots
|
||||
pluginRunner.getPluginUiSlots();
|
||||
|
||||
// Simulate plugin:updated event
|
||||
const updatedHandler = mockPluginStore.on.mock.calls.find(
|
||||
call => call[0] === "plugin:updated"
|
||||
)?.[1];
|
||||
|
||||
if (updatedHandler) {
|
||||
updatedHandler();
|
||||
}
|
||||
|
||||
// Next call should rebuild cache
|
||||
pluginRunner.getPluginUiSlots();
|
||||
expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should invalidate cache on reloadPlugin()", async () => {
|
||||
mockPluginLoader.getPluginUiSlots.mockReturnValue([]);
|
||||
await pluginRunner.init();
|
||||
|
||||
// Get initial slots
|
||||
pluginRunner.getPluginUiSlots();
|
||||
|
||||
// Call reloadPlugin
|
||||
await pluginRunner.reloadPlugin("test-plugin");
|
||||
|
||||
// Next call should rebuild cache
|
||||
pluginRunner.getPluginUiSlots();
|
||||
expect(mockPluginLoader.getPluginUiSlots).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLoader() / getStore()", () => {
|
||||
it("should return the plugin loader", () => {
|
||||
const loader = pluginRunner.getLoader();
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
FusionPlugin,
|
||||
PluginToolDefinition,
|
||||
PluginRouteDefinition,
|
||||
PluginUiSlotDefinition,
|
||||
PluginContext,
|
||||
} from "@fusion/core";
|
||||
import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
@@ -51,14 +52,24 @@ interface CachedRoutes {
|
||||
version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached UI slots - rebuilt when plugin state changes
|
||||
*/
|
||||
interface CachedUiSlots {
|
||||
slots: Array<{ pluginId: string; slot: PluginUiSlotDefinition }>;
|
||||
version: number;
|
||||
}
|
||||
|
||||
const DEFAULT_HOOK_TIMEOUT_MS = 5000;
|
||||
|
||||
export class PluginRunner {
|
||||
private readonly log = createLogger("plugin-runner");
|
||||
private cachedTools: CachedTools | null = null;
|
||||
private cachedRoutes: CachedRoutes | null = null;
|
||||
private cachedUiSlots: CachedUiSlots | null = null;
|
||||
private toolsCacheVersion = 0;
|
||||
private routesCacheVersion = 0;
|
||||
private uiSlotsCacheVersion = 0;
|
||||
private hookTimeoutMs: number;
|
||||
|
||||
// Event handler references for cleanup
|
||||
@@ -114,6 +125,7 @@ export class PluginRunner {
|
||||
// Build initial caches
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -182,6 +194,20 @@ export class PluginRunner {
|
||||
return this.cachedRoutes.routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all UI slot definitions from loaded plugins.
|
||||
* UI slots are cached and only rebuilt when plugin state changes.
|
||||
*/
|
||||
getPluginUiSlots(): Array<{ pluginId: string; slot: PluginUiSlotDefinition }> {
|
||||
if (!this.cachedUiSlots || this.cachedUiSlots.version !== this.uiSlotsCacheVersion) {
|
||||
this.cachedUiSlots = {
|
||||
slots: this.options.pluginLoader.getPluginUiSlots(),
|
||||
version: this.uiSlotsCacheVersion,
|
||||
};
|
||||
}
|
||||
return this.cachedUiSlots.slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the underlying plugin loader.
|
||||
*/
|
||||
@@ -205,6 +231,7 @@ export class PluginRunner {
|
||||
await this.options.pluginLoader.reloadPlugin(pluginId);
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
executorLog.log(`Plugin ${pluginId} reloaded`);
|
||||
}
|
||||
|
||||
@@ -214,11 +241,14 @@ export class PluginRunner {
|
||||
* Handle plugin:enabled event - automatically load the plugin.
|
||||
*/
|
||||
private async onPluginEnabled(plugin: import("@fusion/core").PluginInstallation): Promise<void> {
|
||||
// Invalidate caches before the operation to ensure fresh state regardless of outcome
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
|
||||
try {
|
||||
executorLog.log(`Auto-loading enabled plugin: ${plugin.id}`);
|
||||
await this.options.pluginLoader.loadPlugin(plugin.id);
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
} catch (err) {
|
||||
this.log.error(`Failed to auto-load plugin ${plugin.id}:`, err);
|
||||
// Don't rethrow - error isolation
|
||||
@@ -229,11 +259,14 @@ export class PluginRunner {
|
||||
* Handle plugin:disabled event - automatically stop the plugin.
|
||||
*/
|
||||
private async onPluginDisabled(plugin: import("@fusion/core").PluginInstallation): Promise<void> {
|
||||
// Invalidate caches before the operation to ensure fresh state regardless of outcome
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
|
||||
try {
|
||||
executorLog.log(`Auto-stopping disabled plugin: ${plugin.id}`);
|
||||
await this.options.pluginLoader.stopPlugin(plugin.id);
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
} catch (err) {
|
||||
this.log.error(`Failed to auto-stop plugin ${plugin.id}:`, err);
|
||||
// Don't rethrow - error isolation
|
||||
@@ -244,11 +277,14 @@ export class PluginRunner {
|
||||
* Handle plugin:unregistered event - ensure plugin is stopped.
|
||||
*/
|
||||
private async onPluginUnregistered(plugin: import("@fusion/core").PluginInstallation): Promise<void> {
|
||||
// Invalidate caches before the operation to ensure fresh state regardless of outcome
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
|
||||
try {
|
||||
executorLog.log(`Stopping unregistered plugin: ${plugin.id}`);
|
||||
await this.options.pluginLoader.stopPlugin(plugin.id);
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
} catch {
|
||||
// Ignore - plugin might not be loaded
|
||||
}
|
||||
@@ -260,6 +296,7 @@ export class PluginRunner {
|
||||
private onPluginStateChanged(): void {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -268,6 +305,7 @@ export class PluginRunner {
|
||||
private onPluginUpdated(): void {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -276,6 +314,7 @@ export class PluginRunner {
|
||||
private onPluginLoaded(_event: { pluginId: string }): void {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -284,6 +323,7 @@ export class PluginRunner {
|
||||
private onPluginUnloaded(_event: { pluginId: string }): void {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -292,6 +332,7 @@ export class PluginRunner {
|
||||
private onPluginReloaded(_event: { pluginId: string }): void {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
}
|
||||
|
||||
// ── Tool Conversion ───────────────────────────────────────────────
|
||||
@@ -451,6 +492,14 @@ export class PluginRunner {
|
||||
this.log.log(`Routes cache invalidated (version: ${this.routesCacheVersion})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the UI slots cache, forcing rebuild on next access.
|
||||
*/
|
||||
private invalidateUiSlotsCache(): void {
|
||||
this.uiSlotsCacheVersion++;
|
||||
this.log.log(`UI slots cache invalidated (version: ${this.uiSlotsCacheVersion})`);
|
||||
}
|
||||
|
||||
// ── Store Event Subscriptions ────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
||||
@@ -189,6 +189,55 @@ describe("Plugin SDK", () => {
|
||||
};
|
||||
expect(ctx.pluginId).toBe("test");
|
||||
});
|
||||
|
||||
it("exports PluginUiSlotDefinition type", () => {
|
||||
// This is a compile-time test - if types are exported correctly, this will compile
|
||||
const slot: import("../../core/src/plugin-types.js").PluginUiSlotDefinition = {
|
||||
slotId: "task-detail-tab",
|
||||
label: "Task Details",
|
||||
icon: "FileText",
|
||||
componentPath: "./components/TaskDetailTab.js",
|
||||
};
|
||||
expect(slot.slotId).toBe("task-detail-tab");
|
||||
expect(slot.label).toBe("Task Details");
|
||||
expect(slot.icon).toBe("FileText");
|
||||
expect(slot.componentPath).toBe("./components/TaskDetailTab.js");
|
||||
});
|
||||
|
||||
it("PluginUiSlotDefinition icon is optional", () => {
|
||||
// This is a compile-time test - if types are exported correctly, this will compile
|
||||
const slot: import("../../core/src/plugin-types.js").PluginUiSlotDefinition = {
|
||||
slotId: "header-action",
|
||||
label: "Header Action",
|
||||
componentPath: "./components/HeaderAction.js",
|
||||
};
|
||||
expect(slot.slotId).toBe("header-action");
|
||||
expect((slot as any).icon).toBeUndefined();
|
||||
});
|
||||
|
||||
it("PluginUiSlotDefinition can be used in FusionPlugin", () => {
|
||||
// This verifies that PluginUiSlotDefinition can be used in the FusionPlugin interface
|
||||
const plugin: FusionPlugin = {
|
||||
manifest: {
|
||||
id: "test",
|
||||
name: "Test",
|
||||
version: "1.0.0",
|
||||
},
|
||||
state: "installed",
|
||||
hooks: {},
|
||||
tools: [],
|
||||
routes: [],
|
||||
uiSlots: [
|
||||
{
|
||||
slotId: "custom-tab",
|
||||
label: "Custom Tab",
|
||||
componentPath: "./components/CustomTab.js",
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(plugin.uiSlots).toHaveLength(1);
|
||||
expect(plugin.uiSlots![0].slotId).toBe("custom-tab");
|
||||
});
|
||||
});
|
||||
|
||||
// ── validatePluginManifest ───────────────────────────────────────────
|
||||
|
||||
@@ -48,6 +48,7 @@ export type {
|
||||
PluginToolResult,
|
||||
PluginRouteDefinition,
|
||||
PluginRouteMethod,
|
||||
PluginUiSlotDefinition,
|
||||
PluginContext,
|
||||
PluginLogger,
|
||||
FusionPlugin,
|
||||
|
||||
Reference in New Issue
Block a user