feat(FN-3166): remove dashboard roadmap backend and add reports plugin scaf
Completes the FN-3166 multi-step removal of the legacy dashboard roadmap feature (backend routes, frontend API wrappers, nav state), along with FN-3792 deep-link URL support, and initial reports plugin scaffold with WhatsApp dedupe retention pruning. Fusion-Task-Id: FN-3166
This commit is contained in:
5
.changeset/fn-3166-removal.md
Normal file
5
.changeset/fn-3166-removal.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Remove dashboard-owned roadmap backend routing and legacy `/api/roadmaps` integration so roadmap APIs are plugin-owned under `/api/plugins/roadmap-planner/...`.
|
||||||
@@ -177,8 +177,7 @@ Concrete references:
|
|||||||
- Core schema tables include: `tasks`, `config`, `workflow_steps`, `activityLog`, `archivedTasks`, `automations`, `agents`, `agentHeartbeats`, approval tables (`approval_requests`, `approval_request_audit_events`), `task_documents`, `task_document_revisions`, mission hierarchy tables (`missions`, `milestones`, `slices`, `mission_features`, `mission_events`), plugin/routine tables (`plugins`, `routines`), roadmap tables (`roadmaps`, `roadmap_milestones`, `roadmap_features`), insight tables (`project_insights`, `project_insight_runs`), research tables (`research_runs`, `research_exports`, `research_run_events`), eval tables (`eval_runs`, `eval_task_results`, `eval_run_events`), todo tables (`todo_lists`, `todo_items`), `__meta`
|
- Core schema tables include: `tasks`, `config`, `workflow_steps`, `activityLog`, `archivedTasks`, `automations`, `agents`, `agentHeartbeats`, approval tables (`approval_requests`, `approval_request_audit_events`), `task_documents`, `task_document_revisions`, mission hierarchy tables (`missions`, `milestones`, `slices`, `mission_features`, `mission_events`), plugin/routine tables (`plugins`, `routines`), roadmap tables (`roadmaps`, `roadmap_milestones`, `roadmap_features`), insight tables (`project_insights`, `project_insight_runs`), research tables (`research_runs`, `research_exports`, `research_run_events`), eval tables (`eval_runs`, `eval_task_results`, `eval_run_events`), todo tables (`todo_lists`, `todo_items`), `__meta`
|
||||||
- Migration-created tables include: `ai_sessions`, `messages`, `agentRatings`, `chat_sessions`, `chat_messages`, `runAuditEvents`, `mission_contract_assertions`, `mission_feature_assertions`, `mission_validator_runs`, `mission_validator_failures`, `mission_fix_feature_lineage`
|
- Migration-created tables include: `ai_sessions`, `messages`, `agentRatings`, `chat_sessions`, `chat_messages`, `runAuditEvents`, `mission_contract_assertions`, `mission_feature_assertions`, `mission_validator_runs`, `mission_validator_failures`, `mission_fix_feature_lineage`
|
||||||
- `ai_sessions.status` lifecycle includes `draft` (pre-start planning session), then `generating`, `awaiting_input`, terminal `complete` / `error`
|
- `ai_sessions.status` lifecycle includes `draft` (pre-start planning session), then `generating`, `awaiting_input`, terminal `complete` / `error`
|
||||||
- **Roadmap feature ownership**: roadmap contracts, ordering/handoff helpers, and persistence live in `plugins/fusion-plugin-roadmap` (package `@fusion-plugin-examples/roadmap`) rather than `@fusion/core`
|
- **Roadmap feature ownership**: roadmap contracts, ordering/handoff helpers, persistence, routes, and dashboard UI live in `plugins/fusion-plugin-roadmap` (package `@fusion-plugin-examples/roadmap`) rather than dashboard/core ownership.
|
||||||
- Dashboard route adapter remains in `packages/dashboard/src/roadmap-routes.ts` and consumes the plugin API
|
|
||||||
- **CentralCore**: `packages/core/src/central-core.ts`
|
- **CentralCore**: `packages/core/src/central-core.ts`
|
||||||
- Global project registry, health, central activity feed, global concurrency
|
- Global project registry, health, central activity feed, global concurrency
|
||||||
- Backed by `packages/core/src/central-db.ts` (`~/.fusion/fusion-central.db`)
|
- Backed by `packages/core/src/central-db.ts` (`~/.fusion/fusion-central.db`)
|
||||||
@@ -403,7 +402,7 @@ Key roadmap invariants:
|
|||||||
- Milestones: `GET /:roadmapId/milestones`, `POST /:roadmapId/milestones`, `PATCH /milestones/:milestoneId`, `DELETE /milestones/:milestoneId`, `POST /:roadmapId/milestones/reorder`
|
- Milestones: `GET /:roadmapId/milestones`, `POST /:roadmapId/milestones`, `PATCH /milestones/:milestoneId`, `DELETE /milestones/:milestoneId`, `POST /:roadmapId/milestones/reorder`
|
||||||
- Features: `GET /milestones/:milestoneId/features`, `POST /milestones/:milestoneId/features`, `PATCH /features/:featureId`, `DELETE /features/:featureId`, `POST /milestones/:milestoneId/features/reorder`, `POST /features/:featureId/move`
|
- Features: `GET /milestones/:milestoneId/features`, `POST /milestones/:milestoneId/features`, `PATCH /features/:featureId`, `DELETE /features/:featureId`, `POST /milestones/:milestoneId/features/reorder`, `POST /features/:featureId/move`
|
||||||
- Export/Handoff: `GET /:roadmapId/export`, `GET /:roadmapId/handoff`, `GET /:roadmapId/handoff/mission`, `GET /:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task`
|
- Export/Handoff: `GET /:roadmapId/export`, `GET /:roadmapId/handoff`, `GET /:roadmapId/handoff/mission`, `GET /:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task`
|
||||||
- Note: the integrated dashboard server may still mount legacy `/api/roadmaps` routes for compatibility, but frontend callers should use the plugin namespace.
|
- Dashboard host no longer mounts legacy `/api/roadmaps`; roadmap REST traffic goes through the plugin namespace only.
|
||||||
|
|
||||||
**Database schema:**
|
**Database schema:**
|
||||||
- `roadmaps` — roadmap metadata (id, title, description, timestamps)
|
- `roadmaps` — roadmap metadata (id, title, description, timestamps)
|
||||||
@@ -678,7 +677,7 @@ Key server capabilities:
|
|||||||
- Insights routes (`insights-routes.ts`)
|
- Insights routes (`insights-routes.ts`)
|
||||||
- Evals routes (`evals-routes.ts`) — `/api/evals` read surface for eval result listing/filtering, drill-down detail, and eval run metadata
|
- Evals routes (`evals-routes.ts`) — `/api/evals` read surface for eval result listing/filtering, drill-down detail, and eval run metadata
|
||||||
- Research routes (`research-routes.ts`) — `/api/research` surface for runs, details, cancel/retry, exports, create-task, and attach-task actions; supports graceful degradation envelopes via availability payloads when capabilities are unavailable
|
- Research routes (`research-routes.ts`) — `/api/research` surface for runs, details, cancel/retry, exports, create-task, and attach-task actions; supports graceful degradation envelopes via availability payloads when capabilities are unavailable
|
||||||
- Roadmap routes (`roadmap-routes.ts`)
|
- Plugin-defined roadmap routes under `plugin-routes.ts` dispatch (`/api/plugins/roadmap-planner/...`)
|
||||||
- Project-scoped store reuse via `project-store-resolver.ts`
|
- Project-scoped store reuse via `project-store-resolver.ts`
|
||||||
- Rate limiting (`rate-limit.ts`)
|
- Rate limiting (`rate-limit.ts`)
|
||||||
- Static SPA hosting (Vite build output)
|
- Static SPA hosting (Vite build output)
|
||||||
@@ -724,7 +723,7 @@ Key server capabilities:
|
|||||||
- Task detail surface is shared through `TaskDetailContent` (exported from `TaskDetailModal.tsx`): desktop/tablet `ListView` renders it inline in the split right pane, while mobile and non-list entry points continue using `TaskDetailModal`.
|
- Task detail surface is shared through `TaskDetailContent` (exported from `TaskDetailModal.tsx`): desktop/tablet `ListView` renders it inline in the split right pane, while mobile and non-list entry points continue using `TaskDetailModal`.
|
||||||
- In desktop split mode, `ListView` now uses a compact sidebar-first control layout (count/actions/summary chips + collapsible "View options" panel) to keep list controls dense alongside the inline detail pane; mobile keeps the card-first flow with a toolbar "View options" entry point for the same visibility/filter toggles.
|
- In desktop split mode, `ListView` now uses a compact sidebar-first control layout (count/actions/summary chips + collapsible "View options" panel) to keep list controls dense alongside the inline detail pane; mobile keeps the card-first flow with a toolbar "View options" entry point for the same visibility/filter toggles.
|
||||||
- Chat system UI: `ChatView.tsx`, `QuickChatFAB.tsx`
|
- Chat system UI: `ChatView.tsx`, `QuickChatFAB.tsx`
|
||||||
- Planning/roadmap/insight UI: `MissionManager.tsx`, `RoadmapsView.tsx`, `TodoView.tsx`, `InsightsView.tsx`, `DocumentsView.tsx`
|
- Planning/insight UI: `MissionManager.tsx`, `TodoView.tsx`, `InsightsView.tsx`, `DocumentsView.tsx` (roadmap view is plugin-owned)
|
||||||
- Dev server UI: `DevServerView.tsx` (controls + status/log panel + embedded preview with iframe fallback messaging)
|
- Dev server UI: `DevServerView.tsx` (controls + status/log panel + embedded preview with iframe fallback messaging)
|
||||||
|
|
||||||
### CSS Architecture
|
### CSS Architecture
|
||||||
@@ -742,7 +741,7 @@ The dashboard's CSS is split between a consolidated global stylesheet and modula
|
|||||||
|
|
||||||
**Lazy-loaded views** (bundle size optimization):
|
**Lazy-loaded views** (bundle size optimization):
|
||||||
The following 15 views are lazy-loaded via `React.lazy()` with `<Suspense fallback={null}>`:
|
The following 15 views are lazy-loaded via `React.lazy()` with `<Suspense fallback={null}>`:
|
||||||
- `AgentsView`, `RoadmapsView`, `TodoView`, `NodesView`, `ChatView`, `MemoryView`, `ResearchView`
|
- `AgentsView`, `TodoView`, `NodesView`, `ChatView`, `MemoryView`, `ResearchView`
|
||||||
- `DevServerView`, `InsightsView`, `DocumentsView`, `SkillsView`
|
- `DevServerView`, `InsightsView`, `DocumentsView`, `SkillsView`
|
||||||
- `SetupWizardModal`, `PluginManager`, `PiExtensionsManager`, `AgentDetailView
|
- `SetupWizardModal`, `PluginManager`, `PiExtensionsManager`, `AgentDetailView
|
||||||
|
|
||||||
@@ -752,13 +751,13 @@ A `prefetchLazyViews()` function runs once on mount via `requestIdleCallback` to
|
|||||||
- Task + realtime: `useTasks.ts`, `useBadgeWebSocket.ts`, `useAiSessionSync.ts`
|
- Task + realtime: `useTasks.ts`, `useBadgeWebSocket.ts`, `useAiSessionSync.ts`
|
||||||
- Chat: `useChat.ts`, `useQuickChat.ts`
|
- Chat: `useChat.ts`, `useQuickChat.ts`
|
||||||
- Documents/insights/memory: `useDocuments.ts`, `useInsights.ts`, `useMemoryBackendStatus.ts`, `useMemoryData.ts`
|
- Documents/insights/memory: `useDocuments.ts`, `useInsights.ts`, `useMemoryBackendStatus.ts`, `useMemoryData.ts`
|
||||||
- Planning/roadmaps: `useRoadmaps.ts`
|
- Plugin roadmap state/hooks: owned by `plugins/fusion-plugin-roadmap/src/dashboard/*`
|
||||||
- Dev server: `useDevServer.ts` (status hydration, command controls, reconnect stream handling, project-scope reset)
|
- Dev server: `useDevServer.ts` (status hydration, command controls, reconnect stream handling, project-scope reset)
|
||||||
- Project/agents/setup: `useProjects.ts`, `useCurrentProject.ts`, `useAgents.ts`, `useSetupReadiness.ts`
|
- Project/agents/setup: `useProjects.ts`, `useCurrentProject.ts`, `useAgents.ts`, `useSetupReadiness.ts`
|
||||||
- UX/platform helpers: `useFavorites.ts`, `useAuthOnboarding.ts`, `useDeepLink.ts`, `useTerminal.ts`
|
- UX/platform helpers: `useFavorites.ts`, `useAuthOnboarding.ts`, `useDeepLink.ts`, `useTerminal.ts`
|
||||||
|
|
||||||
### Planning and decomposition features
|
### Planning and decomposition features
|
||||||
- Backend planners: `planning.ts`, `subtask-breakdown.ts`, `roadmap-suggestions.ts`
|
- Backend planners: `planning.ts`, `subtask-breakdown.ts` (roadmap suggestion generation is plugin-owned)
|
||||||
- UI modals: `PlanningModeModal.tsx`, `SubtaskBreakdownModal.tsx`, milestone interview flows
|
- UI modals: `PlanningModeModal.tsx`, `SubtaskBreakdownModal.tsx`, milestone interview flows
|
||||||
- Multi-task creation endpoints are wired under planning/subtask routes in `routes.ts`
|
- Multi-task creation endpoints are wired under planning/subtask routes in `routes.ts`
|
||||||
|
|
||||||
|
|||||||
@@ -536,7 +536,6 @@ function AppInner() {
|
|||||||
settingsLoaded,
|
settingsLoaded,
|
||||||
experimentalFeatures,
|
experimentalFeatures,
|
||||||
insightsEnabled,
|
insightsEnabled,
|
||||||
roadmapEnabled,
|
|
||||||
memoryEnabled,
|
memoryEnabled,
|
||||||
devServerEnabled,
|
devServerEnabled,
|
||||||
todosEnabled,
|
todosEnabled,
|
||||||
@@ -1386,7 +1385,6 @@ function AppInner() {
|
|||||||
isRemote={isRemote}
|
isRemote={isRemote}
|
||||||
experimentalFeatures={{
|
experimentalFeatures={{
|
||||||
insights: insightsEnabled,
|
insights: insightsEnabled,
|
||||||
roadmap: roadmapEnabled,
|
|
||||||
memoryView: memoryEnabled,
|
memoryView: memoryEnabled,
|
||||||
devServer: devServerEnabled,
|
devServer: devServerEnabled,
|
||||||
devServerView: devServerEnabled,
|
devServerView: devServerEnabled,
|
||||||
@@ -1492,7 +1490,6 @@ function AppInner() {
|
|||||||
showSkillsTab={skillsEnabled}
|
showSkillsTab={skillsEnabled}
|
||||||
experimentalFeatures={{
|
experimentalFeatures={{
|
||||||
insights: insightsEnabled,
|
insights: insightsEnabled,
|
||||||
roadmap: roadmapEnabled,
|
|
||||||
memoryView: memoryEnabled,
|
memoryView: memoryEnabled,
|
||||||
devServer: devServerEnabled,
|
devServer: devServerEnabled,
|
||||||
devServerView: devServerEnabled,
|
devServerView: devServerEnabled,
|
||||||
|
|||||||
@@ -244,392 +244,6 @@ function mockSchedulingFetchResponse(
|
|||||||
} as unknown as Response);
|
} as unknown as Response);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("Roadmap API wrappers", () => {
|
|
||||||
const originalFetch = globalThis.fetch;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve([]),
|
|
||||||
text: () => Promise.resolve("[]"),
|
|
||||||
} as unknown as Response);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockRoadmap = {
|
|
||||||
id: "RM-001",
|
|
||||||
title: "Q2 Roadmap",
|
|
||||||
description: "Q2 product roadmap",
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockRoadmapHierarchy = {
|
|
||||||
...mockRoadmap,
|
|
||||||
milestones: [
|
|
||||||
{
|
|
||||||
id: "RMS-001",
|
|
||||||
roadmapId: "RM-001",
|
|
||||||
title: "Milestone 1",
|
|
||||||
description: "First milestone",
|
|
||||||
orderIndex: 0,
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
features: [
|
|
||||||
{
|
|
||||||
id: "RF-001",
|
|
||||||
milestoneId: "RMS-001",
|
|
||||||
title: "Feature 1",
|
|
||||||
description: "First feature",
|
|
||||||
orderIndex: 0,
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
it("fetchRoadmaps sends GET and propagates projectId", async () => {
|
|
||||||
const { fetchRoadmaps } = await import("../api");
|
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve([mockRoadmap]),
|
|
||||||
text: () => Promise.resolve(JSON.stringify([mockRoadmap])),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const result = await fetchRoadmaps("proj_abc");
|
|
||||||
|
|
||||||
expect(result).toHaveLength(1);
|
|
||||||
expect(result[0].id).toBe("RM-001");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("projectId=proj_abc");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("createRoadmap sends POST with input payload", async () => {
|
|
||||||
const { createRoadmap } = await import("../api");
|
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve(mockRoadmap),
|
|
||||||
text: () => Promise.resolve(JSON.stringify(mockRoadmap)),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const result = await createRoadmap({ title: "Q2 Roadmap", description: "Q2 product roadmap" }, "proj_abc");
|
|
||||||
|
|
||||||
expect(result.id).toBe("RM-001");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("projectId=proj_abc");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("POST");
|
|
||||||
const body = JSON.parse((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].body);
|
|
||||||
expect(body.title).toBe("Q2 Roadmap");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fetchRoadmap returns roadmap with hierarchy", async () => {
|
|
||||||
const { fetchRoadmap } = await import("../api");
|
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve(mockRoadmapHierarchy),
|
|
||||||
text: () => Promise.resolve(JSON.stringify(mockRoadmapHierarchy)),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const result = await fetchRoadmap("RM-001");
|
|
||||||
|
|
||||||
expect(result.id).toBe("RM-001");
|
|
||||||
expect(result.milestones).toHaveLength(1);
|
|
||||||
expect(result.milestones[0].features).toHaveLength(1);
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updateRoadmap sends PATCH with updates", async () => {
|
|
||||||
const { updateRoadmap } = await import("../api");
|
|
||||||
|
|
||||||
const updatedRoadmap = { ...mockRoadmap, title: "Updated Roadmap" };
|
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve(updatedRoadmap),
|
|
||||||
text: () => Promise.resolve(JSON.stringify(updatedRoadmap)),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const result = await updateRoadmap("RM-001", { title: "Updated Roadmap" });
|
|
||||||
|
|
||||||
expect(result.title).toBe("Updated Roadmap");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("PATCH");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deleteRoadmap sends DELETE and returns void", async () => {
|
|
||||||
const { deleteRoadmap } = await import("../api");
|
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 204,
|
|
||||||
statusText: "No Content",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? null : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve(undefined),
|
|
||||||
text: () => Promise.resolve(""),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const result = await deleteRoadmap("RM-001");
|
|
||||||
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("DELETE");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("createRoadmapMilestone sends POST with milestone input", async () => {
|
|
||||||
const { createRoadmapMilestone } = await import("../api");
|
|
||||||
|
|
||||||
const mockMilestone = {
|
|
||||||
id: "RMS-001",
|
|
||||||
roadmapId: "RM-001",
|
|
||||||
title: "Milestone 1",
|
|
||||||
orderIndex: 0,
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve(mockMilestone),
|
|
||||||
text: () => Promise.resolve(JSON.stringify(mockMilestone)),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const result = await createRoadmapMilestone("RM-001", { title: "Milestone 1" });
|
|
||||||
|
|
||||||
expect(result.id).toBe("RMS-001");
|
|
||||||
expect(result.roadmapId).toBe("RM-001");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001/milestones");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("POST");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updateRoadmapMilestone sends PATCH", async () => {
|
|
||||||
const { updateRoadmapMilestone } = await import("../api");
|
|
||||||
|
|
||||||
const updatedMilestone = {
|
|
||||||
id: "RMS-001",
|
|
||||||
roadmapId: "RM-001",
|
|
||||||
title: "Updated Milestone",
|
|
||||||
orderIndex: 0,
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve(updatedMilestone),
|
|
||||||
text: () => Promise.resolve(JSON.stringify(updatedMilestone)),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const result = await updateRoadmapMilestone("RMS-001", { title: "Updated Milestone" });
|
|
||||||
|
|
||||||
expect(result.title).toBe("Updated Milestone");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("PATCH");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deleteRoadmapMilestone sends DELETE", async () => {
|
|
||||||
const { deleteRoadmapMilestone } = await import("../api");
|
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 204,
|
|
||||||
statusText: "No Content",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? null : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve(undefined),
|
|
||||||
text: () => Promise.resolve(""),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const result = await deleteRoadmapMilestone("RMS-001");
|
|
||||||
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("DELETE");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("createRoadmapFeature sends POST with feature input", async () => {
|
|
||||||
const { createRoadmapFeature } = await import("../api");
|
|
||||||
|
|
||||||
const mockFeature = {
|
|
||||||
id: "RF-001",
|
|
||||||
milestoneId: "RMS-001",
|
|
||||||
title: "Feature 1",
|
|
||||||
orderIndex: 0,
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve(mockFeature),
|
|
||||||
text: () => Promise.resolve(JSON.stringify(mockFeature)),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const result = await createRoadmapFeature("RMS-001", { title: "Feature 1" });
|
|
||||||
|
|
||||||
expect(result.id).toBe("RF-001");
|
|
||||||
expect(result.milestoneId).toBe("RMS-001");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001/features");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("POST");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("updateRoadmapFeature sends PATCH", async () => {
|
|
||||||
const { updateRoadmapFeature } = await import("../api");
|
|
||||||
|
|
||||||
const updatedFeature = {
|
|
||||||
id: "RF-001",
|
|
||||||
milestoneId: "RMS-001",
|
|
||||||
title: "Updated Feature",
|
|
||||||
orderIndex: 0,
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve(updatedFeature),
|
|
||||||
text: () => Promise.resolve(JSON.stringify(updatedFeature)),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const result = await updateRoadmapFeature("RF-001", { title: "Updated Feature" });
|
|
||||||
|
|
||||||
expect(result.title).toBe("Updated Feature");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/features/RF-001");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("PATCH");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("deleteRoadmapFeature sends DELETE", async () => {
|
|
||||||
const { deleteRoadmapFeature } = await import("../api");
|
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 204,
|
|
||||||
statusText: "No Content",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? null : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve(undefined),
|
|
||||||
text: () => Promise.resolve(""),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const result = await deleteRoadmapFeature("RF-001");
|
|
||||||
|
|
||||||
expect(result).toBeUndefined();
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/features/RF-001");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][1].method).toBe("DELETE");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fetchRoadmapFeatures returns features for a milestone", async () => {
|
|
||||||
const { fetchRoadmapFeatures } = await import("../api");
|
|
||||||
|
|
||||||
const mockFeatures = [
|
|
||||||
{
|
|
||||||
id: "RF-001",
|
|
||||||
milestoneId: "RMS-001",
|
|
||||||
title: "Feature 1",
|
|
||||||
orderIndex: 0,
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "RF-002",
|
|
||||||
milestoneId: "RMS-001",
|
|
||||||
title: "Feature 2",
|
|
||||||
orderIndex: 1,
|
|
||||||
createdAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve(mockFeatures),
|
|
||||||
text: () => Promise.resolve(JSON.stringify(mockFeatures)),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const result = await fetchRoadmapFeatures("RMS-001");
|
|
||||||
|
|
||||||
expect(result).toHaveLength(2);
|
|
||||||
expect(result[0].id).toBe("RF-001");
|
|
||||||
expect((globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0][0]).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001/features");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Settings API wrapper tests for FN-1712 (scope-split settings UX).
|
* Settings API wrapper tests for FN-1712 (scope-split settings UX).
|
||||||
* These tests verify the API contract for:
|
* These tests verify the API contract for:
|
||||||
@@ -936,331 +550,7 @@ describe("Settings API wrappers", () => {
|
|||||||
await expect(fetchGlobalSettings()).rejects.toThrow("Settings file corrupted");
|
await expect(fetchGlobalSettings()).rejects.toThrow("Settings file corrupted");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("roadmap reorder APIs", () => {
|
|
||||||
it("reorderRoadmapMilestones sends POST with orderedMilestoneIds", async () => {
|
|
||||||
const { reorderRoadmapMilestones } = await import("../api");
|
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 204,
|
|
||||||
statusText: "No Content",
|
|
||||||
headers: {
|
|
||||||
get: () => null,
|
|
||||||
},
|
|
||||||
text: () => Promise.resolve(""),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
await reorderRoadmapMilestones("RM-001", ["RMS-002", "RMS-001", "RMS-003"]);
|
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
||||||
const [url, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toBe("/api/plugins/roadmap-planner/roadmaps/RM-001/milestones/reorder");
|
|
||||||
expect(options.method).toBe("POST");
|
|
||||||
expect(JSON.parse(options.body as string)).toEqual({
|
|
||||||
orderedMilestoneIds: ["RMS-002", "RMS-001", "RMS-003"],
|
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it("reorderRoadmapMilestones includes projectId when provided", async () => {
|
|
||||||
const { reorderRoadmapMilestones } = await import("../api");
|
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 204,
|
|
||||||
statusText: "No Content",
|
|
||||||
headers: {
|
|
||||||
get: () => null,
|
|
||||||
},
|
|
||||||
text: () => Promise.resolve(""),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
await reorderRoadmapMilestones("RM-001", ["RMS-001", "RMS-002"], "proj_abc");
|
|
||||||
|
|
||||||
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toBe("/api/plugins/roadmap-planner/roadmaps/RM-001/milestones/reorder?projectId=proj_abc");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reorderRoadmapFeatures sends POST with orderedFeatureIds", async () => {
|
|
||||||
const { reorderRoadmapFeatures } = await import("../api");
|
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 204,
|
|
||||||
statusText: "No Content",
|
|
||||||
headers: {
|
|
||||||
get: () => null,
|
|
||||||
},
|
|
||||||
text: () => Promise.resolve(""),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
await reorderRoadmapFeatures("RMS-001", ["RF-002", "RF-001"]);
|
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
||||||
const [url, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toBe("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001/features/reorder");
|
|
||||||
expect(options.method).toBe("POST");
|
|
||||||
expect(JSON.parse(options.body as string)).toEqual({
|
|
||||||
orderedFeatureIds: ["RF-002", "RF-001"],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moveRoadmapFeature sends POST with targetMilestoneId and targetIndex", async () => {
|
|
||||||
const { moveRoadmapFeature } = await import("../api");
|
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 204,
|
|
||||||
statusText: "No Content",
|
|
||||||
headers: {
|
|
||||||
get: () => null,
|
|
||||||
},
|
|
||||||
text: () => Promise.resolve(""),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
await moveRoadmapFeature("RF-001", "RMS-002", 2);
|
|
||||||
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
||||||
const [url, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toBe("/api/plugins/roadmap-planner/roadmaps/features/RF-001/move");
|
|
||||||
expect(options.method).toBe("POST");
|
|
||||||
expect(JSON.parse(options.body as string)).toEqual({
|
|
||||||
targetMilestoneId: "RMS-002",
|
|
||||||
targetIndex: 2,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("moveRoadmapFeature includes projectId when provided", async () => {
|
|
||||||
const { moveRoadmapFeature } = await import("../api");
|
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 204,
|
|
||||||
statusText: "No Content",
|
|
||||||
headers: {
|
|
||||||
get: () => null,
|
|
||||||
},
|
|
||||||
text: () => Promise.resolve(""),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
await moveRoadmapFeature("RF-001", "RMS-002", 0, "proj_xyz");
|
|
||||||
|
|
||||||
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toBe("/api/plugins/roadmap-planner/roadmaps/features/RF-001/move?projectId=proj_xyz");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("generateFeatureSuggestions sends POST with milestone ID", async () => {
|
|
||||||
const { generateFeatureSuggestions } = await import("../api");
|
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve({ suggestions: [{ title: "Feature 1" }, { title: "Feature 2" }] }),
|
|
||||||
text: () => Promise.resolve(JSON.stringify({ suggestions: [{ title: "Feature 1" }, { title: "Feature 2" }] })),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const result = await generateFeatureSuggestions("RMS-001");
|
|
||||||
|
|
||||||
expect(result.suggestions).toHaveLength(2);
|
|
||||||
expect(result.suggestions[0].title).toBe("Feature 1");
|
|
||||||
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001/suggestions/features");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("generateFeatureSuggestions includes input parameters in body", async () => {
|
|
||||||
const { generateFeatureSuggestions } = await import("../api");
|
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve({ suggestions: [] }),
|
|
||||||
text: () => Promise.resolve(JSON.stringify({ suggestions: [] })),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
await generateFeatureSuggestions("RMS-001", { prompt: "Focus on auth", count: 3 });
|
|
||||||
|
|
||||||
const [, options] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
const body = JSON.parse((options as RequestInit).body as string);
|
|
||||||
expect(body.prompt).toBe("Focus on auth");
|
|
||||||
expect(body.count).toBe(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("generateFeatureSuggestions includes projectId when provided", async () => {
|
|
||||||
const { generateFeatureSuggestions } = await import("../api");
|
|
||||||
|
|
||||||
globalThis.fetch = vi.fn().mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve({ suggestions: [] }),
|
|
||||||
text: () => Promise.resolve(JSON.stringify({ suggestions: [] })),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
await generateFeatureSuggestions("RMS-001", undefined, "proj_abc");
|
|
||||||
|
|
||||||
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/milestones/RMS-001/suggestions/features");
|
|
||||||
expect(url).toContain("projectId=proj_abc");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("roadmap export/handoff APIs", () => {
|
|
||||||
it("exportRoadmap sends GET to export endpoint", async () => {
|
|
||||||
const { exportRoadmap } = await import("../api");
|
|
||||||
const exportData = {
|
|
||||||
roadmap: { id: "RM-001", title: "Test", createdAt: "2024-01-01", updatedAt: "2024-01-01" },
|
|
||||||
milestones: [],
|
|
||||||
features: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve(exportData),
|
|
||||||
text: () => Promise.resolve(JSON.stringify(exportData)),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const result = await exportRoadmap("RM-001");
|
|
||||||
|
|
||||||
expect(result.roadmap.id).toBe("RM-001");
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
||||||
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001/export");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("exportRoadmap includes projectId when provided", async () => {
|
|
||||||
const { exportRoadmap } = await import("../api");
|
|
||||||
const exportData = { roadmap: { id: "RM-001", title: "Test", createdAt: "2024-01-01", updatedAt: "2024-01-01" }, milestones: [], features: [] };
|
|
||||||
|
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve(exportData),
|
|
||||||
text: () => Promise.resolve(JSON.stringify(exportData)),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
await exportRoadmap("RM-001", "proj_abc");
|
|
||||||
|
|
||||||
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001/export");
|
|
||||||
expect(url).toContain("projectId=proj_abc");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getRoadmapMissionHandoff sends GET to mission handoff endpoint", async () => {
|
|
||||||
const { getRoadmapMissionHandoff } = await import("../api");
|
|
||||||
const handoffData = {
|
|
||||||
sourceRoadmapId: "RM-001",
|
|
||||||
title: "Test Roadmap",
|
|
||||||
milestones: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve(handoffData),
|
|
||||||
text: () => Promise.resolve(JSON.stringify(handoffData)),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const result = await getRoadmapMissionHandoff("RM-001");
|
|
||||||
|
|
||||||
expect(result.sourceRoadmapId).toBe("RM-001");
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
||||||
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001/handoff/mission");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getRoadmapFeatureHandoff sends GET to feature handoff endpoint", async () => {
|
|
||||||
const { getRoadmapFeatureHandoff } = await import("../api");
|
|
||||||
const handoffData = {
|
|
||||||
source: {
|
|
||||||
roadmapId: "RM-001",
|
|
||||||
milestoneId: "RMS-001",
|
|
||||||
featureId: "RF-001",
|
|
||||||
roadmapTitle: "Test",
|
|
||||||
milestoneTitle: "Phase 1",
|
|
||||||
milestoneOrderIndex: 0,
|
|
||||||
featureOrderIndex: 0,
|
|
||||||
},
|
|
||||||
title: "Feature 1",
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve(handoffData),
|
|
||||||
text: () => Promise.resolve(JSON.stringify(handoffData)),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
const result = await getRoadmapFeatureHandoff("RM-001", "RMS-001", "RF-001");
|
|
||||||
|
|
||||||
expect(result.source.featureId).toBe("RF-001");
|
|
||||||
expect(globalThis.fetch).toHaveBeenCalledTimes(1);
|
|
||||||
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001/milestones/RMS-001/features/RF-001/handoff/task");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("getRoadmapFeatureHandoff includes projectId when provided", async () => {
|
|
||||||
const { getRoadmapFeatureHandoff } = await import("../api");
|
|
||||||
const handoffData = {
|
|
||||||
source: { roadmapId: "RM-001", milestoneId: "RMS-001", featureId: "RF-001", roadmapTitle: "T", milestoneTitle: "M", milestoneOrderIndex: 0, featureOrderIndex: 0 },
|
|
||||||
title: "F",
|
|
||||||
};
|
|
||||||
|
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
|
||||||
ok: true,
|
|
||||||
status: 200,
|
|
||||||
statusText: "OK",
|
|
||||||
headers: {
|
|
||||||
get: (name: string) =>
|
|
||||||
name.toLowerCase() === "content-type" ? "application/json" : null,
|
|
||||||
},
|
|
||||||
json: () => Promise.resolve(handoffData),
|
|
||||||
text: () => Promise.resolve(JSON.stringify(handoffData)),
|
|
||||||
} as unknown as Response);
|
|
||||||
|
|
||||||
await getRoadmapFeatureHandoff("RM-001", "RMS-001", "RF-001", "proj_xyz");
|
|
||||||
|
|
||||||
const [url] = (globalThis.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
|
||||||
expect(url).toContain("/api/plugins/roadmap-planner/roadmaps/RM-001/milestones/RMS-001/features/RF-001/handoff/task");
|
|
||||||
expect(url).toContain("projectId=proj_xyz");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
|
|
||||||
describe("Automation API scope forwarding", () => {
|
describe("Automation API scope forwarding", () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe("tablet header controls", () => {
|
|||||||
expect(screen.getByTitle("Board view")).toBeDefined();
|
expect(screen.getByTitle("Board view")).toBeDefined();
|
||||||
expect(screen.getByTitle("List view")).toBeDefined();
|
expect(screen.getByTitle("List view")).toBeDefined();
|
||||||
expect(screen.getByTitle("Agents view")).toBeDefined();
|
expect(screen.getByTitle("Agents view")).toBeDefined();
|
||||||
// Skills, Roadmaps, Insights are NOT inline (they're in overflow)
|
// Skills and Insights are NOT inline (they're in overflow)
|
||||||
expect(screen.queryByTitle("Skills view")).toBeNull();
|
expect(screen.queryByTitle("Skills view")).toBeNull();
|
||||||
expect(screen.queryByTitle("Roadmaps view")).toBeNull();
|
expect(screen.queryByTitle("Roadmaps view")).toBeNull();
|
||||||
expect(screen.queryByTitle("Insights view")).toBeNull();
|
expect(screen.queryByTitle("Insights view")).toBeNull();
|
||||||
@@ -106,7 +106,7 @@ describe("tablet header controls", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("opens overflow menu with Insights and Skills on tablet when trigger is clicked", () => {
|
it("opens overflow menu with Insights and Skills on tablet when trigger is clicked", () => {
|
||||||
renderTabletHeader({ onChangeView: noop, showSkillsTab: true, experimentalFeatures: { insights: true, roadmap: true } });
|
renderTabletHeader({ onChangeView: noop, showSkillsTab: true, experimentalFeatures: { insights: true } });
|
||||||
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||||
expect(screen.getByTestId("view-overflow-insights")).toBeDefined();
|
expect(screen.getByTestId("view-overflow-insights")).toBeDefined();
|
||||||
expect(screen.getByTestId("view-overflow-skills")).toBeDefined();
|
expect(screen.getByTestId("view-overflow-skills")).toBeDefined();
|
||||||
@@ -121,7 +121,7 @@ describe("tablet header controls", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("closes overflow menu on tablet after selecting an item", async () => {
|
it("closes overflow menu on tablet after selecting an item", async () => {
|
||||||
renderTabletHeader({ onChangeView: noop, showSkillsTab: true, experimentalFeatures: { insights: true, roadmap: true } });
|
renderTabletHeader({ onChangeView: noop, showSkillsTab: true, experimentalFeatures: { insights: true } });
|
||||||
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||||
expect(screen.getByTestId("view-overflow-insights")).toBeDefined();
|
expect(screen.getByTestId("view-overflow-insights")).toBeDefined();
|
||||||
fireEvent.click(screen.getByTestId("view-overflow-skills"));
|
fireEvent.click(screen.getByTestId("view-overflow-skills"));
|
||||||
|
|||||||
@@ -70,21 +70,6 @@ import type {
|
|||||||
ProjectNodePathMapping,
|
ProjectNodePathMapping,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
import type { PlanningQuestion, PlanningSummary } from "@fusion/core";
|
||||||
import type {
|
|
||||||
Roadmap,
|
|
||||||
RoadmapMilestone,
|
|
||||||
RoadmapFeature,
|
|
||||||
RoadmapCreateInput,
|
|
||||||
RoadmapUpdateInput,
|
|
||||||
RoadmapMilestoneCreateInput,
|
|
||||||
RoadmapMilestoneUpdateInput,
|
|
||||||
RoadmapFeatureCreateInput,
|
|
||||||
RoadmapFeatureUpdateInput,
|
|
||||||
RoadmapWithHierarchy,
|
|
||||||
RoadmapExportBundle,
|
|
||||||
RoadmapMissionPlanningHandoff,
|
|
||||||
RoadmapFeatureTaskPlanningHandoff,
|
|
||||||
} from "@fusion-plugin-examples/roadmap";
|
|
||||||
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
|
import type { ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, Routine, RoutineCreateInput, RoutineUpdateInput, RoutineExecutionResult } from "@fusion/core";
|
||||||
import type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry } from "@fusion/dashboard";
|
import type { DiscoveredSkill, CatalogEntry, CatalogFetchResult, ToggleSkillResult, SkillContent, SkillFileEntry } from "@fusion/dashboard";
|
||||||
import type { MilestoneValidationTelemetry } from "../components/mission-types";
|
import type { MilestoneValidationTelemetry } from "../components/mission-types";
|
||||||
@@ -7305,223 +7290,6 @@ export async function previewEnrichedDescription(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Roadmap API ─────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
/** Fetch all roadmaps */
|
|
||||||
export function fetchRoadmaps(projectId?: string): Promise<Roadmap[]> {
|
|
||||||
return api<Roadmap[]>(withProjectId("/plugins/roadmap-planner/roadmaps", projectId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Create a new roadmap */
|
|
||||||
export function createRoadmap(input: RoadmapCreateInput, projectId?: string): Promise<Roadmap> {
|
|
||||||
return api<Roadmap>(withProjectId("/plugins/roadmap-planner/roadmaps", projectId), {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch a single roadmap with full hierarchy (milestones and features) */
|
|
||||||
export function fetchRoadmap(roadmapId: string, projectId?: string): Promise<RoadmapWithHierarchy> {
|
|
||||||
return api<RoadmapWithHierarchy>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}`, projectId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update roadmap metadata */
|
|
||||||
export function updateRoadmap(roadmapId: string, updates: RoadmapUpdateInput, projectId?: string): Promise<Roadmap> {
|
|
||||||
return api<Roadmap>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}`, projectId), {
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify(updates),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Delete a roadmap */
|
|
||||||
export function deleteRoadmap(roadmapId: string, projectId?: string): Promise<void> {
|
|
||||||
return api<void>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}`, projectId), {
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch milestones for a roadmap */
|
|
||||||
export function fetchRoadmapMilestones(roadmapId: string, projectId?: string): Promise<RoadmapMilestone[]> {
|
|
||||||
return api<RoadmapMilestone[]>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/milestones`, projectId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Create a milestone in a roadmap */
|
|
||||||
export function createRoadmapMilestone(roadmapId: string, input: RoadmapMilestoneCreateInput, projectId?: string): Promise<RoadmapMilestone> {
|
|
||||||
return api<RoadmapMilestone>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/milestones`, projectId), {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update milestone metadata */
|
|
||||||
export function updateRoadmapMilestone(milestoneId: string, updates: RoadmapMilestoneUpdateInput, projectId?: string): Promise<RoadmapMilestone> {
|
|
||||||
return api<RoadmapMilestone>(withProjectId(`/plugins/roadmap-planner/roadmaps/milestones/${encodeURIComponent(milestoneId)}`, projectId), {
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify(updates),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Delete a milestone */
|
|
||||||
export function deleteRoadmapMilestone(milestoneId: string, projectId?: string): Promise<void> {
|
|
||||||
return api<void>(withProjectId(`/plugins/roadmap-planner/roadmaps/milestones/${encodeURIComponent(milestoneId)}`, projectId), {
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Fetch features for a milestone */
|
|
||||||
export function fetchRoadmapFeatures(milestoneId: string, projectId?: string): Promise<RoadmapFeature[]> {
|
|
||||||
return api<RoadmapFeature[]>(withProjectId(`/plugins/roadmap-planner/roadmaps/milestones/${encodeURIComponent(milestoneId)}/features`, projectId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Create a feature in a milestone */
|
|
||||||
export function createRoadmapFeature(milestoneId: string, input: RoadmapFeatureCreateInput, projectId?: string): Promise<RoadmapFeature> {
|
|
||||||
return api<RoadmapFeature>(withProjectId(`/plugins/roadmap-planner/roadmaps/milestones/${encodeURIComponent(milestoneId)}/features`, projectId), {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update feature metadata */
|
|
||||||
export function updateRoadmapFeature(featureId: string, updates: RoadmapFeatureUpdateInput, projectId?: string): Promise<RoadmapFeature> {
|
|
||||||
return api<RoadmapFeature>(withProjectId(`/plugins/roadmap-planner/roadmaps/features/${encodeURIComponent(featureId)}`, projectId), {
|
|
||||||
method: "PATCH",
|
|
||||||
body: JSON.stringify(updates),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Delete a feature */
|
|
||||||
export function deleteRoadmapFeature(featureId: string, projectId?: string): Promise<void> {
|
|
||||||
return api<void>(withProjectId(`/plugins/roadmap-planner/roadmaps/features/${encodeURIComponent(featureId)}`, projectId), {
|
|
||||||
method: "DELETE",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reorder milestones within a roadmap */
|
|
||||||
export function reorderRoadmapMilestones(roadmapId: string, orderedMilestoneIds: string[], projectId?: string): Promise<void> {
|
|
||||||
return api<void>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/milestones/reorder`, projectId), {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ orderedMilestoneIds }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Reorder features within a milestone */
|
|
||||||
export function reorderRoadmapFeatures(milestoneId: string, orderedFeatureIds: string[], projectId?: string): Promise<void> {
|
|
||||||
return api<void>(withProjectId(`/plugins/roadmap-planner/roadmaps/milestones/${encodeURIComponent(milestoneId)}/features/reorder`, projectId), {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ orderedFeatureIds }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Move a feature to a different milestone or position */
|
|
||||||
export function moveRoadmapFeature(
|
|
||||||
featureId: string,
|
|
||||||
targetMilestoneId: string,
|
|
||||||
targetIndex: number,
|
|
||||||
projectId?: string
|
|
||||||
): Promise<void> {
|
|
||||||
return api<void>(withProjectId(`/plugins/roadmap-planner/roadmaps/features/${encodeURIComponent(featureId)}/move`, projectId), {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ targetMilestoneId, targetIndex }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Export a roadmap as a flat bundle for persistence/import/export */
|
|
||||||
export function exportRoadmap(roadmapId: string, projectId?: string): Promise<RoadmapExportBundle> {
|
|
||||||
return api<RoadmapExportBundle>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/export`, projectId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get mission planning handoff payload for a roadmap */
|
|
||||||
export function getRoadmapMissionHandoff(roadmapId: string, projectId?: string): Promise<RoadmapMissionPlanningHandoff> {
|
|
||||||
return api<RoadmapMissionPlanningHandoff>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/handoff/mission`, projectId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get task planning handoff payload for a single roadmap feature */
|
|
||||||
export function getRoadmapFeatureHandoff(
|
|
||||||
roadmapId: string,
|
|
||||||
milestoneId: string,
|
|
||||||
featureId: string,
|
|
||||||
projectId?: string
|
|
||||||
): Promise<RoadmapFeatureTaskPlanningHandoff> {
|
|
||||||
return api<RoadmapFeatureTaskPlanningHandoff>(
|
|
||||||
withProjectId(
|
|
||||||
`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/milestones/${encodeURIComponent(milestoneId)}/features/${encodeURIComponent(featureId)}/handoff/task`,
|
|
||||||
projectId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Combined handoff response type for roadmap handoff endpoint */
|
|
||||||
export interface RoadmapHandoffResponse {
|
|
||||||
mission: RoadmapMissionPlanningHandoff;
|
|
||||||
features: RoadmapFeatureTaskPlanningHandoff[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get both mission and feature handoff payloads for a roadmap */
|
|
||||||
export function fetchRoadmapHandoff(roadmapId: string, projectId?: string): Promise<RoadmapHandoffResponse> {
|
|
||||||
return api<RoadmapHandoffResponse>(withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/handoff`, projectId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Response from milestone suggestion generation */
|
|
||||||
export interface MilestoneSuggestionsResponse {
|
|
||||||
suggestions: Array<{
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generate milestone suggestions from a goal prompt */
|
|
||||||
export function generateMilestoneSuggestions(
|
|
||||||
roadmapId: string,
|
|
||||||
goalPrompt: string,
|
|
||||||
count?: number,
|
|
||||||
projectId?: string
|
|
||||||
): Promise<MilestoneSuggestionsResponse> {
|
|
||||||
return api<MilestoneSuggestionsResponse>(
|
|
||||||
withProjectId(`/plugins/roadmap-planner/roadmaps/${encodeURIComponent(roadmapId)}/suggestions/milestones`, projectId),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
goalPrompt: goalPrompt.trim(),
|
|
||||||
...(count !== undefined ? { count } : {}),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Response type for feature suggestions */
|
|
||||||
export interface FeatureSuggestionsResponse {
|
|
||||||
suggestions: Array<{
|
|
||||||
title: string;
|
|
||||||
description?: string;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Input for generating feature suggestions */
|
|
||||||
export interface GenerateFeatureSuggestionsInput {
|
|
||||||
/** Optional prompt to guide feature generation */
|
|
||||||
prompt?: string;
|
|
||||||
/** Number of features to generate (default 5, max 10) */
|
|
||||||
count?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generate feature suggestions for a milestone */
|
|
||||||
export function generateFeatureSuggestions(
|
|
||||||
milestoneId: string,
|
|
||||||
input?: GenerateFeatureSuggestionsInput,
|
|
||||||
projectId?: string
|
|
||||||
): Promise<FeatureSuggestionsResponse> {
|
|
||||||
return api<FeatureSuggestionsResponse>(
|
|
||||||
withProjectId(`/plugins/roadmap-planner/roadmaps/milestones/${encodeURIComponent(milestoneId)}/suggestions/features`, projectId),
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
...(input?.prompt !== undefined ? { prompt: input.prompt.trim() } : {}),
|
|
||||||
...(input?.count !== undefined ? { count: input.count } : {}),
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Todo API ─────────────────────────────────────────────────────────────────
|
// ── Todo API ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Fetch all todo lists with their items */
|
/** Fetch all todo lists with their items */
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ export interface HeaderProps {
|
|||||||
/** Whether the current view is a remote node */
|
/** Whether the current view is a remote node */
|
||||||
isRemote?: boolean;
|
isRemote?: boolean;
|
||||||
/** Experimental feature flags controlling visibility of nav items. */
|
/** Experimental feature flags controlling visibility of nav items. */
|
||||||
experimentalFeatures?: { insights?: boolean; roadmap?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean; evalsView?: boolean };
|
experimentalFeatures?: { insights?: boolean; memoryView?: boolean; devServer?: boolean; devServerView?: boolean; researchView?: boolean; evalsView?: boolean };
|
||||||
pluginDashboardViews?: PluginDashboardViewEntry[];
|
pluginDashboardViews?: PluginDashboardViewEntry[];
|
||||||
shellConnectionControl?: ReactNode;
|
shellConnectionControl?: ReactNode;
|
||||||
}
|
}
|
||||||
@@ -358,11 +358,6 @@ export function Header({
|
|||||||
return Object.entries(overflowScripts).sort(([a], [b]) => a.localeCompare(b));
|
return Object.entries(overflowScripts).sort(([a], [b]) => a.localeCompare(b));
|
||||||
}, [overflowScripts]);
|
}, [overflowScripts]);
|
||||||
|
|
||||||
const hasRoadmapsPluginView = useMemo(
|
|
||||||
() => pluginDashboardViews.some((entry) => entry.pluginId === "roadmap-planner"),
|
|
||||||
[pluginDashboardViews],
|
|
||||||
);
|
|
||||||
|
|
||||||
const hasViewOverflowItems = useMemo(() => {
|
const hasViewOverflowItems = useMemo(() => {
|
||||||
return !!(
|
return !!(
|
||||||
onChangeView ||
|
onChangeView ||
|
||||||
@@ -376,7 +371,7 @@ export function Header({
|
|||||||
!hideFullNav ||
|
!hideFullNav ||
|
||||||
pluginDashboardViews.some((entry) => entry.view.placement !== "primary")
|
pluginDashboardViews.some((entry) => entry.view.placement !== "primary")
|
||||||
);
|
);
|
||||||
}, [onChangeView, experimentalFeatures, todosEnabled, showSkillsTab, hideFullNav, pluginDashboardViews, hasRoadmapsPluginView]);
|
}, [onChangeView, experimentalFeatures, todosEnabled, showSkillsTab, hideFullNav, pluginDashboardViews]);
|
||||||
|
|
||||||
const getEffectiveViewport = useCallback(() => {
|
const getEffectiveViewport = useCallback(() => {
|
||||||
const vv = window.visualViewport;
|
const vv = window.visualViewport;
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ export interface MobileNavBarProps {
|
|||||||
/** Experimental feature flags controlling visibility of nav items. */
|
/** Experimental feature flags controlling visibility of nav items. */
|
||||||
experimentalFeatures?: {
|
experimentalFeatures?: {
|
||||||
insights?: boolean;
|
insights?: boolean;
|
||||||
roadmap?: boolean;
|
|
||||||
memoryView?: boolean;
|
memoryView?: boolean;
|
||||||
devServer?: boolean;
|
devServer?: boolean;
|
||||||
devServerView?: boolean;
|
devServerView?: boolean;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ const defaultSettings: Settings = {
|
|||||||
worktreeInitCommand: "",
|
worktreeInitCommand: "",
|
||||||
testCommand: "",
|
testCommand: "",
|
||||||
buildCommand: "",
|
buildCommand: "",
|
||||||
experimentalFeatures: { insights: true, roadmap: true, skillsView: true, agentsView: true, memoryView: true, evalsView: true },
|
experimentalFeatures: { insights: true, skillsView: true, agentsView: true, memoryView: true, evalsView: true },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -2025,7 +2025,7 @@ describe("App view switching", () => {
|
|||||||
// Override the default mock to exclude agentsView
|
// Override the default mock to exclude agentsView
|
||||||
vi.mocked(fetchSettings).mockResolvedValue({
|
vi.mocked(fetchSettings).mockResolvedValue({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
experimentalFeatures: { insights: true, roadmap: true, skillsView: true }, // no agentsView
|
experimentalFeatures: { insights: true, skillsView: true }, // no agentsView
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@@ -2201,7 +2201,7 @@ describe("App view switching", () => {
|
|||||||
// Keep at least one overflow item enabled so the overflow trigger still renders.
|
// Keep at least one overflow item enabled so the overflow trigger still renders.
|
||||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
experimentalFeatures: { insights: false, roadmap: true },
|
experimentalFeatures: { insights: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ describe("Header", () => {
|
|||||||
it("hides legacy roadmaps overflow item when roadmap plugin view is present", () => {
|
it("hides legacy roadmaps overflow item when roadmap plugin view is present", () => {
|
||||||
renderHeader({
|
renderHeader({
|
||||||
onChangeView: noop,
|
onChangeView: noop,
|
||||||
experimentalFeatures: { roadmap: true },
|
experimentalFeatures: {},
|
||||||
pluginDashboardViews: [
|
pluginDashboardViews: [
|
||||||
{
|
{
|
||||||
pluginId: "fusion-plugin-roadmap",
|
pluginId: "fusion-plugin-roadmap",
|
||||||
@@ -253,7 +253,7 @@ describe("Header", () => {
|
|||||||
renderHeader({
|
renderHeader({
|
||||||
onChangeView: noop,
|
onChangeView: noop,
|
||||||
showSkillsTab: false,
|
showSkillsTab: false,
|
||||||
experimentalFeatures: { insights: false, roadmap: false, memoryView: false, devServerView: false, researchView: false },
|
experimentalFeatures: { insights: false, memoryView: false, devServerView: false, researchView: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
fireEvent.click(screen.getByTestId("view-toggle-overflow-trigger"));
|
||||||
|
|||||||
@@ -70,13 +70,13 @@ describe("MobileNavBar", () => {
|
|||||||
expect(screen.getByTestId("mobile-nav-tab-more")).toBeDefined();
|
expect(screen.getByTestId("mobile-nav-tab-more")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not render legacy roadmaps tab when experimentalFeatures.roadmap is true", () => {
|
it("does not render legacy roadmaps tab", () => {
|
||||||
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{ roadmap: true }} />);
|
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{}} />);
|
||||||
expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();
|
expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps skills available without rendering legacy roadmaps destinations", () => {
|
it("keeps skills available without rendering legacy roadmaps destinations", () => {
|
||||||
render(<MobileNavBar {...createDefaultProps()} showSkillsTab={true} experimentalFeatures={{ roadmap: true }} />);
|
render(<MobileNavBar {...createDefaultProps()} showSkillsTab={true} experimentalFeatures={{}} />);
|
||||||
|
|
||||||
expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined();
|
expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined();
|
||||||
expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();
|
expect(screen.queryByTestId("mobile-nav-tab-roadmaps")).toBeNull();
|
||||||
@@ -86,7 +86,7 @@ describe("MobileNavBar", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("keeps skills top-level regardless of legacy roadmaps view value", () => {
|
it("keeps skills top-level regardless of legacy roadmaps view value", () => {
|
||||||
render(<MobileNavBar {...createDefaultProps()} view="board" showSkillsTab={true} experimentalFeatures={{ roadmap: true }} />);
|
render(<MobileNavBar {...createDefaultProps()} view="board" showSkillsTab={true} experimentalFeatures={{}} />);
|
||||||
|
|
||||||
expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined();
|
expect(screen.getByTestId("mobile-nav-tab-skills")).toBeDefined();
|
||||||
|
|
||||||
@@ -387,8 +387,8 @@ describe("MobileNavBar", () => {
|
|||||||
expect(screen.getByTestId("mobile-more-item-settings")).toBeDefined();
|
expect(screen.getByTestId("mobile-more-item-settings")).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not show legacy roadmaps in more sheet when experimentalFeatures.roadmap is true", () => {
|
it("does not show legacy roadmaps in more sheet", () => {
|
||||||
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{ roadmap: true }} />);
|
render(<MobileNavBar {...createDefaultProps()} experimentalFeatures={{}} />);
|
||||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
||||||
expect(screen.queryByTestId("mobile-more-item-roadmaps")).toBeNull();
|
expect(screen.queryByTestId("mobile-more-item-roadmaps")).toBeNull();
|
||||||
});
|
});
|
||||||
@@ -397,7 +397,7 @@ describe("MobileNavBar", () => {
|
|||||||
render(
|
render(
|
||||||
<MobileNavBar
|
<MobileNavBar
|
||||||
{...createDefaultProps()}
|
{...createDefaultProps()}
|
||||||
experimentalFeatures={{ roadmap: true }}
|
experimentalFeatures={{}}
|
||||||
pluginDashboardViews={[
|
pluginDashboardViews={[
|
||||||
{
|
{
|
||||||
pluginId: "roadmap-planner",
|
pluginId: "roadmap-planner",
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ export interface UseAppSettingsResult {
|
|||||||
settingsLoaded: boolean;
|
settingsLoaded: boolean;
|
||||||
experimentalFeatures: Record<string, boolean>;
|
experimentalFeatures: Record<string, boolean>;
|
||||||
insightsEnabled: boolean;
|
insightsEnabled: boolean;
|
||||||
roadmapEnabled: boolean;
|
|
||||||
memoryEnabled: boolean;
|
memoryEnabled: boolean;
|
||||||
devServerEnabled: boolean;
|
devServerEnabled: boolean;
|
||||||
todosEnabled: boolean;
|
todosEnabled: boolean;
|
||||||
@@ -46,7 +45,6 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
|||||||
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
const [settingsLoaded, setSettingsLoaded] = useState(false);
|
||||||
const [experimentalFeatures, setExperimentalFeatures] = useState<Record<string, boolean>>({});
|
const [experimentalFeatures, setExperimentalFeatures] = useState<Record<string, boolean>>({});
|
||||||
const [insightsEnabled, setInsightsEnabled] = useState(false);
|
const [insightsEnabled, setInsightsEnabled] = useState(false);
|
||||||
const [roadmapEnabled, setRoadmapEnabled] = useState(false);
|
|
||||||
const [memoryEnabled, setMemoryEnabled] = useState(false);
|
const [memoryEnabled, setMemoryEnabled] = useState(false);
|
||||||
const [devServerEnabled, setDevServerEnabled] = useState(false);
|
const [devServerEnabled, setDevServerEnabled] = useState(false);
|
||||||
const [todosEnabled, setTodosEnabled] = useState(false);
|
const [todosEnabled, setTodosEnabled] = useState(false);
|
||||||
@@ -78,7 +76,6 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
|||||||
setExperimentalFeatures(settings.experimentalFeatures ?? {});
|
setExperimentalFeatures(settings.experimentalFeatures ?? {});
|
||||||
const features = settings.experimentalFeatures ?? {};
|
const features = settings.experimentalFeatures ?? {};
|
||||||
setInsightsEnabled(features.insights === true);
|
setInsightsEnabled(features.insights === true);
|
||||||
setRoadmapEnabled(features.roadmap === true);
|
|
||||||
setMemoryEnabled(features.memoryView === true);
|
setMemoryEnabled(features.memoryView === true);
|
||||||
setDevServerEnabled(features.devServerView === true || features.devServer === true);
|
setDevServerEnabled(features.devServerView === true || features.devServer === true);
|
||||||
setTodosEnabled(features.todoView === true);
|
setTodosEnabled(features.todoView === true);
|
||||||
@@ -95,7 +92,6 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
|||||||
setSettingsLoaded(false);
|
setSettingsLoaded(false);
|
||||||
setExperimentalFeatures({});
|
setExperimentalFeatures({});
|
||||||
setInsightsEnabled(false);
|
setInsightsEnabled(false);
|
||||||
setRoadmapEnabled(false);
|
|
||||||
setMemoryEnabled(false);
|
setMemoryEnabled(false);
|
||||||
setDevServerEnabled(false);
|
setDevServerEnabled(false);
|
||||||
setTodosEnabled(false);
|
setTodosEnabled(false);
|
||||||
@@ -177,7 +173,6 @@ export function useAppSettings(projectId?: string): UseAppSettingsResult {
|
|||||||
settingsLoaded,
|
settingsLoaded,
|
||||||
experimentalFeatures,
|
experimentalFeatures,
|
||||||
insightsEnabled,
|
insightsEnabled,
|
||||||
roadmapEnabled,
|
|
||||||
memoryEnabled,
|
memoryEnabled,
|
||||||
devServerEnabled,
|
devServerEnabled,
|
||||||
todosEnabled,
|
todosEnabled,
|
||||||
|
|||||||
@@ -1,612 +1,21 @@
|
|||||||
// @vitest-environment node
|
// @vitest-environment node
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import { get as performGet, request as performRequest } from "../test-request.js";
|
import { registerIntegratedRouters } from "../routes/register-integrated-routers.js";
|
||||||
import { createRoadmapRouter } from "../roadmap-routes.js";
|
|
||||||
import { ApiError } from "../api-error.js";
|
|
||||||
import type { Roadmap, RoadmapMilestone, RoadmapFeature, RoadmapStore } from "@fusion-plugin-examples/roadmap";
|
|
||||||
|
|
||||||
|
describe("integrated roadmap routes removed", () => {
|
||||||
// vi.mock is hoisted
|
it("does not register a legacy /roadmaps mount", () => {
|
||||||
vi.mock("@fusion-plugin-examples/roadmap/roadmap-suggestions", () => {
|
const router = express.Router();
|
||||||
// Define error classes inside the factory - these will be used by the mocked module
|
registerIntegratedRouters({
|
||||||
class MockValidationError extends Error { name = "ValidationError"; constructor(m: string) { super(m); } }
|
router,
|
||||||
class MockParseError extends Error { name = "ParseError"; constructor(m: string) { super(m); } }
|
store: {} as never,
|
||||||
class MockServiceUnavailableError extends Error { name = "ServiceUnavailableError"; constructor(m: string) { super(m); } }
|
|
||||||
|
|
||||||
return {
|
|
||||||
generateMilestoneSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
|
|
||||||
validateSuggestionInput: vi.fn(),
|
|
||||||
generateFeatureSuggestions: vi.fn().mockResolvedValue({ suggestions: [] }),
|
|
||||||
validateFeatureSuggestionInput: vi.fn(),
|
|
||||||
ValidationError: MockValidationError,
|
|
||||||
ParseError: MockParseError,
|
|
||||||
ServiceUnavailableError: MockServiceUnavailableError,
|
|
||||||
SUGGESTION_TIMEOUT_MS: 120_000,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const mockGetOrCreateProjectStore = vi.fn();
|
const mountedPaths = (router as unknown as { stack?: Array<{ regexp?: { source?: string } }> }).stack
|
||||||
vi.mock("../project-store-resolver.js", () => ({
|
?.map((layer) => layer.regexp?.source ?? "")
|
||||||
getOrCreateProjectStore: (...args: unknown[]) => mockGetOrCreateProjectStore(...args),
|
?? [];
|
||||||
}));
|
|
||||||
|
|
||||||
function createMockRoadmapStore(): RoadmapStore {
|
expect(mountedPaths.some((path) => path.includes("roadmaps"))).toBe(false);
|
||||||
const roadmaps = new Map<string, Roadmap>();
|
|
||||||
const milestones = new Map<string, RoadmapMilestone>();
|
|
||||||
const features = new Map<string, RoadmapFeature>();
|
|
||||||
return {
|
|
||||||
createRoadmap: vi.fn((input: { title: string; description?: string }) => {
|
|
||||||
const id = "RM-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6).toUpperCase();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const roadmap: Roadmap = { id, title: input.title, description: input.description, createdAt: now, updatedAt: now };
|
|
||||||
roadmaps.set(id, roadmap);
|
|
||||||
return roadmap;
|
|
||||||
}),
|
|
||||||
getRoadmap: vi.fn((id: string) => roadmaps.get(id)),
|
|
||||||
listRoadmaps: vi.fn(() => Array.from(roadmaps.values())),
|
|
||||||
updateRoadmap: vi.fn((id: string, updates: Partial<Roadmap>) => {
|
|
||||||
const roadmap = roadmaps.get(id);
|
|
||||||
if (!roadmap) throw new Error("Roadmap " + id + " not found");
|
|
||||||
const updated = { ...roadmap, ...updates, updatedAt: new Date().toISOString() };
|
|
||||||
roadmaps.set(id, updated);
|
|
||||||
return updated;
|
|
||||||
}),
|
|
||||||
deleteRoadmap: vi.fn((id: string) => { roadmaps.delete(id); }),
|
|
||||||
createMilestone: vi.fn((roadmapId: string, input: { title: string; description?: string }) => {
|
|
||||||
const roadmap = roadmaps.get(roadmapId);
|
|
||||||
if (!roadmap) throw new Error("Roadmap " + roadmapId + " not found");
|
|
||||||
const id = "RMS-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6).toUpperCase();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const existingMilestones = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId);
|
|
||||||
const orderIndex = existingMilestones.length > 0 ? Math.max(...existingMilestones.map((m) => m.orderIndex)) + 1 : 0;
|
|
||||||
const milestone: RoadmapMilestone = { id, roadmapId, title: input.title, description: input.description, orderIndex, createdAt: now, updatedAt: now };
|
|
||||||
milestones.set(id, milestone);
|
|
||||||
return milestone;
|
|
||||||
}),
|
|
||||||
getMilestone: vi.fn((id: string) => milestones.get(id)),
|
|
||||||
listMilestones: vi.fn((roadmapId: string) => Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex)),
|
|
||||||
updateMilestone: vi.fn((id: string, updates: Partial<RoadmapMilestone>) => {
|
|
||||||
const milestone = milestones.get(id);
|
|
||||||
if (!milestone) throw new Error("Milestone " + id + " not found");
|
|
||||||
const updated = { ...milestone, ...updates, updatedAt: new Date().toISOString() };
|
|
||||||
milestones.set(id, updated);
|
|
||||||
return updated;
|
|
||||||
}),
|
|
||||||
deleteMilestone: vi.fn((id: string) => { milestones.delete(id); }),
|
|
||||||
createFeature: vi.fn((milestoneId: string, input: { title: string; description?: string }) => {
|
|
||||||
const milestone = milestones.get(milestoneId);
|
|
||||||
if (!milestone) throw new Error("Milestone " + milestoneId + " not found");
|
|
||||||
const id = "RF-" + Date.now() + "-" + Math.random().toString(36).slice(2, 6).toUpperCase();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
const existingFeatures = Array.from(features.values()).filter((f) => f.milestoneId === milestoneId);
|
|
||||||
const orderIndex = existingFeatures.length > 0 ? Math.max(...existingFeatures.map((f) => f.orderIndex)) + 1 : 0;
|
|
||||||
const feature: RoadmapFeature = { id, milestoneId, title: input.title, description: input.description, orderIndex, createdAt: now, updatedAt: now };
|
|
||||||
features.set(id, feature);
|
|
||||||
return feature;
|
|
||||||
}),
|
|
||||||
getFeature: vi.fn((id: string) => features.get(id)),
|
|
||||||
listFeatures: vi.fn((milestoneId: string) => Array.from(features.values()).filter((f) => f.milestoneId === milestoneId).sort((a, b) => a.orderIndex - b.orderIndex)),
|
|
||||||
updateFeature: vi.fn((id: string, updates: Partial<RoadmapFeature>) => {
|
|
||||||
const feature = features.get(id);
|
|
||||||
if (!feature) throw new Error("Feature " + id + " not found");
|
|
||||||
const updated = { ...feature, ...updates, updatedAt: new Date().toISOString() };
|
|
||||||
features.set(id, updated);
|
|
||||||
return updated;
|
|
||||||
}),
|
|
||||||
deleteFeature: vi.fn((id: string) => { features.delete(id); }),
|
|
||||||
reorderMilestones: vi.fn((input: { roadmapId: string; orderedMilestoneIds: string[] }) => {
|
|
||||||
const { roadmapId, orderedMilestoneIds } = input;
|
|
||||||
orderedMilestoneIds.forEach((id, index) => {
|
|
||||||
const milestone = milestones.get(id);
|
|
||||||
if (milestone) milestones.set(id, { ...milestone, orderIndex: index, updatedAt: new Date().toISOString() });
|
|
||||||
});
|
|
||||||
return Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
|
|
||||||
}),
|
|
||||||
reorderFeatures: vi.fn((input: { roadmapId: string; milestoneId: string; orderedFeatureIds: string[] }) => {
|
|
||||||
const { milestoneId, orderedFeatureIds } = input;
|
|
||||||
orderedFeatureIds.forEach((id, index) => {
|
|
||||||
const feature = features.get(id);
|
|
||||||
if (feature) features.set(id, { ...feature, orderIndex: index, updatedAt: new Date().toISOString() });
|
|
||||||
});
|
|
||||||
return Array.from(features.values()).filter((f) => f.milestoneId === milestoneId).sort((a, b) => a.orderIndex - b.orderIndex);
|
|
||||||
}),
|
|
||||||
moveFeature: vi.fn((input: { roadmapId: string; featureId: string; fromMilestoneId: string; toMilestoneId: string; targetOrderIndex: number }) => {
|
|
||||||
const { featureId, toMilestoneId, targetOrderIndex } = input;
|
|
||||||
const feature = features.get(featureId);
|
|
||||||
if (!feature) throw new Error("Feature " + featureId + " not found");
|
|
||||||
const updated: RoadmapFeature = { ...feature, milestoneId: toMilestoneId, orderIndex: targetOrderIndex, updatedAt: new Date().toISOString() };
|
|
||||||
features.set(featureId, updated);
|
|
||||||
return { movedFeature: updated, sourceMilestoneFeatures: [], targetMilestoneFeatures: [] };
|
|
||||||
}),
|
|
||||||
getMilestoneWithFeatures: vi.fn((id: string) => {
|
|
||||||
const milestone = milestones.get(id);
|
|
||||||
if (!milestone) return undefined;
|
|
||||||
return { ...milestone, features: [] };
|
|
||||||
}),
|
|
||||||
getRoadmapWithHierarchy: vi.fn((id: string) => {
|
|
||||||
const roadmap = roadmaps.get(id);
|
|
||||||
if (!roadmap) return undefined;
|
|
||||||
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === id).sort((a, b) => a.orderIndex - b.orderIndex);
|
|
||||||
return { ...roadmap, milestones: ms.map((m) => ({ ...m, features: [] })) };
|
|
||||||
}),
|
|
||||||
getRoadmapExport: vi.fn((roadmapId: string) => {
|
|
||||||
const roadmap = roadmaps.get(roadmapId);
|
|
||||||
if (!roadmap) throw new ApiError(500, "Roadmap " + roadmapId + " not found");
|
|
||||||
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
|
|
||||||
const allFeatures = ms.flatMap((m) => Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex));
|
|
||||||
return { roadmap, milestones: ms, features: allFeatures };
|
|
||||||
}),
|
|
||||||
getRoadmapMissionHandoff: vi.fn((roadmapId: string) => {
|
|
||||||
const roadmap = roadmaps.get(roadmapId);
|
|
||||||
if (!roadmap) throw new ApiError(500, "Roadmap " + roadmapId + " not found");
|
|
||||||
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
|
|
||||||
return {
|
|
||||||
sourceRoadmapId: roadmap.id,
|
|
||||||
title: roadmap.title,
|
|
||||||
description: roadmap.description,
|
|
||||||
milestones: ms.map((m) => {
|
|
||||||
const fs = Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex);
|
|
||||||
return {
|
|
||||||
sourceMilestoneId: m.id,
|
|
||||||
title: m.title,
|
|
||||||
description: m.description,
|
|
||||||
orderIndex: m.orderIndex,
|
|
||||||
features: fs.map((f) => ({ sourceFeatureId: f.id, title: f.title, description: f.description, orderIndex: f.orderIndex })),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
getRoadmapFeatureHandoff: vi.fn((roadmapId: string, milestoneId: string, featureId: string) => {
|
|
||||||
const roadmap = roadmaps.get(roadmapId);
|
|
||||||
if (!roadmap) throw new ApiError(500, "Roadmap " + roadmapId + " not found");
|
|
||||||
const milestone = milestones.get(milestoneId);
|
|
||||||
if (!milestone) throw new ApiError(500, "Milestone " + milestoneId + " not found");
|
|
||||||
if (milestone.roadmapId !== roadmapId) throw new ApiError(500, "Milestone " + milestoneId + " does not belong to roadmap " + roadmapId);
|
|
||||||
const feature = features.get(featureId);
|
|
||||||
if (!feature) throw new ApiError(500, "Feature " + featureId + " not found");
|
|
||||||
if (feature.milestoneId !== milestoneId) throw new ApiError(500, "Feature " + featureId + " does not belong to milestone " + milestoneId);
|
|
||||||
return {
|
|
||||||
source: {
|
|
||||||
roadmapId: roadmap.id,
|
|
||||||
milestoneId: milestone.id,
|
|
||||||
featureId: feature.id,
|
|
||||||
roadmapTitle: roadmap.title,
|
|
||||||
milestoneTitle: milestone.title,
|
|
||||||
milestoneOrderIndex: milestone.orderIndex,
|
|
||||||
featureOrderIndex: feature.orderIndex,
|
|
||||||
},
|
|
||||||
title: feature.title,
|
|
||||||
description: feature.description,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
getMissionPlanningHandoff: vi.fn((roadmapId: string) => {
|
|
||||||
const roadmap = roadmaps.get(roadmapId);
|
|
||||||
if (!roadmap) throw new Error("Roadmap " + roadmapId + " not found");
|
|
||||||
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
|
|
||||||
return {
|
|
||||||
sourceRoadmapId: roadmap.id,
|
|
||||||
title: roadmap.title,
|
|
||||||
description: roadmap.description,
|
|
||||||
milestones: ms.map((m) => {
|
|
||||||
const fs = Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex);
|
|
||||||
return {
|
|
||||||
sourceMilestoneId: m.id,
|
|
||||||
title: m.title,
|
|
||||||
description: m.description,
|
|
||||||
orderIndex: m.orderIndex,
|
|
||||||
features: fs.map((f) => ({ sourceFeatureId: f.id, title: f.title, description: f.description, orderIndex: f.orderIndex })),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
listFeatureTaskPlanningHandoffs: vi.fn((roadmapId: string) => {
|
|
||||||
const roadmap = roadmaps.get(roadmapId);
|
|
||||||
if (!roadmap) throw new Error("Roadmap " + roadmapId + " not found");
|
|
||||||
const ms = Array.from(milestones.values()).filter((m) => m.roadmapId === roadmapId).sort((a, b) => a.orderIndex - b.orderIndex);
|
|
||||||
const handoffs = [];
|
|
||||||
for (const m of ms) {
|
|
||||||
const fs = Array.from(features.values()).filter((f) => f.milestoneId === m.id).sort((a, b) => a.orderIndex - b.orderIndex);
|
|
||||||
for (const f of fs) {
|
|
||||||
handoffs.push({
|
|
||||||
source: {
|
|
||||||
roadmapId: roadmap.id,
|
|
||||||
milestoneId: m.id,
|
|
||||||
featureId: f.id,
|
|
||||||
roadmapTitle: roadmap.title,
|
|
||||||
milestoneTitle: m.title,
|
|
||||||
milestoneOrderIndex: m.orderIndex,
|
|
||||||
featureOrderIndex: f.orderIndex,
|
|
||||||
},
|
|
||||||
title: f.title,
|
|
||||||
description: f.description,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return handoffs;
|
|
||||||
}),
|
|
||||||
} as unknown as RoadmapStore;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("Roadmap Routes", () => {
|
|
||||||
let app: express.Express;
|
|
||||||
let mockStore: { getRoadmapStore: ReturnType<typeof vi.fn>; getRootDir: ReturnType<typeof vi.fn> };
|
|
||||||
let mockRoadmapStore: ReturnType<typeof createMockRoadmapStore>;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockRoadmapStore = createMockRoadmapStore();
|
|
||||||
mockStore = {
|
|
||||||
getRoadmapStore: vi.fn(() => mockRoadmapStore),
|
|
||||||
getRootDir: vi.fn(() => "/test/root"),
|
|
||||||
};
|
|
||||||
mockGetOrCreateProjectStore.mockResolvedValue(mockStore);
|
|
||||||
|
|
||||||
app = express();
|
|
||||||
app.use(express.json());
|
|
||||||
app.use("/api/roadmaps", createRoadmapRouter(mockStore));
|
|
||||||
|
|
||||||
// Add error handler for tests that check 404 responses
|
|
||||||
app.use((err: unknown, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
res.status(err.statusCode).json({ error: err.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (err instanceof Error) {
|
|
||||||
res.status(500).json({ error: err.message });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(500).json({ error: "Internal server error" });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /api/roadmaps", () => {
|
|
||||||
it("returns empty list when no roadmaps exist", async () => {
|
|
||||||
const response = await performGet(app, "/api/roadmaps");
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns all roadmaps", async () => {
|
|
||||||
mockRoadmapStore.createRoadmap({ title: "Roadmap 1" });
|
|
||||||
mockRoadmapStore.createRoadmap({ title: "Roadmap 2" });
|
|
||||||
const response = await performGet(app, "/api/roadmaps");
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body).toHaveLength(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("POST /api/roadmaps", () => {
|
|
||||||
it("creates a new roadmap", async () => {
|
|
||||||
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: "New Roadmap" }), { "Content-Type": "application/json" });
|
|
||||||
expect(response.status).toBe(201);
|
|
||||||
expect(response.body.title).toBe("New Roadmap");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 400 when title is missing", async () => {
|
|
||||||
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({}), { "Content-Type": "application/json" });
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(response.body.error).toContain("title is required");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 400 when title is empty", async () => {
|
|
||||||
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: "" }), { "Content-Type": "application/json" });
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(response.body.error).toContain("title is required");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 400 when title is whitespace-only", async () => {
|
|
||||||
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: " " }), { "Content-Type": "application/json" });
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(response.body.error).toContain("title is required");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 400 when title exceeds 200 characters", async () => {
|
|
||||||
const longTitle = "A".repeat(201);
|
|
||||||
const response = await performRequest(app, "POST", "/api/roadmaps", JSON.stringify({ title: longTitle }), { "Content-Type": "application/json" });
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(response.body.error).toContain("200 characters");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /api/roadmaps/:roadmapId", () => {
|
|
||||||
it("returns roadmap with hierarchy", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
|
|
||||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Milestone 1" });
|
|
||||||
mockRoadmapStore.createFeature(milestone.id, { title: "Feature 1" });
|
|
||||||
const response = await performGet(app, "/api/roadmaps/" + roadmap.id);
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body.title).toBe("Test Roadmap");
|
|
||||||
expect(response.body.milestones).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("PATCH /api/roadmaps/:roadmapId", () => {
|
|
||||||
it("updates roadmap title", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Original Title" });
|
|
||||||
const response = await performRequest(app, "PATCH", "/api/roadmaps/" + roadmap.id, JSON.stringify({ title: "Updated Title" }), { "Content-Type": "application/json" });
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body.title).toBe("Updated Title");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("DELETE /api/roadmaps/:roadmapId", () => {
|
|
||||||
it("deletes a roadmap", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "To Delete" });
|
|
||||||
const response = await performRequest(app, "DELETE", "/api/roadmaps/" + roadmap.id);
|
|
||||||
expect(response.status).toBe(204);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("POST /api/roadmaps/:roadmapId/milestones", () => {
|
|
||||||
it("creates a milestone with auto orderIndex", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
|
||||||
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones", JSON.stringify({ title: "New Milestone" }), { "Content-Type": "application/json" });
|
|
||||||
expect(response.status).toBe(201);
|
|
||||||
expect(response.body.roadmapId).toBe(roadmap.id);
|
|
||||||
expect(response.body.orderIndex).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("POST /api/roadmaps/:roadmapId/milestones/reorder", () => {
|
|
||||||
it("reorders milestones", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
|
||||||
const m1 = mockRoadmapStore.createMilestone(roadmap.id, { title: "First" });
|
|
||||||
const m2 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Second" });
|
|
||||||
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones/reorder", JSON.stringify({ orderedMilestoneIds: [m2.id, m1.id] }), { "Content-Type": "application/json" });
|
|
||||||
expect(response.status).toBe(204);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 400 when orderedMilestoneIds is not an array", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
|
||||||
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones/reorder", JSON.stringify({ orderedMilestoneIds: "not-an-array" }), { "Content-Type": "application/json" });
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(response.body.error).toContain("must be an array");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 400 when orderedMilestoneIds contains non-strings", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
|
||||||
const response = await performRequest(app, "POST", "/api/roadmaps/" + roadmap.id + "/milestones/reorder", JSON.stringify({ orderedMilestoneIds: ["id1", 123, "id3"] }), { "Content-Type": "application/json" });
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(response.body.error).toContain("must be an array of strings");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("PATCH /api/roadmaps/milestones/:milestoneId", () => {
|
|
||||||
it("updates a milestone", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
|
||||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Original" });
|
|
||||||
const response = await performRequest(app, "PATCH", "/api/roadmaps/milestones/" + milestone.id, JSON.stringify({ title: "Updated" }), { "Content-Type": "application/json" });
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body.title).toBe("Updated");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("DELETE /api/roadmaps/milestones/:milestoneId", () => {
|
|
||||||
it("deletes a milestone", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
|
||||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "To Delete" });
|
|
||||||
const response = await performRequest(app, "DELETE", "/api/roadmaps/milestones/" + milestone.id);
|
|
||||||
expect(response.status).toBe(204);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("POST /api/roadmaps/milestones/:milestoneId/features", () => {
|
|
||||||
it("creates a feature", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
|
||||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
|
|
||||||
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features", JSON.stringify({ title: "New Feature" }), { "Content-Type": "application/json" });
|
|
||||||
expect(response.status).toBe(201);
|
|
||||||
expect(response.body.title).toBe("New Feature");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 400 when title is missing", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
|
||||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
|
|
||||||
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features", JSON.stringify({}), { "Content-Type": "application/json" });
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(response.body.error).toContain("title is required");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("POST /api/roadmaps/milestones/:milestoneId/features/reorder", () => {
|
|
||||||
it("returns 400 when orderedFeatureIds is not an array", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
|
||||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
|
|
||||||
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features/reorder", JSON.stringify({ orderedFeatureIds: "not-an-array" }), { "Content-Type": "application/json" });
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(response.body.error).toContain("must be an array");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 400 when orderedFeatureIds contains non-strings", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
|
||||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
|
|
||||||
const response = await performRequest(app, "POST", "/api/roadmaps/milestones/" + milestone.id + "/features/reorder", JSON.stringify({ orderedFeatureIds: [123, "id2"] }), { "Content-Type": "application/json" });
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(response.body.error).toContain("must be an array of strings");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("PATCH /api/roadmaps/features/:featureId", () => {
|
|
||||||
it("updates a feature", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
|
||||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
|
|
||||||
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "Original" });
|
|
||||||
const response = await performRequest(app, "PATCH", "/api/roadmaps/features/" + feature.id, JSON.stringify({ title: "Updated" }), { "Content-Type": "application/json" });
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body.title).toBe("Updated");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("DELETE /api/roadmaps/features/:featureId", () => {
|
|
||||||
it("deletes a feature", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test" });
|
|
||||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS" });
|
|
||||||
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "To Delete" });
|
|
||||||
const response = await performRequest(app, "DELETE", "/api/roadmaps/features/" + feature.id);
|
|
||||||
expect(response.status).toBe(204);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("projectId scoping", () => {
|
|
||||||
it("ignores projectId query param in legacy adapter", async () => {
|
|
||||||
mockRoadmapStore.createRoadmap({ title: "Project Roadmap" });
|
|
||||||
const response = await performGet(app, "/api/roadmaps?projectId=test-project");
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(mockGetOrCreateProjectStore).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /api/roadmaps/:roadmapId/export", () => {
|
|
||||||
it("returns export bundle with all entities", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Export Test", description: "Test desc" });
|
|
||||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "MS1" });
|
|
||||||
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "F1" });
|
|
||||||
|
|
||||||
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/export");
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body.roadmap.id).toBe(roadmap.id);
|
|
||||||
expect(response.body.roadmap.title).toBe("Export Test");
|
|
||||||
expect(response.body.milestones.length).toBe(1);
|
|
||||||
expect(response.body.features.length).toBe(1);
|
|
||||||
expect(response.body.features[0].id).toBe(feature.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /api/roadmaps/:roadmapId/handoff", () => {
|
|
||||||
it("returns both mission and feature handoffs", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Combined Handoff" });
|
|
||||||
const milestone1 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
|
|
||||||
const milestone2 = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 2" });
|
|
||||||
const feature1 = mockRoadmapStore.createFeature(milestone1.id, { title: "Feature A" });
|
|
||||||
const feature2 = mockRoadmapStore.createFeature(milestone2.id, { title: "Feature B" });
|
|
||||||
|
|
||||||
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff");
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
|
|
||||||
// Verify mission handoff structure
|
|
||||||
expect(response.body.mission).toBeDefined();
|
|
||||||
expect(response.body.mission.sourceRoadmapId).toBe(roadmap.id);
|
|
||||||
expect(response.body.mission.title).toBe("Combined Handoff");
|
|
||||||
expect(response.body.mission.milestones).toHaveLength(2);
|
|
||||||
|
|
||||||
// Verify feature handoffs structure
|
|
||||||
expect(response.body.features).toBeDefined();
|
|
||||||
expect(response.body.features).toHaveLength(2);
|
|
||||||
expect(response.body.features[0].title).toBe("Feature A");
|
|
||||||
expect(response.body.features[0].source.milestoneId).toBe(milestone1.id);
|
|
||||||
expect(response.body.features[1].title).toBe("Feature B");
|
|
||||||
expect(response.body.features[1].source.milestoneId).toBe(milestone2.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns empty features array when roadmap has no features", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Empty Handoff" });
|
|
||||||
mockRoadmapStore.createMilestone(roadmap.id, { title: "Empty Phase" });
|
|
||||||
|
|
||||||
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff");
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body.features).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 404 when roadmap not found", async () => {
|
|
||||||
const response = await performGet(app, "/api/roadmaps/nonexistent/handoff");
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns 404 for cross-project isolation", async () => {
|
|
||||||
// Create roadmap in default store
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Isolated Roadmap" });
|
|
||||||
|
|
||||||
// Mock a different project store that returns no roadmap
|
|
||||||
mockGetOrCreateProjectStore.mockResolvedValueOnce({
|
|
||||||
getRoadmapStore: vi.fn(() => ({
|
|
||||||
getMissionPlanningHandoff: vi.fn(() => {
|
|
||||||
throw new Error("Roadmap nonexistent not found");
|
|
||||||
}),
|
|
||||||
listFeatureTaskPlanningHandoffs: vi.fn(() => {
|
|
||||||
throw new Error("Roadmap nonexistent not found");
|
|
||||||
}),
|
|
||||||
})),
|
|
||||||
getRootDir: vi.fn(() => "/test/root"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await performGet(app, "/api/roadmaps/nonexistent/handoff?projectId=other-project");
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /api/roadmaps/:roadmapId/handoff/mission", () => {
|
|
||||||
it("returns mission handoff payload", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Mission Handoff", description: "Mission desc" });
|
|
||||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
|
|
||||||
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "Feature A" });
|
|
||||||
|
|
||||||
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/handoff/mission");
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body.sourceRoadmapId).toBe(roadmap.id);
|
|
||||||
expect(response.body.title).toBe("Mission Handoff");
|
|
||||||
expect(response.body.description).toBe("Mission desc");
|
|
||||||
expect(response.body.milestones.length).toBe(1);
|
|
||||||
expect(response.body.milestones[0].sourceMilestoneId).toBe(milestone.id);
|
|
||||||
expect(response.body.milestones[0].features.length).toBe(1);
|
|
||||||
expect(response.body.milestones[0].features[0].sourceFeatureId).toBe(feature.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("GET /api/roadmaps/:roadmapId/milestones/:milestoneId/features/:featureId/handoff/task", () => {
|
|
||||||
it("returns task handoff payload for feature", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Feature Handoff" });
|
|
||||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
|
|
||||||
const feature = mockRoadmapStore.createFeature(milestone.id, { title: "Feature A", description: "Feature desc" });
|
|
||||||
|
|
||||||
const response = await performGet(app, "/api/roadmaps/" + roadmap.id + "/milestones/" + milestone.id + "/features/" + feature.id + "/handoff/task");
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.body.source.roadmapId).toBe(roadmap.id);
|
|
||||||
expect(response.body.source.milestoneId).toBe(milestone.id);
|
|
||||||
expect(response.body.source.featureId).toBe(feature.id);
|
|
||||||
expect(response.body.source.roadmapTitle).toBe("Feature Handoff");
|
|
||||||
expect(response.body.source.milestoneTitle).toBe("Phase 1");
|
|
||||||
expect(response.body.title).toBe("Feature A");
|
|
||||||
expect(response.body.description).toBe("Feature desc");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("POST /api/roadmaps/:roadmapId/suggestions/milestones", () => {
|
|
||||||
it("returns 503 when AI is unavailable", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
|
|
||||||
|
|
||||||
const response = await performRequest(
|
|
||||||
app,
|
|
||||||
"POST",
|
|
||||||
"/api/roadmaps/" + roadmap.id + "/suggestions/milestones",
|
|
||||||
JSON.stringify({ goalPrompt: "Build a platform", count: 5 }),
|
|
||||||
{ "Content-Type": "application/json" }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(503);
|
|
||||||
expect(response.body.error).toContain("AI service is not available");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("POST /api/roadmaps/milestones/:milestoneId/suggestions/features", () => {
|
|
||||||
it("returns 503 when AI is unavailable", async () => {
|
|
||||||
const roadmap = mockRoadmapStore.createRoadmap({ title: "Test Roadmap" });
|
|
||||||
const milestone = mockRoadmapStore.createMilestone(roadmap.id, { title: "Phase 1" });
|
|
||||||
|
|
||||||
const response = await performRequest(
|
|
||||||
app,
|
|
||||||
"POST",
|
|
||||||
"/api/roadmaps/milestones/" + milestone.id + "/suggestions/features",
|
|
||||||
JSON.stringify({ count: 5 }),
|
|
||||||
{ "Content-Type": "application/json" }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(503);
|
|
||||||
expect(response.body.error).toContain("AI service is not available");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -33,8 +33,6 @@ import {
|
|||||||
} from "./api-error.js";
|
} from "./api-error.js";
|
||||||
import { getOrCreateProjectStore } from "./project-store-resolver.js";
|
import { getOrCreateProjectStore } from "./project-store-resolver.js";
|
||||||
|
|
||||||
const ROADMAP_PLUGIN_ID = "fusion-plugin-roadmap";
|
|
||||||
const ROADMAP_PLUGIN_ROUTE_NAMESPACE = "roadmap-planner";
|
|
||||||
|
|
||||||
// PluginRunner interface for optional plugin runner
|
// PluginRunner interface for optional plugin runner
|
||||||
function isPluginRouteResponse(result: unknown): result is import("@fusion/core").PluginRouteResponse {
|
function isPluginRouteResponse(result: unknown): result is import("@fusion/core").PluginRouteResponse {
|
||||||
@@ -535,8 +533,7 @@ export function createPluginRouter(
|
|||||||
const pluginRoutes = pluginRunner.getPluginRoutes();
|
const pluginRoutes = pluginRunner.getPluginRoutes();
|
||||||
|
|
||||||
for (const { pluginId, route } of pluginRoutes) {
|
for (const { pluginId, route } of pluginRoutes) {
|
||||||
const routePluginId = pluginId === ROADMAP_PLUGIN_ID ? ROADMAP_PLUGIN_ROUTE_NAMESPACE : pluginId;
|
const fullPath = `/${pluginId}${route.path.startsWith("/") ? route.path : `/${route.path}`}`;
|
||||||
const fullPath = `/${routePluginId}${route.path.startsWith("/") ? route.path : `/${route.path}`}`;
|
|
||||||
|
|
||||||
const handler = catchHandler(async (req: Request, res: Response) => {
|
const handler = catchHandler(async (req: Request, res: Response) => {
|
||||||
// Get the plugin context
|
// Get the plugin context
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import { Router, type Request, type Response } from "express";
|
|
||||||
import { type PluginContext, type PluginRouteDefinition, type TaskStore } from "@fusion/core";
|
|
||||||
import { createRoadmapPluginRoutes } from "@fusion-plugin-examples/roadmap/server";
|
|
||||||
|
|
||||||
function isRouteResponse(value: unknown): value is { status: number; body?: unknown } {
|
|
||||||
return (
|
|
||||||
typeof value === "object"
|
|
||||||
&& value !== null
|
|
||||||
&& "status" in value
|
|
||||||
&& typeof (value as { status?: unknown }).status === "number"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function buildContext(store: TaskStore): Promise<PluginContext> {
|
|
||||||
return {
|
|
||||||
pluginId: "roadmap-planner",
|
|
||||||
taskStore: store,
|
|
||||||
settings: {},
|
|
||||||
logger: {
|
|
||||||
info: () => {},
|
|
||||||
warn: () => {},
|
|
||||||
error: () => {},
|
|
||||||
debug: () => {},
|
|
||||||
},
|
|
||||||
emitEvent: () => {},
|
|
||||||
createAiSession: undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createRoadmapRouter(store: TaskStore): Router {
|
|
||||||
const router = Router();
|
|
||||||
const routes = createRoadmapPluginRoutes();
|
|
||||||
|
|
||||||
for (const route of routes) {
|
|
||||||
const handler = async (req: Request, res: Response) => {
|
|
||||||
const result = await route.handler(req, await buildContext(store));
|
|
||||||
if (isRouteResponse(result)) {
|
|
||||||
if (result.status === 204) {
|
|
||||||
res.status(204).send();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (result.body === undefined) {
|
|
||||||
res.status(result.status).send();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(result.status).json(result.body);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.status(200).json(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
registerRoute(router, route, handler, normalizeLegacyRoadmapPath(route.path));
|
|
||||||
}
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeLegacyRoadmapPath(path: string): string {
|
|
||||||
if (path === "/roadmaps") return "/";
|
|
||||||
if (path.startsWith("/roadmaps/")) return path.slice("/roadmaps".length);
|
|
||||||
return path;
|
|
||||||
}
|
|
||||||
|
|
||||||
function registerRoute(
|
|
||||||
router: Router,
|
|
||||||
route: PluginRouteDefinition,
|
|
||||||
handler: (req: Request, res: Response) => Promise<void>,
|
|
||||||
normalizedPath: string,
|
|
||||||
): void {
|
|
||||||
switch (route.method) {
|
|
||||||
case "GET":
|
|
||||||
router.get(normalizedPath, handler);
|
|
||||||
break;
|
|
||||||
case "POST":
|
|
||||||
router.post(normalizedPath, handler);
|
|
||||||
break;
|
|
||||||
case "PUT":
|
|
||||||
router.put(normalizedPath, handler);
|
|
||||||
break;
|
|
||||||
case "PATCH":
|
|
||||||
router.patch(normalizedPath, handler);
|
|
||||||
break;
|
|
||||||
case "DELETE":
|
|
||||||
router.delete(normalizedPath, handler);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export { createRoadmapPluginRoutes };
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
export {
|
|
||||||
FEATURE_SUGGESTION_SYSTEM_PROMPT,
|
|
||||||
MILESTONE_SUGGESTION_SYSTEM_PROMPT,
|
|
||||||
ParseError,
|
|
||||||
ServiceUnavailableError,
|
|
||||||
SUGGESTION_TIMEOUT_MS,
|
|
||||||
ValidationError,
|
|
||||||
__resetSuggestionState,
|
|
||||||
__setCreateAiSessionFactory,
|
|
||||||
__setCreateFnAgent,
|
|
||||||
generateFeatureSuggestions,
|
|
||||||
generateMilestoneSuggestions,
|
|
||||||
validateFeatureSuggestionInput,
|
|
||||||
validateSuggestionInput,
|
|
||||||
} from "@fusion-plugin-examples/roadmap/roadmap-suggestions";
|
|
||||||
@@ -3131,7 +3131,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
// Keep this call at the current position to preserve precedence with
|
// Keep this call at the current position to preserve precedence with
|
||||||
// surrounding route handlers. registerIntegratedRouters() mounts:
|
// surrounding route handlers. registerIntegratedRouters() mounts:
|
||||||
// - /missions
|
// - /missions
|
||||||
// - /roadmaps
|
|
||||||
// - /insights
|
// - /insights
|
||||||
// - /todos
|
// - /todos
|
||||||
registerIntegratedRouters({
|
registerIntegratedRouters({
|
||||||
|
|||||||
@@ -135,8 +135,9 @@ Integrated routers are mounted through `register-integrated-routers.ts` and inte
|
|||||||
|
|
||||||
- `registerIntegratedRouters(...)` mounts:
|
- `registerIntegratedRouters(...)` mounts:
|
||||||
- `createMissionRouter` → `/api/missions`
|
- `createMissionRouter` → `/api/missions`
|
||||||
- `createRoadmapRouter` → `/api/roadmaps`
|
|
||||||
- `createInsightsRouter` → `/api/insights`
|
- `createInsightsRouter` → `/api/insights`
|
||||||
|
- `createTodoRouter` → `/api/todos`
|
||||||
|
- Roadmap endpoints are plugin-owned and exposed under `/api/plugins/roadmap-planner/...`.
|
||||||
- `registerIntegratedDevServerRouter(...)` mounts:
|
- `registerIntegratedDevServerRouter(...)` mounts:
|
||||||
- `createDevServerRouter` → `/api/dev-server`
|
- `createDevServerRouter` → `/api/dev-server`
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { createInsightsRouter } from "../insights-routes.js";
|
|||||||
import { createEvalsRouter } from "../evals-routes.js";
|
import { createEvalsRouter } from "../evals-routes.js";
|
||||||
import { createResearchRouter } from "../research-routes.js";
|
import { createResearchRouter } from "../research-routes.js";
|
||||||
import { createTodoRouter } from "../todo-routes.js";
|
import { createTodoRouter } from "../todo-routes.js";
|
||||||
import { createRoadmapRouter } from "../roadmap-routes.js";
|
|
||||||
import { createDevServerRouter } from "../dev-server-routes.js";
|
import { createDevServerRouter } from "../dev-server-routes.js";
|
||||||
import type { AiSessionStore } from "../ai-session-store.js";
|
import type { AiSessionStore } from "../ai-session-store.js";
|
||||||
|
|
||||||
@@ -37,7 +36,6 @@ export function registerIntegratedRouters({
|
|||||||
router.use("/evals", createEvalsRouter(store));
|
router.use("/evals", createEvalsRouter(store));
|
||||||
router.use("/research", createResearchRouter(store));
|
router.use("/research", createResearchRouter(store));
|
||||||
router.use("/todos", createTodoRouter(store));
|
router.use("/todos", createTodoRouter(store));
|
||||||
router.use("/roadmaps", createRoadmapRouter(store));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerIntegratedDevServerRouter({ router, store }: DevServerRouterOptions): void {
|
export function registerIntegratedDevServerRouter({ router, store }: DevServerRouterOptions): void {
|
||||||
|
|||||||
Reference in New Issue
Block a user