feat(FN-3168): add plugin routes wiring and listTasksModifiedSince contract

This merge adds plugin routes wiring to the dashboard with a new `listTasksModifiedSince` task store contract (FN-3796/FN-3798), stabilizes flaky tests for initial-open scroll behavior and favorites rollback timing, and removes the roadmap backend in favor of a reports plugin scaffold (FN-3824); cov

Fusion-Task-Id: FN-3168
This commit is contained in:
Fusion
2026-05-09 01:51:13 -07:00
committed by gsxdsm
parent 2dcfb19b35
commit cd97d08372
7 changed files with 230 additions and 1 deletions

View File

@@ -7,6 +7,7 @@ import { join, dirname } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { rm } from "node:fs/promises"; import { rm } from "node:fs/promises";
import { ensureRoadmapSchema } from "../../../../plugins/fusion-plugin-roadmap/src/roadmap-schema.js";
function makeTmpDir(): string { function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-db-test-")); 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();
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", () => { describe("foreign key cascade", () => {

View File

@@ -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.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"); 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", () => { describe("CreateAiSession types", () => {

View File

@@ -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<string, unknown>;
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 // Optional: setup manifest metadata
if (m.setup !== undefined) { if (m.setup !== undefined) {
if (typeof m.setup !== "object" || m.setup === null) { if (typeof m.setup !== "object" || m.setup === null) {

View File

@@ -71,6 +71,8 @@ import {
fetchAgentRunTimeline, fetchAgentRunTimeline,
streamChatResponse, streamChatResponse,
fetchMemoryBackendStatus, fetchMemoryBackendStatus,
fetchPluginDashboardViews,
fetchPluginUiSlots,
type ProjectInfo, type ProjectInfo,
type ProjectHealth, type ProjectHealth,
type ActivityFeedEntry, 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", () => { describe("fetchModels", () => {
const originalFetch = globalThis.fetch; const originalFetch = globalThis.fetch;

View File

@@ -18,6 +18,7 @@ describe("pluginViewRegistry", () => {
it("builds plugin IDs", () => { it("builds plugin IDs", () => {
expect(getPluginViewId("plugin-a", "main")).toBe("plugin:plugin-a:main"); 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", () => { it("parses and validates plugin IDs", () => {
@@ -68,6 +69,16 @@ describe("pluginViewRegistry", () => {
expect(await screen.findByText("proj-1")).toBeInTheDocument(); expect(await screen.findByText("proj-1")).toBeInTheDocument();
}); });
it("resolves roadmap-planner registry entry and avoids unavailable fallback", async () => {
const RoadmapsView = lazy(async () => ({ default: () => <div>Roadmaps Plugin View</div> }));
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", () => { it("renders unavailable fallback for unregistered views", () => {
render(<>{PluginDashboardViewHost({ viewId: "plugin:plugin-a:missing" })}</>); render(<>{PluginDashboardViewHost({ viewId: "plugin:plugin-a:missing" })}</>);
expect(screen.getByTestId("plugin-view-unavailable")).toBeInTheDocument(); expect(screen.getByTestId("plugin-view-unavailable")).toBeInTheDocument();

View File

@@ -101,6 +101,20 @@ function createMockPluginLoader(overrides: Partial<PluginLoader> = {}): PluginLo
getPluginUiContributions: vi.fn().mockReturnValue([]), getPluginUiContributions: vi.fn().mockReturnValue([]),
getPluginRuntimes: vi.fn().mockReturnValue([]), getPluginRuntimes: vi.fn().mockReturnValue([]),
getPluginDashboardViews: vi.fn().mockReturnValue([]), getPluginDashboardViews: vi.fn().mockReturnValue([]),
createRouteContext: vi.fn(async (pluginId: string, overrides?: { taskStore?: TaskStore; settings?: Record<string, unknown>; resolveProjectTaskStore?: (projectId: string) => Promise<TaskStore> }) => ({
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 }), loadAllPlugins: vi.fn().mockResolvedValue({ loaded: 0, errors: 0 }),
stopAllPlugins: vi.fn().mockResolvedValue(undefined), stopAllPlugins: vi.fn().mockResolvedValue(undefined),
invokeHook: 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" }, view: { viewId: "graph", label: "Graph", componentPath: "./Graph.js" },
}); });
}); });
it("keeps dashboard-views payload separate from ui-slots payload", async () => {
(pluginLoader.getPluginDashboardViews as ReturnType<typeof vi.fn>).mockReturnValue([
{
pluginId: "roadmap-planner",
view: {
viewId: "roadmaps",
label: "Roadmaps",
componentPath: "./dashboard-view",
},
},
]);
(pluginLoader.getPluginUiSlots as ReturnType<typeof vi.fn>).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", () => { describe("GET /api/plugins/ui-slots", () => {

View File

@@ -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-store.test.ts`
- `src/store/__tests__/roadmap-ordering.test.ts` - `src/store/__tests__/roadmap-ordering.test.ts`
- `src/store/__tests__/roadmap-handoff.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-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/__tests__/api-client.test.ts`
- `src/dashboard/__tests__/useRoadmaps.test.ts` - `src/dashboard/__tests__/useRoadmaps.test.ts`
- `src/dashboard/__tests__/RoadmapsView.test.tsx` - `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. 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 ## 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. 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.