feat(FN-1914): merge fusion/fn-1914

This commit is contained in:
gsxdsm
2026-04-16 07:53:31 -07:00
parent 0d507091b2
commit 68b1651e0b
11 changed files with 745 additions and 6 deletions

View File

@@ -107,6 +107,7 @@ export type {
PluginToolResult,
PluginRouteDefinition,
PluginRouteMethod,
PluginUiSlotDefinition,
PluginContext,
PluginLogger,
FusionPlugin,

View File

@@ -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", () => {

View File

@@ -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.
*/

View File

@@ -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();
});
});

View File

@@ -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 ───────────────────────────────────────────────