From 4426eb8d743075646e864c2d7296b8f700bd14a3 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 21:57:46 -0700 Subject: [PATCH 1/5] Fix Settings plugin installs registering unloadable directory paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins installed from Settings → Built-in Plugins registered the manifest directory as the plugin path, but since FN-4128 the loader requires a loadable entry FILE (Node ESM cannot import directories), so enabling failed with "Plugin entry must be a file, got directory". Only the CLI startup path had been migrated to entry-file resolution, which is why CLI-auto-installed plugins worked and Settings installs never did. - Add resolvePluginEntryPath (bundled.js → dist/index.js → src/index.ts) to @fusion/core; the CLI keeps its local copy (its test fs mocks don't reach externalized core) with sync comments both ways. - Register the resolved entry file in both dashboard install routes; 400 with a clear message when a package has no loadable entry. - Heal legacy directory-path registrations on enable, mirroring the CLI's startup heal, so existing broken rows recover from the UI without a restart. - Route tests: assert installs register entry files, cover the enable-route heal, and update existing install tests to the entry-file contract. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...rkflow-graph-editor-and-bundled-plugins.md | 1 + .../bundled-plugin-registration-drift.md | 4 + .../cli/src/plugins/bundled-plugin-install.ts | 3 + packages/core/src/index.ts | 2 +- packages/core/src/plugin-loader.ts | 32 ++++- .../src/__tests__/plugin-routes.test.ts | 119 ++++++++++++++++-- packages/dashboard/src/plugin-routes.ts | 13 +- packages/dashboard/src/routes.ts | 27 +++- 8 files changed, 183 insertions(+), 18 deletions(-) diff --git a/.changeset/workflow-graph-editor-and-bundled-plugins.md b/.changeset/workflow-graph-editor-and-bundled-plugins.md index ebef3b8410..d8b6bb60fc 100644 --- a/.changeset/workflow-graph-editor-and-bundled-plugins.md +++ b/.changeset/workflow-graph-editor-and-bundled-plugins.md @@ -7,3 +7,4 @@ Fix the workflow graph editor opening invisibly and bundle the Compound Engineer - The "Graph editor" button now actually shows the editor: its overlay was rendered without the `open` class, leaving it `display: none`, so opening it looked like the workflow steps view was just dismissed. - `fusion-plugin-compound-engineering` and `fusion-plugin-roadmap` are now listed in the dashboard's built-in plugins, so they appear under Settings → Built-in Plugins (they were implemented and registered but missing from the list). - Installing Compound Engineering (and CLI Printing Press) from Settings → Built-in Plugins no longer fails with "Plugin manifest not found": both ids are now in the dashboard's bundled-plugin fallback set, and the Compound Engineering plugin is staged into `dist/plugins/` so packaged installs can resolve it. +- Plugins installed from Settings now load instead of erroring with "Plugin entry must be a file, got directory": the dashboard install routes register the plugin's loadable entry file (`bundled.js`/`dist/index.js`/`src/index.ts`) rather than the package directory, and enabling a plugin heals legacy directory-path registrations in place. diff --git a/docs/solutions/integration-issues/bundled-plugin-registration-drift.md b/docs/solutions/integration-issues/bundled-plugin-registration-drift.md index b9b485a893..a7973caad5 100644 --- a/docs/solutions/integration-issues/bundled-plugin-registration-drift.md +++ b/docs/solutions/integration-issues/bundled-plugin-registration-drift.md @@ -63,6 +63,10 @@ await bundlePluginEntry({ }); ``` +## Follow-up failure: directory registered as plugin path + +Fixing the fallback surfaced a second, independent bug: both dashboard install routes registered the **manifest directory** as the plugin path, but since FN-4128 the loader requires a loadable entry **file** (Node ESM cannot import directories) — enable then failed with `Plugin entry must be a file, got directory: `. Only the CLI startup path had been migrated to `resolvePluginEntryPath` (`bundled.js` → `dist/index.js` → `src/index.ts`), which is why CLI-auto-installed plugins worked and Settings-installed ones never did. Fix: the install routes now resolve and register the entry file (helper added to `@fusion/core`), and the enable route heals legacy directory-path rows in place — mirroring the CLI's startup heal. + ## Why This Works The Settings card sends a relative `./plugins/` path. The server resolves it against `process.cwd()` — normally the user's project dir, not the Fusion repo — so it 404s and falls back to `extractBundledPluginId()`, which only recognizes ids in routes.ts's `BUNDLED_PLUGIN_IDS`. Adding the id makes the fallback resolve the staged bundled copy; the tsup staging block guarantees that copy exists in packaged installs. diff --git a/packages/cli/src/plugins/bundled-plugin-install.ts b/packages/cli/src/plugins/bundled-plugin-install.ts index 7ddf24a102..6f5beede25 100644 --- a/packages/cli/src/plugins/bundled-plugin-install.ts +++ b/packages/cli/src/plugins/bundled-plugin-install.ts @@ -78,6 +78,9 @@ function resolveBundledPluginDir(pluginId: string): string | null { * Returns null when the directory exists but none of the loadable entry files * are present. Callers must treat that as a missing bundle rather than * persisting a directory path that Node cannot import. + * + * Keep in sync with resolvePluginEntryPath in @fusion/core (plugin-loader.ts), + * which the dashboard install/enable routes use for the same contract. */ export function resolvePluginEntryPath(pluginDir: string): string | null { const candidates = [ diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index dc731acb8f..7d89c6fa08 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -587,7 +587,7 @@ export type { 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"; +export { PluginLoader, resolvePluginEntryPath } from "./plugin-loader.js"; export { scanPluginSecurity } from "./plugin-security-scan.js"; export type { PluginSecurityScanResult, PluginSecurityFinding } from "./plugin-security-scan.js"; export type { diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index 4d06bb6532..c9a5b28472 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -9,7 +9,8 @@ * - Error isolation (plugin crashes don't crash the loader) */ -import { basename, dirname, extname, isAbsolute, resolve } from "node:path"; +import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path"; +import { existsSync } from "node:fs"; import { stat } from "node:fs/promises"; import { copyFile } from "node:fs/promises"; import { pathToFileURL } from "node:url"; @@ -47,6 +48,35 @@ import { scanPluginSecurity } from "./plugin-security-scan.js"; const MINIMUM_FUSION_VERSION = "0.1.0"; let moduleImportVersion = 0; +/** + * Resolve the actual loadable entry FILE path for a plugin directory. Node ESM + * does not allow directory imports, so the registered plugin path must be the + * explicit file the loader will dynamic-import. Preference order: + * 1. ./bundled.js (esbuild-bundled, shipped in npm tarball) + * 2. ./dist/index.js (legacy prebuilt fallback) + * 3. ./src/index.ts (workspace/dev fallback when no bundle exists) + * + * Returns null when the directory exists but none of the loadable entry files + * are present. Callers must treat that as a missing/unloadable plugin rather + * than persisting a directory path that Node cannot import. + * + * Keep in sync with resolvePluginEntryPath in the CLI's + * bundled-plugin-install.ts, which keeps a local copy so its fs mocks work. + */ +export function resolvePluginEntryPath(pluginDir: string): string | null { + const candidates = [ + join(pluginDir, "bundled.js"), + join(pluginDir, "dist", "index.js"), + join(pluginDir, "src", "index.ts"), + ]; + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate; + } + } + return null; +} + export interface PluginLoaderOptions { /** Plugin store for persistence */ pluginStore: PluginStore; diff --git a/packages/dashboard/src/__tests__/plugin-routes.test.ts b/packages/dashboard/src/__tests__/plugin-routes.test.ts index 62dce6a2f6..b6e7f54eb2 100644 --- a/packages/dashboard/src/__tests__/plugin-routes.test.ts +++ b/packages/dashboard/src/__tests__/plugin-routes.test.ts @@ -280,6 +280,9 @@ describe("POST /api/plugins mode:install — package root path", () => { beforeEach(() => { vi.clearAllMocks(); + // Install now registers the loadable entry file; pretend each + // package ships an esbuild bundle. + mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js")); pluginStore = createMockPluginStore(); pluginLoader = createMockPluginLoader(); store = createMockTaskStore({ @@ -313,7 +316,8 @@ describe("POST /api/plugins mode:install — package root path", () => { expect(pluginStore.registerPlugin).toHaveBeenCalledWith( expect.objectContaining({ manifest: expect.objectContaining({ id: "my-plugin" }), - path: pkgRoot, + // Registered path is the loadable entry file inside the package root + path: `${pkgRoot}/bundled.js`, }), ); }); @@ -338,7 +342,7 @@ describe("POST /api/plugins mode:install — package root path", () => { expect(res.status).toBe(201); expect(res.body).toMatchObject({ id: "my-plugin" }); expect(pluginStore.registerPlugin).toHaveBeenCalledWith( - expect.objectContaining({ path: distPath }), + expect.objectContaining({ path: `${distPath}/bundled.js` }), ); }); @@ -434,6 +438,7 @@ describe("POST /api/plugins central persistence integration", () => { if (p === pluginPath || p === `${pluginPath}/manifest.json`) return Promise.resolve(); return Promise.reject(new Error("not found")); }); + mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js")); mockReadFile.mockResolvedValueOnce(JSON.stringify(VALID_MANIFEST)); const app = buildRealApp(pluginStore); @@ -471,6 +476,9 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () = beforeEach(() => { vi.clearAllMocks(); + // Install now registers the loadable entry file; pretend each + // package ships an esbuild bundle. + mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js")); pluginStore = createMockPluginStore(); pluginLoader = createMockPluginLoader(); store = createMockTaskStore({ @@ -491,7 +499,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () = id: "fusion-plugin-dependency-graph", name: "Dependency Graph", }; - mockExistsSync.mockImplementation((p: string) => p.includes("fusion-plugin-dependency-graph/manifest.json")); + mockExistsSync.mockImplementation((p: string) => p.includes("fusion-plugin-dependency-graph/manifest.json") || p.endsWith("bundled.js")); mockAccess.mockImplementation((p: string) => { if (p.includes("fusion-plugin-dependency-graph")) return Promise.resolve(); return Promise.reject(new Error("not found")); @@ -523,7 +531,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () = id: "fusion-plugin-reports", name: "Reports", }; - mockExistsSync.mockImplementation((p: string) => p.includes("fusion-plugin-reports/manifest.json")); + mockExistsSync.mockImplementation((p: string) => p.includes("fusion-plugin-reports/manifest.json") || p.endsWith("bundled.js")); mockAccess.mockImplementation((p: string) => { if (p.includes("fusion-plugin-reports")) return Promise.resolve(); return Promise.reject(new Error("not found")); @@ -557,7 +565,9 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () = }; // Only the staged bundled copy under dist/plugins exists — the // cwd-relative path must miss so the bundled fallback is exercised. - mockExistsSync.mockImplementation((p: string) => p.includes("dist/plugins/fusion-plugin-compound-engineering/manifest.json")); + mockExistsSync.mockImplementation((p: string) => + p.includes("dist/plugins/fusion-plugin-compound-engineering/manifest.json") + || p.includes("dist/plugins/fusion-plugin-compound-engineering/bundled.js")); mockAccess.mockImplementation((p: string) => { if (p.includes("dist/plugins/fusion-plugin-compound-engineering")) return Promise.resolve(); return Promise.reject(new Error("not found")); @@ -575,10 +585,12 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () = }); expect(res.status).toBe(201); + // The registered path must be the loadable entry FILE, not the + // package directory — the loader rejects directory imports. expect(pluginStore.registerPlugin).toHaveBeenCalledWith( expect.objectContaining({ manifest: expect.objectContaining({ id: "fusion-plugin-compound-engineering" }), - path: expect.stringContaining("fusion-plugin-compound-engineering"), + path: expect.stringMatching(/fusion-plugin-compound-engineering[\\/]bundled\.js$/), }), ); }); @@ -591,7 +603,9 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () = }; // Only the staged bundled copy under dist/plugins exists — the // cwd-relative path must miss so the bundled fallback is exercised. - mockExistsSync.mockImplementation((p: string) => p.includes("dist/plugins/fusion-plugin-cli-printing-press/manifest.json")); + mockExistsSync.mockImplementation((p: string) => + p.includes("dist/plugins/fusion-plugin-cli-printing-press/manifest.json") + || p.includes("dist/plugins/fusion-plugin-cli-printing-press/bundled.js")); mockAccess.mockImplementation((p: string) => { if (p.includes("dist/plugins/fusion-plugin-cli-printing-press")) return Promise.resolve(); return Promise.reject(new Error("not found")); @@ -612,7 +626,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () = expect(pluginStore.registerPlugin).toHaveBeenCalledWith( expect.objectContaining({ manifest: expect.objectContaining({ id: "fusion-plugin-cli-printing-press" }), - path: expect.stringContaining("fusion-plugin-cli-printing-press"), + path: expect.stringMatching(/fusion-plugin-cli-printing-press[\\/]bundled\.js$/), }), ); }); @@ -648,6 +662,64 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () = }); }); +describe("POST /api/plugins/:id/enable — legacy directory path heal", () => { + 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("re-points a directory plugin path at its loadable entry before loading", async () => { + // Legacy registration stored the package directory; the loader rejects + // directory imports, so enable must heal the path first. + const dirPath = "/home/user/plugins/my-plugin"; + mockStatSync.mockReturnValue({ isDirectory: () => true }); + mockExistsSync.mockImplementation((p: string) => p === `${dirPath}/bundled.js`); + (pluginStore.enablePlugin as ReturnType).mockResolvedValue({ + ...INSTALLED_PLUGIN, + path: dirPath, + }); + (pluginStore.updatePlugin as ReturnType).mockResolvedValue({ + ...INSTALLED_PLUGIN, + path: `${dirPath}/bundled.js`, + }); + + const res = await REQUEST(buildApp(), "POST", "/api/plugins/my-plugin/enable", {}); + + expect(res.status).toBe(200); + expect(pluginStore.updatePlugin).toHaveBeenCalledWith("my-plugin", { path: `${dirPath}/bundled.js` }); + expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("my-plugin"); + }); + + it("leaves file paths untouched on enable", async () => { + mockStatSync.mockReturnValue({ isDirectory: () => false }); + (pluginStore.enablePlugin as ReturnType).mockResolvedValue({ + ...INSTALLED_PLUGIN, + path: "/home/user/plugins/my-plugin/bundled.js", + }); + + const res = await REQUEST(buildApp(), "POST", "/api/plugins/my-plugin/enable", {}); + + expect(res.status).toBe(200); + expect(pluginStore.updatePlugin).not.toHaveBeenCalled(); + expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("my-plugin"); + }); +}); + describe("POST /api/plugins mode:install — negative paths", () => { let pluginStore: PluginStore; let pluginLoader: PluginLoader; @@ -655,6 +727,9 @@ describe("POST /api/plugins mode:install — negative paths", () => { beforeEach(() => { vi.clearAllMocks(); + // Install now registers the loadable entry file; pretend each + // package ships an esbuild bundle. + mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js")); pluginStore = createMockPluginStore(); pluginLoader = createMockPluginLoader(); store = createMockTaskStore({ @@ -835,6 +910,9 @@ describe("POST /api/plugins mode:install — manifest validation edge cases", () beforeEach(() => { vi.clearAllMocks(); + // Install now registers the loadable entry file; pretend each + // package ships an esbuild bundle. + mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js")); pluginStore = createMockPluginStore(); pluginLoader = createMockPluginLoader(); store = createMockTaskStore({ @@ -921,6 +999,9 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", () beforeEach(() => { vi.clearAllMocks(); + // Install now registers the loadable entry file; pretend each + // package ships an esbuild bundle. + mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js")); pluginStore = createMockPluginStore({ registerPlugin: vi.fn().mockResolvedValue(INSTALLED_PLUGIN), }); @@ -954,7 +1035,7 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", () expect(res.status).toBe(201); expect(pluginStore.registerPlugin).toHaveBeenCalledWith( - expect.objectContaining({ path: parentPath }), + expect.objectContaining({ path: `${parentPath}/bundled.js` }), ); }); @@ -974,7 +1055,7 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", () expect(res.status).toBe(201); expect(pluginStore.registerPlugin).toHaveBeenCalledWith( - expect.objectContaining({ path: parentPath }), + expect.objectContaining({ path: `${parentPath}/bundled.js` }), ); }); @@ -994,7 +1075,7 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", () expect(res.status).toBe(201); expect(pluginStore.registerPlugin).toHaveBeenCalledWith( - expect.objectContaining({ path: parentPath }), + expect.objectContaining({ path: `${parentPath}/bundled.js` }), ); }); @@ -1032,9 +1113,9 @@ describe("POST /api/plugins mode:install — dist-folder parent resolution", () }); expect(res.status).toBe(201); - // Should use the dist dir path since it has its own manifest + // Should use the dist dir entry since it has its own manifest expect(pluginStore.registerPlugin).toHaveBeenCalledWith( - expect.objectContaining({ path: distPath }), + expect.objectContaining({ path: `${distPath}/bundled.js` }), ); }); }); @@ -1049,6 +1130,9 @@ describe("GET /api/plugins/dashboard-views", () => { beforeEach(() => { vi.clearAllMocks(); + // Install now registers the loadable entry file; pretend each + // package ships an esbuild bundle. + mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js")); pluginStore = createMockPluginStore(); pluginLoader = createMockPluginLoader(); store = createMockTaskStore({ @@ -1166,6 +1250,9 @@ describe("GET /api/plugins/ui-slots", () => { beforeEach(() => { vi.clearAllMocks(); + // Install now registers the loadable entry file; pretend each + // package ships an esbuild bundle. + mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js")); pluginStore = createMockPluginStore(); pluginLoader = createMockPluginLoader(); store = createMockTaskStore({ @@ -1335,6 +1422,9 @@ describe("GET /api/plugins/ui-contributions", () => { beforeEach(() => { vi.clearAllMocks(); + // Install now registers the loadable entry file; pretend each + // package ships an esbuild bundle. + mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js")); pluginStore = createMockPluginStore(); pluginLoader = createMockPluginLoader(); store = createMockTaskStore({ @@ -1842,6 +1932,9 @@ describe("GET /api/plugins/runtimes", () => { beforeEach(() => { vi.clearAllMocks(); + // Install now registers the loadable entry file; pretend each + // package ships an esbuild bundle. + mockExistsSync.mockImplementation((p: string) => p.endsWith("bundled.js")); pluginStore = createMockPluginStore(); pluginLoader = createMockPluginLoader(); store = createMockTaskStore({ diff --git a/packages/dashboard/src/plugin-routes.ts b/packages/dashboard/src/plugin-routes.ts index d12cf471d8..883498d78a 100644 --- a/packages/dashboard/src/plugin-routes.ts +++ b/packages/dashboard/src/plugin-routes.ts @@ -24,7 +24,7 @@ import type { PluginStore, PluginContext, } from "@fusion/core"; -import { validatePluginManifest } from "@fusion/core"; +import { resolvePluginEntryPath, validatePluginManifest } from "@fusion/core"; import { ApiError, badRequest, @@ -251,7 +251,16 @@ export function createPluginRouter( if (source.path) { const resolved = await resolvePluginManifest(source.path); manifest = resolved.manifest; - installPath = resolved.manifestDir; + // Register the loadable entry FILE, not the package directory — Node + // ESM cannot import directories, so the loader rejects directory paths. + const entryPath = resolvePluginEntryPath(resolved.manifestDir); + if (!entryPath) { + throw badRequest( + `Plugin at ${resolved.manifestDir} has no loadable entry file ` + + "(expected bundled.js, dist/index.js, or src/index.ts)", + ); + } + installPath = entryPath; } else if (source.package) { // npm packages not yet supported throw badRequest("Installing plugins from npm packages is not yet implemented"); diff --git a/packages/dashboard/src/routes.ts b/packages/dashboard/src/routes.ts index 57ec951ebd..47e8bff7d1 100644 --- a/packages/dashboard/src/routes.ts +++ b/packages/dashboard/src/routes.ts @@ -29,6 +29,7 @@ import { listAgentMemoryFiles, readAgentMemoryFile, resolvePlanningSettingsModel, + resolvePluginEntryPath, resolveProjectDefaultModel, resolveTitleSummarizerSettingsModel, writeAgentMemoryFile, @@ -3618,10 +3619,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout // Resolve manifest — supports package root and dist-folder selections const { manifestDir, manifest } = await resolvePluginManifest(manifestPathForInstall); + // Register the loadable entry FILE, not the package directory — Node ESM + // cannot import directories, so the loader rejects directory paths. + const entryPath = resolvePluginEntryPath(manifestDir); + if (!entryPath) { + throw badRequest( + `Plugin at ${manifestDir} has no loadable entry file ` + + "(expected bundled.js, dist/index.js, or src/index.ts)", + ); + } + try { const plugin = await pluginStore.registerPlugin({ manifest, - path: manifestDir, + path: entryPath, ...(typeof aiScanOnLoad === "boolean" ? { aiScanOnLoad } : {}), }); @@ -3668,6 +3679,20 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout let plugin = await pluginStore.enablePlugin(id); + // Heal legacy registrations that stored the package directory instead of + // a loadable entry file (Node ESM cannot import directories). Mirrors the + // CLI's startup heal in ensureBundledPluginInstalled. + try { + if (nodeFs.statSync(plugin.path).isDirectory()) { + const entryPath = resolvePluginEntryPath(plugin.path); + if (entryPath) { + plugin = await pluginStore.updatePlugin(id, { path: entryPath }); + } + } + } catch { + // Path missing or unreadable — let loadPlugin surface the real error. + } + // Start the plugin if loader is available if (options?.pluginLoader) { try { From 7dde43a63b0321961eceda35ad2abe714929d41b Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 22:10:20 -0700 Subject: [PATCH 2/5] Address PR review feedback (#1428) - Add heal block to createPluginRouter's enable handler so it matches routes.ts (directory-path registrations re-pointed at entry files) - Test the 400 "no loadable entry file" install branch - Add a real-fs drift-guard test asserting the CLI and @fusion/core copies of resolvePluginEntryPath resolve identically Co-Authored-By: Claude Opus 4.8 (1M context) --- .../resolve-plugin-entry-path-sync.test.ts | 57 +++++++++++++++++++ .../src/__tests__/plugin-routes.test.ts | 43 ++++++++++++++ packages/dashboard/src/plugin-routes.ts | 14 +++++ 3 files changed, 114 insertions(+) create mode 100644 packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts diff --git a/packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts b/packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts new file mode 100644 index 0000000000..0fa5e80258 --- /dev/null +++ b/packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts @@ -0,0 +1,57 @@ +/** + * Drift guard for the intentionally duplicated resolvePluginEntryPath. + * + * The CLI keeps a local copy in bundled-plugin-install.ts (so its fs mocks + * work in tests) while @fusion/core owns the copy used by the dashboard + * install/enable routes. This test runs both against real on-disk layouts and + * asserts identical results, so a candidate-list change applied to one copy + * but not the other fails CI instead of silently diverging. + * + * No fs mocks here on purpose — vitest module mocks don't reach the + * externalized @fusion/core import, so real temp directories are the only + * seam that exercises both implementations equally. + */ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { resolvePluginEntryPath as cliResolve } from "../bundled-plugin-install.js"; +import { resolvePluginEntryPath as coreResolve } from "@fusion/core"; + +describe("resolvePluginEntryPath: CLI copy stays in sync with @fusion/core", () => { + let dir: string; + + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), "entry-path-sync-")); + }); + + afterEach(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + function touch(relative: string) { + const full = join(dir, relative); + mkdirSync(join(full, ".."), { recursive: true }); + writeFileSync(full, "// entry\n"); + } + + const layouts: Array<{ name: string; files: string[]; expected: string | null }> = [ + { name: "bundled.js only", files: ["bundled.js"], expected: "bundled.js" }, + { name: "dist/index.js only", files: ["dist/index.js"], expected: "dist/index.js" }, + { name: "src/index.ts only", files: ["src/index.ts"], expected: "src/index.ts" }, + { name: "bundled.js preferred over src", files: ["bundled.js", "src/index.ts"], expected: "bundled.js" }, + { name: "dist preferred over src", files: ["dist/index.js", "src/index.ts"], expected: "dist/index.js" }, + { name: "all three → bundled.js", files: ["bundled.js", "dist/index.js", "src/index.ts"], expected: "bundled.js" }, + { name: "no entry files", files: ["README.md"], expected: null }, + ]; + + for (const layout of layouts) { + it(`resolves identically for: ${layout.name}`, () => { + for (const f of layout.files) touch(f); + const expected = layout.expected === null ? null : join(dir, layout.expected); + + expect(cliResolve(dir)).toBe(expected); + expect(coreResolve(dir)).toBe(expected); + }); + } +}); diff --git a/packages/dashboard/src/__tests__/plugin-routes.test.ts b/packages/dashboard/src/__tests__/plugin-routes.test.ts index b6e7f54eb2..6d09caf506 100644 --- a/packages/dashboard/src/__tests__/plugin-routes.test.ts +++ b/packages/dashboard/src/__tests__/plugin-routes.test.ts @@ -705,6 +705,29 @@ describe("POST /api/plugins/:id/enable — legacy directory path heal", () => { expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("my-plugin"); }); + it("heals directory paths in createPluginRouter's enable handler too", async () => { + const dirPath = "/home/user/plugins/my-plugin"; + mockStat.mockResolvedValue({ isDirectory: () => true }); + mockExistsSync.mockImplementation((p: string) => p === `${dirPath}/bundled.js`); + (pluginStore.enablePlugin as ReturnType).mockResolvedValue({ + ...INSTALLED_PLUGIN, + path: dirPath, + }); + (pluginStore.updatePlugin as ReturnType).mockResolvedValue({ + ...INSTALLED_PLUGIN, + path: `${dirPath}/bundled.js`, + }); + + const app = express(); + app.use(express.json()); + app.use("/api/plugins", createPluginRouter(pluginStore, pluginLoader)); + const res = await REQUEST(app, "POST", "/api/plugins/my-plugin/enable", {}); + + expect(res.status).toBe(200); + expect(pluginStore.updatePlugin).toHaveBeenCalledWith("my-plugin", { path: `${dirPath}/bundled.js` }); + expect(pluginLoader.loadPlugin).toHaveBeenCalledWith("my-plugin"); + }); + it("leaves file paths untouched on enable", async () => { mockStatSync.mockReturnValue({ isDirectory: () => false }); (pluginStore.enablePlugin as ReturnType).mockResolvedValue({ @@ -744,6 +767,26 @@ describe("POST /api/plugins mode:install — negative paths", () => { return app; } + it("returns 400 when the package has no loadable entry file", async () => { + const pkgRoot = "/home/user/plugins/my-plugin"; + mockAccess.mockImplementation((p: string) => { + if (p === pkgRoot || p === `${pkgRoot}/manifest.json`) return Promise.resolve(); + return Promise.reject(new Error("not found")); + }); + mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST)); + // Manifest resolves, but no bundled.js / dist/index.js / src/index.ts exists. + mockExistsSync.mockReturnValue(false); + + const res = await REQUEST(buildApp(), "POST", "/api/plugins", { + mode: "install", + path: pkgRoot, + }); + + expect(res.status).toBe(400); + expect(res.body.error).toContain("no loadable entry file"); + expect(pluginStore.registerPlugin).not.toHaveBeenCalled(); + }); + it("returns 404 when path does not exist", async () => { mockAccess.mockRejectedValue(new Error("not found")); diff --git a/packages/dashboard/src/plugin-routes.ts b/packages/dashboard/src/plugin-routes.ts index 883498d78a..9720c9bf84 100644 --- a/packages/dashboard/src/plugin-routes.ts +++ b/packages/dashboard/src/plugin-routes.ts @@ -307,6 +307,20 @@ export function createPluginRouter( // Enable in store let plugin = await pluginStore.enablePlugin(id); + // Heal legacy registrations that stored the package directory instead of + // a loadable entry file (Node ESM cannot import directories). Mirrors the + // heal in routes.ts's enable handler and the CLI's startup heal. + try { + if ((await stat(plugin.path)).isDirectory()) { + const entryPath = resolvePluginEntryPath(plugin.path); + if (entryPath) { + plugin = await pluginStore.updatePlugin(id, { path: entryPath }); + } + } + } catch { + // Path missing or unreadable — let loadPlugin surface the real error. + } + // Start the plugin try { await pluginLoader.loadPlugin(id); From 784021e95c2e0300000bd90f70007fe6f50880a6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 22:12:36 -0700 Subject: [PATCH 3/5] Tighten plugin install route tests to full entry-resolution contract (#1428) - Bundled fallback assertions for dependency-graph/reports now require the bundled.js entry-file suffix instead of just containing the id - Add route-level fallback cases for dist/index.js and src/index.ts Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/__tests__/plugin-routes.test.ts | 46 ++++++++++++++++++- 1 file changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/dashboard/src/__tests__/plugin-routes.test.ts b/packages/dashboard/src/__tests__/plugin-routes.test.ts index 6d09caf506..8a74f0b5f3 100644 --- a/packages/dashboard/src/__tests__/plugin-routes.test.ts +++ b/packages/dashboard/src/__tests__/plugin-routes.test.ts @@ -322,6 +322,48 @@ describe("POST /api/plugins mode:install — package root path", () => { ); }); + it("falls back to dist/index.js when no bundled.js exists", async () => { + const pkgRoot = "/home/user/plugins/my-plugin"; + mockAccess.mockImplementation((p: string) => { + if (p === pkgRoot || p === `${pkgRoot}/manifest.json`) return Promise.resolve(); + return Promise.reject(new Error("not found")); + }); + mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST)); + mockExistsSync.mockImplementation((p: string) => p === `${pkgRoot}/dist/index.js`); + (pluginStore.registerPlugin as ReturnType).mockResolvedValue(INSTALLED_PLUGIN); + + const res = await REQUEST(buildApp(), "POST", "/api/plugins", { + mode: "install", + path: pkgRoot, + }); + + expect(res.status).toBe(201); + expect(pluginStore.registerPlugin).toHaveBeenCalledWith( + expect.objectContaining({ path: `${pkgRoot}/dist/index.js` }), + ); + }); + + it("falls back to src/index.ts for workspace-dev packages without build outputs", async () => { + const pkgRoot = "/home/user/plugins/my-plugin"; + mockAccess.mockImplementation((p: string) => { + if (p === pkgRoot || p === `${pkgRoot}/manifest.json`) return Promise.resolve(); + return Promise.reject(new Error("not found")); + }); + mockReadFile.mockResolvedValue(JSON.stringify(VALID_MANIFEST)); + mockExistsSync.mockImplementation((p: string) => p === `${pkgRoot}/src/index.ts`); + (pluginStore.registerPlugin as ReturnType).mockResolvedValue(INSTALLED_PLUGIN); + + const res = await REQUEST(buildApp(), "POST", "/api/plugins", { + mode: "install", + path: pkgRoot, + }); + + expect(res.status).toBe(201); + expect(pluginStore.registerPlugin).toHaveBeenCalledWith( + expect.objectContaining({ path: `${pkgRoot}/src/index.ts` }), + ); + }); + it("accepts a dist folder path with valid manifest.json and returns 201", async () => { const distPath = "/home/user/plugins/my-plugin/dist"; mockAccess.mockImplementation((p: string) => { @@ -520,7 +562,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () = expect(pluginStore.registerPlugin).toHaveBeenCalledWith( expect.objectContaining({ manifest: expect.objectContaining({ id: "fusion-plugin-dependency-graph" }), - path: expect.stringContaining("fusion-plugin-dependency-graph"), + path: expect.stringMatching(/fusion-plugin-dependency-graph[\\/]bundled\.js$/), }), ); }); @@ -552,7 +594,7 @@ describe("POST /api/plugins mode:install — bundled plugin path fallback", () = expect(pluginStore.registerPlugin).toHaveBeenCalledWith( expect.objectContaining({ manifest: expect.objectContaining({ id: "fusion-plugin-reports" }), - path: expect.stringContaining("fusion-plugin-reports"), + path: expect.stringMatching(/fusion-plugin-reports[\\/]bundled\.js$/), }), ); }); From 3bf803e6f3997345a76133d604c1a8ca67c62a28 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 22:33:23 -0700 Subject: [PATCH 4/5] Extend plugin registration-drift learning with entry-file contract details - Expand the directory-path follow-up section: PR #1428, 400 no-entry branch, heal-on-enable in both routers - Document the vitest fs-mock vs externalized workspace-dep trap and the real-fs drift-guard test pattern - Add Plugin Entry to CONCEPTS.md Plugins cluster Co-Authored-By: Claude Opus 4.8 (1M context) --- CONCEPTS.md | 4 ++++ .../bundled-plugin-registration-drift.md | 15 ++++++++++++--- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CONCEPTS.md b/CONCEPTS.md index 7d892fe025..0a5bdce4e1 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -141,6 +141,10 @@ A plugin that ships inside the Fusion distribution itself rather than being inst *Avoid:* built-in plugin (as a distinct concept; the Settings label uses "Built-in" for the same thing) A Bundled Plugin must be registered in several independently maintained surfaces — the Settings catalog, the dashboard server's bundled-id fallback set, the CLI's startup auto-install list, and the build step that stages a loadable copy into the distribution. The surfaces do not cross-check each other: a plugin registered in some but not all appears installable yet fails to install or load, so adding one means mirroring an existing bundled plugin across every surface. + +### Plugin Entry +The single loadable file persisted as a plugin's path and dynamically imported by the loader. The contract is strict: a package directory is never a valid entry (ESM cannot import directories), so every install surface must resolve a concrete file before persisting, preferring the shipped bundle, then a prebuilt output, then raw workspace source. Legacy registrations that stored a directory are healed in place — re-pointed at a resolved entry — the next time the plugin is enabled or auto-installed. + ## Workflow columns & traits *Behind the `experimentalFeatures.workflowColumns` flag. With the flag off, the legacy fixed pipeline (the closed column enum + `VALID_TRANSITIONS`) is authoritative and unchanged.* diff --git a/docs/solutions/integration-issues/bundled-plugin-registration-drift.md b/docs/solutions/integration-issues/bundled-plugin-registration-drift.md index a7973caad5..7c8ba38f33 100644 --- a/docs/solutions/integration-issues/bundled-plugin-registration-drift.md +++ b/docs/solutions/integration-issues/bundled-plugin-registration-drift.md @@ -12,7 +12,8 @@ symptoms: root_cause: incomplete_setup resolution_type: code_fix severity: medium -tags: [plugins, bundled-plugins, settings, install, tsup, registration-drift] +last_updated: 2026-06-05 +tags: [plugins, bundled-plugins, settings, install, tsup, registration-drift, entry-file, fs-mock] --- # Bundled plugins must be registered in 4 independent places — they drift @@ -65,7 +66,11 @@ await bundlePluginEntry({ ## Follow-up failure: directory registered as plugin path -Fixing the fallback surfaced a second, independent bug: both dashboard install routes registered the **manifest directory** as the plugin path, but since FN-4128 the loader requires a loadable entry **file** (Node ESM cannot import directories) — enable then failed with `Plugin entry must be a file, got directory: `. Only the CLI startup path had been migrated to `resolvePluginEntryPath` (`bundled.js` → `dist/index.js` → `src/index.ts`), which is why CLI-auto-installed plugins worked and Settings-installed ones never did. Fix: the install routes now resolve and register the entry file (helper added to `@fusion/core`), and the enable route heals legacy directory-path rows in place — mirroring the CLI's startup heal. +Fixing the fallback surfaced a second, independent bug (fixed in PR #1428): both dashboard install routes registered the **manifest directory** as the plugin path, but since FN-4128 the loader requires a loadable entry **file** (Node ESM cannot import directories) — enable then failed with `Plugin entry must be a file, got directory: `. Only the CLI startup path had been migrated to `resolvePluginEntryPath` (`bundled.js` → `dist/index.js` → `src/index.ts`), which is why CLI-auto-installed plugins worked and Settings-installed ones never did. Fix: both install routes now resolve and register the entry file (helper added to `@fusion/core`; 400 with "no loadable entry file" when none exists), and **both** enable routes heal legacy directory-path rows in place before `loadPlugin` — mirroring the CLI's startup heal — so pre-fix broken registrations self-repair on first enable without a migration. + +### Trap: vitest fs mocks don't reach externalized workspace deps + +Moving `resolvePluginEntryPath` to `@fusion/core` and re-exporting from the CLI broke the CLI's tests: `vi.mock("node:fs")` in the CLI package does **not** intercept fs calls made inside the externalized `@fusion/core` import (vitest only inlines/mocks modules in the test package's transform graph — the dashboard package inlines core, the CLI doesn't). Resolution: the CLI keeps an intentionally duplicated local copy (its fs mocks work against it), both copies carry keep-in-sync comments, and a **real-fs drift-guard test** (`packages/cli/src/plugins/__tests__/resolve-plugin-entry-path-sync.test.ts`) imports both copies and asserts identical resolution across real temp-dir layouts — each candidate alone, precedence pairs, all three, and the no-entry → `null` case. Real directories are the only seam that exercises both implementations equally; a candidate-list change applied to one copy but not the other now fails CI. ## Why This Works @@ -75,9 +80,13 @@ The Settings card sends a relative `./plugins/` path. The server resolves it - **When adding a bundled plugin, grep for an existing one** (e.g. `rg -l "fusion-plugin-roadmap" packages/` ) and mirror every hit — that surfaces all four lists plus view registration. - Route tests must force the fallback: mock fs so the cwd-relative path **misses** and only `dist/plugins/` exists (see "installs bundled compound engineering plugin when relative path misses cwd" in `packages/dashboard/src/__tests__/plugin-routes.test.ts`). A mock that matches any path containing the plugin id tests nothing. +- **Pin assertions to the exact contract, not substring containment.** `stringContaining(pluginId)` passed for both the correct entry-file path and the buggy directory path — when a mock or matcher can satisfy both the correct and the buggy value, the test proves nothing. Route tests now assert the registered path ends in an entry-file suffix, cover the `dist/index.js` and `src/index.ts` fallbacks, and the 400 no-entry branch. +- When duplicating a helper is forced by test infrastructure (fs mocks vs externalized deps), add a real-fs drift-guard test that runs every copy against the same on-disk fixtures and asserts identical output. - Consider a future consistency test asserting every `BUILTIN_PLUGINS` UI entry with a `path` is present in both server-side `BUNDLED_PLUGIN_IDS` sets. ## Related Issues -- PR #1423 — the fix +- PR #1423 — the registration-drift fix +- PR #1428 — the entry-file/heal follow-up fix +- Issue #1096 — same Settings-install bundled-plugin failure family (missing-bundle symptom for the Paperclip runtime in global npm installs); different root cause - Commit `ff0750cd1` — added CE/Roadmap to the UI list (2 of 4 registrations) From 66f1db122b9613e87c1dca442dc08daadca95e3d Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 4 Jun 2026 23:02:58 -0700 Subject: [PATCH 5/5] Fix quality-backfill suites broken by fast-tests x workflow-columns merge race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Main went red when the fast-tests quality-backfill projects (PR #1385) landed alongside the workflow-columns stream (PR #1424) — the new test projects were written against pre-stream code: - TaskFieldsSection.css toggle knob used background: #fff, violating the theme-token assertion in AgentListModal's styling-parity test; use var(--card) per the SkillsView toggle convention - ListView.test.tsx api mock lacked fetchBoardWorkflows (TaskDetailModal now calls it on mount) - chat.test.ts and routes-agent-import.test.ts @fusion/core mocks lacked registerTraitHookImpl (engine merge-trait registers hooks at import) - auto-merge-toggle-blank.mobile and board-mobile-initial-render used vi.runAllTimers(), which never terminates now that sse-bus starts a keepalive setInterval; use vi.runOnlyPendingTimers() Both quality-backfill projects now pass fully: 7151/7151 across 414 files. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../app/components/TaskFieldsSection.css | 2 +- .../app/components/__tests__/ListView.test.tsx | 1 + .../auto-merge-toggle-blank.mobile.test.tsx | 18 +++++++++--------- .../board-mobile-initial-render.test.tsx | 8 ++++---- packages/dashboard/src/__tests__/chat.test.ts | 1 + .../src/__tests__/routes-agent-import.test.ts | 1 + 6 files changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/dashboard/app/components/TaskFieldsSection.css b/packages/dashboard/app/components/TaskFieldsSection.css index 6e0d69f31e..95181aef57 100644 --- a/packages/dashboard/app/components/TaskFieldsSection.css +++ b/packages/dashboard/app/components/TaskFieldsSection.css @@ -139,7 +139,7 @@ width: 14px; height: 14px; border-radius: 50%; - background: #fff; + background: var(--card); transition: transform 0.15s ease; } diff --git a/packages/dashboard/app/components/__tests__/ListView.test.tsx b/packages/dashboard/app/components/__tests__/ListView.test.tsx index a835b80587..c80aacc9a3 100644 --- a/packages/dashboard/app/components/__tests__/ListView.test.tsx +++ b/packages/dashboard/app/components/__tests__/ListView.test.tsx @@ -23,6 +23,7 @@ vi.mock("../../api", () => ({ fetchTaskDetail: vi.fn(), batchUpdateTaskModels: vi.fn(), fetchNodes: vi.fn().mockResolvedValue([]), + fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }), })); import { fetchTaskDetail, batchUpdateTaskModels, fetchNodes } from "../../api"; diff --git a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx index b825adf972..b639379313 100644 --- a/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/auto-merge-toggle-blank.mobile.test.tsx @@ -209,13 +209,13 @@ describe("auto-merge toggle mobile blank regression", () => { expectBoardVisible(); act(() => { - vi.runAllTimers(); + vi.runOnlyPendingTimers(); }); board.scrollLeft = 240; act(() => { visualViewport.dispatchResize(); - vi.runAllTimers(); + vi.runOnlyPendingTimers(); }); expect(board.scrollLeft).toBe(0); @@ -227,7 +227,7 @@ describe("auto-merge toggle mobile blank regression", () => { board.scrollLeft = 240; act(() => { visualViewport.dispatchResize(); - vi.runAllTimers(); + vi.runOnlyPendingTimers(); }); expectBoardVisible(); @@ -248,7 +248,7 @@ describe("auto-merge toggle mobile blank regression", () => { const board = document.querySelector("main.board") as HTMLElement; act(() => { - vi.runAllTimers(); + vi.runOnlyPendingTimers(); }); const toggle = screen.getByRole("checkbox", { name: "Auto-merge" }); @@ -260,7 +260,7 @@ describe("auto-merge toggle mobile blank regression", () => { board.scrollLeft = 180; act(() => { visualViewport.dispatchResize(); - vi.runAllTimers(); + vi.runOnlyPendingTimers(); }); expectBoardVisible(); expect(board.scrollLeft).toBe(0); @@ -270,7 +270,7 @@ describe("auto-merge toggle mobile blank regression", () => { board.scrollLeft = 180; act(() => { visualViewport.dispatchResize(); - vi.runAllTimers(); + vi.runOnlyPendingTimers(); }); expectBoardVisible(); expect(board.scrollLeft).toBe(0); @@ -296,7 +296,7 @@ describe("auto-merge toggle mobile blank regression", () => { ); act(() => { - vi.runAllTimers(); + vi.runOnlyPendingTimers(); }); expect(screen.getByTestId("task-card-FN-5936")).toHaveTextContent("true"); @@ -322,7 +322,7 @@ describe("auto-merge toggle mobile blank regression", () => { const board = document.querySelector("main.board") as HTMLElement; act(() => { - vi.runAllTimers(); + vi.runOnlyPendingTimers(); }); fireEvent.click(screen.getByRole("checkbox", { name: "Auto-merge" })); @@ -332,7 +332,7 @@ describe("auto-merge toggle mobile blank regression", () => { Object.defineProperty(pageShow, "persisted", { configurable: true, value: true }); act(() => { window.dispatchEvent(pageShow); - vi.runAllTimers(); + vi.runOnlyPendingTimers(); }); expectBoardVisible(); diff --git a/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx b/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx index 038e12dc0f..b13d9b4e6d 100644 --- a/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx +++ b/packages/dashboard/app/components/__tests__/board-mobile-initial-render.test.tsx @@ -109,7 +109,7 @@ describe("Board mobile initial render stabilization (FN-4574)", () => { board.scrollLeft = 500; act(() => { - vi.runAllTimers(); + vi.runOnlyPendingTimers(); }); expect(board.scrollLeft).toBe(0); expect(raf).toHaveBeenCalled(); @@ -130,7 +130,7 @@ describe("Board mobile initial render stabilization (FN-4574)", () => { const board = document.querySelector("main.board") as HTMLElement; act(() => { - vi.runAllTimers(); + vi.runOnlyPendingTimers(); }); expect(board.scrollLeft).toBe(0); @@ -140,7 +140,7 @@ describe("Board mobile initial render stabilization (FN-4574)", () => { window.dispatchEvent(pageShow); act(() => { - vi.runAllTimers(); + vi.runOnlyPendingTimers(); }); expect(board.scrollLeft).toBe(0); @@ -190,7 +190,7 @@ describe("Board mobile initial render stabilization (FN-4574)", () => { window.dispatchEvent(pageShow); act(() => { - vi.runAllTimers(); + vi.runOnlyPendingTimers(); }); expect(board.scrollLeft).toBe(500); expect(addEventListenerSpy).not.toHaveBeenCalledWith("pageshow", expect.any(Function)); diff --git a/packages/dashboard/src/__tests__/chat.test.ts b/packages/dashboard/src/__tests__/chat.test.ts index 1fadb23015..e4b205d143 100644 --- a/packages/dashboard/src/__tests__/chat.test.ts +++ b/packages/dashboard/src/__tests__/chat.test.ts @@ -28,6 +28,7 @@ vi.mock("@fusion/core", () => ({ summarizeTitle: vi.fn(), AgentStore: vi.fn(), ChatStore: vi.fn(), + registerTraitHookImpl: vi.fn(), })); describe("resolveFileReferences", () => { diff --git a/packages/dashboard/src/__tests__/routes-agent-import.test.ts b/packages/dashboard/src/__tests__/routes-agent-import.test.ts index 82f9997ff4..5df5cb43a2 100644 --- a/packages/dashboard/src/__tests__/routes-agent-import.test.ts +++ b/packages/dashboard/src/__tests__/routes-agent-import.test.ts @@ -75,6 +75,7 @@ vi.mock("@fusion/core", () => { isEphemeralAgent: (agent: { metadata?: Record }) => agent?.metadata?.agentKind === "task-worker", deterministicGuardLocks: new Map(), + registerTraitHookImpl: () => {}, }; });