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 */
|
||||
|
||||
Reference in New Issue
Block a user