From b2e1c3efc24ecdc206c92e9baafa83575de77fec Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 28 Jun 2026 13:23:00 -0700 Subject: [PATCH] FN-7201: refresh plugin dashboard-view metadata from manifests Refresh plugin dashboard navigation metadata from the current on-disk manifest so rebuilt plugins do not serve stale view details. - Resolve the authoritative manifest path from plugin entry directories and package roots. - Re-read and validate dashboardViews metadata before returning dashboard navigation entries, falling back only when the manifest cannot be used. - Await refreshed dashboard-view metadata in the dashboard route and cover rebuild/removal cases with tests. - Add a patch changeset for the published CLI package. Files changed: .../FN-7201-plugin-dashboard-view-refresh.md | 7 ++ packages/core/src/__tests__/plugin-loader.test.ts | 114 ++++++++++++++++++++- packages/core/src/plugin-loader.ts | 94 ++++++++++++++++- .../dashboard/src/__tests__/plugin-routes.test.ts | 42 +++++++- packages/dashboard/src/routes.ts | 2 +- 5 files changed, 244 insertions(+), 15 deletions(-) Fusion-Task-Id: FN-7201 Fusion-Task-Lineage: dbdd5575-1999-4c9c-93a4-25eb8f213e07 Co-authored-by: Fusion (runfusion.ai) --- .../FN-7201-plugin-dashboard-view-refresh.md | 7 ++ .../core/src/__tests__/plugin-loader.test.ts | 114 +++++++++++++++++- packages/core/src/plugin-loader.ts | 94 ++++++++++++++- .../src/__tests__/plugin-routes.test.ts | 42 ++++++- packages/dashboard/src/routes.ts | 2 +- 5 files changed, 244 insertions(+), 15 deletions(-) create mode 100644 .changeset/FN-7201-plugin-dashboard-view-refresh.md diff --git a/.changeset/FN-7201-plugin-dashboard-view-refresh.md b/.changeset/FN-7201-plugin-dashboard-view-refresh.md new file mode 100644 index 0000000000..3e5b041234 --- /dev/null +++ b/.changeset/FN-7201-plugin-dashboard-view-refresh.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Plugin sidebar icons now refresh after a plugin rebuild instead of showing stale glyphs. +category: fix +dev: Dashboard-view metadata is re-derived from the authoritative on-disk manifest so rebuilt plugins do not serve stale dashboardViews icon, label, or placement values to navigation while the in-view bundle is current. diff --git a/packages/core/src/__tests__/plugin-loader.test.ts b/packages/core/src/__tests__/plugin-loader.test.ts index 4d7edea98d..440a0a5911 100644 --- a/packages/core/src/__tests__/plugin-loader.test.ts +++ b/packages/core/src/__tests__/plugin-loader.test.ts @@ -1969,7 +1969,7 @@ export default plugin; it("returns empty array when no plugins loaded", async () => { await pluginStore.init(); const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore }); - expect(loader.getPluginDashboardViews()).toEqual([]); + await expect(loader.getPluginDashboardViews()).resolves.toEqual([]); }); it("returns aggregated views from a single plugin", async () => { @@ -1985,7 +1985,7 @@ export default plugin; ], } as FusionPlugin); - const views = loader.getPluginDashboardViews(); + const views = await loader.getPluginDashboardViews(); expect(views.map((entry) => entry.pluginId + ":" + entry.view.viewId)).toEqual([ "views-a:graph", "views-a:timeline", @@ -2009,7 +2009,7 @@ export default plugin; dashboardViews: [{ viewId: "timeline", label: "Timeline", componentPath: "./timeline.js" }], } as FusionPlugin); - const views = loader.getPluginDashboardViews(); + const views = await loader.getPluginDashboardViews(); expect(views).toHaveLength(2); expect(views.map((entry) => entry.pluginId + ":" + entry.view.viewId)).toEqual([ "views-a:graph", @@ -2040,7 +2040,7 @@ export default plugin; ], } as FusionPlugin); - expect(loader.getPluginDashboardViews()).toEqual([ + await expect(loader.getPluginDashboardViews()).resolves.toEqual([ { pluginId: "views-shape", view: { @@ -2055,6 +2055,112 @@ export default plugin; }, ]); }); + + it("serves current on-disk manifest dashboard-view metadata when the loaded module is stale", async () => { + await pluginStore.init(); + const pluginDir = join(rootDir, "generic-nav-plugin"); + await mkdir(pluginDir, { recursive: true }); + const entryPath = join(pluginDir, "bundled.js"); + await writeFile(entryPath, "export default {};\n"); + const currentDashboardViews = [ + { + viewId: "overview", + label: "Current Overview", + componentPath: "./dashboard/overview.js", + icon: "Boxes", + placement: "primary" as const, + order: 10, + }, + { + viewId: "details", + label: "Current Details", + componentPath: "./dashboard/details.js", + icon: "Network", + placement: "more" as const, + description: "Fresh manifest metadata", + }, + ]; + const currentManifest = { + ...makeManifest({ id: "generic-nav-plugin", name: "Generic Nav Plugin" }), + dashboardViews: currentDashboardViews, + }; + await writeFile(join(pluginDir, "manifest.json"), JSON.stringify(currentManifest)); + await pluginStore.registerPlugin({ manifest: currentManifest as PluginManifest, path: entryPath }); + + const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore }); + (loader as any).plugins.set("generic-nav-plugin", { + manifest: makeManifest({ id: "generic-nav-plugin", name: "Generic Nav Plugin" }), + state: "started", + hooks: {}, + dashboardViews: [ + { + viewId: "overview", + label: "Stale Overview", + componentPath: "./dashboard/overview.js", + icon: "Sparkles", + placement: "overflow", + }, + ], + } as FusionPlugin); + + await expect(loader.getPluginDashboardViews()).resolves.toEqual([ + { pluginId: "generic-nav-plugin", view: currentDashboardViews[0] }, + { pluginId: "generic-nav-plugin", view: currentDashboardViews[1] }, + ]); + }); + + it("treats an empty on-disk dashboardViews array as current metadata", async () => { + await pluginStore.init(); + const pluginDir = join(rootDir, "generic-empty-plugin"); + await mkdir(pluginDir, { recursive: true }); + const entryPath = join(pluginDir, "dist", "index.js"); + await mkdir(join(pluginDir, "dist"), { recursive: true }); + await writeFile(entryPath, "export default {};\n"); + const currentManifest = { + ...makeManifest({ id: "generic-empty-plugin", name: "Generic Empty Plugin" }), + dashboardViews: [], + }; + await writeFile(join(pluginDir, "manifest.json"), JSON.stringify(currentManifest)); + await pluginStore.registerPlugin({ manifest: currentManifest as PluginManifest, path: entryPath }); + + const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore }); + (loader as any).plugins.set("generic-empty-plugin", { + manifest: makeManifest({ id: "generic-empty-plugin", name: "Generic Empty Plugin" }), + state: "started", + hooks: {}, + dashboardViews: [{ viewId: "old", label: "Old", componentPath: "./old.js", icon: "Sparkles" }], + } as FusionPlugin); + + await expect(loader.getPluginDashboardViews()).resolves.toEqual([]); + }); + + it("treats a valid on-disk manifest without dashboardViews as no current nav entries", async () => { + await pluginStore.init(); + const pluginDir = join(rootDir, "generic-removed-views-plugin"); + await mkdir(pluginDir, { recursive: true }); + const entryPath = join(pluginDir, "dist", "index.js"); + await mkdir(join(pluginDir, "dist"), { recursive: true }); + await writeFile(entryPath, "export default {};\n"); + const currentManifest = makeManifest({ + id: "generic-removed-views-plugin", + name: "Generic Removed Views Plugin", + }); + await writeFile(join(pluginDir, "manifest.json"), JSON.stringify(currentManifest)); + await pluginStore.registerPlugin({ manifest: currentManifest as PluginManifest, path: entryPath }); + + const loader = new PluginLoader({ pluginStore, taskStore: mockTaskStore }); + (loader as any).plugins.set("generic-removed-views-plugin", { + manifest: { + ...makeManifest({ id: "generic-removed-views-plugin", name: "Generic Removed Views Plugin" }), + dashboardViews: [{ viewId: "old", label: "Old", componentPath: "./old.js", icon: "Sparkles" }], + }, + state: "started", + hooks: {}, + dashboardViews: [{ viewId: "old", label: "Old", componentPath: "./old.js", icon: "Sparkles" }], + } as FusionPlugin); + + await expect(loader.getPluginDashboardViews()).resolves.toEqual([]); + }); }); describe("getPluginSchemaInitHooks", () => { diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index f283de2f90..3e0d26644b 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -11,8 +11,7 @@ import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path"; import { existsSync, readdirSync, statSync } from "node:fs"; -import { stat } from "node:fs/promises"; -import { copyFile } from "node:fs/promises"; +import { copyFile, readFile, stat } from "node:fs/promises"; import { pathToFileURL } from "node:url"; import { EventEmitter } from "node:events"; import type { TaskStore } from "./store.js"; @@ -49,6 +48,10 @@ import { scanPluginSecurity } from "./plugin-security-scan.js"; // Minimum Fusion version for plugin compatibility checks (can be expanded later) const MINIMUM_FUSION_VERSION = "0.1.0"; let moduleImportVersion = 0; +const PLUGIN_MANIFEST_PARENT_DIR_NAMES = new Set(["dist", "build", "lib", "src"]); +type CurrentManifestDashboardViewsResult = + | { found: true; dashboardViews: PluginDashboardViewDefinition[] } + | { found: false }; /** * Resolve the actual loadable entry FILE path for a plugin directory. Node ESM @@ -1074,14 +1077,95 @@ export class PluginLoader extends EventEmitter<{ }); } + private async resolveCurrentManifestPath(pluginEntryPath: string): Promise { + const candidates = new Set(); + + try { + const entryStats = await stat(pluginEntryPath); + if (entryStats.isDirectory()) { + candidates.add(join(pluginEntryPath, "manifest.json")); + } + } catch { + // The entry file can be temporarily absent during rebuilds; still try the + // package-root candidates derived from the persisted loadable path. + } + + const entryDir = dirname(pluginEntryPath); + candidates.add(join(entryDir, "manifest.json")); + + if (PLUGIN_MANIFEST_PARENT_DIR_NAMES.has(basename(entryDir))) { + candidates.add(join(dirname(entryDir), "manifest.json")); + } + + for (const candidate of candidates) { + try { + const candidateStats = await stat(candidate); + if (candidateStats.isFile()) { + return candidate; + } + } catch { + // Try the next candidate so unusual installs keep falling back safely. + } + } + + return null; + } + + private async getCurrentManifestDashboardViews(pluginId: string): Promise { + let installation: PluginInstallation; + try { + installation = await this.options.pluginStore.getPlugin(pluginId); + } catch (err) { + this.log.warn(`Could not refresh dashboard views for ${pluginId}:`, err); + return { found: false }; + } + + const pluginPath = this.resolvePluginPath(installation.path); + const manifestPath = await this.resolveCurrentManifestPath(pluginPath); + if (!manifestPath) { + return { found: false }; + } + + let manifest: unknown; + try { + manifest = JSON.parse(await readFile(manifestPath, "utf8")); + } catch (err) { + this.log.warn(`Could not read dashboard-view manifest metadata for ${pluginId}:`, err); + return { found: false }; + } + + const validation = validatePluginManifest(manifest); + if (!validation.valid) { + this.log.warn(`Could not refresh dashboard views for ${pluginId}: ${validation.errors.join(", ")}`); + return { found: false }; + } + + const dashboardViews = (manifest as { dashboardViews?: unknown }).dashboardViews; + if (dashboardViews === undefined) { + return { found: true, dashboardViews: [] }; + } + + return { found: true, dashboardViews: dashboardViews as PluginDashboardViewDefinition[] }; + } + /** * Get all top-level dashboard view definitions from loaded plugins. + * + * FNXC:Plugins 2026-06-28-12:30: + * Navigation metadata must come from the current on-disk manifest when present because dashboard component bundles can update immediately after a rebuild while the loaded plugin module instance remains cached. Reading manifest dashboardViews here keeps desktop and mobile nav icon/label/placement in sync with the served in-view bundle for every plugin, without per-plugin pins. + * + * FNXC:Plugins 2026-06-28-19:58: + * A valid manifest that omits dashboardViews is authoritative and means the rebuilt plugin now exposes no top-level views. Do not fall back to stale module dashboardViews after a successful manifest read; fallback is only for missing/unreadable/invalid manifests. */ - getPluginDashboardViews(): Array<{ pluginId: string; view: PluginDashboardViewDefinition }> { + async getPluginDashboardViews(): Promise> { const views: Array<{ pluginId: string; view: PluginDashboardViewDefinition }> = []; for (const [pluginId, plugin] of this.plugins) { - if (plugin.dashboardViews) { - for (const view of plugin.dashboardViews) { + const currentManifestDashboardViews = await this.getCurrentManifestDashboardViews(pluginId); + const dashboardViews = currentManifestDashboardViews.found + ? currentManifestDashboardViews.dashboardViews + : plugin.dashboardViews; + if (dashboardViews) { + for (const view of dashboardViews) { views.push({ pluginId, view }); } } diff --git a/packages/dashboard/src/__tests__/plugin-routes.test.ts b/packages/dashboard/src/__tests__/plugin-routes.test.ts index ede05ee893..17bc2b19b4 100644 --- a/packages/dashboard/src/__tests__/plugin-routes.test.ts +++ b/packages/dashboard/src/__tests__/plugin-routes.test.ts @@ -100,7 +100,7 @@ function createMockPluginLoader(overrides: Partial = {}): PluginLo getPluginUiSlots: vi.fn().mockReturnValue([]), getPluginUiContributions: vi.fn().mockReturnValue([]), getPluginRuntimes: vi.fn().mockReturnValue([]), - getPluginDashboardViews: vi.fn().mockReturnValue([]), + getPluginDashboardViews: vi.fn().mockResolvedValue([]), createRouteContext: vi.fn(async (pluginId: string, overrides?: { taskStore?: TaskStore; settings?: Record; resolveProjectTaskStore?: (projectId: string) => Promise }) => ({ pluginId, taskStore: overrides?.taskStore ?? createMockTaskStore(), @@ -1243,7 +1243,7 @@ describe("GET /api/plugins/dashboard-views", () => { }); it("returns 200 with empty array when no plugins have dashboard views", async () => { - (pluginLoader.getPluginDashboardViews as ReturnType).mockReturnValue([]); + (pluginLoader.getPluginDashboardViews as ReturnType).mockResolvedValue([]); const res = await performGet(buildApp(), "/api/plugins/dashboard-views"); expect(res.status).toBe(200); expect(res.body).toEqual([]); @@ -1264,7 +1264,7 @@ describe("GET /api/plugins/dashboard-views", () => { }, }, ]; - (pluginLoader.getPluginDashboardViews as ReturnType).mockReturnValue(mockViews); + (pluginLoader.getPluginDashboardViews as ReturnType).mockResolvedValue(mockViews); const res = await performGet(buildApp(), "/api/plugins/dashboard-views"); @@ -1272,8 +1272,40 @@ describe("GET /api/plugins/dashboard-views", () => { expect(res.body).toEqual(mockViews); }); + it("awaits refreshed loader dashboard-view metadata before responding", async () => { + const refreshedViews = [ + { + pluginId: "generic-nav-plugin", + view: { + viewId: "overview", + label: "Current Overview", + componentPath: "./dashboard/overview.js", + icon: "Boxes", + placement: "primary", + }, + }, + { + pluginId: "generic-reports-plugin", + view: { + viewId: "reports", + label: "Current Reports", + componentPath: "./dashboard/reports.js", + icon: "FileText", + placement: "more", + }, + }, + ]; + (pluginLoader.getPluginDashboardViews as ReturnType).mockResolvedValue(refreshedViews); + + const res = await performGet(buildApp(), "/api/plugins/dashboard-views"); + + expect(res.status).toBe(200); + expect(pluginLoader.getPluginDashboardViews).toHaveBeenCalledTimes(1); + expect(res.body).toEqual(refreshedViews); + }); + it("returns exactly pluginLoader dashboard-view entries (no synthesized plugin rows)", async () => { - (pluginLoader.getPluginDashboardViews as ReturnType).mockReturnValue([ + (pluginLoader.getPluginDashboardViews as ReturnType).mockResolvedValue([ { pluginId: "with-view", view: { @@ -1295,7 +1327,7 @@ describe("GET /api/plugins/dashboard-views", () => { }); it("keeps dashboard-views payload separate from ui-slots payload", async () => { - (pluginLoader.getPluginDashboardViews as ReturnType).mockReturnValue([ + (pluginLoader.getPluginDashboardViews as ReturnType).mockResolvedValue([ { pluginId: "fusion-plugin-roadmap", view: { diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 0c80162935..0142f89c42 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -3432,7 +3432,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout * Returns aggregated array of { pluginId, view } objects. */ router.get("/plugins/dashboard-views", async (_req: Request, res: Response) => { - const views = options?.pluginLoader?.getPluginDashboardViews() ?? []; + const views = await options?.pluginLoader?.getPluginDashboardViews() ?? []; res.json(views); });