feat(core,dashboard): workflow-centric task creation with atomic workflowId materialization
This commit is contained in:
@@ -23,6 +23,23 @@ function linearIr(): WorkflowIr {
|
||||
};
|
||||
}
|
||||
|
||||
/** A single-node fragment IR (start → one node → end). */
|
||||
function fragmentIr(): WorkflowIr {
|
||||
return {
|
||||
version: "v1",
|
||||
name: "frag",
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "step-1", kind: "prompt", config: { name: "Doc", prompt: "doc it" } },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "step-1", condition: "success" },
|
||||
{ from: "step-1", to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function branchingIr(): WorkflowIr {
|
||||
return {
|
||||
version: "v1",
|
||||
@@ -172,4 +189,71 @@ describe("TaskStore workflow selection (U3)", () => {
|
||||
await store.setDefaultWorkflowId(null);
|
||||
expect(await store.getDefaultWorkflowId()).toBeUndefined();
|
||||
});
|
||||
|
||||
// U6/R3/KTD-4: create-time `workflowId` materializes the selection atomically.
|
||||
describe("create-time workflowId (U6/R3)", () => {
|
||||
it("materializes enabledWorkflowSteps atomically when workflowId is given", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "Pick", ir: linearIr() });
|
||||
|
||||
const task = await store.createTask({ description: "with workflow", workflowId: wf.id });
|
||||
// Reading the task right after create observes the populated steps — no
|
||||
// intermediate empty state visible to the executor.
|
||||
const detail = await store.getTask(task.id);
|
||||
expect(detail.enabledWorkflowSteps).toHaveLength(2);
|
||||
expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(wf.id);
|
||||
expect(store.getTaskWorkflowSelection(task.id)?.stepIds).toEqual(detail.enabledWorkflowSteps);
|
||||
});
|
||||
|
||||
it("explicit workflowId overrides the project default", async () => {
|
||||
const def = await store.createWorkflowDefinition({ name: "Default", ir: linearIr() });
|
||||
const chosen = await store.createWorkflowDefinition({ name: "Chosen", ir: linearIr() });
|
||||
await store.setDefaultWorkflowId(def.id);
|
||||
|
||||
const task = await store.createTask({ description: "override default", workflowId: chosen.id });
|
||||
expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(chosen.id);
|
||||
});
|
||||
|
||||
it("workflowId: null skips default materialization (explicit No workflow)", async () => {
|
||||
const def = await store.createWorkflowDefinition({ name: "Default", ir: linearIr() });
|
||||
await store.setDefaultWorkflowId(def.id);
|
||||
|
||||
const task = await store.createTask({ description: "no workflow", workflowId: null });
|
||||
const detail = await store.getTask(task.id);
|
||||
expect(detail.enabledWorkflowSteps ?? []).toHaveLength(0);
|
||||
expect(store.getTaskWorkflowSelection(task.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("undefined workflowId still inherits the project default (unchanged)", async () => {
|
||||
const def = await store.createWorkflowDefinition({ name: "Default", ir: linearIr() });
|
||||
await store.setDefaultWorkflowId(def.id);
|
||||
|
||||
const task = await store.createTask({ description: "inherit" });
|
||||
const detail = await store.getTask(task.id);
|
||||
expect(detail.enabledWorkflowSteps).toHaveLength(2);
|
||||
expect(store.getTaskWorkflowSelection(task.id)?.workflowId).toBe(def.id);
|
||||
});
|
||||
|
||||
it("rejects a fragment id before creating the task row", async () => {
|
||||
const frag = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" });
|
||||
const before = (await store.listTasks({ includeArchived: true })).length;
|
||||
|
||||
await expect(
|
||||
store.createTask({ description: "frag pick", workflowId: frag.id }),
|
||||
).rejects.toThrow(/fragment/i);
|
||||
|
||||
const after = (await store.listTasks({ includeArchived: true })).length;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it("rejects an unknown workflow id before creating the task row", async () => {
|
||||
const before = (await store.listTasks({ includeArchived: true })).length;
|
||||
|
||||
await expect(
|
||||
store.createTask({ description: "bad pick", workflowId: "WF-404" }),
|
||||
).rejects.toThrow(/not found/i);
|
||||
|
||||
const after = (await store.listTasks({ includeArchived: true })).length;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3975,7 +3975,24 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
// When a project default workflow is configured, new tasks inherit it
|
||||
// (compiled to steps) ahead of the legacy default-on step behavior.
|
||||
let pendingWorkflowSelection: { workflowId: string; stepIds: string[] } | undefined;
|
||||
if (input.enabledWorkflowSteps === undefined) {
|
||||
// U6/R3/KTD-4: an explicit create-time workflowId beats the project default.
|
||||
// `null` is an explicit opt-out (no workflow), `string` materializes that
|
||||
// workflow, `undefined` falls through to the default-workflow behavior below.
|
||||
// Explicit enabledWorkflowSteps still wins over workflowId for trusted callers.
|
||||
const explicitWorkflowId =
|
||||
input.enabledWorkflowSteps === undefined ? input.workflowId : undefined;
|
||||
if (explicitWorkflowId !== undefined) {
|
||||
if (explicitWorkflowId === null) {
|
||||
// Explicit "No workflow": skip default materialization entirely.
|
||||
resolvedWorkflowSteps = undefined;
|
||||
} else {
|
||||
// Compile + materialize up front so unknown/fragment ids throw BEFORE
|
||||
// the task row is created (no orphaned steps, no half-created task).
|
||||
const selected = await this.materializeExplicitWorkflowSteps(explicitWorkflowId);
|
||||
resolvedWorkflowSteps = selected.stepIds;
|
||||
pendingWorkflowSelection = selected;
|
||||
}
|
||||
} else if (input.enabledWorkflowSteps === undefined) {
|
||||
try {
|
||||
const inherited = await this.materializeDefaultWorkflowSteps();
|
||||
if (inherited) {
|
||||
@@ -13448,6 +13465,25 @@ ${stepsSection}`;
|
||||
return { workflowId, stepIds };
|
||||
}
|
||||
|
||||
/** Resolve an EXPLICITLY requested workflow id (U6/R3/KTD-4) into materialized
|
||||
* step ids for the create-time `workflowId` parameter. Unlike
|
||||
* `materializeDefaultWorkflowSteps`, unknown ids and fragments are hard errors
|
||||
* (thrown BEFORE any task row is created) rather than silent fallbacks, since
|
||||
* the caller asked for a specific workflow. Compilation happens up front so a
|
||||
* non-compilable workflow aborts before any rows are written. */
|
||||
private async materializeExplicitWorkflowSteps(
|
||||
workflowId: string,
|
||||
): Promise<{ workflowId: string; stepIds: string[] }> {
|
||||
const def = await this.getWorkflowDefinition(workflowId);
|
||||
if (!def) throw new Error(`Workflow '${workflowId}' not found`);
|
||||
if (def.kind === "fragment") {
|
||||
throw new Error(`Workflow '${workflowId}' is a fragment and cannot be selected for a task`);
|
||||
}
|
||||
const inputs = compileWorkflowToSteps(def.ir);
|
||||
const stepIds = await this.materializeWorkflowSteps(workflowId, inputs);
|
||||
return { workflowId, stepIds };
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a workflow for a task: compile it, materialize its steps, and write
|
||||
* their ids into the task's enabledWorkflowSteps. Replaces any prior selection
|
||||
|
||||
@@ -2291,6 +2291,23 @@ export interface TaskCreateInput {
|
||||
noCommitsExpected?: boolean;
|
||||
/** IDs of workflow steps to enable for this task */
|
||||
enabledWorkflowSteps?: string[];
|
||||
/**
|
||||
* Workflow selection applied atomically at task creation (U6/R3/KTD-4).
|
||||
*
|
||||
* Semantics:
|
||||
* - `undefined` → inherit the project default workflow (today's behavior:
|
||||
* `materializeDefaultWorkflowSteps` runs, falling back to default-on steps).
|
||||
* - `null` → explicitly NO workflow: skip default materialization entirely;
|
||||
* the task is created with no custom workflow steps.
|
||||
* - `string` → that workflow's compiled steps are materialized and selected
|
||||
* inside the creation flow, overriding any project default. Fragment IDs
|
||||
* and unknown IDs are rejected with a clear error BEFORE the task row is
|
||||
* created.
|
||||
*
|
||||
* Mutually exclusive with `enabledWorkflowSteps`: when `enabledWorkflowSteps`
|
||||
* is provided, it takes precedence and `workflowId` materialization is skipped.
|
||||
*/
|
||||
workflowId?: string | null;
|
||||
/** Model preset selected during task creation. Presets resolve to concrete model overrides at creation time. */
|
||||
modelPresetId?: string;
|
||||
/** AI model provider override for the executor agent (e.g., "anthropic").
|
||||
|
||||
@@ -371,6 +371,7 @@ export async function createTask(
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
enabledWorkflowSteps,
|
||||
workflowId,
|
||||
assignedAgentId,
|
||||
modelPresetId,
|
||||
modelProvider,
|
||||
@@ -407,6 +408,7 @@ export async function createTask(
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
enabledWorkflowSteps,
|
||||
workflowId,
|
||||
assignedAgentId,
|
||||
modelPresetId,
|
||||
modelProvider,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { DEFAULT_TASK_PRIORITY, type Task, type TaskCreateInput, type TaskPriority } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { uploadAttachment, selectTaskWorkflow } from "../api";
|
||||
import { uploadAttachment } from "../api";
|
||||
import { Bot } from "lucide-react";
|
||||
import { useSetupReadiness } from "../hooks/useSetupReadiness";
|
||||
import { SetupWarningBanner } from "./SetupWarningBanner";
|
||||
@@ -16,7 +16,6 @@ import { useMobileScrollLock } from "../hooks/useMobileScrollLock";
|
||||
import { useNodes } from "../hooks/useNodes";
|
||||
import { useViewportMode } from "../hooks/useViewportMode";
|
||||
import { useAgentsMapCache } from "../hooks/useAgentsMapCache";
|
||||
import { WorkflowSelector } from "./WorkflowSelector";
|
||||
|
||||
interface NewTaskModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -56,9 +55,10 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
const [selectedPresetId, setSelectedPresetId] = useState<string>("");
|
||||
const [presetMode, setPresetMode] = useState<"default" | "preset" | "custom">("default");
|
||||
const [hasDirtyState, setHasDirtyState] = useState(false);
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null>(null);
|
||||
const [selectedWorkflowSteps, setSelectedWorkflowSteps] = useState<string[]>([]);
|
||||
const [workflowStepsExplicitlySet, setWorkflowStepsExplicitlySet] = useState(false);
|
||||
// U6/R3: tri-state workflow selection. `undefined` = inherit project default,
|
||||
// `null` = explicit "No workflow", `string` = a specific workflow. Materialized
|
||||
// atomically at create time via the `workflowId` create parameter.
|
||||
const [selectedWorkflowId, setSelectedWorkflowId] = useState<string | null | undefined>(undefined);
|
||||
const [reviewLevel, setReviewLevel] = useState<number | undefined>(undefined);
|
||||
const [autoMerge, setAutoMerge] = useState<boolean | undefined>(undefined);
|
||||
const [priority, setPriority] = useState<TaskPriority>(DEFAULT_TASK_PRIORITY);
|
||||
@@ -80,18 +80,6 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
const { hasAiProvider, hasGithub, loading: setupReadinessLoading } = useSetupReadiness(projectId);
|
||||
const { nodes } = useNodes();
|
||||
|
||||
// Handler for workflow step changes that detects explicit user interaction
|
||||
const handleWorkflowStepsChange = useCallback((steps: string[]) => {
|
||||
setWorkflowStepsExplicitlySet(true);
|
||||
setSelectedWorkflowSteps(steps);
|
||||
}, []);
|
||||
|
||||
// Callback when defaultOn steps are auto-applied by TaskForm
|
||||
const handleDefaultOnApplied = useCallback(() => {
|
||||
// defaultOn auto-selection is not "explicit" user interaction
|
||||
setWorkflowStepsExplicitlySet(false);
|
||||
}, []);
|
||||
|
||||
// Load agents for agent picker
|
||||
const loadAgents = useCallback(() => {
|
||||
setShowAgentPicker(true);
|
||||
@@ -159,12 +147,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
description.trim() !== "" ||
|
||||
dependencies.length > 0 ||
|
||||
pendingImages.length > 0 ||
|
||||
selectedWorkflowId !== null ||
|
||||
selectedWorkflowId !== undefined ||
|
||||
executorModel !== "" ||
|
||||
validatorModel !== "" ||
|
||||
planningModel !== "" ||
|
||||
thinkingLevel !== "" ||
|
||||
selectedWorkflowSteps.length > 0 ||
|
||||
selectedAgentId !== null ||
|
||||
reviewLevel !== undefined ||
|
||||
autoMerge !== undefined ||
|
||||
@@ -176,7 +163,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
githubTrackingEnabled ||
|
||||
githubRepoOverrideTrimmed !== "";
|
||||
setHasDirtyState(isDirty);
|
||||
}, [description, dependencies, pendingImages, selectedWorkflowId, executorModel, validatorModel, planningModel, thinkingLevel, selectedWorkflowSteps, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
|
||||
}, [description, dependencies, pendingImages, selectedWorkflowId, executorModel, validatorModel, planningModel, thinkingLevel, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed]);
|
||||
|
||||
const handleClose = useCallback(async () => {
|
||||
if (hasDirtyState) {
|
||||
@@ -199,9 +186,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setThinkingLevel("");
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setSelectedWorkflowId(null);
|
||||
setSelectedWorkflowSteps([]);
|
||||
setWorkflowStepsExplicitlySet(false);
|
||||
setSelectedWorkflowId(undefined);
|
||||
setSelectedAgentId(null);
|
||||
setShowAgentPicker(false);
|
||||
setReviewLevel(undefined);
|
||||
@@ -238,9 +223,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
description: trimmedDesc,
|
||||
column: "triage",
|
||||
dependencies: dependencies.length ? dependencies : undefined,
|
||||
// When user explicitly cleared all workflow steps, send empty array to prevent backend re-applying defaults.
|
||||
// When user hasn't interacted with workflow steps (or left auto-selected defaults), send undefined to let backend apply defaults.
|
||||
enabledWorkflowSteps: workflowStepsExplicitlySet ? (selectedWorkflowSteps.length > 0 ? selectedWorkflowSteps : []) : undefined,
|
||||
// U6/R3: forward the workflow selection only when the user changed it.
|
||||
// - undefined → omit (store inherits the project default, today's behavior)
|
||||
// - null → explicit "No workflow" (store skips default materialization)
|
||||
// - string → that workflow, materialized atomically at create time.
|
||||
...(selectedWorkflowId !== undefined ? { workflowId: selectedWorkflowId } : {}),
|
||||
...(selectedAgentId ? { assignedAgentId: selectedAgentId } : {}),
|
||||
modelPresetId: presetMode === "preset" ? selectedPresetId || undefined : undefined,
|
||||
modelProvider: executorModel && executorSlashIdx !== -1 ? executorModel.slice(0, executorSlashIdx) : undefined,
|
||||
@@ -269,17 +256,11 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
: {}),
|
||||
};
|
||||
|
||||
// U6/R3: the workflow is now materialized atomically inside createTask via
|
||||
// the `workflowId` parameter — no post-create selectTaskWorkflow call, so
|
||||
// the executor can never observe the task with the wrong step set.
|
||||
const task = await onCreateTask(createInput);
|
||||
|
||||
// Apply custom workflow if selected (non-blocking — task already exists)
|
||||
if (selectedWorkflowId) {
|
||||
try {
|
||||
await selectTaskWorkflow(task.id, selectedWorkflowId, projectId);
|
||||
} catch (err) {
|
||||
addToast(getErrorMessage(err) || "Failed to apply workflow", "error");
|
||||
}
|
||||
}
|
||||
|
||||
// Upload pending images as attachments
|
||||
if (pendingImages.length > 0) {
|
||||
const failures: string[] = [];
|
||||
@@ -306,9 +287,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
setThinkingLevel("");
|
||||
setSelectedPresetId("");
|
||||
setPresetMode("default");
|
||||
setSelectedWorkflowId(null);
|
||||
setSelectedWorkflowSteps([]);
|
||||
setWorkflowStepsExplicitlySet(false);
|
||||
setSelectedWorkflowId(undefined);
|
||||
setSelectedAgentId(null);
|
||||
setShowAgentPicker(false);
|
||||
setReviewLevel(undefined);
|
||||
@@ -326,7 +305,7 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowId, selectedWorkflowSteps, workflowStepsExplicitlySet, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, t]);
|
||||
}, [description, dependencies, pendingImages, executorModel, validatorModel, planningModel, thinkingLevel, isSubmitting, githubRepoOverrideInvalid, hasInvalidBranchSelection, onCreateTask, addToast, onClose, projectId, presetMode, selectedPresetId, selectedWorkflowId, selectedAgentId, reviewLevel, autoMerge, priority, nodeId, branchMode, isBranchNameRequired, branch, baseBranch, githubTrackingEnabled, githubRepoOverrideTrimmed, t]);
|
||||
|
||||
// Handle keyboard shortcuts
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
@@ -468,17 +447,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Custom Workflow */}
|
||||
<div className="form-group">
|
||||
<WorkflowSelector
|
||||
value={selectedWorkflowId}
|
||||
onChange={(id) => setSelectedWorkflowId(id)}
|
||||
projectId={projectId}
|
||||
addToast={addToast}
|
||||
label="Custom workflow"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
{/* U6/R3: the workflow picker now lives inside TaskForm (a whole-workflow
|
||||
dropdown materialized atomically at create time), replacing the prior
|
||||
standalone WorkflowSelector + post-create selectTaskWorkflow flow. */}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -520,9 +491,8 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
|
||||
onPresetModeChange={setPresetMode}
|
||||
selectedPresetId={selectedPresetId}
|
||||
onSelectedPresetIdChange={setSelectedPresetId}
|
||||
selectedWorkflowSteps={selectedWorkflowSteps}
|
||||
onWorkflowStepsChange={handleWorkflowStepsChange}
|
||||
onDefaultOnApplied={handleDefaultOnApplied}
|
||||
selectedWorkflowId={selectedWorkflowId}
|
||||
onWorkflowIdChange={setSelectedWorkflowId}
|
||||
pendingImages={pendingImages}
|
||||
onImagesChange={setPendingImages}
|
||||
tasks={tasks}
|
||||
|
||||
@@ -2468,8 +2468,6 @@ export function TaskDetailContent({
|
||||
onPresetModeChange={setEditPresetMode}
|
||||
selectedPresetId={editSelectedPresetId}
|
||||
onSelectedPresetIdChange={setEditSelectedPresetId}
|
||||
selectedWorkflowSteps={editSelectedWorkflowSteps}
|
||||
onWorkflowStepsChange={setEditSelectedWorkflowSteps}
|
||||
pendingImages={editPendingImages}
|
||||
onImagesChange={setEditPendingImages}
|
||||
tasks={tasks.filter((t) => t.id !== task.id)}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useState, useCallback, useEffect, useRef, type ReactNode } from "react";
|
||||
import { useState, useCallback, useEffect, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type GlobalSettings, type Task, type TaskPriority, type Settings, type WorkflowStep } from "@fusion/core";
|
||||
import { DEFAULT_TASK_PRIORITY, TASK_PRIORITIES, type GlobalSettings, type Task, type TaskPriority, type Settings, type WorkflowDefinition } from "@fusion/core";
|
||||
import type { ToastType } from "../hooks/useToast";
|
||||
import { fetchModels, fetchSettings, fetchWorkflowSteps, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, fetchGitBranches, type RefinementType, type ModelInfo, type NodeInfo } from "../api";
|
||||
import { fetchModels, fetchSettings, fetchWorkflows, refineText, getRefineErrorMessage, updateGlobalSettings, fetchGlobalSettings, fetchGitBranches, type RefinementType, type ModelInfo, type NodeInfo } from "../api";
|
||||
import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/modelPresets";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { NodeHealthDot } from "./NodeHealthDot";
|
||||
import { Sparkles, ChevronUp, ChevronDown, X, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { Sparkles, ChevronUp, ChevronDown, Maximize2, Minimize2 } from "lucide-react";
|
||||
import { REPO_OVERRIDE_RE, resolveEffectiveGithubRepoDefault } from "./githubTracking";
|
||||
|
||||
function getNodeStatusLabel(status: NodeInfo["status"], t: (key: string, defaultValue: string) => string): string {
|
||||
@@ -39,17 +39,6 @@ function sortBranchNames(branches: string[]): string[] {
|
||||
}
|
||||
|
||||
/** Renders a phase badge using shared .phase-badge classes for consistency */
|
||||
function phaseBadge(phase: "pre-merge" | "post-merge", id: string, prefix: string, t: (key: string, defaultValue: string) => string): ReactNode {
|
||||
const phaseClass = phase === "post-merge" ? "phase-badge--post-merge" : "phase-badge--pre-merge";
|
||||
return (
|
||||
<span
|
||||
className={`phase-badge ${phaseClass}`}
|
||||
data-testid={`${prefix}-${id}`}
|
||||
>
|
||||
{phase === "post-merge" ? t("taskForm.phasePostMerge", "Post-merge") : t("taskForm.phasePreMerge", "Pre-merge")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export interface PendingImage {
|
||||
file: File;
|
||||
@@ -99,11 +88,15 @@ export interface TaskFormProps {
|
||||
selectedPresetId: string;
|
||||
onSelectedPresetIdChange: (id: string) => void;
|
||||
|
||||
// Workflow steps
|
||||
selectedWorkflowSteps: string[];
|
||||
onWorkflowStepsChange: (steps: string[]) => void;
|
||||
/** Callback fired when defaultOn steps have been preselected (create mode). Parent can use this to distinguish "no selection yet" from "user explicitly cleared". */
|
||||
onDefaultOnApplied?: (stepIds: string[]) => void;
|
||||
// Workflow selection (U6/R3). The form picks a whole workflow (not individual
|
||||
// steps), applied atomically at task creation via the create-time `workflowId`.
|
||||
// - `undefined` → inherit the project default (preselected + "(default)" badge).
|
||||
// - `null` → "No workflow" (listed first).
|
||||
// - `string` → a specific workflow id.
|
||||
// The dropdown only renders when `onWorkflowIdChange` is provided (create mode);
|
||||
// edit-mode workflow management lives in the task detail Workflow tab.
|
||||
selectedWorkflowId?: string | null;
|
||||
onWorkflowIdChange?: (workflowId: string | null) => void;
|
||||
|
||||
// Attachments
|
||||
pendingImages: PendingImage[];
|
||||
@@ -179,9 +172,8 @@ export function TaskForm({
|
||||
onPresetModeChange,
|
||||
selectedPresetId,
|
||||
onSelectedPresetIdChange,
|
||||
selectedWorkflowSteps,
|
||||
onWorkflowStepsChange,
|
||||
onDefaultOnApplied,
|
||||
selectedWorkflowId,
|
||||
onWorkflowIdChange,
|
||||
pendingImages,
|
||||
onImagesChange,
|
||||
tasks,
|
||||
@@ -212,7 +204,6 @@ export function TaskForm({
|
||||
const hasInitialMoreOptions =
|
||||
(hideDependencies ? false : dependencies.length > 0) ||
|
||||
pendingImages.length > 0 ||
|
||||
selectedWorkflowSteps.length > 0 ||
|
||||
presetMode !== "default" ||
|
||||
(priority ?? DEFAULT_TASK_PRIORITY) !== DEFAULT_TASK_PRIORITY ||
|
||||
executorModel !== "" ||
|
||||
@@ -239,7 +230,9 @@ export function TaskForm({
|
||||
const [modelsLoading, setModelsLoading] = useState(false);
|
||||
const [settings, setSettings] = useState<Settings | null>(null);
|
||||
const [globalSettings, setGlobalSettings] = useState<GlobalSettings | null>(null);
|
||||
const [workflowSteps, setWorkflowSteps] = useState<WorkflowStep[]>([]);
|
||||
// U6/R3: full workflow definitions for the picker (fragments excluded below).
|
||||
const [workflows, setWorkflows] = useState<WorkflowDefinition[]>([]);
|
||||
const [workflowsLoading, setWorkflowsLoading] = useState(false);
|
||||
const [autoSaveStatus, setAutoSaveStatus] = useState<"idle" | "saving" | "saved">("idle");
|
||||
const [baseBranchOptions, setBaseBranchOptions] = useState<string[]>([]);
|
||||
const [baseBranchCustomMode, setBaseBranchCustomMode] = useState(false);
|
||||
@@ -276,13 +269,19 @@ export function TaskForm({
|
||||
fetchSettings(projectId)
|
||||
.then((nextSettings) => setSettings(nextSettings))
|
||||
.catch(() => setSettings(null));
|
||||
fetchWorkflowSteps(projectId)
|
||||
.then((steps) => setWorkflowSteps(steps.filter((s) => s.enabled)))
|
||||
.catch(() => setWorkflowSteps([]));
|
||||
// U6/R3: load selectable workflows for the picker. Fragments are excluded
|
||||
// (KTD-1) so they never appear as selectable task workflows.
|
||||
if (onWorkflowIdChange) {
|
||||
setWorkflowsLoading(true);
|
||||
fetchWorkflows(projectId)
|
||||
.then((defs) => setWorkflows(defs.filter((d) => d.kind !== "fragment")))
|
||||
.catch(() => setWorkflows([]))
|
||||
.finally(() => setWorkflowsLoading(false));
|
||||
}
|
||||
fetchGlobalSettings()
|
||||
.then((nextGlobalSettings) => setGlobalSettings(nextGlobalSettings))
|
||||
.catch(() => setGlobalSettings(null));
|
||||
}, [isActive, projectId]);
|
||||
}, [isActive, projectId, onWorkflowIdChange]);
|
||||
|
||||
const availablePresets = settings?.modelPresets || [];
|
||||
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
|
||||
@@ -292,7 +291,6 @@ export function TaskForm({
|
||||
const hasMoreOptionSelections =
|
||||
(hideDependencies ? false : dependencies.length > 0) ||
|
||||
pendingImages.length > 0 ||
|
||||
selectedWorkflowSteps.length > 0 ||
|
||||
presetMode !== "default" ||
|
||||
(priority ?? DEFAULT_TASK_PRIORITY) !== DEFAULT_TASK_PRIORITY ||
|
||||
executorModel !== "" ||
|
||||
@@ -321,30 +319,9 @@ export function TaskForm({
|
||||
}
|
||||
}, [isActive, settings, availablePresets, mode]);
|
||||
|
||||
// Auto-select defaultOn workflow steps (create mode, once per activation)
|
||||
const defaultOnAppliedRef = useRef(false);
|
||||
// U6/R3: the workflow picker preselects the project default (undefined →
|
||||
// "(default)"); there is no longer a per-step defaultOn auto-select effect.
|
||||
const githubTrackingDefaultAppliedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (mode !== "create" || !isActive) return;
|
||||
if (defaultOnAppliedRef.current) return;
|
||||
if (workflowSteps.length === 0) return;
|
||||
|
||||
const defaultOnSteps = workflowSteps.filter((s) => s.defaultOn);
|
||||
if (defaultOnSteps.length === 0) return;
|
||||
|
||||
defaultOnAppliedRef.current = true;
|
||||
const stepIds = defaultOnSteps.map((s) => s.id);
|
||||
onWorkflowStepsChange(stepIds);
|
||||
onDefaultOnApplied?.(stepIds);
|
||||
}, [mode, isActive, workflowSteps]);
|
||||
|
||||
// Reset defaultOn tracking when form deactivates
|
||||
useEffect(() => {
|
||||
if (!isActive) {
|
||||
defaultOnAppliedRef.current = false;
|
||||
}
|
||||
}, [isActive]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "create" || !isActive) return;
|
||||
if (!onGithubTrackingEnabledChange) return;
|
||||
@@ -637,30 +614,8 @@ export function TaskForm({
|
||||
}
|
||||
}, [favoriteModels, favoriteProviders]);
|
||||
|
||||
// Workflow step reorder helpers
|
||||
const moveWorkflowStepUp = useCallback((index: number) => {
|
||||
if (index <= 0) return;
|
||||
const updated = [...selectedWorkflowSteps];
|
||||
[updated[index - 1], updated[index]] = [updated[index], updated[index - 1]];
|
||||
onWorkflowStepsChange(updated);
|
||||
}, [selectedWorkflowSteps, onWorkflowStepsChange]);
|
||||
|
||||
const moveWorkflowStepDown = useCallback((index: number) => {
|
||||
if (index >= selectedWorkflowSteps.length - 1) return;
|
||||
const updated = [...selectedWorkflowSteps];
|
||||
[updated[index], updated[index + 1]] = [updated[index + 1], updated[index]];
|
||||
onWorkflowStepsChange(updated);
|
||||
}, [selectedWorkflowSteps, onWorkflowStepsChange]);
|
||||
|
||||
const removeWorkflowStep = useCallback((stepId: string) => {
|
||||
onWorkflowStepsChange(selectedWorkflowSteps.filter((id) => id !== stepId));
|
||||
}, [selectedWorkflowSteps, onWorkflowStepsChange]);
|
||||
|
||||
// Build a lookup for step names.
|
||||
const workflowStepLookup = new Map<string, { name: string; description: string }>();
|
||||
for (const step of workflowSteps) {
|
||||
workflowStepLookup.set(step.id, { name: step.name, description: step.description });
|
||||
}
|
||||
// U6/R3: the project default workflow id (preselected + "(default)" badged).
|
||||
const defaultWorkflowId = settings?.defaultWorkflowId ?? null;
|
||||
|
||||
const availableDeps = tasks
|
||||
.filter((t) => !dependencies.includes(t.id))
|
||||
@@ -1326,94 +1281,63 @@ export function TaskForm({
|
||||
|
||||
{renderBelowModelConfiguration}
|
||||
|
||||
{/* Workflow Steps */}
|
||||
<div className="form-group" data-testid="workflow-steps-section">
|
||||
<label>{t("taskForm.workflowStepsLabel", "Workflow Steps")}</label>
|
||||
<div className="workflow-steps-section">
|
||||
<small className="workflow-steps-description">
|
||||
{t("taskForm.workflowStepsDescription", "Select steps to run after task implementation completes")}
|
||||
{/* Workflow picker (U6/R3). A task picks a whole workflow at creation; the
|
||||
selection is materialized atomically server-side via `workflowId`. */}
|
||||
{onWorkflowIdChange && (
|
||||
<div className="form-group" data-testid="workflow-steps-section">
|
||||
<label htmlFor="task-workflow-select">{t("taskForm.workflowLabel", "Workflow")}</label>
|
||||
{workflowsLoading ? (
|
||||
<div className="workflow-select-loading" data-testid="task-workflow-loading">
|
||||
{t("taskForm.workflowsLoading", "Loading workflows…")}
|
||||
</div>
|
||||
) : workflows.length === 0 ? (
|
||||
// Built-ins are always present, so an empty list means the fetch
|
||||
// failed. No editor-open prop is plumbed through TaskForm, so we
|
||||
// surface a plain-text CTA rather than inventing new prop wiring.
|
||||
<div className="workflow-select-cta" data-testid="task-workflow-cta">
|
||||
{t("taskForm.workflowsCta", "Set up workflows in the editor")}
|
||||
</div>
|
||||
) : (
|
||||
<select
|
||||
id="task-workflow-select"
|
||||
data-testid="task-workflow-select"
|
||||
value={
|
||||
selectedWorkflowId === null
|
||||
? "__none__"
|
||||
: selectedWorkflowId === undefined
|
||||
// Inherit project default: show the default option preselected,
|
||||
// or "No workflow" when no project default is configured.
|
||||
? (defaultWorkflowId ?? "__none__")
|
||||
: selectedWorkflowId
|
||||
}
|
||||
disabled={disabled}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
onWorkflowIdChange(next === "__none__" ? null : next);
|
||||
}}
|
||||
>
|
||||
{/* "No workflow" listed FIRST (maps to null → explicit opt-out). */}
|
||||
<option value="__none__">{t("taskForm.workflowNone", "No workflow")}</option>
|
||||
{/* Project default preselected + badged when configured. */}
|
||||
{defaultWorkflowId && workflows.some((w) => w.id === defaultWorkflowId) && (
|
||||
<option value={defaultWorkflowId}>
|
||||
{`${workflows.find((w) => w.id === defaultWorkflowId)?.name ?? defaultWorkflowId} ${t("taskForm.workflowDefaultBadge", "(default)")}`}
|
||||
</option>
|
||||
)}
|
||||
{workflows
|
||||
.filter((w) => w.id !== defaultWorkflowId)
|
||||
.map((w) => (
|
||||
<option key={w.id} value={w.id}>
|
||||
{w.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<small className="workflow-select-help" data-testid="task-workflow-help">
|
||||
{t("taskForm.workflowHelp", "The selected workflow's steps run automatically around this task's execution.")}
|
||||
</small>
|
||||
<div className="workflow-steps-list">
|
||||
{workflowSteps.length > 0 && workflowSteps.map((step) => (
|
||||
<label
|
||||
key={step.id}
|
||||
className="checkbox-label workflow-step-item"
|
||||
data-testid={`workflow-step-checkbox-${step.id}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedWorkflowSteps.includes(step.id)}
|
||||
onChange={(e) => {
|
||||
onWorkflowStepsChange(
|
||||
e.target.checked
|
||||
? [...selectedWorkflowSteps, step.id]
|
||||
: selectedWorkflowSteps.filter((id) => id !== step.id)
|
||||
);
|
||||
}}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<div>
|
||||
<span className="workflow-step-name">
|
||||
{step.name}
|
||||
{phaseBadge(step.phase || "pre-merge", step.id, "workflow-step-phase", t)}
|
||||
</span>
|
||||
<div className="workflow-step-description">
|
||||
{step.description}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected steps — execution order with reorder controls */}
|
||||
{selectedWorkflowSteps.length > 1 && (
|
||||
<div className="workflow-step-order" data-testid="workflow-step-order">
|
||||
<small className="workflow-step-order-label">{t("taskForm.executionOrderLabel", "Execution order:")}</small>
|
||||
{selectedWorkflowSteps.map((stepId, index) => {
|
||||
const stepInfo = workflowStepLookup.get(stepId);
|
||||
return (
|
||||
<div key={stepId} className="workflow-step-order-item" data-testid={`workflow-step-order-item-${stepId}`}>
|
||||
<span className="workflow-step-order-number">{index + 1}</span>
|
||||
<span className="workflow-step-order-name">{stepInfo?.name || stepId}</span>
|
||||
<div className="workflow-step-order-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={() => moveWorkflowStepUp(index)}
|
||||
disabled={disabled || index === 0}
|
||||
data-testid={`workflow-step-move-up-${stepId}`}
|
||||
title={t("taskForm.moveUp", "Move up")}
|
||||
>
|
||||
<ChevronUp />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={() => moveWorkflowStepDown(index)}
|
||||
disabled={disabled || index === selectedWorkflowSteps.length - 1}
|
||||
data-testid={`workflow-step-move-down-${stepId}`}
|
||||
title={t("taskForm.moveDown", "Move down")}
|
||||
>
|
||||
<ChevronDown />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-icon btn-sm"
|
||||
onClick={() => removeWorkflowStep(stepId)}
|
||||
disabled={disabled}
|
||||
data-testid={`workflow-step-remove-${stepId}`}
|
||||
title={t("taskForm.removeStep", "Remove")}
|
||||
>
|
||||
<X />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(onGithubTrackingEnabledChange || onGithubRepoOverrideChange) && (
|
||||
<div className="form-group" data-testid="task-form-github-tracking">
|
||||
|
||||
@@ -16,11 +16,6 @@ vi.mock("lucide-react", () => ({
|
||||
Workflow: () => null,
|
||||
}));
|
||||
|
||||
// Mock WorkflowSelector to avoid API calls in tests
|
||||
vi.mock("../WorkflowSelector", () => ({
|
||||
WorkflowSelector: ({ label }: { label?: string }) => <div data-testid="workflow-selector">{label ?? "Workflow"}</div>,
|
||||
}));
|
||||
|
||||
// Mock the api module
|
||||
vi.mock("../../api", () => ({
|
||||
uploadAttachment: vi.fn().mockResolvedValue({}),
|
||||
@@ -33,7 +28,9 @@ vi.mock("../../api", () => ({
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
}),
|
||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
// U6/R3: TaskForm's picker fetches whole workflows; the per-step
|
||||
// fetchWorkflowSteps + post-create selectTaskWorkflow flow is gone.
|
||||
fetchWorkflows: vi.fn().mockResolvedValue([]),
|
||||
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
fetchGitBranches: vi.fn().mockResolvedValue([]),
|
||||
fetchAgents: vi.fn().mockResolvedValue([]),
|
||||
@@ -41,7 +38,6 @@ vi.mock("../../api", () => ({
|
||||
refineText: vi.fn(),
|
||||
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
||||
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
selectTaskWorkflow: vi.fn().mockResolvedValue({ workflowId: null, enabledWorkflowSteps: [] }),
|
||||
}));
|
||||
|
||||
const mockConfirm = vi.fn();
|
||||
@@ -165,10 +161,10 @@ describe("NewTaskModal", () => {
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
expect(moreOptions).not.toHaveAttribute("hidden");
|
||||
});
|
||||
// Model Configuration, Attachments, and Workflow Steps are revealed
|
||||
// Model Configuration, Attachments, and the Workflow picker are revealed
|
||||
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Attachments/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Workflow Steps/i)).toBeTruthy();
|
||||
expect(screen.getByText("Workflow")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows dependencies and agent picker by default without expanding More options", () => {
|
||||
@@ -595,227 +591,91 @@ describe("NewTaskModal", () => {
|
||||
});
|
||||
|
||||
// Workflow step ordering tests (FN-836)
|
||||
describe("workflow step ordering", () => {
|
||||
it("sends selected enabledWorkflowSteps in create payload", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
describe("workflow selection (U6/R3)", () => {
|
||||
function mockWorkflows(defs: Array<{ id: string; name: string; kind?: "workflow" | "fragment" }>) {
|
||||
return import("../../api").then(({ fetchWorkflows }) => {
|
||||
vi.mocked(fetchWorkflows).mockResolvedValueOnce(
|
||||
defs.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
description: "",
|
||||
kind: d.kind ?? "workflow",
|
||||
ir: { version: "v1", name: d.name, nodes: [], edges: [] },
|
||||
layout: {},
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
})) as any,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
it("omits workflowId from the payload when the picker is untouched (inherit default)", async () => {
|
||||
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
|
||||
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
|
||||
});
|
||||
|
||||
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Inherit default" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: "Task with workflow step" } });
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalled();
|
||||
});
|
||||
const payload = vi.mocked(props.onCreateTask).mock.calls[0][0] as Record<string, unknown>;
|
||||
expect("workflowId" in payload).toBe(false);
|
||||
});
|
||||
|
||||
it("sends the chosen workflowId in the create payload", async () => {
|
||||
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
|
||||
});
|
||||
|
||||
fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "WF-1" } });
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Pick a workflow" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabledWorkflowSteps: ["WS-001"],
|
||||
}),
|
||||
expect.objectContaining({ workflowId: "WF-1" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("sends ordered enabledWorkflowSteps in create payload when steps are selected in order", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
it("sends workflowId: null when 'No workflow' is chosen", async () => {
|
||||
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
|
||||
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select WS-001, then WS-002 — order should be preserved
|
||||
const checkbox1 = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
fireEvent.click(checkbox1);
|
||||
|
||||
const checkbox2 = screen.getByTestId("workflow-step-checkbox-WS-002").querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
fireEvent.click(checkbox2);
|
||||
|
||||
// Type description and submit
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: "Ordered task" } });
|
||||
// Pick a workflow, then switch to "No workflow" to register an explicit null.
|
||||
fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "WF-1" } });
|
||||
fireEvent.change(screen.getByTestId("task-workflow-select"), { target: { value: "__none__" } });
|
||||
fireEvent.change(screen.getByRole("textbox"), { target: { value: "No workflow task" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabledWorkflowSteps: ["WS-001", "WS-002"],
|
||||
}),
|
||||
expect.objectContaining({ workflowId: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("sends reordered enabledWorkflowSteps after user reorders steps", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Select WS-001, then WS-002
|
||||
const checkbox1 = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
fireEvent.click(checkbox1);
|
||||
|
||||
const checkbox2 = screen.getByTestId("workflow-step-checkbox-WS-002").querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
fireEvent.click(checkbox2);
|
||||
|
||||
// Now reorder: move WS-002 up
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-move-up-WS-002")).toBeTruthy();
|
||||
});
|
||||
fireEvent.click(screen.getByTestId("workflow-step-move-up-WS-002"));
|
||||
|
||||
// Type description and submit
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: "Reordered task" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabledWorkflowSteps: ["WS-002", "WS-001"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// DefaultOn workflow step handling (FN-883)
|
||||
describe("defaultOn workflow step handling", () => {
|
||||
it("keeps More options collapsed by default even when defaultOn workflow steps are auto-applied", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
it("does not render the legacy per-step checkbox UI", async () => {
|
||||
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
|
||||
renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
|
||||
});
|
||||
|
||||
const toggle = screen.getByTestId("task-form-more-options-toggle");
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Attachments/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Workflow Steps/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sends undefined enabledWorkflowSteps when no defaultOn steps and user hasn't interacted", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Don't interact with workflow steps at all
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: "No interaction task" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabledWorkflowSteps: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("sends empty array when user explicitly deselects all defaultOn steps", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Wait for auto-selection to happen
|
||||
await waitFor(() => {
|
||||
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
// User explicitly deselects the auto-selected step
|
||||
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: "Deselected task" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabledWorkflowSteps: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("sends defaultOn step IDs when user doesn't modify the auto-selected steps", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security", description: "Check security", prompt: "Check", mode: "prompt" as const, enabled: true, defaultOn: false, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const { props } = renderNewTaskModal();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-checkbox-WS-001")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Wait for auto-selection to happen
|
||||
await waitFor(() => {
|
||||
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-001").querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(true);
|
||||
});
|
||||
|
||||
// Don't modify the selection — just submit.
|
||||
// Since user hasn't explicitly changed steps, the explicitlySet flag is false,
|
||||
// so the modal sends undefined (backend applies its own defaults)
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: "Auto-selected task" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(props.onCreateTask).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
enabledWorkflowSteps: undefined,
|
||||
}),
|
||||
);
|
||||
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
|
||||
});
|
||||
expect(screen.queryByTestId("workflow-step-order")).toBeNull();
|
||||
expect(document.querySelector('[data-testid^="workflow-step-checkbox-"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1195,12 +1055,12 @@ describe("NewTaskModal", () => {
|
||||
});
|
||||
|
||||
describe("GitHub tracking", () => {
|
||||
it("renders GitHub tracking after Workflow Steps in more options", async () => {
|
||||
it("renders GitHub tracking after the Workflow picker in more options", async () => {
|
||||
renderNewTaskModal();
|
||||
|
||||
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
|
||||
|
||||
const workflowLabel = await screen.findByText("Workflow Steps");
|
||||
const workflowLabel = await screen.findByText("Workflow");
|
||||
const githubTrackingSection = screen.getByTestId("task-form-github-tracking");
|
||||
|
||||
expect(
|
||||
|
||||
@@ -236,14 +236,12 @@ describe("TaskDetailModal", () => {
|
||||
await waitFor(() => {
|
||||
const modelLabel = screen.getByText("Model Configuration");
|
||||
const sourceLabel = screen.getByText("Source Issue");
|
||||
const workflowSection = screen.getByTestId("workflow-steps-section");
|
||||
|
||||
// U6/R3: the per-step workflow section no longer renders in edit mode,
|
||||
// so we only assert Source Issue stays below Model Configuration.
|
||||
expect(
|
||||
modelLabel.compareDocumentPosition(sourceLabel) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
expect(
|
||||
sourceLabel.compareDocumentPosition(workflowSection) & Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -734,9 +732,11 @@ describe("TaskDetailModal", () => {
|
||||
// Enter edit mode
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
// Model configuration and workflow steps should be present via TaskForm
|
||||
// Model configuration is present via TaskForm in edit mode.
|
||||
// U6/R3: the per-step "Workflow Steps" section was removed from TaskForm;
|
||||
// workflow management for an existing task lives in the Workflow tab.
|
||||
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
|
||||
expect(screen.getByText(/Workflow Steps/i)).toBeTruthy();
|
||||
expect(screen.queryByText(/Workflow Steps/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("save sends only changed fields via updateTask", async () => {
|
||||
@@ -1611,57 +1611,6 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
|
||||
|
||||
describe("Workflow step ordering in edit mode (FN-836)", () => {
|
||||
it("sends ordered enabledWorkflowSteps when saving with reordered steps", async () => {
|
||||
const { updateTask, fetchWorkflowSteps } = await import("../../api");
|
||||
const mockUpdate = vi.mocked(updateTask);
|
||||
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({
|
||||
id: "FN-001",
|
||||
column: "triage",
|
||||
title: "Test",
|
||||
description: "Desc",
|
||||
enabledWorkflowSteps: ["WS-001", "WS-002"],
|
||||
})}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
// Wait for workflow steps to load and reorder controls to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Move WS-002 up (swap with WS-001)
|
||||
fireEvent.click(screen.getByTestId("workflow-step-move-up-WS-002"));
|
||||
|
||||
// Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({
|
||||
enabledWorkflowSteps: ["WS-002", "WS-001"],
|
||||
}), undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("agent assignment", () => {
|
||||
it("shows Assign Agent button when task has no assigned agent", () => {
|
||||
render(
|
||||
|
||||
@@ -916,57 +916,6 @@ describe("TaskDetailModal", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workflow step ordering in edit mode (FN-836)", () => {
|
||||
it("sends ordered enabledWorkflowSteps when saving with reordered steps", async () => {
|
||||
const { updateTask, fetchWorkflowSteps } = await import("../../api");
|
||||
const mockUpdate = vi.mocked(updateTask);
|
||||
mockUpdate.mockResolvedValueOnce({ id: "FN-001" } as Task);
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const { container } = render(
|
||||
<TaskDetailModal
|
||||
task={makeTask({
|
||||
id: "FN-001",
|
||||
column: "triage",
|
||||
title: "Test",
|
||||
description: "Desc",
|
||||
enabledWorkflowSteps: ["WS-001", "WS-002"],
|
||||
})}
|
||||
onClose={noop}
|
||||
onMoveTask={noopMove}
|
||||
onDeleteTask={noopDelete}
|
||||
onMergeTask={noopMerge}
|
||||
onOpenDetail={noopOpenDetail}
|
||||
addToast={noop}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Enter edit mode
|
||||
fireEvent.click(container.querySelector(".modal-edit-btn")!);
|
||||
|
||||
// Wait for workflow steps to load and reorder controls to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Move WS-002 up (swap with WS-001)
|
||||
fireEvent.click(screen.getByTestId("workflow-step-move-up-WS-002"));
|
||||
|
||||
// Save
|
||||
fireEvent.click(screen.getByText("Save"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdate).toHaveBeenCalledWith("FN-001", expect.objectContaining({
|
||||
enabledWorkflowSteps: ["WS-002", "WS-001"],
|
||||
}), undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Workflow tab", () => {
|
||||
it.each<[string, Parameters<typeof makeTask>[0]]>([
|
||||
["empty enabledWorkflowSteps", { enabledWorkflowSteps: [] }],
|
||||
|
||||
@@ -25,7 +25,8 @@ vi.mock("../../api", () => ({
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
}),
|
||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
// U6/R3: TaskForm now fetches whole workflows (not steps) for the picker.
|
||||
fetchWorkflows: vi.fn().mockResolvedValue([]),
|
||||
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
refineText: vi.fn().mockResolvedValue("Refined text"),
|
||||
getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."),
|
||||
@@ -64,8 +65,8 @@ function renderTaskForm(props: Partial<React.ComponentProps<typeof TaskForm>> =
|
||||
onPresetModeChange: vi.fn(),
|
||||
selectedPresetId: "",
|
||||
onSelectedPresetIdChange: vi.fn(),
|
||||
selectedWorkflowSteps: [],
|
||||
onWorkflowStepsChange: vi.fn(),
|
||||
selectedWorkflowId: undefined,
|
||||
onWorkflowIdChange: vi.fn(),
|
||||
pendingImages: [],
|
||||
onImagesChange: vi.fn(),
|
||||
tasks: [],
|
||||
@@ -96,8 +97,6 @@ function renderTaskFormWithDescriptionState(props: Partial<React.ComponentProps<
|
||||
onPresetModeChange: vi.fn(),
|
||||
selectedPresetId: "",
|
||||
onSelectedPresetIdChange: vi.fn(),
|
||||
selectedWorkflowSteps: [],
|
||||
onWorkflowStepsChange: vi.fn(),
|
||||
pendingImages: [],
|
||||
onImagesChange: vi.fn(),
|
||||
tasks: [],
|
||||
@@ -642,33 +641,31 @@ describe("TaskForm", () => {
|
||||
expect(container.querySelector(".inline-create-previews")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("calls onWorkflowStepsChange when a fetched workflow step is toggled", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
it("renders a fetched workflow as a dropdown option (U6/R3)", async () => {
|
||||
const { fetchWorkflows } = await import("../../api");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValueOnce([
|
||||
{
|
||||
id: "WS-005",
|
||||
id: "WF-1",
|
||||
name: "Browser Verification",
|
||||
description: "Verify in browser",
|
||||
prompt: "Run browser verification",
|
||||
templateId: "browser-verification",
|
||||
mode: "prompt" as const,
|
||||
enabled: true,
|
||||
kind: "workflow",
|
||||
ir: { version: "v1", name: "Browser Verification", nodes: [], edges: [] },
|
||||
layout: {},
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
]);
|
||||
] as any);
|
||||
|
||||
const onWorkflowStepsChange = vi.fn();
|
||||
renderTaskForm({ onWorkflowStepsChange });
|
||||
const onWorkflowIdChange = vi.fn();
|
||||
renderTaskForm({ onWorkflowIdChange });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-checkbox-WS-005")).toBeTruthy();
|
||||
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
|
||||
});
|
||||
|
||||
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-005").querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-005"]);
|
||||
const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: "WF-1" } });
|
||||
expect(onWorkflowIdChange).toHaveBeenCalledWith("WF-1");
|
||||
});
|
||||
|
||||
it("disables all inputs when disabled prop is true", () => {
|
||||
@@ -1004,405 +1001,129 @@ describe("TaskForm preset selection (FN-819)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskForm workflow step reordering (FN-836)", () => {
|
||||
describe("TaskForm workflow picker (U6/R3)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("does not show reorder controls when no steps are selected", () => {
|
||||
renderTaskForm({ selectedWorkflowSteps: [] });
|
||||
expect(screen.queryByTestId("workflow-step-order")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not show reorder controls when only one step is selected", () => {
|
||||
renderTaskForm({ selectedWorkflowSteps: ["WS-001"] });
|
||||
expect(screen.queryByTestId("workflow-step-order")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows reorder controls when two or more steps are selected", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("workflow-step-order-item-WS-001")).toBeTruthy();
|
||||
expect(screen.getByTestId("workflow-step-order-item-WS-002")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows numbered execution order", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("1")).toBeTruthy();
|
||||
expect(screen.getByText("2")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it("disables move-up button on first step", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
|
||||
});
|
||||
|
||||
const moveUpFirst = screen.getByTestId("workflow-step-move-up-WS-001") as HTMLButtonElement;
|
||||
expect(moveUpFirst.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("disables move-down button on last step", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
|
||||
});
|
||||
|
||||
const moveDownLast = screen.getByTestId("workflow-step-move-down-WS-002") as HTMLButtonElement;
|
||||
expect(moveDownLast.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("calls onWorkflowStepsChange with swapped order when move-up is clicked", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const onWorkflowStepsChange = vi.fn();
|
||||
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"], onWorkflowStepsChange });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Move WS-002 up (swap with WS-001)
|
||||
fireEvent.click(screen.getByTestId("workflow-step-move-up-WS-002"));
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-002", "WS-001"]);
|
||||
});
|
||||
|
||||
it("calls onWorkflowStepsChange with swapped order when move-down is clicked", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const onWorkflowStepsChange = vi.fn();
|
||||
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"], onWorkflowStepsChange });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Move WS-001 down (swap with WS-002)
|
||||
fireEvent.click(screen.getByTestId("workflow-step-move-down-WS-001"));
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-002", "WS-001"]);
|
||||
});
|
||||
|
||||
it("calls onWorkflowStepsChange with step removed when remove button is clicked", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const onWorkflowStepsChange = vi.fn();
|
||||
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"], onWorkflowStepsChange });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Remove WS-001
|
||||
fireEvent.click(screen.getByTestId("workflow-step-remove-WS-001"));
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-002"]);
|
||||
});
|
||||
|
||||
it("falls back to raw step ID in reorder list when metadata is missing", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-999"] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
|
||||
});
|
||||
|
||||
const orderItem1 = screen.getByTestId("workflow-step-order-item-WS-001");
|
||||
const orderItem2 = screen.getByTestId("workflow-step-order-item-WS-999");
|
||||
|
||||
// Step metadata can resolve slightly after initial render under heavy suite load.
|
||||
// Accept either the friendly name (preferred) or raw ID fallback.
|
||||
expect(orderItem1.textContent).toMatch(/QA Check|WS-001/);
|
||||
expect(orderItem2.textContent).toContain("WS-999");
|
||||
});
|
||||
|
||||
it("preserves order when adding a new step via checkbox after reorder", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-003", name: "Doc Review", description: "Check docs", prompt: "Check docs", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const onWorkflowStepsChange = vi.fn();
|
||||
// Start with WS-001, WS-002
|
||||
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002"], onWorkflowStepsChange });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Click checkbox to add WS-003 — it should be appended
|
||||
const checkboxRow = await screen.findByTestId("workflow-step-checkbox-WS-003");
|
||||
const checkbox = checkboxRow.querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001", "WS-002", "WS-003"]);
|
||||
});
|
||||
|
||||
it("preserves order when removing a step via remove action (not reorder remove)", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-003", name: "Doc Review", description: "Check docs", prompt: "Check docs", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const onWorkflowStepsChange = vi.fn();
|
||||
renderTaskForm({ selectedWorkflowSteps: ["WS-001", "WS-002", "WS-003"], onWorkflowStepsChange });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-order")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Remove WS-002 via explicit remove action
|
||||
fireEvent.click(screen.getByTestId("workflow-step-remove-WS-002"));
|
||||
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001", "WS-003"]);
|
||||
});
|
||||
|
||||
it("shows phase badge for workflow steps with phase info", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, phase: "pre-merge", enabled: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Post-merge Notify", description: "Notify team", prompt: "Notify", mode: "prompt" as const, phase: "post-merge", enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
renderTaskForm({ selectedWorkflowSteps: [] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-phase-WS-001")).toHaveTextContent("Pre-merge");
|
||||
expect(screen.getByTestId("workflow-step-phase-WS-002")).toHaveTextContent("Post-merge");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows Pre-merge phase badge for legacy steps without phase", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "Legacy Check", description: "No phase field", prompt: "Check", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
renderTaskForm({ selectedWorkflowSteps: [] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("workflow-step-phase-WS-001")).toHaveTextContent("Pre-merge");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskForm defaultOn auto-selection (FN-883)", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("auto-selects defaultOn workflow steps in create mode", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, defaultOn: false, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const onWorkflowStepsChange = vi.fn();
|
||||
const onDefaultOnApplied = vi.fn();
|
||||
renderTaskForm({
|
||||
mode: "create",
|
||||
onWorkflowStepsChange,
|
||||
onDefaultOnApplied,
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001"]);
|
||||
});
|
||||
expect(onDefaultOnApplied).toHaveBeenCalledWith(["WS-001"]);
|
||||
});
|
||||
|
||||
it("auto-expands More options by default when advanced selections are prefilled", () => {
|
||||
renderTaskForm({
|
||||
mode: "create",
|
||||
selectedWorkflowSteps: ["WS-001"],
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("task-form-more-options-toggle")).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("stays collapsed when auto-expand is disabled even with prefilled advanced selections", () => {
|
||||
renderTaskForm({
|
||||
mode: "create",
|
||||
selectedWorkflowSteps: ["WS-001"],
|
||||
autoExpandMoreOptionsOnSelection: false,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId("task-form-more-options-toggle")).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("hides advanced controls from interaction when More options is collapsed", () => {
|
||||
renderTaskForm({ mode: "create" });
|
||||
|
||||
const toggle = screen.getByTestId("task-form-more-options-toggle");
|
||||
const moreOptions = screen.getByTestId("task-form-more-options");
|
||||
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
expect(moreOptions).toHaveAttribute("hidden");
|
||||
expect(moreOptions).toHaveAttribute("aria-hidden", "true");
|
||||
|
||||
fireEvent.click(toggle);
|
||||
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
expect(moreOptions).not.toHaveAttribute("hidden");
|
||||
expect(moreOptions).toHaveAttribute("aria-hidden", "false");
|
||||
});
|
||||
|
||||
it("does not auto-select workflow steps in edit mode", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const onWorkflowStepsChange = vi.fn();
|
||||
renderTaskForm({
|
||||
mode: "edit",
|
||||
title: "Test",
|
||||
onTitleChange: vi.fn(),
|
||||
onWorkflowStepsChange,
|
||||
});
|
||||
|
||||
// Wait for workflow steps to load
|
||||
await waitFor(() => {
|
||||
expect(fetchWorkflowSteps).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Should NOT have called onWorkflowStepsChange with defaults
|
||||
expect(onWorkflowStepsChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not re-apply defaults after user changes workflow steps", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, defaultOn: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, defaultOn: false, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
let currentSteps: string[] = [];
|
||||
const onWorkflowStepsChange = vi.fn((steps: string[]) => {
|
||||
currentSteps = steps;
|
||||
});
|
||||
|
||||
const { rerender } = renderTaskForm({
|
||||
mode: "create",
|
||||
selectedWorkflowSteps: currentSteps,
|
||||
onWorkflowStepsChange,
|
||||
});
|
||||
|
||||
// Wait for auto-selection
|
||||
await waitFor(() => {
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001"]);
|
||||
});
|
||||
|
||||
// Simulate parent state update by rerendering with new steps
|
||||
rerender(
|
||||
<TaskForm
|
||||
mode="create"
|
||||
description=""
|
||||
onDescriptionChange={vi.fn()}
|
||||
dependencies={[]}
|
||||
onDependenciesChange={vi.fn()}
|
||||
executorModel=""
|
||||
onExecutorModelChange={vi.fn()}
|
||||
validatorModel=""
|
||||
onValidatorModelChange={vi.fn()}
|
||||
presetMode="default"
|
||||
onPresetModeChange={vi.fn()}
|
||||
selectedPresetId=""
|
||||
onSelectedPresetIdChange={vi.fn()}
|
||||
selectedWorkflowSteps={currentSteps}
|
||||
onWorkflowStepsChange={onWorkflowStepsChange}
|
||||
pendingImages={[]}
|
||||
onImagesChange={vi.fn()}
|
||||
tasks={[]}
|
||||
addToast={vi.fn()}
|
||||
isActive={true}
|
||||
/>
|
||||
async function mockWorkflows(defs: Array<{ id: string; name: string; kind?: "workflow" | "fragment" }>) {
|
||||
const { fetchWorkflows } = await import("../../api");
|
||||
vi.mocked(fetchWorkflows).mockResolvedValueOnce(
|
||||
defs.map((d) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
description: "",
|
||||
kind: d.kind ?? "workflow",
|
||||
ir: { version: "v1", name: d.name, nodes: [], edges: [] },
|
||||
layout: {},
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
})) as any,
|
||||
);
|
||||
}
|
||||
|
||||
// Clear the mock to track further calls
|
||||
onWorkflowStepsChange.mockClear();
|
||||
|
||||
// Simulate user toggling WS-002 checkbox
|
||||
const checkbox = screen.getByTestId("workflow-step-checkbox-WS-002").querySelector('input[type="checkbox"]') as HTMLInputElement;
|
||||
fireEvent.click(checkbox);
|
||||
|
||||
// Should have been called with user action (adding WS-002 to existing WS-001)
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledWith(["WS-001", "WS-002"]);
|
||||
// No additional auto-selection calls
|
||||
expect(onWorkflowStepsChange).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not auto-select when no steps have defaultOn", async () => {
|
||||
const { fetchWorkflowSteps } = await import("../../api");
|
||||
vi.mocked(fetchWorkflowSteps).mockResolvedValueOnce([
|
||||
{ id: "WS-001", name: "QA Check", description: "Run tests", prompt: "Check tests", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
{ id: "WS-002", name: "Security Audit", description: "Check security", prompt: "Check security", mode: "prompt" as const, enabled: true, createdAt: "", updatedAt: "" },
|
||||
]);
|
||||
|
||||
const onWorkflowStepsChange = vi.fn();
|
||||
renderTaskForm({
|
||||
mode: "create",
|
||||
onWorkflowStepsChange,
|
||||
});
|
||||
it("renders the dropdown with 'No workflow' first and the help text", async () => {
|
||||
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
|
||||
renderTaskForm({ onWorkflowIdChange: vi.fn() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(fetchWorkflowSteps).toHaveBeenCalled();
|
||||
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
|
||||
});
|
||||
const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement;
|
||||
expect(select.options[0].textContent).toBe("No workflow");
|
||||
expect(screen.getByTestId("task-workflow-help")).toBeTruthy();
|
||||
});
|
||||
|
||||
// Should NOT have called onWorkflowStepsChange
|
||||
expect(onWorkflowStepsChange).not.toHaveBeenCalled();
|
||||
it("badges the project default workflow with (default)", async () => {
|
||||
const { fetchSettings } = await import("../../api");
|
||||
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||
modelPresets: [],
|
||||
autoSelectModelPreset: false,
|
||||
defaultPresetBySize: {},
|
||||
defaultWorkflowId: "WF-1",
|
||||
} as any);
|
||||
await mockWorkflows([
|
||||
{ id: "WF-1", name: "QA" },
|
||||
{ id: "WF-2", name: "Docs" },
|
||||
]);
|
||||
|
||||
renderTaskForm({ onWorkflowIdChange: vi.fn() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
|
||||
});
|
||||
expect(screen.getByText("QA (default)")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("excludes fragments from the dropdown", async () => {
|
||||
await mockWorkflows([
|
||||
{ id: "WF-1", name: "QA", kind: "workflow" },
|
||||
{ id: "FRAG-1", name: "Doc Fragment", kind: "fragment" },
|
||||
]);
|
||||
renderTaskForm({ onWorkflowIdChange: vi.fn() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
|
||||
});
|
||||
const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement;
|
||||
const labels = Array.from(select.options).map((o) => o.textContent);
|
||||
expect(labels).toContain("QA");
|
||||
expect(labels).not.toContain("Doc Fragment");
|
||||
});
|
||||
|
||||
it("passes the chosen workflow id via onWorkflowIdChange", async () => {
|
||||
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
|
||||
const onWorkflowIdChange = vi.fn();
|
||||
renderTaskForm({ onWorkflowIdChange });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
|
||||
});
|
||||
const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: "WF-1" } });
|
||||
expect(onWorkflowIdChange).toHaveBeenCalledWith("WF-1");
|
||||
});
|
||||
|
||||
it("maps 'No workflow' to null", async () => {
|
||||
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
|
||||
const onWorkflowIdChange = vi.fn();
|
||||
renderTaskForm({ onWorkflowIdChange, selectedWorkflowId: "WF-1" });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
|
||||
});
|
||||
const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement;
|
||||
fireEvent.change(select, { target: { value: "__none__" } });
|
||||
expect(onWorkflowIdChange).toHaveBeenCalledWith(null);
|
||||
});
|
||||
|
||||
it("shows a loading placeholder while workflows load", async () => {
|
||||
const { fetchWorkflows } = await import("../../api");
|
||||
let resolveFn: (v: unknown) => void = () => {};
|
||||
vi.mocked(fetchWorkflows).mockReturnValueOnce(
|
||||
new Promise((resolve) => {
|
||||
resolveFn = resolve;
|
||||
}) as any,
|
||||
);
|
||||
renderTaskForm({ onWorkflowIdChange: vi.fn() });
|
||||
|
||||
expect(screen.getByTestId("task-workflow-loading")).toBeTruthy();
|
||||
resolveFn([]);
|
||||
});
|
||||
|
||||
it("regression: no per-step checkboxes and no fetchWorkflowSteps usage", async () => {
|
||||
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
|
||||
renderTaskForm({ onWorkflowIdChange: vi.fn() });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("task-workflow-select")).toBeTruthy();
|
||||
});
|
||||
// The old per-step checkbox UI and execution-order controls are gone.
|
||||
expect(screen.queryByTestId("workflow-step-order")).toBeNull();
|
||||
expect(document.querySelector('[data-testid^="workflow-step-checkbox-"]')).toBeNull();
|
||||
// The api mock no longer needs a fetchWorkflowSteps export — TaskForm never
|
||||
// calls it. (If it still did, rendering above would have thrown on the
|
||||
// missing mock export, so reaching this point is itself the regression proof.)
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1511,7 +1232,8 @@ describe("TaskForm focus behavior (FN-1459)", () => {
|
||||
});
|
||||
|
||||
const modelLabel = screen.getByText("Model Configuration");
|
||||
const workflowLabel = screen.getByText("Workflow Steps");
|
||||
// U6/R3: the per-step "Workflow Steps" section is now the "Workflow" picker.
|
||||
const workflowLabel = screen.getByText("Workflow");
|
||||
const injectedBottom = screen.getByTestId("injected-below-model");
|
||||
const githubTrackingSection = screen.getByTestId("task-form-github-tracking");
|
||||
|
||||
@@ -1540,7 +1262,9 @@ describe("TaskForm focus behavior (FN-1459)", () => {
|
||||
// Dependencies label and dep-trigger should not be in the document
|
||||
expect(screen.queryByText("Dependencies")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /Add dependencies/i })).toBeNull();
|
||||
expect(screen.queryByText(/selected/i)).toBeNull();
|
||||
// The dependency "N selected" count must be absent (avoid matching the
|
||||
// workflow picker's help copy, which also contains the word "selected").
|
||||
expect(screen.queryByText(/\d+ selected/i)).toBeNull();
|
||||
});
|
||||
|
||||
it("does not auto-expand More options for dependency selections when hideDependencies is true", async () => {
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment node
|
||||
//
|
||||
// U6/R3/KTD-4: HTTP integration coverage for the create-time `workflowId`
|
||||
// parameter on POST /tasks. Exercises the route end-to-end against a REAL
|
||||
// TaskStore via createApiRoutes:
|
||||
// - workflowId → task's enabledWorkflowSteps populated atomically (the
|
||||
// materialization happens inside createTask, not via a post-create select)
|
||||
// - fragment id → 4xx (rejected before the task row is created)
|
||||
// - unknown id → 4xx
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import express from "express";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { TaskStore } from "@fusion/core";
|
||||
import type { WorkflowIr } from "@fusion/core";
|
||||
import { createApiRoutes } from "../../routes.js";
|
||||
import { request as REQUEST } from "../../test-request.js";
|
||||
|
||||
/** Linear v1 workflow with two pre-merge steps that compiles + selects cleanly. */
|
||||
function linearIr(name: string): WorkflowIr {
|
||||
return {
|
||||
version: "v1",
|
||||
name,
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "lint", kind: "gate", config: { name: "Lint", scriptName: "lint" } },
|
||||
{ id: "spec", kind: "prompt", config: { name: "Spec", prompt: "check" } },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "lint", condition: "success" },
|
||||
{ from: "lint", to: "spec", condition: "success" },
|
||||
{ from: "spec", to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
/** Single-node fragment IR (not selectable for a task). */
|
||||
function fragmentIr(): WorkflowIr {
|
||||
return {
|
||||
version: "v1",
|
||||
name: "frag",
|
||||
nodes: [
|
||||
{ id: "start", kind: "start" },
|
||||
{ id: "step-1", kind: "prompt", config: { name: "Doc", prompt: "doc it" } },
|
||||
{ id: "end", kind: "end" },
|
||||
],
|
||||
edges: [
|
||||
{ from: "start", to: "step-1", condition: "success" },
|
||||
{ from: "step-1", to: "end", condition: "success" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("POST /tasks workflowId (U6/R3)", () => {
|
||||
let store: TaskStore;
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
let app: express.Express;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = mkdtempSync(join(tmpdir(), "task-wf-route-root-"));
|
||||
globalDir = mkdtempSync(join(tmpdir(), "task-wf-route-global-"));
|
||||
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
store.close();
|
||||
rmSync(rootDir, { recursive: true, force: true });
|
||||
rmSync(globalDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const post = (path: string, body: unknown) =>
|
||||
REQUEST(app, "POST", path, JSON.stringify(body), { "content-type": "application/json" });
|
||||
|
||||
it("workflowId → created task has populated enabledWorkflowSteps", async () => {
|
||||
const wf = await store.createWorkflowDefinition({ name: "QA", ir: linearIr("qa") });
|
||||
|
||||
const res = await post("/api/tasks", { description: "with workflow", workflowId: wf.id });
|
||||
expect(res.status).toBe(201);
|
||||
const created = res.body as { id: string };
|
||||
|
||||
const detail = await store.getTask(created.id);
|
||||
expect(detail.enabledWorkflowSteps).toHaveLength(2);
|
||||
expect(store.getTaskWorkflowSelection(created.id)?.workflowId).toBe(wf.id);
|
||||
});
|
||||
|
||||
it("workflowId: null → task created with no workflow steps", async () => {
|
||||
const def = await store.createWorkflowDefinition({ name: "Default", ir: linearIr("def") });
|
||||
await store.setDefaultWorkflowId(def.id);
|
||||
|
||||
const res = await post("/api/tasks", { description: "no workflow", workflowId: null });
|
||||
expect(res.status).toBe(201);
|
||||
const created = res.body as { id: string };
|
||||
|
||||
const detail = await store.getTask(created.id);
|
||||
expect(detail.enabledWorkflowSteps ?? []).toHaveLength(0);
|
||||
expect(store.getTaskWorkflowSelection(created.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fragment id → 4xx, no task created", async () => {
|
||||
const frag = await store.createWorkflowDefinition({ name: "Frag", ir: fragmentIr(), kind: "fragment" });
|
||||
const before = (await store.listTasks({ includeArchived: true })).length;
|
||||
|
||||
const res = await post("/api/tasks", { description: "frag", workflowId: frag.id });
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBeLessThan(500);
|
||||
|
||||
const after = (await store.listTasks({ includeArchived: true })).length;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
|
||||
it("unknown id → 4xx, no task created", async () => {
|
||||
const before = (await store.listTasks({ includeArchived: true })).length;
|
||||
|
||||
const res = await post("/api/tasks", { description: "bad", workflowId: "WF-404" });
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
expect(res.status).toBeLessThan(500);
|
||||
|
||||
const after = (await store.listTasks({ includeArchived: true })).length;
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -874,6 +874,7 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
enabledWorkflowSteps,
|
||||
workflowId,
|
||||
modelPresetId,
|
||||
modelProvider,
|
||||
modelId,
|
||||
@@ -962,6 +963,13 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
}
|
||||
}
|
||||
|
||||
// Validate workflowId (U6/R3): undefined = inherit default, null = no
|
||||
// workflow, string = that workflow. Unknown/fragment ids are rejected by
|
||||
// the store below (mapped to 4xx in the catch handler).
|
||||
if (workflowId !== undefined && workflowId !== null && typeof workflowId !== "string") {
|
||||
throw badRequest("workflowId must be a string or null");
|
||||
}
|
||||
|
||||
// Check for summarize flag in request
|
||||
const summarize = req.body.summarize === true;
|
||||
|
||||
@@ -1245,6 +1253,9 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
dependencies,
|
||||
breakIntoSubtasks,
|
||||
enabledWorkflowSteps,
|
||||
// U6/R3: forward only when the client set it (string | null). Leaving it
|
||||
// absent preserves the project-default inheritance behavior.
|
||||
...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}),
|
||||
modelPresetId: validateOptionalModelField(modelPresetId, "modelPresetId"),
|
||||
modelProvider: executorModel.provider ?? undefined,
|
||||
modelId: executorModel.modelId ?? undefined,
|
||||
@@ -1347,8 +1358,16 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
const status = (err instanceof Error ? err.message : String(err)).includes("must be a string") || (err instanceof Error ? err.message : String(err)).includes("must be an array of strings") ? 400 : 500;
|
||||
throw new ApiError(status, err instanceof Error ? err.message : String(err));
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
// U6/R3: workflowId validation failures from the store (unknown id /
|
||||
// fragment id) are client errors, not server faults.
|
||||
const isClientError =
|
||||
message.includes("must be a string")
|
||||
|| message.includes("must be an array of strings")
|
||||
|| /^Workflow '.*' not found$/.test(message)
|
||||
|| /is a fragment and cannot be selected/.test(message);
|
||||
const status = isClientError ? 400 : 500;
|
||||
throw new ApiError(status, message);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -6218,8 +6218,12 @@
|
||||
"titlePlaceholder": "Task title",
|
||||
"useDropdown": "Use dropdown",
|
||||
"usingPreset": "Using preset: {{name}}",
|
||||
"workflowStepsDescription": "Select steps to run after task implementation completes",
|
||||
"workflowStepsLabel": "Workflow Steps",
|
||||
"workflowDefaultBadge": "(default)",
|
||||
"workflowHelp": "The selected workflow's steps run automatically around this task's execution.",
|
||||
"workflowLabel": "Workflow",
|
||||
"workflowNone": "No workflow",
|
||||
"workflowsCta": "Set up workflows in the editor",
|
||||
"workflowsLoading": "Loading workflows…",
|
||||
"workingBranchLabel": "Working branch"
|
||||
},
|
||||
"taskHandlers": {
|
||||
|
||||
8
packages/i18n/src/resources.d.ts
vendored
8
packages/i18n/src/resources.d.ts
vendored
@@ -6220,8 +6220,12 @@ export default interface Resources {
|
||||
"titlePlaceholder": "Task title",
|
||||
"useDropdown": "Use dropdown",
|
||||
"usingPreset": "Using preset: {{name}}",
|
||||
"workflowStepsDescription": "Select steps to run after task implementation completes",
|
||||
"workflowStepsLabel": "Workflow Steps",
|
||||
"workflowDefaultBadge": "(default)",
|
||||
"workflowHelp": "The selected workflow's steps run automatically around this task's execution.",
|
||||
"workflowLabel": "Workflow",
|
||||
"workflowNone": "No workflow",
|
||||
"workflowsCta": "Set up workflows in the editor",
|
||||
"workflowsLoading": "Loading workflows…",
|
||||
"workingBranchLabel": "Working branch"
|
||||
},
|
||||
"taskHandlers": {
|
||||
|
||||
Reference in New Issue
Block a user