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:
5
.changeset/fn-3904-dependency-graph-fix.md
Normal file
5
.changeset/fn-3904-dependency-graph-fix.md
Normal 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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user