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:
Fusion
2026-05-06 02:08:42 -07:00
committed by gsxdsm
parent 1ada98b53f
commit c630a94ef8
16 changed files with 786 additions and 58 deletions

View File

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

View File

@@ -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 = {

View File

@@ -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";

View File

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

View File

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

View File

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

View File

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

View File

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

View 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],
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -51,6 +51,17 @@ export type {
PluginRouteMethod,
PluginUiSurface,
PluginUiSlotDefinition,
PluginUiContributionSurface,
PluginUiContributionWhen,
PluginUiActionDescriptor,
SettingsProviderCardContribution,
SettingsConfigSectionContribution,
OnboardingProviderCardContribution,
OnboardingSetupHelpContribution,
OnboardingProviderRecommendationContribution,
PostOnboardingRecommendationContribution,
PluginUiContributionDefinition,
PluginUiContributionInputDefinition,
PluginDashboardViewDefinition,
PluginRuntimeManifestMetadata,
PluginRuntimeFactory,