Files
fusion/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts
gsxdsm e610c72034 FN-8943: reconcile spec-lock divergence history
Preserve prior divergence when a task is re-locked after plan changes.

- Persist immutable spec locks, current-plan evidence, and drift reports.
- Reconcile retained divergence into re-approved alignment state across engine, API, and dashboard views.
- Fence Plan Review acceptance and schema upgrades while retaining migration identity parity.

Files changed:
 .changeset/fn-8845-spec-lock-drift-report.md       |   7 +
 .changeset/fn-8943-spec-lock-divergence.md         |   7 +
 docs/architecture.md                               |  17 +-
 docs/dashboard-guide.md                            |   3 +
 docs/missions.md                                   |   4 +
 .../core/src/__tests__/planner/spec-lock.test.ts   | 158 ++++++++++++++
 .../src/__tests__/postgres/schema-applier.test.ts  |  30 ++-
 .../postgres/task-dependency-mutation.pg.test.ts   |  74 +++++++
 packages/core/src/index.gate.ts                    |   4 +
 packages/core/src/index.ts                         |   4 +
 packages/core/src/planner/drift-report.ts          | 152 +++++++++++++
 packages/core/src/planner/spec-lock.ts             | 182 ++++++++++++++++
 .../migrations/0050_spec_lock_drift_report.sql     |  29 +++
 .../0051_spec_lock_source_revision_bigint.sql      |   3 +
 packages/core/src/postgres/schema-applier.ts       |  25 ++-
 packages/core/src/postgres/schema/project.ts       |  13 ++
 packages/core/src/store.ts                         | 242 ++++++++++++++++++++-
 .../core/src/task-store/branch-and-pr-entities.ts  |  32 ++-
 packages/core/src/task-store/project-store-ops.ts  |  34 ++-
 packages/core/src/task-store/task-update.ts        |  66 +++++-
 packages/core/src/task-store/update-task-deps.ts   |  31 ++-
 packages/dashboard/app/api.ts                      |   3 +
 packages/dashboard/app/api/tasks/tasks.ts          |  22 ++
 .../dashboard/app/components/MissionManager.css    |  21 ++
 .../dashboard/app/components/MissionManager.tsx    |  41 +++-
 .../dashboard/app/components/TaskDetailModal.css   |  26 +++
 .../dashboard/app/components/TaskDetailModal.tsx   |  62 +++++-
 .../__tests__/TaskDetailModal.spec-lock.test.tsx   |  80 +++++++
 .../__tests__/TaskDetailModal.test-helpers.ts      |   2 +
 .../src/__tests__/plan-approval-status.pg.test.ts  |  81 ++++++-
 .../src/routes/register-task-workflow-routes.ts    |  59 ++++-
 .../src/__tests__/mission-feature-sync.test.ts     |  15 +-
 .../src/__tests__/spec-drift-reconciler.test.ts    | 108 +++++++++
 .../engine/src/executor/execute-workflow-graph.ts  |  49 ++++-
 .../engine/src/missions/mission-feature-sync.ts    |  55 ++++-
 packages/engine/src/project-engine.ts              |  33 +++
 packages/engine/src/spec-drift-reconciler.ts       | 105 +++++++++
 packages/engine/src/triage.ts                      |  29 +++
 38 files changed, 1842 insertions(+), 66 deletions(-)

Fusion-Task-Id: FN-8943

Fusion-Task-Lineage: 2a7f8a38-99aa-4c4b-8c36-41d14c466d21

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-08-10 02:56:58 -07:00

340 lines
17 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { loadAllAppCss } from "../../test/cssFixture";
import React from "react";
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { render, screen, fireEvent, act, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TaskDetailModal, TaskDetailContent } from "../TaskDetailModal";
import type { TaskDetail, Column, MergeResult, Task } from "@fusion/core";
import { clearAuthToken } from "../../auth";
const taskDetailSseSubscriptions = vi.hoisted(() => [] as Array<{
url: string;
options: { events?: Record<string, (event: MessageEvent) => void> };
}>);
export { taskDetailSseSubscriptions };
/*
FNXC:TaskDetailOptimisticOpening 2026-08-05-07:39:
A running task deliberately exposes its raw runtime status in two ownership regions: the modal
header's lifecycle badge and the Stats panel's Runtime status row. Optimistic-opening assertions
must scope the Stats claim to its named semantic region, then assert one row there and the expected
two owned values overall; this catches a duplicated Stats panel without treating legitimate header
context as a production rendering defect.
FNXC:TaskDetailStatsAssertions 2026-08-09-16:50:
FN-8906 requires every TaskDetailModal test to use this shared helper: a non-empty raw runtime
status is owned by the header lifecycle badge and the Stats Runtime status row, so an unscoped
getByText(status) after opening Stats is ambiguous.
*/
export function expectSingleStatsRuntimeStatus(status: string): void {
const statsPanel = screen.getByRole("region", { name: "Task execution statistics" });
expect(within(statsPanel).getByText(status)).toBeInTheDocument();
expect(within(statsPanel).getAllByText(status)).toHaveLength(1);
expect(screen.getByTestId("task-detail-status-badge")).toHaveTextContent(status);
expect(screen.getAllByText(status)).toHaveLength(2);
}
vi.mock("../../sse-bus", () => ({
subscribeSse: vi.fn((url: string, options: { events?: Record<string, (event: MessageEvent) => void> }) => {
taskDetailSseSubscriptions.push({ url, options });
return vi.fn();
}),
}));
vi.mock("../../api", async (importOriginal) => {
const { createDashboardApiMock } = await import("../../test/mockApi");
return createDashboardApiMock(() => importOriginal<typeof import("../../api")>(), {
uploadAttachment: vi.fn(),
deleteAttachment: vi.fn(),
updateTask: vi.fn().mockResolvedValue({}),
repairOverlapBlocker: vi.fn().mockResolvedValue({ repaired: true, statusCleared: false, reason: "repaired", message: "Repaired", task: makeTask() }),
summarizeTitle: vi.fn().mockResolvedValue("Generated Title"),
fetchTaskDetail: vi.fn().mockResolvedValue(makeTask()),
fetchTaskPrompt: vi.fn().mockResolvedValue({ id: "FN-099", prompt: "# Task FN-099" }),
// FNXC:SpecLockTaskDetail 2026-08-09-19:34: every shared detail fixture provides a stable empty retained-evidence response.
fetchSpecLock: vi.fn().mockResolvedValue({ latestLock: null, activeLock: null, currentPlan: null, report: null, latestReport: null, history: { locks: [], currentPlans: [], reports: [] } }),
// FNXC:DashboardTests 2026-07-19-01:20: FN-8296 TaskDetail polls verification request status.
fetchTaskVerificationRequest: vi.fn().mockResolvedValue(null),
fetchAgentLogs: vi.fn().mockResolvedValue([]),
requestSpecRevision: vi.fn().mockResolvedValue({}),
rebuildTaskSpec: vi.fn().mockResolvedValue(makeTask({ column: "triage", status: "needs-replan" })),
approvePlan: vi.fn().mockResolvedValue({}),
rejectPlan: vi.fn().mockResolvedValue({}),
duplicateTask: vi.fn().mockResolvedValue({}),
refineTask: vi.fn().mockResolvedValue({}),
addSteeringComment: vi.fn(),
assignTask: vi.fn().mockResolvedValue({}),
fetchAgents: vi.fn().mockResolvedValue([]),
fetchAgent: vi.fn().mockResolvedValue(null),
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
fetchNodes: vi.fn().mockResolvedValue([]),
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
fetchWorkflows: vi.fn().mockResolvedValue([]),
fetchTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null }),
fetchBoardWorkflows: vi.fn().mockResolvedValue({ flagEnabled: false, defaultWorkflowId: "", workflows: [], taskWorkflowIds: {} }),
fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]),
fetchWorkflow: vi.fn().mockResolvedValue({ id: "builtin:coding", name: "Coding", ir: { version: 1, nodes: [], edges: [] } }),
selectTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null, enabledWorkflowSteps: [] }),
submitTaskWorkflowInput: vi.fn().mockResolvedValue({ ok: true }),
approveTaskWorkflowCli: vi.fn().mockResolvedValue({ approved: "ok" }),
refineText: vi.fn(),
getRefineErrorMessage: vi.fn((err: any) => err?.message || "Failed to refine"),
updateGlobalSettings: vi.fn().mockResolvedValue({}),
pauseTask: vi.fn().mockResolvedValue({}),
unpauseTask: vi.fn().mockResolvedValue({}),
refreshPrStatus: vi.fn(),
fetchWorkflowResults: vi.fn().mockResolvedValue([]),
fetchTaskReview: vi.fn().mockResolvedValue({ reviewState: { source: "reviewer-agent", items: [], addressing: [] }, automationStatus: null, emptyMessage: "No reviewer feedback yet — this task has not produced reviewer-agent feedback in direct mode." }),
refreshTaskReview: vi.fn().mockResolvedValue({ reviewState: undefined, automationStatus: null }),
reviseTaskReviewItems: vi.fn().mockResolvedValue({ task: makeTask(), reviewState: undefined }),
ensureTaskPlannerChatSession: vi.fn().mockResolvedValue({ session: { id: "chat-task-planner", agentId: "task-planner:FN-099", title: "FN-099 planner chat", status: "active", projectId: null, modelProvider: null, modelId: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", cliSessionFile: null, cliExecutorAdapterId: null, inFlightGeneration: null } }),
fetchChatMessages: vi.fn().mockResolvedValue({ messages: [] }),
streamChatResponse: vi.fn(),
// FNXC:PlannerOversight 2026-07-04-17:00: FN-7517 default overseer-control
// mocks so every existing TaskDetailModal suite gets deterministic
// no-op behavior for the new quick oversight-level-change resolution
// fetch and the nudge/stop/explain controls, rather than falling through
// to the auto-synthesized `undefined`-resolving fallback.
fetchWorkflowSettingValues: vi.fn().mockResolvedValue({ stored: {}, effective: {}, defaults: {} }),
nudgeOverseer: vi.fn().mockResolvedValue({ applied: false, reason: "oversight-off" }),
stopOverseer: vi.fn().mockResolvedValue({ applied: true, reason: "stopped" }),
explainOverseer: vi.fn().mockResolvedValue({ snapshot: null }),
});
});
// Mock lucide-react icons used by TaskDetailModal, TaskForm, PrPanel, CustomModelDropdown
vi.mock("lucide-react", () => ({
Pencil: () => null,
Sparkles: (props: any) => React.createElement("svg", { "data-testid": "sparkles-icon", ...props }),
Globe: () => null,
GitPullRequest: () => null,
ExternalLink: () => null,
RefreshCw: () => null,
Plus: () => null,
MessageSquare: () => null,
Check: () => null,
ChevronUp: () => null,
ChevronDown: () => null,
ChevronRight: (props: any) => React.createElement("svg", { "data-testid": "chevron-right-icon", ...props }),
ArrowLeft: () => null,
Zap: () => null,
X: () => null,
Maximize2: () => null,
Minimize2: () => null,
Loader2: (props: any) => React.createElement("svg", { "data-testid": "loader2-icon", ...props }),
/*
FNXC:TaskDetailTabPersistence 2026-07-20-19:10:
FN-8394's restored mocked-session guard reaches TaskVerificationStatus. Keep
its success-icon mock available so this deterministic tab-state regression
does not depend on the unrelated lucide module implementation.
*/
CheckCircle2: () => null,
Send: (props: any) => React.createElement("svg", { "data-testid": "send-icon", ...props }),
Square: (props: any) => React.createElement("svg", { "data-testid": "square-icon", ...props }),
Info: (props: any) => React.createElement("svg", { "data-testid": "info-icon", ...props }),
Bot: () => null,
CircleDot: () => null,
XCircle: () => null,
Workflow: () => null,
GitMerge: () => null,
GitBranch: () => null,
Gitlab: () => null,
AlertTriangle: () => null,
Play: () => null,
Flag: () => null,
ArrowDown: () => null,
ArrowUp: () => null,
TriangleAlert: () => null,
Terminal: () => null,
Shield: () => null,
PauseCircle: () => null,
Split: () => null,
Merge: () => null,
Repeat: () => null,
// FNXC:Test 2026-06-24-23:30: WorkflowNodeEditor (lazy-loaded by TaskDetailModal) uses ToggleRight
// for the optional-group node (FN-6880); the explicit mock list omitted it, breaking every
// TaskDetailModal suite at import. Keep this list in sync with the node-editor icon set.
ToggleRight: () => null,
ClipboardCheck: () => null,
ListChecks: () => null,
Code2: () => null,
Cpu: () => null,
Bell: () => null,
/*
FNXC:DashboardTests 2026-07-16-16:00:
FN-8194 replaces the Task Detail oversight dots with Eye and adds Paperclip
to its inline action row. Keep both exports in the shared modal mock so every
focused TaskDetailModal suite mounts the production affordances.
*/
Paperclip: (props: any) => React.createElement("svg", { "data-testid": "paperclip-icon", ...props }),
Eye: (props: any) => React.createElement("svg", { "data-testid": "eye-icon", ...props }),
EyeOff: (props: any) => React.createElement("svg", { "data-testid": "eye-off-icon", ...props }),
// FNXC:Test 2026-07-05-11:20: FN-7579 added "ask-user"/"exit-gate" workflow node types to
// WorkflowNodeTypes.tsx (HelpCircle, DoorOpen), which WorkflowNodeEditor/WorkflowResultsTab
// import transitively behind TaskDetailModal's lazy workflow surfaces. The explicit mock list
// omitted them, breaking every TaskDetailModal suite at import (pre-existing gap, unrelated to
// FN-7582's copy change) — keep this list in sync with the node-editor icon set.
HelpCircle: () => null,
DoorOpen: () => null,
/*
FNXC:ReviewArtifacts 2026-07-18-13:25:
FN-8286 mounts ArtifactsGallery + TaskReviewTab from TaskDetailModal. Their lucide imports
(Image/FileText/FileType/Video/AudioLines/Package/Download/User) must stay on this shared mock
or every focused TaskDetailModal suite fails at import with missing lucide exports.
*/
Image: (props: any) => React.createElement("svg", { "data-testid": "image-icon", ...props }),
FileText: (props: any) => React.createElement("svg", { "data-testid": "file-text-icon", ...props }),
FileType: (props: any) => React.createElement("svg", { "data-testid": "file-type-icon", ...props }),
Video: (props: any) => React.createElement("svg", { "data-testid": "video-icon", ...props }),
AudioLines: (props: any) => React.createElement("svg", { "data-testid": "audio-lines-icon", ...props }),
Package: (props: any) => React.createElement("svg", { "data-testid": "package-icon", ...props }),
Download: (props: any) => React.createElement("svg", { "data-testid": "download-icon", ...props }),
User: (props: any) => React.createElement("svg", { "data-testid": "user-icon", ...props }),
/*
FNXC:NativeStructureEmbed 2026-07-19-04:30:
FN-8288/FN-8291/8292/8293 mount NativeStructurePreview from chat/mail surfaces reachable via
StandardChatSurface under TaskDetailModal. Keep Map/Lightbulb/BarChart3/Target/CircleAlert
on this shared mock or every focused TaskDetailModal suite fails at import.
*/
Map: (props: any) => React.createElement("svg", { "data-testid": "map-icon", ...props }),
Lightbulb: (props: any) => React.createElement("svg", { "data-testid": "lightbulb-icon", ...props }),
BarChart3: (props: any) => React.createElement("svg", { "data-testid": "bar-chart-3-icon", ...props }),
Target: (props: any) => React.createElement("svg", { "data-testid": "target-icon", ...props }),
CircleAlert: (props: any) => React.createElement("svg", { "data-testid": "circle-alert-icon", ...props }),
}));
vi.mock("../../hooks/useAgentLogs", () => ({
useAgentLogs: vi.fn(() => ({ entries: [], loading: false, clear: vi.fn(), loadMore: vi.fn(async () => {}), hasMore: false, total: null, loadingMore: false })),
}));
// Mock usePluginUiSlots hook
export const mockUsePluginUiSlots = vi.fn((_projectId?: string) => ({
slots: [] as any[],
getSlotsForId: vi.fn((_id: string) => [] as any[]),
loading: false,
error: null,
}));
vi.mock("../../hooks/usePluginUiSlots", () => ({
usePluginUiSlots: (projectId?: string) => mockUsePluginUiSlots(projectId),
}));
export const mockConfirm = vi.fn();
export const mockConfirmWithChoice = vi.fn();
export const mockConfirmWithCheckbox = vi.fn();
vi.mock("../../hooks/useConfirm", () => ({
useConfirm: () => ({
confirm: mockConfirm,
confirmWithChoice: mockConfirmWithChoice,
confirmWithCheckbox: mockConfirmWithCheckbox,
}),
}));
export function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
return {
id: "FN-099",
description: "Test task",
column: "in-progress" as Column,
dependencies: [],
prompt: "",
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
...overrides,
} as TaskDetail;
}
/**
* FNXC:PlannerOversight 2026-08-09-08:59:
* Mutation-response fixtures must model TaskStore's advancing update clock.
* `mergeTaskSnapshot` intentionally preserves populated fields from an equal-clock sparse response,
* so reusing `makeTask`'s fixed clock would simulate a stale payload rather than a server mutation.
*/
export function makeUpdatedTask(current: TaskDetail, patch: Partial<TaskDetail>): TaskDetail {
const currentUpdatedAt = Date.parse(current.updatedAt);
const nextUpdatedAt = new Date(
Number.isFinite(currentUpdatedAt) ? currentUpdatedAt + 1_000 : Date.now(),
).toISOString().replace(/\.\d{3}Z$/, "Z");
return makeTask({ ...current, ...patch, updatedAt: nextUpdatedAt });
}
/*
FNXC:DashboardTests 2026-08-05-07:32:
FN-8803 confirms initial slim-task hydration uses `fetchTaskDetail`, while visible
Definition refresh uses `fetchTaskPrompt`. Both mocks must retain Promise-returning
defaults after an individual test resets them, so later tests cannot leak an impossible
undefined response into either production request boundary.
*/
export async function resetTaskDetailFetchMock(): Promise<void> {
const { fetchTaskDetail } = await import("../../api");
vi.mocked(fetchTaskDetail).mockReset();
vi.mocked(fetchTaskDetail).mockResolvedValue(makeTask());
}
export async function resetTaskPromptFetchMock(): Promise<void> {
const { fetchTaskPrompt } = await import("../../api");
vi.mocked(fetchTaskPrompt).mockReset();
vi.mocked(fetchTaskPrompt).mockResolvedValue({ id: "FN-099", prompt: "# Task FN-099" });
}
export const noop = vi.fn();
export const noopMove = vi.fn(async () => ({}) as Task);
export const noopDelete = vi.fn(async () => ({}) as Task);
export const noopMerge = vi.fn(async () => ({ merged: false }) as MergeResult);
export const noopRetry = vi.fn(async () => ({}) as Task);
export const noopOpenDetail = vi.fn();
export function getCssRuleBlock(css: string, selector: string): string {
const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const ruleMatch = css.match(new RegExp(`${escapedSelector}\\s*\\{([^}]*)\\}`));
return ruleMatch?.[1] ?? "";
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
export function expectBaseRule(css: string, selector: string, declaration: string): void {
const pattern = new RegExp(`${escapeRegExp(selector)}\\s*\\{[^}]*${escapeRegExp(declaration)}`);
expect(pattern.test(css)).toBe(true);
}
export function readDashboardStylesSource(): string {
return loadAllAppCss();
}
export function loadDashboardCss(): string {
return loadAllAppCss();
}
export function setupTaskDetailModalHooks(): void {
beforeEach(async () => {
// FNXC:DashboardTests 2026-08-05-07:32: Every TaskDetailModal suite begins with Promise-returning full-detail and narrow-prompt contracts; tests layer pending/rejected/custom responses after these resets.
await resetTaskDetailFetchMock();
await resetTaskPromptFetchMock();
mockConfirm.mockReset();
mockConfirmWithChoice.mockReset();
mockConfirmWithCheckbox.mockReset();
mockConfirm.mockResolvedValue(true);
mockConfirmWithChoice.mockResolvedValue("primary");
mockConfirmWithCheckbox.mockResolvedValue({ choice: "primary", checkboxValue: false });
clearAuthToken();
localStorage.removeItem("fn.authToken");
taskDetailSseSubscriptions.length = 0;
});
afterEach(() => {
clearAuthToken();
localStorage.removeItem("fn.authToken");
});
}