diff --git a/packages/core/src/__tests__/db.test.ts b/packages/core/src/__tests__/db.test.ts index b0980bb51..bc1df4275 100644 --- a/packages/core/src/__tests__/db.test.ts +++ b/packages/core/src/__tests__/db.test.ts @@ -7,6 +7,7 @@ import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; import { rm } from "node:fs/promises"; +import { ensureRoadmapSchema } from "../../../../plugins/fusion-plugin-roadmap/src/roadmap-schema.js"; function makeTmpDir(): string { return mkdtempSync(join(tmpdir(), "kb-db-test-")); @@ -553,6 +554,32 @@ describe("Database", () => { await expect(db.runPluginSchemaInits(hooks)).resolves.toBeUndefined(); await expect(db.runPluginSchemaInits(hooks)).resolves.toBeUndefined(); }); + + it("executes roadmap plugin schema hook to create roadmap-owned tables and indexes", async () => { + await db.runPluginSchemaInits([ + { + pluginId: "roadmap-planner", + hook: ensureRoadmapSchema, + }, + ]); + + const roadmapTables = db + .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('roadmaps', 'roadmap_milestones', 'roadmap_features') ORDER BY name") + .all() as Array<{ name: string }>; + expect(roadmapTables.map((table) => table.name)).toEqual([ + "roadmap_features", + "roadmap_milestones", + "roadmaps", + ]); + + const roadmapIndexes = db + .prepare("SELECT name FROM sqlite_master WHERE type='index' AND name IN ('idxRoadmapMilestonesRoadmapOrder', 'idxRoadmapFeaturesMilestoneOrder') ORDER BY name") + .all() as Array<{ name: string }>; + expect(roadmapIndexes.map((index) => index.name)).toEqual([ + "idxRoadmapFeaturesMilestoneOrder", + "idxRoadmapMilestonesRoadmapOrder", + ]); + }); }); describe("foreign key cascade", () => { diff --git a/packages/core/src/__tests__/plugin-types.test.ts b/packages/core/src/__tests__/plugin-types.test.ts index c3953bda9..5a21fb9c7 100644 --- a/packages/core/src/__tests__/plugin-types.test.ts +++ b/packages/core/src/__tests__/plugin-types.test.ts @@ -1404,6 +1404,47 @@ describe("validatePluginManifest contribution metadata", () => { expect(result.errors).toContain("setup.binaryName is required and must be a non-empty string"); expect(result.errors).toContain("setup.description is required and must be a non-empty string"); }); + + it("accepts valid dashboardViews metadata", () => { + const result = validatePluginManifest({ + id: "plugin-a", + name: "Plugin A", + version: "1.0.0", + dashboardViews: [ + { + viewId: "roadmaps", + label: "Roadmaps", + componentPath: "./dashboard-view", + placement: "primary", + }, + ], + }); + + expect(result.valid).toBe(true); + expect(result.errors).toEqual([]); + }); + + it("rejects dashboardViews entries with malformed fields", () => { + const result = validatePluginManifest({ + id: "plugin-a", + name: "Plugin A", + version: "1.0.0", + dashboardViews: [ + { + viewId: "Roadmaps", + label: "", + componentPath: "", + placement: "sidebar", + }, + ], + }); + + expect(result.valid).toBe(false); + expect(result.errors).toContain("dashboardViews[0].viewId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)"); + expect(result.errors).toContain("dashboardViews[0].label is required and must be a non-empty string"); + expect(result.errors).toContain("dashboardViews[0].componentPath is required and must be a non-empty string"); + expect(result.errors).toContain("dashboardViews[0].placement must be one of: primary, overflow, more"); + }); }); describe("CreateAiSession types", () => { diff --git a/packages/core/src/plugin-types.ts b/packages/core/src/plugin-types.ts index cf4f38c66..6423eb4e3 100644 --- a/packages/core/src/plugin-types.ts +++ b/packages/core/src/plugin-types.ts @@ -873,6 +873,47 @@ export function validatePluginManifest(manifest: unknown): { valid: boolean; err } } + // Optional: top-level dashboard view metadata + if (m.dashboardViews !== undefined) { + if (!Array.isArray(m.dashboardViews)) { + errors.push("dashboardViews must be an array"); + } else { + for (const [index, view] of m.dashboardViews.entries()) { + if (!view || typeof view !== "object") { + errors.push(`dashboardViews[${index}] must be an object`); + continue; + } + + const dashboardView = view as Record; + + if (!dashboardView.viewId || typeof dashboardView.viewId !== "string" || dashboardView.viewId.trim() === "") { + errors.push(`dashboardViews[${index}].viewId is required and must be a non-empty string`); + } else if (!SLUG_PATTERN.test(dashboardView.viewId)) { + errors.push(`dashboardViews[${index}].viewId must be a valid slug (lowercase, alphanumeric, hyphens only, cannot start or end with hyphen)`); + } + + if (!dashboardView.label || typeof dashboardView.label !== "string" || dashboardView.label.trim() === "") { + errors.push(`dashboardViews[${index}].label is required and must be a non-empty string`); + } + + if ( + !dashboardView.componentPath + || typeof dashboardView.componentPath !== "string" + || dashboardView.componentPath.trim() === "" + ) { + errors.push(`dashboardViews[${index}].componentPath is required and must be a non-empty string`); + } + + if ( + dashboardView.placement !== undefined + && (typeof dashboardView.placement !== "string" || !["primary", "overflow", "more"].includes(dashboardView.placement)) + ) { + errors.push(`dashboardViews[${index}].placement must be one of: primary, overflow, more`); + } + } + } + } + // Optional: setup manifest metadata if (m.setup !== undefined) { if (typeof m.setup !== "object" || m.setup === null) { diff --git a/packages/dashboard/app/__tests__/api-tasks.test.ts b/packages/dashboard/app/__tests__/api-tasks.test.ts index 6e6b6211f..9d3e53459 100644 --- a/packages/dashboard/app/__tests__/api-tasks.test.ts +++ b/packages/dashboard/app/__tests__/api-tasks.test.ts @@ -71,6 +71,8 @@ import { fetchAgentRunTimeline, streamChatResponse, fetchMemoryBackendStatus, + fetchPluginDashboardViews, + fetchPluginUiSlots, type ProjectInfo, type ProjectHealth, type ActivityFeedEntry, @@ -781,6 +783,51 @@ describe("task comments api", () => { }); }); +describe("plugin dashboard view API wrappers", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + it("fetchPluginDashboardViews calls /api/plugins/dashboard-views", async () => { + globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, [ + { + pluginId: "roadmap-planner", + view: { viewId: "roadmaps", label: "Roadmaps", componentPath: "./dashboard-view" }, + }, + ])); + + const result = await fetchPluginDashboardViews("project-a"); + + expect(result).toHaveLength(1); + expect(globalThis.fetch).toHaveBeenCalledWith("/api/plugins/dashboard-views?projectId=project-a", { + headers: { "Content-Type": "application/json" }, + }); + }); + + it("fetchPluginUiSlots calls /api/plugins/ui-slots and keeps slot shape", async () => { + globalThis.fetch = vi.fn().mockReturnValue(mockFetchResponse(true, [ + { + pluginId: "roadmap-planner", + slot: { + slotId: "task-detail-tab", + label: "Roadmap Details", + componentPath: "./task-detail.js", + }, + }, + ])); + + const result = await fetchPluginUiSlots("project-a"); + + expect(result).toHaveLength(1); + expect(result[0]).toHaveProperty("slot"); + expect(globalThis.fetch).toHaveBeenCalledWith("/api/plugins/ui-slots?projectId=project-a", { + headers: { "Content-Type": "application/json" }, + }); + }); +}); + describe("fetchModels", () => { const originalFetch = globalThis.fetch; diff --git a/packages/dashboard/app/plugins/__tests__/pluginViewRegistry.test.tsx b/packages/dashboard/app/plugins/__tests__/pluginViewRegistry.test.tsx index 50c121638..a484a4a29 100644 --- a/packages/dashboard/app/plugins/__tests__/pluginViewRegistry.test.tsx +++ b/packages/dashboard/app/plugins/__tests__/pluginViewRegistry.test.tsx @@ -18,6 +18,7 @@ describe("pluginViewRegistry", () => { it("builds plugin IDs", () => { expect(getPluginViewId("plugin-a", "main")).toBe("plugin:plugin-a:main"); + expect(getPluginViewId("roadmap-planner", "roadmaps")).toBe("plugin:roadmap-planner:roadmaps"); }); it("parses and validates plugin IDs", () => { @@ -68,6 +69,16 @@ describe("pluginViewRegistry", () => { expect(await screen.findByText("proj-1")).toBeInTheDocument(); }); + it("resolves roadmap-planner registry entry and avoids unavailable fallback", async () => { + const RoadmapsView = lazy(async () => ({ default: () =>
Roadmaps Plugin View
})); + registerPluginView("roadmap-planner", "roadmaps", RoadmapsView); + + render(<>{PluginDashboardViewHost({ viewId: "plugin:roadmap-planner:roadmaps" })}); + + expect(await screen.findByText("Roadmaps Plugin View")).toBeInTheDocument(); + expect(screen.queryByTestId("plugin-view-unavailable")).toBeNull(); + }); + it("renders unavailable fallback for unregistered views", () => { render(<>{PluginDashboardViewHost({ viewId: "plugin:plugin-a:missing" })}); expect(screen.getByTestId("plugin-view-unavailable")).toBeInTheDocument(); diff --git a/packages/dashboard/src/__tests__/plugin-routes.test.ts b/packages/dashboard/src/__tests__/plugin-routes.test.ts index 7a2e36307..aee6ae99c 100644 --- a/packages/dashboard/src/__tests__/plugin-routes.test.ts +++ b/packages/dashboard/src/__tests__/plugin-routes.test.ts @@ -101,6 +101,20 @@ function createMockPluginLoader(overrides: Partial = {}): PluginLo getPluginUiContributions: vi.fn().mockReturnValue([]), getPluginRuntimes: vi.fn().mockReturnValue([]), getPluginDashboardViews: vi.fn().mockReturnValue([]), + createRouteContext: vi.fn(async (pluginId: string, overrides?: { taskStore?: TaskStore; settings?: Record; resolveProjectTaskStore?: (projectId: string) => Promise }) => ({ + pluginId, + taskStore: overrides?.taskStore ?? createMockTaskStore(), + settings: overrides?.settings ?? {}, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + emitEvent: vi.fn(), + createAiSession: await fusionCore.getCreateAiSessionFactory(), + resolveProjectTaskStore: overrides?.resolveProjectTaskStore, + })), loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }), stopAllPlugins: vi.fn().mockResolvedValue(undefined), invokeHook: vi.fn().mockResolvedValue(undefined), @@ -935,6 +949,39 @@ describe("GET /api/plugins/dashboard-views", () => { view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" }, }); }); + + it("keeps dashboard-views payload separate from ui-slots payload", async () => { + (pluginLoader.getPluginDashboardViews as ReturnType).mockReturnValue([ + { + pluginId: "roadmap-planner", + view: { + viewId: "roadmaps", + label: "Roadmaps", + componentPath: "./dashboard-view", + }, + }, + ]); + (pluginLoader.getPluginUiSlots as ReturnType).mockReturnValue([ + { + pluginId: "roadmap-planner", + slot: { + slotId: "task-detail-tab", + label: "Roadmap Details", + componentPath: "./task-detail.js", + }, + }, + ]); + + const viewsRes = await performGet(buildApp(), "/api/plugins/dashboard-views"); + const slotsRes = await performGet(buildApp(), "/api/plugins/ui-slots"); + + expect(viewsRes.status).toBe(200); + expect(slotsRes.status).toBe(200); + expect(viewsRes.body[0]).toHaveProperty("view"); + expect(viewsRes.body[0]).not.toHaveProperty("slot"); + expect(slotsRes.body[0]).toHaveProperty("slot"); + expect(slotsRes.body[0]).not.toHaveProperty("view"); + }); }); describe("GET /api/plugins/ui-slots", () => { diff --git a/plugins/fusion-plugin-roadmap/README.md b/plugins/fusion-plugin-roadmap/README.md index da7888559..1859f92fb 100644 --- a/plugins/fusion-plugin-roadmap/README.md +++ b/plugins/fusion-plugin-roadmap/README.md @@ -34,8 +34,9 @@ Roadmap behavior regression tests live in this plugin package and should stay he - `src/store/__tests__/roadmap-store.test.ts` - `src/store/__tests__/roadmap-ordering.test.ts` - `src/store/__tests__/roadmap-handoff.test.ts` +- `src/__tests__/index.test.ts` *(plugin contract: `hooks.onSchemaInit`, dashboard view metadata registration)* - `src/__tests__/roadmap-routes.test.ts` -- `src/__tests__/roadmap-suggestions.test.ts` +- `src/__tests__/roadmap-suggestions.test.ts` *(AI suggestion flow uses injected `PluginContext.createAiSession()` and session lifecycle handling)* - `src/__tests__/api-client.test.ts` - `src/dashboard/__tests__/useRoadmaps.test.ts` - `src/dashboard/__tests__/RoadmapsView.test.tsx` @@ -47,6 +48,20 @@ Prefer canonical package exports in tests: Use deep source imports only when no package export exists for the target module. +## Host vs plugin capability boundaries + +Plugin-owned responsibilities: + +- Define roadmap schema DDL in `src/roadmap-schema.ts` and register it via `hooks.onSchemaInit` in `src/index.ts`. +- Implement roadmap AI suggestion behavior through the injected `PluginContext.createAiSession()` seam. +- Declare plugin dashboard view metadata (`dashboardViews`) and export the real view entrypoint (`./dashboard-view`). + +Host-owned responsibilities: + +- Execute plugin schema hooks during DB startup and expose resulting tables/indexes to plugin routes. +- Inject `createAiSession()` into plugin runtime/route context. +- Discover plugin dashboard views via `/api/plugins/dashboard-views` and resolve plugin view IDs (for roadmap: `plugin:roadmap-planner:roadmaps`) through the host view registry. + ## Notes Roadmap tables are plugin-owned and created via `hooks.onSchemaInit` in `src/index.ts`, which delegates to `src/roadmap-schema.ts`. Core database bootstrap no longer creates roadmap tables/indexes.