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:
@@ -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", () => {
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user