feat(FN-3904): support static bundled plugin views in dashboard routing

Fixed the dependency graph for bundled plugin views by switching to static imports in the dashboard router and adding a bundled install path fallback in the plugin view registration, with corresponding tests.

Fusion-Task-Id: FN-3904
This commit is contained in:
Fusion
2026-05-09 20:25:10 -07:00
committed by gsxdsm
parent 772c9f6b24
commit da101efdab
5 changed files with 233 additions and 15 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": patch
---
Fix bundled Dependency Graph plugin reliability in the dashboard. Built-in plugin view registration now uses literal-specifier lazy imports so production bundles can resolve and load the bundled graph/roadmap dashboard views instead of falling back to an unavailable placeholder. Plugin install mode now resolves bundled plugin paths server-side when relative `./plugins/...` inputs do not exist under the current working directory, so installing built-in plugins from Settings works reliably across runtime locations.

View File

@@ -0,0 +1,36 @@
import { describe, expect, it, beforeEach, vi } from "vitest";
import { getPluginViewComponent, __test_clearPluginViewRegistry } from "../pluginViewRegistry";
import {
__test_resetBundledPluginViewRegistration,
registerBundledPluginViews,
} from "../registerBundledPluginViews";
vi.mock("@fusion-plugin-examples/dependency-graph/dashboard-view", () => ({
DependencyGraphDashboardView: () => null,
}));
vi.mock("@fusion-plugin-examples/roadmap/dashboard-view", () => ({
RoadmapDashboardView: () => null,
}));
describe("registerBundledPluginViews", () => {
beforeEach(() => {
__test_clearPluginViewRegistry();
__test_resetBundledPluginViewRegistration();
});
it("registers dependency graph and roadmap bundled views", () => {
registerBundledPluginViews();
expect(getPluginViewComponent("fusion-plugin-dependency-graph", "graph")).toBeTruthy();
expect(getPluginViewComponent("roadmap-planner", "roadmaps")).toBeTruthy();
});
it("is idempotent when called more than once", () => {
registerBundledPluginViews();
const firstGraph = getPluginViewComponent("fusion-plugin-dependency-graph", "graph");
expect(() => registerBundledPluginViews()).not.toThrow();
expect(getPluginViewComponent("fusion-plugin-dependency-graph", "graph")).toBe(firstGraph);
});
});

View File

@@ -7,24 +7,34 @@ let registered = false;
type PluginViewComponent = ({ context }: { context?: PluginDashboardViewContext }) => ReactElement;
function createMissingPluginView(moduleId: string): PluginViewComponent {
function createMissingPluginView(moduleId: string, exportName: string): PluginViewComponent {
return function MissingPluginView() {
return createElement("span", null, `Bundled plugin view unavailable: ${moduleId}`);
return createElement("span", null, `Bundled plugin view unavailable: ${moduleId}#${exportName}`);
};
}
async function loadBundledPluginView(moduleId: string, exportName: string): Promise<{ default: PluginViewComponent }> {
try {
const mod = await import(/* @vite-ignore */ moduleId) as Record<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
const component = mod[exportName];
if (component) {
return { default: component as PluginViewComponent };
}
} catch {
// Fall back to placeholder view when optional bundled plugin examples are unavailable.
async function loadDependencyGraphView(): Promise<{ default: PluginViewComponent }> {
const moduleId = "@fusion-plugin-examples/dependency-graph/dashboard-view";
const exportName = "DependencyGraphDashboardView";
const mod = await import("@fusion-plugin-examples/dependency-graph/dashboard-view") as Record<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
const component = mod[exportName];
if (!component) {
console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`);
return { default: createMissingPluginView(moduleId, exportName) };
}
return { default: component as PluginViewComponent };
}
return { default: createMissingPluginView(moduleId) };
async function loadRoadmapView(): Promise<{ default: PluginViewComponent }> {
const moduleId = "@fusion-plugin-examples/roadmap/dashboard-view";
const exportName = "RoadmapDashboardView";
const mod = await import("@fusion-plugin-examples/roadmap/dashboard-view") as Record<string, ComponentType<{ context?: PluginDashboardViewContext }>>;
const component = mod[exportName];
if (!component) {
console.warn(`[plugin-views] Missing export ${exportName} from ${moduleId}`);
return { default: createMissingPluginView(moduleId, exportName) };
}
return { default: component as PluginViewComponent };
}
export function registerBundledPluginViews(): void {
@@ -34,12 +44,16 @@ export function registerBundledPluginViews(): void {
registerPluginView(
"fusion-plugin-dependency-graph",
"graph",
lazy(() => loadBundledPluginView("@fusion-plugin-examples/dependency-graph/dashboard-view", "DependencyGraphDashboardView")),
lazy(loadDependencyGraphView),
);
registerPluginView(
"roadmap-planner",
"roadmaps",
lazy(() => loadBundledPluginView("@fusion-plugin-examples/roadmap/dashboard-view", "RoadmapDashboardView")),
lazy(loadRoadmapView),
);
}
export function __test_resetBundledPluginViewRegistration(): void {
registered = false;
}

View File

@@ -473,6 +473,90 @@ describe("POST /api/plugins central persistence integration", () => {
});
});
describe("POST /api/plugins mode:install — bundled plugin path fallback", () => {
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("installs bundled dependency graph plugin when relative path misses cwd", async () => {
const bundledManifest = {
...VALID_MANIFEST,
id: "fusion-plugin-dependency-graph",
name: "Dependency Graph",
};
mockExistsSync.mockImplementation((p: string) => p.includes("fusion-plugin-dependency-graph/manifest.json"));
mockAccess.mockImplementation((p: string) => {
if (p.includes("fusion-plugin-dependency-graph")) return Promise.resolve();
return Promise.reject(new Error("not found"));
});
mockReadFile.mockResolvedValue(JSON.stringify(bundledManifest));
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValue({
...INSTALLED_PLUGIN,
id: "fusion-plugin-dependency-graph",
name: "Dependency Graph",
});
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: "./plugins/fusion-plugin-dependency-graph",
});
expect(res.status).toBe(201);
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({
manifest: expect.objectContaining({ id: "fusion-plugin-dependency-graph" }),
path: expect.stringContaining("fusion-plugin-dependency-graph"),
}),
);
});
it("returns 404 with helpful message when local and bundled paths are unresolved", async () => {
mockExistsSync.mockReturnValue(false);
mockAccess.mockRejectedValue(new Error("not found"));
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "install",
path: "./plugins/fusion-plugin-dependency-graph",
});
expect(res.status).toBe(404);
expect(res.body.error).toContain("Checked resolved local path and bundled plugin locations");
});
it("keeps register mode behavior unchanged", async () => {
(pluginStore.registerPlugin as ReturnType<typeof vi.fn>).mockResolvedValue(INSTALLED_PLUGIN);
const res = await REQUEST(buildApp(), "POST", "/api/plugins", {
mode: "register",
id: "my-plugin",
name: "My Plugin",
version: "1.0.0",
path: "./plugins/fusion-plugin-dependency-graph",
});
expect(res.status).toBe(201);
expect(pluginStore.registerPlugin).toHaveBeenCalledWith(
expect.objectContaining({ path: "./plugins/fusion-plugin-dependency-graph" }),
);
});
});
describe("POST /api/plugins mode:install — negative paths", () => {
let pluginStore: PluginStore;
let pluginLoader: PluginLoader;

View File

@@ -8,6 +8,7 @@ declare module "express" {
}
import multer from "multer";
import { resolve, sep, join, isAbsolute } from "node:path";
import { fileURLToPath } from "node:url";
import * as nodeFs from "node:fs";
import os from "node:os";
import v8 from "node:v8";
@@ -86,6 +87,49 @@ const BUNDLED_PLUGIN_RUNTIMES: Array<{
version: "1.0.0",
},
];
const BUNDLED_PLUGIN_IDS = new Set([
"fusion-plugin-dependency-graph",
"fusion-plugin-whatsapp-chat",
"fusion-plugin-roadmap",
"fusion-plugin-hermes-runtime",
"fusion-plugin-openclaw-runtime",
"fusion-plugin-paperclip-runtime",
"fusion-plugin-cursor-runtime",
]);
function extractBundledPluginId(pathInput: string): string | null {
const normalized = pathInput.replace(/\\/gu, "/").replace(/\/+$/u, "").trim();
if (BUNDLED_PLUGIN_IDS.has(normalized)) {
return normalized;
}
for (const pluginId of BUNDLED_PLUGIN_IDS) {
if (normalized.endsWith(`/plugins/${pluginId}`)) {
return pluginId;
}
}
return null;
}
function resolveBundledPluginDirInDashboard(pluginId: string): string | null {
const moduleDir = resolve(fileURLToPath(import.meta.url), "..");
const dashboardPackageRoot = resolve(moduleDir, "..");
const candidates = [
join(dashboardPackageRoot, "dist", "plugins", pluginId),
join(dashboardPackageRoot, "plugins", pluginId),
join(dashboardPackageRoot, "..", "..", "plugins", pluginId),
];
for (const candidate of candidates) {
if (nodeFs.existsSync(join(candidate, "manifest.json"))) {
return candidate;
}
}
return null;
}
import { createSessionDiagnostics } from "./ai-session-diagnostics.js";
import { createApiRoutesContext } from "./routes/context.js";
import { registerTaskWorkflowRoutes } from "./routes/register-task-workflow-routes.js";
@@ -3399,8 +3443,43 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
throw badRequest("'aiScanOnLoad' must be a boolean when provided");
}
const requestPath = (body.path as string).trim();
const absoluteRequestPath = isAbsolute(requestPath) ? requestPath : resolve(process.cwd(), requestPath);
let manifestPathForInstall = absoluteRequestPath;
let manifestResolutionError: ApiError | null = null;
let attemptedBundledLookup = false;
try {
await resolvePluginManifest(manifestPathForInstall);
} catch (err) {
if (err instanceof ApiError && err.statusCode === 404) {
manifestResolutionError = err;
const bundledPluginId = extractBundledPluginId(requestPath) ?? extractBundledPluginId(absoluteRequestPath);
if (bundledPluginId) {
attemptedBundledLookup = true;
const bundledPath = resolveBundledPluginDirInDashboard(bundledPluginId);
if (bundledPath) {
manifestPathForInstall = bundledPath;
}
}
} else {
throw err;
}
}
if (manifestResolutionError && manifestPathForInstall === absoluteRequestPath) {
if (attemptedBundledLookup) {
throw notFound(
`Plugin install path not found: ${requestPath}. `
+ "Checked resolved local path and bundled plugin locations.",
);
}
throw manifestResolutionError;
}
// Resolve manifest — supports package root and dist-folder selections
const { manifestDir, manifest } = await resolvePluginManifest(body.path as string);
const { manifestDir, manifest } = await resolvePluginManifest(manifestPathForInstall);
try {
const plugin = await pluginStore.registerPlugin({