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) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/FN-7201-plugin-dashboard-view-refresh.md
Normal file
7
.changeset/FN-7201-plugin-dashboard-view-refresh.md
Normal file
@@ -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.
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string | null> {
|
||||
const candidates = new Set<string>();
|
||||
|
||||
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<CurrentManifestDashboardViewsResult> {
|
||||
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<Array<{ pluginId: string; view: PluginDashboardViewDefinition }>> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): 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<string, unknown>; resolveProjectTaskStore?: (projectId: string) => Promise<TaskStore> }) => ({
|
||||
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<typeof vi.fn>).mockReturnValue([]);
|
||||
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue(mockViews);
|
||||
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue([
|
||||
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).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<typeof vi.fn>).mockReturnValue([
|
||||
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockResolvedValue([
|
||||
{
|
||||
pluginId: "fusion-plugin-roadmap",
|
||||
view: {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user