feat(FN-3564): isolate plugin loader test state to prevent cross-test conta
Refactored plugin-loader tests and implementation to isolate plugin test contamination, improving test independence in `@fusion/core`. Fusion-Task-Id: FN-3564
This commit is contained in:
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
||||
import { mkdtempSync, existsSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { PluginLoader } from "../plugin-loader.js";
|
||||
import * as loggerModule from "../logger.js";
|
||||
|
||||
vi.mock("@mariozechner/pi-ai", () => ({
|
||||
AssistantMessageEventStream: class AssistantMessageEventStream {
|
||||
@@ -131,8 +132,7 @@ type MockStructuredLogger = {
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
async function loadPluginLoaderWithMockedLogger() {
|
||||
vi.resetModules();
|
||||
function mockStructuredLoggerFactory() {
|
||||
const loggerMap = new Map<string, MockStructuredLogger>();
|
||||
const createLoggerMock = vi.fn((prefix: string): MockStructuredLogger => {
|
||||
const existing = loggerMap.get(prefix);
|
||||
@@ -147,12 +147,10 @@ async function loadPluginLoaderWithMockedLogger() {
|
||||
return logger;
|
||||
});
|
||||
|
||||
vi.doMock("../logger.js", () => ({
|
||||
createLogger: createLoggerMock,
|
||||
}));
|
||||
|
||||
const { PluginLoader: MockedPluginLoader } = await import("../plugin-loader.js");
|
||||
return { MockedPluginLoader, createLoggerMock, loggerMap };
|
||||
// Use a spy instead of resetModules/doMock so this suite cannot corrupt
|
||||
// other modules' live exports (notably plugin-types normalization helpers).
|
||||
vi.spyOn(loggerModule, "createLogger").mockImplementation(createLoggerMock);
|
||||
return { createLoggerMock, loggerMap };
|
||||
}
|
||||
|
||||
describe("PluginLoader", () => {
|
||||
@@ -876,8 +874,12 @@ export default plugin;
|
||||
// ── structured logging ──────────────────────────────────────────────
|
||||
|
||||
describe("structured logging", () => {
|
||||
afterEach(() => {
|
||||
vi.doUnmock("./logger.js");
|
||||
|
||||
it("keeps plugin-types normalization exports callable after logger mocking", async () => {
|
||||
mockStructuredLoggerFactory();
|
||||
const pluginTypes = await import("../plugin-types.js");
|
||||
expect(typeof pluginTypes.normalizePluginUiContributionDefinition).toBe("function");
|
||||
expect(typeof pluginTypes.normalizePluginUiContributionSurface).toBe("function");
|
||||
});
|
||||
|
||||
it("logs when skipping a disabled plugin", async () => {
|
||||
@@ -893,8 +895,8 @@ export default plugin;
|
||||
});
|
||||
await pluginStore.disablePlugin("disabled-log-test");
|
||||
|
||||
const { MockedPluginLoader, loggerMap } = await loadPluginLoaderWithMockedLogger();
|
||||
const loader = new MockedPluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
const { loggerMap } = mockStructuredLoggerFactory();
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
|
||||
await expect(loader.loadPlugin("disabled-log-test")).rejects.toThrow("disabled");
|
||||
expect(loggerMap.get("plugin-loader")?.log).toHaveBeenCalledWith(
|
||||
@@ -914,8 +916,8 @@ export default plugin;
|
||||
path: pluginPath,
|
||||
});
|
||||
|
||||
const { MockedPluginLoader, loggerMap } = await loadPluginLoaderWithMockedLogger();
|
||||
const loader = new MockedPluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
const { loggerMap } = mockStructuredLoggerFactory();
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
|
||||
await loader.loadPlugin("already-loaded-log");
|
||||
await loader.loadPlugin("already-loaded-log");
|
||||
@@ -937,8 +939,8 @@ export default plugin;
|
||||
path: pluginPath,
|
||||
});
|
||||
|
||||
const { MockedPluginLoader, loggerMap } = await loadPluginLoaderWithMockedLogger();
|
||||
const loader = new MockedPluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
const { loggerMap } = mockStructuredLoggerFactory();
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
|
||||
await loader.loadPlugin("reload-log-test");
|
||||
await loader.reloadPlugin("reload-log-test");
|
||||
@@ -961,8 +963,8 @@ export default plugin;
|
||||
path: pluginPath,
|
||||
});
|
||||
|
||||
const { MockedPluginLoader, loggerMap } = await loadPluginLoaderWithMockedLogger();
|
||||
const loader = new MockedPluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
const { loggerMap } = mockStructuredLoggerFactory();
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
|
||||
await loader.loadPlugin(pluginId);
|
||||
await writePluginWithHooks(
|
||||
@@ -1002,8 +1004,8 @@ export default plugin;
|
||||
path: pluginPath,
|
||||
});
|
||||
|
||||
const { MockedPluginLoader, loggerMap } = await loadPluginLoaderWithMockedLogger();
|
||||
const loader = new MockedPluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
const { loggerMap } = mockStructuredLoggerFactory();
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
|
||||
await loader.loadPlugin(pluginId);
|
||||
await writePluginWithHooks(
|
||||
@@ -1041,8 +1043,8 @@ export default plugin;
|
||||
path: pluginPath,
|
||||
});
|
||||
|
||||
const { MockedPluginLoader, loggerMap } = await loadPluginLoaderWithMockedLogger();
|
||||
const loader = new MockedPluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
const { loggerMap } = mockStructuredLoggerFactory();
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
|
||||
await loader.loadPlugin(pluginId);
|
||||
await loader.stopPlugin(pluginId);
|
||||
@@ -1074,8 +1076,8 @@ export default plugin;
|
||||
path: badPath,
|
||||
});
|
||||
|
||||
const { MockedPluginLoader, loggerMap } = await loadPluginLoaderWithMockedLogger();
|
||||
const loader = new MockedPluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
const { loggerMap } = mockStructuredLoggerFactory();
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
|
||||
await loader.loadAllPlugins();
|
||||
|
||||
@@ -1088,8 +1090,8 @@ export default plugin;
|
||||
it("logs invokeHook failures", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const { MockedPluginLoader, loggerMap } = await loadPluginLoaderWithMockedLogger();
|
||||
const loader = new MockedPluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
const { loggerMap } = mockStructuredLoggerFactory();
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
|
||||
(loader as any).plugins.set("hook-error-log", {
|
||||
manifest: makeManifest({ id: "hook-error-log" }),
|
||||
@@ -1130,8 +1132,8 @@ export default plugin;
|
||||
path: pluginPath,
|
||||
});
|
||||
|
||||
const { MockedPluginLoader, loggerMap } = await loadPluginLoaderWithMockedLogger();
|
||||
const loader = new MockedPluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
const { loggerMap } = mockStructuredLoggerFactory();
|
||||
const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore });
|
||||
|
||||
await loader.loadPlugin(pluginId);
|
||||
|
||||
@@ -1568,6 +1570,56 @@ export default plugin;
|
||||
|
||||
|
||||
|
||||
describe("getPluginUiContributions", () => {
|
||||
it("returns normalized structured contributions and sorts deterministically", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
const loader = new PluginLoader({
|
||||
pluginStore,
|
||||
taskStore: mockTaskStore,
|
||||
});
|
||||
|
||||
(loader as any).plugins.set("plugin-b", {
|
||||
manifest: makeManifest({ id: "plugin-b" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
uiContributions: [
|
||||
{
|
||||
surface: "onboarding-recommendation-card",
|
||||
contributionId: "rec-b",
|
||||
providerId: "openai",
|
||||
title: "OpenAI",
|
||||
reason: "default",
|
||||
order: 10,
|
||||
},
|
||||
],
|
||||
} as FusionPlugin);
|
||||
|
||||
(loader as any).plugins.set("plugin-a", {
|
||||
manifest: makeManifest({ id: "plugin-a" }),
|
||||
state: "started",
|
||||
hooks: {},
|
||||
uiContributions: [
|
||||
{
|
||||
surface: "settings-integration-card",
|
||||
contributionId: "cfg-a",
|
||||
sectionId: "openai",
|
||||
title: "OpenAI settings",
|
||||
pluginSettingKeys: ["openai.apiKey"],
|
||||
order: 1,
|
||||
},
|
||||
],
|
||||
} as FusionPlugin);
|
||||
|
||||
const contributions = loader.getPluginUiContributions();
|
||||
|
||||
expect(contributions).toHaveLength(2);
|
||||
expect(contributions[0]?.pluginId).toBe("plugin-a");
|
||||
expect(contributions[0]?.contribution.surface).toBe("settings-config-section");
|
||||
expect(contributions[1]?.contribution.surface).toBe("onboarding-provider-recommendation");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPluginDashboardViews", () => {
|
||||
it("returns empty array when no plugins loaded", async () => {
|
||||
await pluginStore.init();
|
||||
|
||||
@@ -14,8 +14,14 @@ import type {
|
||||
PluginSetupManifest,
|
||||
PluginSkillContribution,
|
||||
PluginWorkflowStepContribution,
|
||||
PluginUiContributionDefinition,
|
||||
PluginUiContributionInputDefinition,
|
||||
} from "../plugin-types.js";
|
||||
import {
|
||||
normalizePluginUiContributionDefinition,
|
||||
normalizePluginUiContributionSurface,
|
||||
validatePluginManifest,
|
||||
} from "../plugin-types.js";
|
||||
import { validatePluginManifest } from "../plugin-types.js";
|
||||
|
||||
describe("validatePluginManifest", () => {
|
||||
// ── Valid Manifests ─────────────────────────────────────────────────
|
||||
@@ -1065,6 +1071,95 @@ describe("PluginRuntimeRegistration", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin ui contribution normalization", () => {
|
||||
it("normalizes settings-integration-card to settings-config-section", () => {
|
||||
const normalized = normalizePluginUiContributionDefinition({
|
||||
surface: "settings-integration-card",
|
||||
contributionId: "settings-a",
|
||||
sectionId: "provider-a",
|
||||
title: "Provider settings",
|
||||
pluginSettingKeys: ["provider.apiKey"],
|
||||
});
|
||||
expect(normalized.surface).toBe("settings-config-section");
|
||||
});
|
||||
|
||||
it("normalizes onboarding-recommendation-card to onboarding-provider-recommendation", () => {
|
||||
const normalized = normalizePluginUiContributionDefinition({
|
||||
surface: "onboarding-recommendation-card",
|
||||
contributionId: "rec-a",
|
||||
providerId: "openai",
|
||||
title: "OpenAI",
|
||||
reason: "Best default",
|
||||
});
|
||||
expect(normalized.surface).toBe("onboarding-provider-recommendation");
|
||||
});
|
||||
|
||||
it("passes through final structured surface names unchanged", () => {
|
||||
expect(normalizePluginUiContributionSurface("settings-config-section")).toBe("settings-config-section");
|
||||
expect(normalizePluginUiContributionSurface("onboarding-provider-recommendation")).toBe(
|
||||
"onboarding-provider-recommendation",
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts legacy surface names in input definitions for compatibility", () => {
|
||||
const legacyInput: PluginUiContributionInputDefinition = {
|
||||
surface: "settings-integration-card",
|
||||
contributionId: "legacy-settings",
|
||||
sectionId: "provider-a",
|
||||
title: "Provider settings",
|
||||
pluginSettingKeys: ["provider.apiKey"],
|
||||
};
|
||||
expect(legacyInput.surface).toBe("settings-integration-card");
|
||||
});
|
||||
|
||||
it("supports all final structured contribution surfaces", () => {
|
||||
const contributions: PluginUiContributionDefinition[] = [
|
||||
{
|
||||
surface: "settings-provider-card",
|
||||
contributionId: "settings-provider",
|
||||
providerId: "anthropic",
|
||||
title: "Anthropic",
|
||||
providerType: "api_key",
|
||||
},
|
||||
{
|
||||
surface: "settings-config-section",
|
||||
contributionId: "settings-config",
|
||||
sectionId: "anthropic",
|
||||
title: "Anthropic config",
|
||||
pluginSettingKeys: ["anthropic.apiKey"],
|
||||
},
|
||||
{
|
||||
surface: "onboarding-provider-card",
|
||||
contributionId: "onboarding-provider",
|
||||
providerId: "openai",
|
||||
title: "OpenAI",
|
||||
providerType: "oauth",
|
||||
},
|
||||
{
|
||||
surface: "onboarding-setup-help",
|
||||
contributionId: "setup-help",
|
||||
title: "Need help?",
|
||||
body: "Run auth login",
|
||||
bodyFormat: "text",
|
||||
},
|
||||
{
|
||||
surface: "onboarding-provider-recommendation",
|
||||
contributionId: "provider-recommendation",
|
||||
providerId: "openai",
|
||||
title: "Recommended",
|
||||
reason: "Fast setup",
|
||||
},
|
||||
{
|
||||
surface: "post-onboarding-recommendation",
|
||||
contributionId: "post-recommendation",
|
||||
title: "Next step",
|
||||
description: "Enable budgets",
|
||||
},
|
||||
];
|
||||
expect(contributions).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin contribution types", () => {
|
||||
it("accepts a minimal PluginSkillContribution shape", () => {
|
||||
const skill: PluginSkillContribution = {
|
||||
|
||||
@@ -143,6 +143,17 @@ export type {
|
||||
PluginRouteMethod,
|
||||
PluginUiSurface,
|
||||
PluginUiSlotDefinition,
|
||||
PluginUiContributionSurface,
|
||||
PluginUiContributionWhen,
|
||||
PluginUiActionDescriptor,
|
||||
SettingsProviderCardContribution,
|
||||
SettingsConfigSectionContribution,
|
||||
OnboardingProviderCardContribution,
|
||||
OnboardingSetupHelpContribution,
|
||||
OnboardingProviderRecommendationContribution,
|
||||
PostOnboardingRecommendationContribution,
|
||||
PluginUiContributionDefinition,
|
||||
PluginUiContributionInputDefinition,
|
||||
PluginDashboardViewDefinition,
|
||||
PluginRuntimeManifestMetadata,
|
||||
PluginRuntimeFactory,
|
||||
@@ -165,7 +176,7 @@ export type {
|
||||
PluginState,
|
||||
PluginInstallation,
|
||||
} from "./plugin-types.js";
|
||||
export { validatePluginManifest } from "./plugin-types.js";
|
||||
export { validatePluginManifest, normalizePluginUiContributionSurface, normalizePluginUiContributionDefinition } from "./plugin-types.js";
|
||||
export { PluginStore } from "./plugin-store.js";
|
||||
export type { PluginStoreEvents, PluginRegistrationInput, PluginUpdateInput } from "./plugin-store.js";
|
||||
export { PluginLoader } from "./plugin-loader.js";
|
||||
|
||||
@@ -23,6 +23,7 @@ import type {
|
||||
PluginToolDefinition,
|
||||
PluginRouteDefinition,
|
||||
PluginUiSlotDefinition,
|
||||
PluginUiContributionDefinition,
|
||||
PluginDashboardViewDefinition,
|
||||
PluginOnSchemaInit,
|
||||
PluginRuntimeRegistration,
|
||||
@@ -34,13 +35,12 @@ import type {
|
||||
PluginSetupManifest,
|
||||
PluginSetupHooks,
|
||||
} from "./plugin-types.js";
|
||||
import { validatePluginManifest } from "./plugin-types.js";
|
||||
import { normalizePluginUiContributionDefinition, validatePluginManifest } from "./plugin-types.js";
|
||||
import { createLogger } from "./logger.js";
|
||||
import { getCreateAiSessionFactory } from "./ai-engine-loader.js";
|
||||
|
||||
// Minimum Fusion version for plugin compatibility checks (can be expanded later)
|
||||
const MINIMUM_FUSION_VERSION = "0.1.0";
|
||||
const log = createLogger("plugin-loader");
|
||||
let moduleImportVersion = 0;
|
||||
|
||||
export interface PluginLoaderOptions {
|
||||
@@ -98,6 +98,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
/** Cache of dynamically imported modules */
|
||||
private loadedModules: Map<string, unknown> = new Map();
|
||||
|
||||
private readonly log = createLogger("plugin-loader");
|
||||
|
||||
constructor(private options: PluginLoaderOptions) {
|
||||
super();
|
||||
@@ -112,7 +113,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
private async createContext(plugin: FusionPlugin): Promise<PluginContext> {
|
||||
const createAiSession = await getCreateAiSessionFactory();
|
||||
if (process.env.DEBUG?.includes("plugins")) {
|
||||
log.log(
|
||||
this.log.log(
|
||||
createAiSession
|
||||
? `[plugin:${plugin.manifest.id}] createAiSession available`
|
||||
: `[plugin:${plugin.manifest.id}] createAiSession unavailable`,
|
||||
@@ -128,7 +129,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
emitEvent: (event: string, data: unknown) => {
|
||||
this.emit("plugin:error", { pluginId: plugin.manifest.id, error: new Error(`Custom event: ${event}`) });
|
||||
// Custom events are logged but not surfaced as errors
|
||||
log.log(`[plugin:${plugin.manifest.id}] Custom event: ${event}`, data);
|
||||
this.log.log(`[plugin:${plugin.manifest.id}] Custom event: ${event}`, data);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -172,7 +173,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
|
||||
// Skip disabled plugins
|
||||
if (!installation.enabled) {
|
||||
log.log(`Skipping disabled plugin: ${pluginId}`);
|
||||
this.log.log(`Skipping disabled plugin: ${pluginId}`);
|
||||
throw Object.assign(new Error(`Plugin "${pluginId}" is disabled`), {
|
||||
code: "PLUGIN_DISABLED",
|
||||
});
|
||||
@@ -180,7 +181,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
|
||||
// Skip already loaded plugins
|
||||
if (this.plugins.has(pluginId)) {
|
||||
log.log(`Plugin already loaded: ${pluginId}`);
|
||||
this.log.log(`Plugin already loaded: ${pluginId}`);
|
||||
return this.plugins.get(pluginId)!;
|
||||
}
|
||||
|
||||
@@ -207,7 +208,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
plugin.manifest.fusionVersion,
|
||||
);
|
||||
if (!compatible) {
|
||||
log.warn(
|
||||
this.log.warn(
|
||||
`Plugin ${pluginId} requires Fusion ${plugin.manifest.fusionVersion}, minimum is ${MINIMUM_FUSION_VERSION}`,
|
||||
);
|
||||
}
|
||||
@@ -319,7 +320,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
*/
|
||||
private invalidateModuleCache(path: string): void {
|
||||
this.loadedModules.delete(path);
|
||||
log.log(`Module cache invalidated for: ${path}`);
|
||||
this.log.log(`Module cache invalidated for: ${path}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,7 +348,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
const installation = await this.options.pluginStore.getPlugin(pluginId);
|
||||
const pluginPath = this.resolvePluginPath(installation.path);
|
||||
|
||||
log.log(`Reloading plugin: ${pluginId}`);
|
||||
this.log.log(`Reloading plugin: ${pluginId}`);
|
||||
|
||||
// Call onUnload with timeout
|
||||
try {
|
||||
@@ -357,7 +358,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
`onUnload timeout for ${pluginId}`,
|
||||
);
|
||||
} catch (err) {
|
||||
log.warn(`onUnload for ${pluginId} timed out or failed:`, err);
|
||||
this.log.warn(`onUnload for ${pluginId} timed out or failed:`, err);
|
||||
// Continue with reload despite onUnload issues
|
||||
}
|
||||
|
||||
@@ -397,13 +398,13 @@ export class PluginLoader extends EventEmitter<{
|
||||
// State is already "started", no need to update store
|
||||
// (avoiding started -> started transition which is disallowed)
|
||||
|
||||
log.log(`Plugin ${pluginId} reloaded successfully`);
|
||||
this.log.log(`Plugin ${pluginId} reloaded successfully`);
|
||||
|
||||
this.emit("plugin:reloaded", { pluginId, plugin: newPlugin });
|
||||
return newPlugin;
|
||||
} catch (err) {
|
||||
// Rollback: restore old plugin
|
||||
log.error(`Reload failed for ${pluginId}, rolling back:`, err);
|
||||
this.log.error(`Reload failed for ${pluginId}, rolling back:`, err);
|
||||
|
||||
try {
|
||||
// Restore old plugin
|
||||
@@ -420,10 +421,10 @@ export class PluginLoader extends EventEmitter<{
|
||||
// Update store state back to started
|
||||
await this.options.pluginStore.updatePluginState(pluginId, "started");
|
||||
|
||||
log.warn(`Rollback successful for ${pluginId}`);
|
||||
this.log.warn(`Rollback successful for ${pluginId}`);
|
||||
} catch (rollbackErr) {
|
||||
// Rollback also failed - remove plugin and set error state
|
||||
log.error(
|
||||
this.log.error(
|
||||
`Rollback failed for ${pluginId}, removing plugin:`,
|
||||
rollbackErr,
|
||||
);
|
||||
@@ -556,7 +557,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code !== "PLUGIN_DISABLED") {
|
||||
errors++;
|
||||
log.error(
|
||||
this.log.error(
|
||||
`Failed to load plugin ${installation.id}:`,
|
||||
err,
|
||||
);
|
||||
@@ -612,7 +613,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
async stopPlugin(pluginId: string): Promise<void> {
|
||||
const plugin = this.plugins.get(pluginId);
|
||||
if (!plugin) {
|
||||
log.log(`Plugin not loaded: ${pluginId}`);
|
||||
this.log.log(`Plugin not loaded: ${pluginId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -628,7 +629,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
`onUnload timeout for ${pluginId}`,
|
||||
);
|
||||
} catch (err) {
|
||||
log.error(`Error in onUnload for ${pluginId}:`, err);
|
||||
this.log.error(`Error in onUnload for ${pluginId}:`, err);
|
||||
}
|
||||
|
||||
// Update state
|
||||
@@ -673,7 +674,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
try {
|
||||
await this.stopPlugin(plugin.id);
|
||||
} catch (err) {
|
||||
log.error(`Error stopping plugin ${plugin.id}:`, err);
|
||||
this.log.error(`Error stopping plugin ${plugin.id}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -695,7 +696,7 @@ export class PluginLoader extends EventEmitter<{
|
||||
try {
|
||||
await this.safeCallHook(plugin, hookName, args);
|
||||
} catch (err) {
|
||||
log.error(
|
||||
this.log.error(
|
||||
`Error in ${hookName} hook for ${pluginId}:`,
|
||||
err,
|
||||
);
|
||||
@@ -803,6 +804,31 @@ export class PluginLoader extends EventEmitter<{
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Get all structured UI contributions from loaded plugins.
|
||||
*/
|
||||
getPluginUiContributions(): Array<{ pluginId: string; contribution: PluginUiContributionDefinition }> {
|
||||
const contributions: Array<{ pluginId: string; contribution: PluginUiContributionDefinition }> = [];
|
||||
for (const [pluginId, plugin] of this.plugins) {
|
||||
if (plugin.uiContributions) {
|
||||
for (const contribution of plugin.uiContributions) {
|
||||
contributions.push({
|
||||
pluginId,
|
||||
contribution: normalizePluginUiContributionDefinition(contribution),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return contributions.sort((a, b) => {
|
||||
const orderA = a.contribution.order ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = b.contribution.order ?? Number.MAX_SAFE_INTEGER;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
if (a.pluginId !== b.pluginId) return a.pluginId.localeCompare(b.pluginId);
|
||||
return a.contribution.contributionId.localeCompare(b.contribution.contributionId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all top-level dashboard view definitions from loaded plugins.
|
||||
*/
|
||||
|
||||
@@ -252,6 +252,128 @@ export interface PluginUiSlotDefinition {
|
||||
* Top-level dashboard view definition for plugin-provided navigation destinations.
|
||||
* This is separate from embedded uiSlots and is rendered via host-managed registry.
|
||||
*/
|
||||
export type PluginUiContributionSurface =
|
||||
| "settings-provider-card"
|
||||
| "settings-config-section"
|
||||
| "onboarding-provider-card"
|
||||
| "onboarding-setup-help"
|
||||
| "onboarding-provider-recommendation"
|
||||
| "post-onboarding-recommendation";
|
||||
|
||||
export interface PluginUiContributionWhen {
|
||||
providerIds?: string[];
|
||||
runtimeIds?: string[];
|
||||
authState?: "required" | "authenticated" | "unauthenticated";
|
||||
onboardingState?: "required" | "in-progress" | "complete";
|
||||
}
|
||||
|
||||
export interface PluginUiActionDescriptor {
|
||||
kind:
|
||||
| "open-settings-section"
|
||||
| "open-onboarding"
|
||||
| "refresh-auth-status"
|
||||
| "save-api-key"
|
||||
| "toggle-cli-provider"
|
||||
| "open-external-url";
|
||||
target?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
interface PluginUiContributionBase {
|
||||
surface: PluginUiContributionSurface;
|
||||
contributionId: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
order?: number;
|
||||
when?: PluginUiContributionWhen;
|
||||
}
|
||||
|
||||
interface PluginUiProviderCardBase extends PluginUiContributionBase {
|
||||
providerId: string;
|
||||
providerType: "cli" | "oauth" | "api_key" | "custom";
|
||||
settingsSectionId?: string;
|
||||
statusSource?: { kind: string; providerId: string; route?: string };
|
||||
actions?: PluginUiActionDescriptor[];
|
||||
}
|
||||
|
||||
export interface SettingsProviderCardContribution extends PluginUiProviderCardBase {
|
||||
surface: "settings-provider-card";
|
||||
}
|
||||
|
||||
export interface SettingsConfigSectionContribution extends PluginUiContributionBase {
|
||||
surface: "settings-config-section";
|
||||
sectionId: string;
|
||||
pluginSettingKeys: string[];
|
||||
layout?: "section" | "card" | "disclosure";
|
||||
}
|
||||
|
||||
export interface OnboardingProviderCardContribution extends PluginUiProviderCardBase {
|
||||
surface: "onboarding-provider-card";
|
||||
quickStart?: string;
|
||||
recommended?: boolean;
|
||||
}
|
||||
|
||||
export interface OnboardingSetupHelpContribution extends PluginUiContributionBase {
|
||||
surface: "onboarding-setup-help";
|
||||
providerId?: string;
|
||||
body: string;
|
||||
bodyFormat: "text" | "markdown";
|
||||
actions?: PluginUiActionDescriptor[];
|
||||
}
|
||||
|
||||
export interface OnboardingProviderRecommendationContribution extends PluginUiContributionBase {
|
||||
surface: "onboarding-provider-recommendation";
|
||||
providerId: string;
|
||||
reason: string;
|
||||
actions?: PluginUiActionDescriptor[];
|
||||
priority?: number;
|
||||
}
|
||||
|
||||
export interface PostOnboardingRecommendationContribution extends PluginUiContributionBase {
|
||||
surface: "post-onboarding-recommendation";
|
||||
description: string;
|
||||
actions?: PluginUiActionDescriptor[];
|
||||
priority?: number;
|
||||
dismissible?: boolean;
|
||||
}
|
||||
|
||||
export type PluginUiContributionDefinition =
|
||||
| SettingsProviderCardContribution
|
||||
| SettingsConfigSectionContribution
|
||||
| OnboardingProviderCardContribution
|
||||
| OnboardingSetupHelpContribution
|
||||
| OnboardingProviderRecommendationContribution
|
||||
| PostOnboardingRecommendationContribution;
|
||||
|
||||
type LegacyPluginUiContributionSurface =
|
||||
| "settings-integration-card"
|
||||
| "onboarding-recommendation-card";
|
||||
|
||||
export type PluginUiContributionInputDefinition = Omit<PluginUiContributionDefinition, "surface"> & {
|
||||
surface: PluginUiContributionSurface | LegacyPluginUiContributionSurface;
|
||||
};
|
||||
|
||||
export function normalizePluginUiContributionSurface(
|
||||
surface: PluginUiContributionSurface | LegacyPluginUiContributionSurface,
|
||||
): PluginUiContributionSurface {
|
||||
if (surface === "settings-integration-card") {
|
||||
return "settings-config-section";
|
||||
}
|
||||
if (surface === "onboarding-recommendation-card") {
|
||||
return "onboarding-provider-recommendation";
|
||||
}
|
||||
return surface;
|
||||
}
|
||||
|
||||
export function normalizePluginUiContributionDefinition(
|
||||
contribution: PluginUiContributionInputDefinition,
|
||||
): PluginUiContributionDefinition {
|
||||
return {
|
||||
...contribution,
|
||||
surface: normalizePluginUiContributionSurface(contribution.surface),
|
||||
} as PluginUiContributionDefinition;
|
||||
}
|
||||
|
||||
export interface PluginDashboardViewDefinition {
|
||||
/** Unique view identifier within a plugin namespace. */
|
||||
viewId: string;
|
||||
@@ -450,6 +572,7 @@ export interface FusionPlugin {
|
||||
tools?: PluginToolDefinition[];
|
||||
routes?: PluginRouteDefinition[];
|
||||
uiSlots?: PluginUiSlotDefinition[];
|
||||
uiContributions?: PluginUiContributionInputDefinition[];
|
||||
/** Plugin-contributed top-level dashboard views. */
|
||||
dashboardViews?: PluginDashboardViewDefinition[];
|
||||
/** Agent runtime registration for providing custom runtime implementations */
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
WorkflowStepResult,
|
||||
PluginInstallation,
|
||||
PluginUiSlotDefinition,
|
||||
PluginUiContributionDefinition,
|
||||
PluginDashboardViewDefinition,
|
||||
TaskDocument,
|
||||
TaskDocumentRevision,
|
||||
@@ -7788,6 +7789,12 @@ export interface PluginUiSlotEntry {
|
||||
slot: PluginUiSlotDefinition;
|
||||
}
|
||||
|
||||
/** A structured UI contribution entry returned by GET /api/plugins/ui-contributions */
|
||||
export interface PluginUiContributionEntry {
|
||||
pluginId: string;
|
||||
contribution: PluginUiContributionDefinition;
|
||||
}
|
||||
|
||||
/** A dashboard view entry returned by GET /api/plugins/dashboard-views */
|
||||
export interface PluginDashboardViewEntry {
|
||||
pluginId: string;
|
||||
@@ -7809,6 +7816,11 @@ export async function fetchPluginUiSlots(projectId?: string): Promise<PluginUiSl
|
||||
}
|
||||
|
||||
|
||||
/** Fetch all structured UI contributions from active plugins */
|
||||
export async function fetchPluginUiContributions(projectId?: string): Promise<PluginUiContributionEntry[]> {
|
||||
return api<PluginUiContributionEntry[]>(withProjectId("/plugins/ui-contributions", projectId));
|
||||
}
|
||||
|
||||
/** Fetch all top-level dashboard view definitions from active plugins */
|
||||
export async function fetchPluginDashboardViews(projectId?: string): Promise<PluginDashboardViewEntry[]> {
|
||||
return api<PluginDashboardViewEntry[]>(withProjectId("/plugins/dashboard-views", projectId));
|
||||
|
||||
@@ -3,7 +3,7 @@ import { render, screen, fireEvent, waitFor, within } from "@testing-library/rea
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SettingsModal } from "../SettingsModal";
|
||||
import { __test_clearCache as clearPluginUiSlotsCache } from "../../hooks/usePluginUiSlots";
|
||||
import type { SettingsExportData, UpdateCheckResponse } from "../../api";
|
||||
import type { PluginUiContributionEntry, SettingsExportData, UpdateCheckResponse } from "../../api";
|
||||
|
||||
// --- API mocks ---
|
||||
const mockFetchSettings = vi.fn();
|
||||
@@ -2849,3 +2849,21 @@ describe("SettingsModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("plugin structured contribution contract fixtures", () => {
|
||||
it("represents all required structured surfaces without componentPath", () => {
|
||||
const contributions: PluginUiContributionEntry[] = [
|
||||
{ pluginId: "plugin-a", contribution: { surface: "settings-provider-card", contributionId: "a", providerId: "openai", title: "OpenAI", providerType: "api_key" } },
|
||||
{ pluginId: "plugin-a", contribution: { surface: "settings-config-section", contributionId: "b", sectionId: "openai", title: "OpenAI config", pluginSettingKeys: ["openai.apiKey"] } },
|
||||
{ pluginId: "plugin-a", contribution: { surface: "onboarding-provider-card", contributionId: "c", providerId: "openai", title: "OpenAI", providerType: "api_key" } },
|
||||
{ pluginId: "plugin-a", contribution: { surface: "onboarding-setup-help", contributionId: "d", title: "Help", body: "Use API key", bodyFormat: "text" } },
|
||||
{ pluginId: "plugin-a", contribution: { surface: "onboarding-provider-recommendation", contributionId: "e", providerId: "openai", title: "Recommended", reason: "Fast setup" } },
|
||||
{ pluginId: "plugin-a", contribution: { surface: "post-onboarding-recommendation", contributionId: "f", title: "Next step", description: "Enable memory" } },
|
||||
];
|
||||
|
||||
expect(contributions).toHaveLength(6);
|
||||
for (const entry of contributions) {
|
||||
expect("componentPath" in entry.contribution).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { usePluginUiContributions, __test_clearContributionsCache } from "../usePluginUiContributions";
|
||||
import * as api from "../../api";
|
||||
import type { PluginUiContributionEntry } from "../../api";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
fetchPluginUiContributions: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockFetchPluginUiContributions = vi.mocked(api.fetchPluginUiContributions);
|
||||
|
||||
function createContributionEntry(surface: PluginUiContributionEntry["contribution"]["surface"]): PluginUiContributionEntry {
|
||||
return {
|
||||
pluginId: "plugin-a",
|
||||
contribution: {
|
||||
surface,
|
||||
contributionId: `${surface}-id`,
|
||||
title: `${surface} title`,
|
||||
providerId: "openai",
|
||||
providerType: "api_key",
|
||||
} as PluginUiContributionEntry["contribution"],
|
||||
};
|
||||
}
|
||||
|
||||
describe("usePluginUiContributions", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchPluginUiContributions.mockReset();
|
||||
__test_clearContributionsCache();
|
||||
});
|
||||
|
||||
it("fetches contributions and returns them", async () => {
|
||||
const data = [createContributionEntry("settings-provider-card")];
|
||||
mockFetchPluginUiContributions.mockResolvedValueOnce(data);
|
||||
|
||||
const { result } = renderHook(() => usePluginUiContributions());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(result.current.contributions).toEqual(data);
|
||||
});
|
||||
|
||||
it("filters contributions by surface", async () => {
|
||||
const data = [
|
||||
createContributionEntry("settings-provider-card"),
|
||||
createContributionEntry("onboarding-provider-card"),
|
||||
];
|
||||
mockFetchPluginUiContributions.mockResolvedValueOnce(data);
|
||||
|
||||
const { result } = renderHook(() => usePluginUiContributions());
|
||||
|
||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||
const filtered = result.current.getContributionsForSurface("settings-provider-card");
|
||||
expect(filtered).toHaveLength(1);
|
||||
expect(filtered[0]?.contribution.surface).toBe("settings-provider-card");
|
||||
});
|
||||
|
||||
it("uses cache on repeated load", async () => {
|
||||
const data = [createContributionEntry("post-onboarding-recommendation")];
|
||||
mockFetchPluginUiContributions.mockResolvedValueOnce(data);
|
||||
|
||||
const { result: first } = renderHook(() => usePluginUiContributions("proj"));
|
||||
await waitFor(() => expect(first.current.loading).toBe(false));
|
||||
|
||||
mockFetchPluginUiContributions.mockClear();
|
||||
|
||||
const { result: second } = renderHook(() => usePluginUiContributions("proj"));
|
||||
await waitFor(() => expect(second.current.loading).toBe(false));
|
||||
|
||||
expect(second.current.contributions).toEqual(data);
|
||||
expect(mockFetchPluginUiContributions).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
99
packages/dashboard/app/hooks/usePluginUiContributions.ts
Normal file
99
packages/dashboard/app/hooks/usePluginUiContributions.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { fetchPluginUiContributions } from "../api";
|
||||
import type { PluginUiContributionEntry } from "../api";
|
||||
|
||||
const uiContributionsCache = new Map<string, { contributions: PluginUiContributionEntry[]; expiresAt: number }>();
|
||||
const CACHE_TTL_MS = 60_000;
|
||||
|
||||
interface UsePluginUiContributionsResult {
|
||||
contributions: PluginUiContributionEntry[];
|
||||
getContributionsForSurface: (surface: string) => PluginUiContributionEntry[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export function __test_clearContributionsCache(): void {
|
||||
uiContributionsCache.clear();
|
||||
}
|
||||
|
||||
export function usePluginUiContributions(projectId?: string): UsePluginUiContributionsResult {
|
||||
const [contributions, setContributions] = useState<PluginUiContributionEntry[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const initialLoadCompleteRef = useRef(false);
|
||||
const cancelledRef = useRef(false);
|
||||
|
||||
const getContributionsForSurface = useCallback(
|
||||
(surface: string): PluginUiContributionEntry[] => contributions.filter((entry) => entry.contribution.surface === surface),
|
||||
[contributions],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const cacheKey = projectId ?? "default";
|
||||
let cancelled = false;
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const cached = uiContributionsCache.get(cacheKey);
|
||||
if (cached && Date.now() < cached.expiresAt) {
|
||||
if (cancelled || cancelledRef.current) return;
|
||||
setContributions(cached.contributions);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!initialLoadCompleteRef.current) {
|
||||
setLoading(true);
|
||||
}
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const data = await fetchPluginUiContributions(projectId);
|
||||
if (cancelled || cancelledRef.current) return;
|
||||
|
||||
uiContributionsCache.set(cacheKey, {
|
||||
contributions: data,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
});
|
||||
|
||||
setContributions(data);
|
||||
initialLoadCompleteRef.current = true;
|
||||
} catch (err) {
|
||||
if (cancelled || cancelledRef.current) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to fetch UI contributions");
|
||||
initialLoadCompleteRef.current = true;
|
||||
} finally {
|
||||
if (!cancelled && !cancelledRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void load();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
initialLoadCompleteRef.current = false;
|
||||
cancelledRef.current = false;
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cancelledRef.current = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return useMemo(
|
||||
() => ({
|
||||
contributions,
|
||||
getContributionsForSurface,
|
||||
loading,
|
||||
error,
|
||||
}),
|
||||
[contributions, getContributionsForSurface, loading, error],
|
||||
);
|
||||
}
|
||||
@@ -92,6 +92,7 @@ function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): PluginLo
|
||||
getPluginTools: vi.fn().mockReturnValue([]),
|
||||
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||
getPluginUiSlots: vi.fn().mockReturnValue([]),
|
||||
getPluginUiContributions: vi.fn().mockReturnValue([]),
|
||||
getPluginRuntimes: vi.fn().mockReturnValue([]),
|
||||
getPluginDashboardViews: vi.fn().mockReturnValue([]),
|
||||
loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
|
||||
@@ -911,6 +912,72 @@ describe("GET /api/plugins/ui-slots", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/plugins/ui-contributions", () => {
|
||||
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 normalized and sorted structured contributions", async () => {
|
||||
(pluginLoader.getPluginUiContributions as ReturnType<typeof vi.fn>).mockReturnValue([
|
||||
{
|
||||
pluginId: "b-plugin",
|
||||
contribution: {
|
||||
surface: "onboarding-provider-recommendation",
|
||||
contributionId: "rec-b",
|
||||
providerId: "openai",
|
||||
title: "OpenAI",
|
||||
reason: "default",
|
||||
order: 10,
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginId: "a-plugin",
|
||||
contribution: {
|
||||
surface: "settings-config-section",
|
||||
contributionId: "cfg-a",
|
||||
sectionId: "openai",
|
||||
title: "OpenAI settings",
|
||||
pluginSettingKeys: ["openai.apiKey"],
|
||||
order: 1,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await performGet(buildApp(), "/api/plugins/ui-contributions");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.map((entry: { pluginId: string }) => entry.pluginId)).toEqual(["a-plugin", "b-plugin"]);
|
||||
expect(res.body[0].contribution.surface).toBe("settings-config-section");
|
||||
});
|
||||
|
||||
it("returns empty array when pluginLoader is missing", async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store, { pluginStore }));
|
||||
|
||||
const res = await performGet(app, "/api/plugins/ui-contributions");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /api/plugins/runtimes", () => {
|
||||
let pluginStore: PluginStore;
|
||||
let pluginLoader: PluginLoader;
|
||||
|
||||
@@ -3156,6 +3156,30 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
res.json(normalizedSlots);
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/plugins/ui-contributions
|
||||
* Get all structured UI contributions from active plugins.
|
||||
*/
|
||||
router.get("/plugins/ui-contributions", async (_req: Request, res: Response) => {
|
||||
const contributions = options?.pluginLoader?.getPluginUiContributions() ?? [];
|
||||
const normalizedContributions = contributions
|
||||
.map((entry) => ({
|
||||
pluginId: entry.pluginId,
|
||||
contribution: {
|
||||
...entry.contribution,
|
||||
order: entry.contribution.order ?? null,
|
||||
},
|
||||
}))
|
||||
.sort((a, b) => {
|
||||
const orderA = typeof a.contribution.order === "number" ? a.contribution.order : Number.MAX_SAFE_INTEGER;
|
||||
const orderB = typeof b.contribution.order === "number" ? b.contribution.order : Number.MAX_SAFE_INTEGER;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
if (a.pluginId !== b.pluginId) return a.pluginId.localeCompare(b.pluginId);
|
||||
return a.contribution.contributionId.localeCompare(b.contribution.contributionId);
|
||||
});
|
||||
res.json(normalizedContributions);
|
||||
});
|
||||
|
||||
|
||||
/**
|
||||
* GET /api/plugins/dashboard-views
|
||||
|
||||
@@ -34,6 +34,7 @@ describe("PluginRunner", () => {
|
||||
getPluginTools: ReturnType<typeof vi.fn>;
|
||||
getPluginRoutes: ReturnType<typeof vi.fn>;
|
||||
getPluginUiSlots: ReturnType<typeof vi.fn>;
|
||||
getPluginUiContributions: ReturnType<typeof vi.fn>;
|
||||
getPluginRuntimes: ReturnType<typeof vi.fn>;
|
||||
getPluginSkills: ReturnType<typeof vi.fn>;
|
||||
getPluginWorkflowSteps: ReturnType<typeof vi.fn>;
|
||||
@@ -94,6 +95,7 @@ describe("PluginRunner", () => {
|
||||
getPluginTools: vi.fn().mockReturnValue([]),
|
||||
getPluginRoutes: vi.fn().mockReturnValue([]),
|
||||
getPluginUiSlots: vi.fn().mockReturnValue([]),
|
||||
getPluginUiContributions: vi.fn().mockReturnValue([]),
|
||||
getPluginRuntimes: vi.fn().mockReturnValue([]),
|
||||
getPluginSkills: vi.fn().mockReturnValue([]),
|
||||
getPluginWorkflowSteps: vi.fn().mockReturnValue([]),
|
||||
@@ -534,6 +536,50 @@ describe("PluginRunner", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPluginUiContributions()", () => {
|
||||
it("returns structured contributions from pluginLoader", () => {
|
||||
const contributions = [
|
||||
{
|
||||
pluginId: "plugin-a",
|
||||
contribution: {
|
||||
surface: "settings-config-section",
|
||||
contributionId: "cfg-a",
|
||||
sectionId: "openai",
|
||||
title: "OpenAI settings",
|
||||
pluginSettingKeys: ["openai.apiKey"],
|
||||
},
|
||||
},
|
||||
];
|
||||
mockPluginLoader.getPluginUiContributions.mockReturnValue(contributions);
|
||||
|
||||
const result = pluginRunner.getPluginUiContributions();
|
||||
|
||||
expect(result).toEqual(contributions);
|
||||
expect(mockPluginLoader.getPluginUiContributions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses cached contributions until invalidated", () => {
|
||||
const contributions = [
|
||||
{
|
||||
pluginId: "plugin-a",
|
||||
contribution: {
|
||||
surface: "onboarding-provider-recommendation",
|
||||
contributionId: "rec-a",
|
||||
providerId: "openai",
|
||||
title: "OpenAI",
|
||||
reason: "default",
|
||||
},
|
||||
},
|
||||
];
|
||||
mockPluginLoader.getPluginUiContributions.mockReturnValue(contributions);
|
||||
|
||||
pluginRunner.getPluginUiContributions();
|
||||
pluginRunner.getPluginUiContributions();
|
||||
|
||||
expect(mockPluginLoader.getPluginUiContributions).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getPluginRuntimes()", () => {
|
||||
it("should return empty array when no plugins have runtimes", async () => {
|
||||
mockPluginLoader.getPluginRuntimes.mockReturnValue([]);
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
PluginToolDefinition,
|
||||
PluginRouteDefinition,
|
||||
PluginUiSlotDefinition,
|
||||
PluginUiContributionDefinition,
|
||||
PluginRuntimeRegistration,
|
||||
PluginContext,
|
||||
PluginSkillContribution,
|
||||
@@ -68,6 +69,11 @@ interface CachedUiSlots {
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface CachedUiContributions {
|
||||
contributions: Array<{ pluginId: string; contribution: PluginUiContributionDefinition }>;
|
||||
version: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached runtimes - rebuilt when plugin state changes
|
||||
*/
|
||||
@@ -112,6 +118,7 @@ export class PluginRunner {
|
||||
private cachedTools: CachedTools | null = null;
|
||||
private cachedRoutes: CachedRoutes | null = null;
|
||||
private cachedUiSlots: CachedUiSlots | null = null;
|
||||
private cachedUiContributions: CachedUiContributions | null = null;
|
||||
private cachedRuntimes: CachedRuntimes | null = null;
|
||||
private cachedSkills: CachedSkills | null = null;
|
||||
private cachedWorkflowSteps: CachedWorkflowSteps | null = null;
|
||||
@@ -121,6 +128,7 @@ export class PluginRunner {
|
||||
private toolsCacheVersion = 0;
|
||||
private routesCacheVersion = 0;
|
||||
private uiSlotsCacheVersion = 0;
|
||||
private uiContributionsCacheVersion = 0;
|
||||
private runtimesCacheVersion = 0;
|
||||
private skillsCacheVersion = 0;
|
||||
private workflowStepsCacheVersion = 0;
|
||||
@@ -196,6 +204,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateUiContributionsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
@@ -284,6 +293,16 @@ export class PluginRunner {
|
||||
return this.cachedUiSlots.slots;
|
||||
}
|
||||
|
||||
getPluginUiContributions(): Array<{ pluginId: string; contribution: PluginUiContributionDefinition }> {
|
||||
if (!this.cachedUiContributions || this.cachedUiContributions.version !== this.uiContributionsCacheVersion) {
|
||||
this.cachedUiContributions = {
|
||||
contributions: this.options.pluginLoader.getPluginUiContributions(),
|
||||
version: this.uiContributionsCacheVersion,
|
||||
};
|
||||
}
|
||||
return this.cachedUiContributions.contributions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all runtime registrations from loaded plugins.
|
||||
* Runtimes are cached and only rebuilt when plugin state changes.
|
||||
@@ -404,6 +423,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateUiContributionsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
@@ -423,6 +443,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateUiContributionsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
@@ -447,6 +468,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateUiContributionsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
@@ -471,6 +493,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateUiContributionsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
@@ -494,6 +517,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateUiContributionsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
@@ -509,6 +533,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateUiContributionsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
@@ -524,6 +549,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateUiContributionsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
@@ -539,6 +565,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateUiContributionsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
@@ -554,6 +581,7 @@ export class PluginRunner {
|
||||
this.invalidateToolsCache();
|
||||
this.invalidateRoutesCache();
|
||||
this.invalidateUiSlotsCache();
|
||||
this.invalidateUiContributionsCache();
|
||||
this.invalidateRuntimesCache();
|
||||
this.invalidateSkillsCache();
|
||||
this.invalidateWorkflowStepsCache();
|
||||
@@ -757,6 +785,11 @@ export class PluginRunner {
|
||||
this.log.log(`UI slots cache invalidated (version: ${this.uiSlotsCacheVersion})`);
|
||||
}
|
||||
|
||||
private invalidateUiContributionsCache(): void {
|
||||
this.uiContributionsCacheVersion++;
|
||||
this.log.log(`UI contributions cache invalidated (version: ${this.uiContributionsCacheVersion})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the runtimes cache, forcing rebuild on next access.
|
||||
*/
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { describe, it, expect, beforeAll } from "vitest";
|
||||
import { definePlugin } from "../index.js";
|
||||
import type { FusionPlugin } from "../../../core/src/plugin-types.js";
|
||||
import type { FusionPlugin, PluginUiContributionSurface } from "../../../core/src/plugin-types.js";
|
||||
import type { TaskStore } from "../../../core/src/store.js";
|
||||
import { validatePluginManifest } from "../../../core/src/plugin-types.js";
|
||||
|
||||
type AssertNever<T extends never> = T;
|
||||
type NoStaleStructuredSurface = AssertNever<Extract<
|
||||
PluginUiContributionSurface,
|
||||
"settings-integration-card" | "onboarding-recommendation-card"
|
||||
>>;
|
||||
|
||||
let validateFn: typeof validatePluginManifest;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -238,10 +244,6 @@ describe("Plugin SDK", () => {
|
||||
expect(plugin.uiSlots).toHaveLength(1);
|
||||
expect(plugin.uiSlots![0].slotId).toBe("custom-tab");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
it("PluginDashboardViewDefinition can be used in FusionPlugin", () => {
|
||||
const plugin: FusionPlugin = {
|
||||
manifest: { id: "test", name: "Test", version: "1.0.0" },
|
||||
@@ -256,6 +258,16 @@ describe("Plugin SDK", () => {
|
||||
expect(plugin.dashboardViews).toHaveLength(1);
|
||||
expect(plugin.dashboardViews?.[0].viewId).toBe("graph");
|
||||
});
|
||||
|
||||
it("exposes only normalized structured surface names", () => {
|
||||
const surface: PluginUiContributionSurface = "settings-config-section";
|
||||
expect(surface).toBe("settings-config-section");
|
||||
|
||||
const compileGuard: NoStaleStructuredSurface | undefined = undefined;
|
||||
expect(compileGuard).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── validatePluginManifest ───────────────────────────────────────────
|
||||
|
||||
describe("validatePluginManifest", () => {
|
||||
|
||||
@@ -51,6 +51,17 @@ export type {
|
||||
PluginRouteMethod,
|
||||
PluginUiSurface,
|
||||
PluginUiSlotDefinition,
|
||||
PluginUiContributionSurface,
|
||||
PluginUiContributionWhen,
|
||||
PluginUiActionDescriptor,
|
||||
SettingsProviderCardContribution,
|
||||
SettingsConfigSectionContribution,
|
||||
OnboardingProviderCardContribution,
|
||||
OnboardingSetupHelpContribution,
|
||||
OnboardingProviderRecommendationContribution,
|
||||
PostOnboardingRecommendationContribution,
|
||||
PluginUiContributionDefinition,
|
||||
PluginUiContributionInputDefinition,
|
||||
PluginDashboardViewDefinition,
|
||||
PluginRuntimeManifestMetadata,
|
||||
PluginRuntimeFactory,
|
||||
|
||||
Reference in New Issue
Block a user