Files
fusion/packages/dashboard/app/components/__tests__/TaskDetailModal.test-helpers.ts
gsxdsm 59803c243d FN-7123: use workflow settings for task models
Task model displays now resolve task-scoped workflow settings consistently with execution.

- Add a shared workflow settings overlay helper for stored workflow values and declaration defaults.
- Expose task effective settings through the dashboard API and consume them in TaskDetail model surfaces.
- Cover core overlay behavior, route output, and Workflow tab model rendering with regression tests.
- Document workflow model lane precedence and add a published package changeset.

Files changed:
 .changeset/fn-7123-workflow-tab-project-model.md   |   7 ++
 docs/settings-reference.md                         |  14 ++-
 .../__tests__/effective-settings-overlay.test.ts   |  70 ++++++++++++++
 packages/core/src/effective-settings-overlay.ts    |  27 ++++++
 packages/core/src/index.ts                         |   4 +
 packages/dashboard/app/api/legacy.ts               |   5 +
 .../dashboard/app/components/TaskDetailModal.tsx   |  13 ++-
 ...skDetailModal.models-progress-workflow.test.tsx |  46 +++++++++
 .../__tests__/TaskDetailModal.test-helpers.ts      |   1 +
 .../__tests__/WorkflowResultsTab.test.tsx          |  33 +++++++
 packages/dashboard/app/test/mockApi.ts             |   9 ++
 .../task-effective-settings-route.test.ts          | 107 +++++++++++++++++++++
 .../src/routes/register-task-workflow-routes.ts    |  21 ++++
 packages/engine/src/effective-settings.ts          |  25 ++---
 14 files changed, 355 insertions(+), 27 deletions(-)

Fusion-Task-Id: FN-7123

Fusion-Task-Lineage: 5fa9d338-c886-44aa-bdba-0d361b3602d5

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-06-27 12:20:42 -07:00

209 lines
8.3 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 } 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 };
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()),
fetchAgentLogs: vi.fn().mockResolvedValue([]),
requestSpecRevision: vi.fn().mockResolvedValue({}),
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: [] }),
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 }),
});
});
// 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 }),
Send: (props: any) => React.createElement("svg", { "data-testid": "send-icon", ...props }),
Bot: () => null,
CircleDot: () => null,
XCircle: () => null,
Workflow: () => null,
GitMerge: () => null,
GitBranch: () => null,
AlertTriangle: () => null,
Play: () => null,
Flag: () => 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,
}));
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;
}
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(() => {
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");
});
}