import { describe, it, expect, vi, beforeEach } from "vitest"; import { useState } from "react"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { TaskForm } from "../TaskForm"; import type { Task, Column } from "@fusion/core"; // Mock lucide-react vi.mock("lucide-react", () => ({ Sparkles: () => null, ChevronUp: () => null, ChevronDown: () => null, X: () => null, Maximize2: () => null, Minimize2: () => null, Paperclip: () => null, Flag: () => null, Zap: () => null, Brain: () => null, Server: () => null, Cpu: () => null, })); // Mock the api module vi.mock("../../api", () => ({ fetchModels: vi.fn().mockResolvedValue({ models: [ { provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true, contextWindow: 200000 }, { provider: "openai", id: "gpt-4o", name: "GPT-4o", reasoning: false, contextWindow: 128000 }, ], favoriteProviders: [], favoriteModels: [] }), fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {}, }), // U6/R3: TaskForm now fetches whole workflows (not steps) for the picker. fetchWorkflows: vi.fn().mockResolvedValue([]), fetchWorkflowOptionalSteps: 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."), updateGlobalSettings: vi.fn().mockResolvedValue({}), fetchGitBranches: vi.fn().mockResolvedValue([]), })); function makeTask(id: string): Task { return { id, title: `Task ${id}`, description: `Description for ${id}`, column: "todo" as Column, status: undefined as any, steps: [], currentStep: 0, dependencies: [], log: [], createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", }; } function renderTaskForm(props: Partial> = {}) { const defaultProps: React.ComponentProps = { mode: "create", description: "", onDescriptionChange: vi.fn(), dependencies: [], onDependenciesChange: vi.fn(), executorModel: "", onExecutorModelChange: vi.fn(), validatorModel: "", onValidatorModelChange: vi.fn(), presetMode: "default" as const, onPresetModeChange: vi.fn(), selectedPresetId: "", onSelectedPresetIdChange: vi.fn(), selectedWorkflowId: undefined, onWorkflowIdChange: vi.fn(), pendingImages: [], onImagesChange: vi.fn(), tasks: [], addToast: vi.fn(), isActive: true, reviewLevel: undefined, onReviewLevelChange: vi.fn(), }; const mergedProps = { ...defaultProps, ...props }; const result = render(); return { ...result, props: mergedProps }; } function renderTaskFormWithDescriptionState(props: Partial> = {}) { const defaultProps: React.ComponentProps = { mode: "edit", title: "Task", onTitleChange: vi.fn(), 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(), pendingImages: [], onImagesChange: vi.fn(), tasks: [], addToast: vi.fn(), isActive: true, reviewLevel: undefined, onReviewLevelChange: vi.fn(), }; const mergedProps = { ...defaultProps, ...props }; function ControlledTaskForm() { const [description, setDescription] = useState(mergedProps.description); return ( ); } return render(); } // Mock URL.createObjectURL / revokeObjectURL globalThis.URL.createObjectURL = vi.fn(() => "blob:mock-url"); globalThis.URL.revokeObjectURL = vi.fn(); describe("TaskForm", () => { beforeEach(async () => { vi.clearAllMocks(); const { fetchGitBranches } = await import("../../api"); vi.mocked(fetchGitBranches).mockResolvedValue([]); }); it("renders xhigh in the shared thinking-level selector", async () => { renderTaskForm({ thinkingLevel: "", onThinkingLevelChange: vi.fn(), }); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); await waitFor(() => { expect(screen.getByRole("combobox", { name: /Thinking/i })).toBeTruthy(); }); expect(screen.getByRole("option", { name: /Very High/i })).toHaveAttribute("value", "xhigh"); }); it("renders description field with AI refine button when text is present", () => { renderTaskForm({ description: "Some text" }); expect(screen.getByRole("textbox", { name: /Description/i })).toBeTruthy(); expect(screen.getByTestId("refine-button")).toBeTruthy(); }); it("does not show refine button when description is empty", () => { renderTaskForm({ description: "" }); expect(screen.getByRole("textbox", { name: /Description/i })).toBeTruthy(); expect(screen.queryByTestId("refine-button")).toBeNull(); }); it("renders dependency selector and can toggle dependencies", () => { const onDependenciesChange = vi.fn(); const tasks = [makeTask("FN-001"), makeTask("FN-002")]; renderTaskForm({ tasks, onDependenciesChange }); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); const depButton = screen.getByRole("button", { name: "Add dependencies" }); expect(depButton).toBeTruthy(); fireEvent.click(depButton); expect(screen.getByPlaceholderText("Search tasks…")).toBeTruthy(); // Click to select a task fireEvent.click(screen.getByText("FN-001")); expect(onDependenciesChange).toHaveBeenCalledWith(["FN-001"]); }); it("renders model configuration section", () => { renderTaskForm(); expect(screen.getByText(/Model Configuration/i)).toBeTruthy(); }); it("renders execution mode selector only when execution mode props are provided", () => { const { rerender, props } = renderTaskForm(); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); expect(screen.queryByTestId("task-form-execution-mode-select")).toBeNull(); rerender( , ); const executionModeSelect = screen.getByTestId("task-form-execution-mode-select") as HTMLSelectElement; expect(executionModeSelect).toBeTruthy(); const options = Array.from(executionModeSelect.options).map((option) => option.value); expect(options).toEqual(["standard", "fast"]); }); it("calls onExecutionModeChange when execution mode selection changes", () => { const onExecutionModeChange = vi.fn(); renderTaskForm({ executionMode: "standard", onExecutionModeChange, }); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByTestId("task-form-execution-mode-select"), { target: { value: "fast" } }); expect(onExecutionModeChange).toHaveBeenCalledWith("fast"); }); it("renders priority select with default normal value when enabled", () => { renderTaskForm({ onPriorityChange: vi.fn() }); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); expect(screen.getByTestId("task-priority-select")).toHaveValue("normal"); }); it("calls onPriorityChange when priority selection changes", () => { const onPriorityChange = vi.fn(); renderTaskForm({ onPriorityChange }); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByTestId("task-priority-select"), { target: { value: "urgent" } }); expect(onPriorityChange).toHaveBeenCalledWith("urgent"); }); it("renders working branch input and base branch custom input when no branch options are available", () => { renderTaskForm({ branch: "feature/fn-3422", baseBranch: "main", onBranchChange: vi.fn(), onBaseBranchChange: vi.fn(), }); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); expect(screen.getByLabelText("Working branch")).toHaveValue("feature/fn-3422"); expect(screen.getByTestId("task-base-branch-custom-input")).toHaveValue("main"); }); it("calls branch change handlers and supports explicit clearing", () => { const onBranchChange = vi.fn(); const onBaseBranchChange = vi.fn(); renderTaskForm({ branch: "feature/fn-3422", baseBranch: "develop", onBranchChange, onBaseBranchChange, }); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByLabelText("Working branch"), { target: { value: "feature/new" } }); fireEvent.change(screen.getByTestId("task-base-branch-custom-input"), { target: { value: "" } }); expect(onBranchChange).toHaveBeenCalledWith("feature/new"); expect(onBaseBranchChange).toHaveBeenCalledWith(""); }); it("auto-expands more options when branch fields are prefilled", () => { renderTaskForm({ branch: "feature/fn-3422", baseBranch: "main", onBranchChange: vi.fn(), onBaseBranchChange: vi.fn(), }); expect(screen.getByTestId("task-form-more-options-toggle")).toHaveAttribute("aria-expanded", "true"); }); it("auto-expands more options when priority is non-default", () => { renderTaskForm({ priority: "high", onPriorityChange: vi.fn() }); expect(screen.getByTestId("task-form-more-options-toggle")).toHaveAttribute("aria-expanded", "true"); }); it("renders base branch dropdown options sorted with common integration branches first", async () => { const { fetchGitBranches } = await import("../../api"); vi.mocked(fetchGitBranches).mockResolvedValueOnce([ { name: "release" }, { name: "develop" }, { name: "feature/foo" }, { name: "main" }, { name: "main" }, { name: "trunk" }, ] as any); const onBaseBranchChange = vi.fn(); renderTaskForm({ onBaseBranchChange }); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); const select = await screen.findByTestId("task-base-branch-select"); const optionValues = Array.from((select as HTMLSelectElement).options).map((option) => option.value); expect(optionValues).toEqual(["", "main", "trunk", "develop", "feature/foo", "release", "__fusion-custom__"]); fireEvent.change(select, { target: { value: "develop" } }); expect(onBaseBranchChange).toHaveBeenCalledWith("develop"); fireEvent.change(select, { target: { value: "__fusion-custom__" } }); expect(screen.getByTestId("task-base-branch-custom-input")).toBeTruthy(); }); it("defaults to custom mode for unknown base branch and supports switching back to dropdown", async () => { const { fetchGitBranches } = await import("../../api"); vi.mocked(fetchGitBranches).mockResolvedValueOnce([{ name: "main" }, { name: "develop" }] as any); const onBaseBranchChange = vi.fn(); renderTaskForm({ baseBranch: "release/candidate", onBaseBranchChange, }); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); expect(await screen.findByTestId("task-base-branch-custom-input")).toHaveValue("release/candidate"); fireEvent.click(screen.getByTestId("task-base-branch-use-dropdown")); expect(onBaseBranchChange).toHaveBeenCalledWith(""); }); it("keeps base branch entry available when branch loading fails", async () => { const { fetchGitBranches } = await import("../../api"); vi.mocked(fetchGitBranches).mockRejectedValueOnce(new Error("boom")); renderTaskForm({ onBaseBranchChange: vi.fn() }); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); await waitFor(() => { expect(screen.getByTestId("task-base-branch-custom-input")).toBeTruthy(); }); }); it("fetches and stores favoriteModels from fetchModels response", async () => { const { fetchModels } = await import("../../api"); vi.mocked(fetchModels).mockResolvedValueOnce({ models: [], favoriteProviders: ["anthropic"], favoriteModels: ["anthropic/claude-sonnet-4-5"], }); renderTaskForm(); // The component fetches models on mount when isActive=true // If no error is thrown, the favoriteModels state is accepted await vi.waitFor(() => { expect(fetchModels).toHaveBeenCalled(); }); }); it("does not render a hardcoded browser verification checkbox", () => { renderTaskForm(); expect(screen.queryByTestId("browser-verification-checkbox")).toBeNull(); expect(screen.queryByText("Browser Verification")).toBeNull(); }); it("in create mode: shows Plan and Subtask buttons", () => { renderTaskForm({ mode: "create", onPlanningMode: vi.fn(), onSubtaskBreakdown: vi.fn(), }); expect(screen.getByRole("button", { name: "Plan" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Subtask" })).toBeTruthy(); }); it("in edit mode: hides Plan/Subtask buttons, shows title field", () => { renderTaskForm({ mode: "edit", title: "My task", onTitleChange: vi.fn(), onPlanningMode: vi.fn(), onSubtaskBreakdown: vi.fn(), }); expect(screen.queryByRole("button", { name: "Plan" })).toBeNull(); expect(screen.queryByRole("button", { name: "Subtask" })).toBeNull(); expect(screen.getByLabelText(/Title/i)).toBeTruthy(); }); it("renders description expand button in edit mode and toggles fullscreen", () => { const { container } = renderTaskForm({ mode: "edit", title: "My task", onTitleChange: vi.fn(), description: "Long task description", }); const expandButton = screen.getByRole("button", { name: "Expand description" }); expect(expandButton).toBeTruthy(); fireEvent.click(expandButton); expect(container.querySelector(".description-with-refine.description--fullscreen")).toBeTruthy(); expect(screen.getByRole("button", { name: "Collapse description" })).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Collapse description" })); expect(container.querySelector(".description-with-refine.description--fullscreen")).toBeNull(); }); it("collapses fullscreen description editor on Escape", () => { const { container } = renderTaskForm({ mode: "edit", title: "My task", onTitleChange: vi.fn(), description: "Long task description", }); fireEvent.click(screen.getByRole("button", { name: "Expand description" })); const textarea = screen.getByRole("textbox", { name: /Description/i }); fireEvent.keyDown(textarea, { key: "Escape" }); expect(container.querySelector(".description-with-refine.description--fullscreen")).toBeNull(); }); it("uses 8 rows for description textarea in edit mode", () => { renderTaskForm({ mode: "edit", title: "My task", onTitleChange: vi.fn(), description: "Edit mode description", }); const textarea = screen.getByRole("textbox", { name: /Description/i }) as HTMLTextAreaElement; expect(textarea.getAttribute("rows")).toBe("8"); }); it("uses 5 rows for description textarea in create mode", () => { renderTaskForm({ mode: "create", description: "Create mode description", }); const textarea = screen.getByRole("textbox", { name: /Description/i }) as HTMLTextAreaElement; expect(textarea.getAttribute("rows")).toBe("5"); }); it("renders expand button in create mode and toggles fullscreen", () => { const { container } = renderTaskForm({ mode: "create", description: "Some description to expand", }); const expandButton = screen.getByRole("button", { name: "Expand description" }); expect(expandButton).toBeTruthy(); fireEvent.click(expandButton); expect(container.querySelector(".description-with-refine.description--fullscreen")).toBeTruthy(); expect(screen.getByRole("button", { name: "Collapse description" })).toBeTruthy(); fireEvent.click(screen.getByRole("button", { name: "Collapse description" })); expect(container.querySelector(".description-with-refine.description--fullscreen")).toBeNull(); }); it("expand button uses flush placement when description is empty (no refine button)", () => { const { container } = renderTaskForm({ mode: "create", description: "", }); const expandButton = screen.getByRole("button", { name: "Expand description" }); expect(expandButton).toBeTruthy(); // Expand button should have flush placement when refine is absent expect(container.querySelector(".description-expand-btn--flush")).toBeTruthy(); expect(container.querySelector(".description-expand-btn--offset")).toBeNull(); // Refine button should NOT be rendered when description is empty expect(screen.queryByTestId("refine-button")).toBeNull(); }); it("expand button uses offset placement when description has content (refine visible)", () => { const { container } = renderTaskForm({ mode: "create", description: "Some task description", }); const expandButton = screen.getByRole("button", { name: "Expand description" }); expect(expandButton).toBeTruthy(); // Expand button should have offset placement when refine is visible expect(container.querySelector(".description-expand-btn--offset")).toBeTruthy(); expect(container.querySelector(".description-expand-btn--flush")).toBeNull(); // Refine button should be rendered when description is non-empty expect(screen.getByTestId("refine-button")).toBeTruthy(); }); it("collapses fullscreen description editor on Escape in create mode", () => { const { container } = renderTaskForm({ mode: "create", description: "Some description", }); fireEvent.click(screen.getByRole("button", { name: "Expand description" })); const textarea = screen.getByRole("textbox", { name: /Description/i }); fireEvent.keyDown(textarea, { key: "Escape" }); expect(container.querySelector(".description-with-refine.description--fullscreen")).toBeNull(); }); it("debounces auto-save in edit mode and calls onAutoSaveDescription after 1.5s", async () => { vi.useFakeTimers(); try { const onAutoSaveDescription = vi.fn().mockResolvedValue(undefined); renderTaskFormWithDescriptionState({ mode: "edit", title: "My task", onTitleChange: vi.fn(), description: "Initial description", onAutoSaveDescription, }); fireEvent.change(screen.getByRole("textbox", { name: /Description/i }), { target: { value: "Updated description" }, }); vi.advanceTimersByTime(1499); expect(onAutoSaveDescription).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); await vi.runOnlyPendingTimersAsync(); expect(onAutoSaveDescription).toHaveBeenCalledWith("Updated description"); } finally { vi.useRealTimers(); } }); it("does not auto-save in create mode", () => { vi.useFakeTimers(); try { const onAutoSaveDescription = vi.fn().mockResolvedValue(undefined); renderTaskFormWithDescriptionState({ mode: "create", description: "", onAutoSaveDescription, }); fireEvent.change(screen.getByRole("textbox", { name: /Description/i }), { target: { value: "Create mode text" }, }); vi.advanceTimersByTime(1600); expect(onAutoSaveDescription).not.toHaveBeenCalled(); } finally { vi.useRealTimers(); } }); it("does not auto-save when onAutoSaveDescription is not provided", () => { vi.useFakeTimers(); try { renderTaskFormWithDescriptionState({ mode: "edit", title: "My task", onTitleChange: vi.fn(), description: "Initial", onAutoSaveDescription: undefined, }); fireEvent.change(screen.getByRole("textbox", { name: /Description/i }), { target: { value: "Updated" }, }); vi.advanceTimersByTime(1600); expect(screen.queryByText("Saved")).toBeNull(); } finally { vi.useRealTimers(); } }); it("resets debounce timer on rapid typing and only auto-saves once", async () => { vi.useFakeTimers(); try { const onAutoSaveDescription = vi.fn().mockResolvedValue(undefined); renderTaskFormWithDescriptionState({ mode: "edit", title: "My task", onTitleChange: vi.fn(), description: "", onAutoSaveDescription, }); const textarea = screen.getByRole("textbox", { name: /Description/i }); fireEvent.change(textarea, { target: { value: "A" } }); vi.advanceTimersByTime(500); fireEvent.change(textarea, { target: { value: "AB" } }); vi.advanceTimersByTime(500); fireEvent.change(textarea, { target: { value: "ABC" } }); vi.advanceTimersByTime(1499); expect(onAutoSaveDescription).not.toHaveBeenCalled(); vi.advanceTimersByTime(1); await vi.runOnlyPendingTimersAsync(); expect(onAutoSaveDescription).toHaveBeenCalledTimes(1); expect(onAutoSaveDescription).toHaveBeenCalledWith("ABC"); } finally { vi.useRealTimers(); } }); it("image paste adds to pending images", () => { const onImagesChange = vi.fn(); const { container } = renderTaskForm({ onImagesChange }); const taskForm = container.querySelector(".task-form")!; const imageFile = new File(["fake"], "test.png", { type: "image/png" }); fireEvent.paste(taskForm, { clipboardData: { items: [ { type: "image/png", getAsFile: () => imageFile, }, ], }, }); expect(onImagesChange).toHaveBeenCalled(); const newImages = onImagesChange.mock.calls[0][0]; expect(newImages).toHaveLength(1); expect(newImages[0].file).toBe(imageFile); }); it("renders selected dependencies as chips", () => { renderTaskForm({ dependencies: ["FN-001", "FN-002"] }); expect(screen.getByText("FN-001")).toBeTruthy(); expect(screen.getByText("FN-002")).toBeTruthy(); }); it("shows pending image previews", () => { const images = [ { file: new File(["fake"], "test.png", { type: "image/png" }), previewUrl: "blob:test" }, ]; const { container } = renderTaskForm({ pendingImages: images }); expect(container.querySelector(".inline-create-previews")).toBeTruthy(); }); it("renders a fetched workflow as a dropdown option (U6/R3)", async () => { const { fetchWorkflows } = await import("../../api"); vi.mocked(fetchWorkflows).mockResolvedValueOnce([ { id: "WF-1", name: "Browser Verification", description: "Verify in browser", kind: "workflow", ir: { version: "v1", name: "Browser Verification", nodes: [], edges: [] }, layout: {}, createdAt: "", updatedAt: "", }, ] as any); 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("disables all inputs when disabled prop is true", () => { renderTaskForm({ disabled: true, description: "Some text", dependencies: ["FN-001"], }); const textarea = screen.getByRole("textbox", { name: /Description/i }) as HTMLTextAreaElement; expect(textarea.disabled).toBe(true); // The dep button should be disabled const depButton = screen.getByRole("button", { name: "1 selected" }); expect(depButton).toHaveProperty("disabled", true); }); it("calls AI refine when menu item is clicked", async () => { const { refineText } = await import("../../api"); const onDescriptionChange = vi.fn(); renderTaskForm({ description: "Some text to refine", onDescriptionChange, }); // Open refine menu fireEvent.click(screen.getByTestId("refine-button")); // Click clarify fireEvent.click(screen.getByTestId("refine-clarify")); await waitFor(() => { // projectId is undefined in this test context expect(refineText).toHaveBeenCalledWith("Some text to refine", "clarify", undefined); expect(onDescriptionChange).toHaveBeenCalledWith("Refined text"); }); }); }); describe("TaskForm description-adjacent actions layout (FN-781)", () => { it("renders Plan and Subtask in description-actions area in create mode", () => { renderTaskForm({ mode: "create", description: "Some task", onPlanningMode: vi.fn(), onSubtaskBreakdown: vi.fn(), }); // The description-actions container should exist expect(screen.getByTestId("task-form-description-actions")).toBeTruthy(); // Plan and Subtask buttons should be inside it const actionsContainer = screen.getByTestId("task-form-description-actions"); expect(actionsContainer.contains(screen.getByTestId("task-form-plan-button"))).toBe(true); expect(actionsContainer.contains(screen.getByTestId("task-form-subtask-button"))).toBe(true); }); it("does not render description-actions in edit mode", () => { renderTaskForm({ mode: "edit", title: "My task", onTitleChange: vi.fn(), description: "Some task", onPlanningMode: vi.fn(), onSubtaskBreakdown: vi.fn(), }); expect(screen.queryByTestId("task-form-description-actions")).toBeNull(); expect(screen.queryByTestId("task-form-inline-optional-steps")).toBeNull(); }); it("Plan and Subtask buttons are disabled when description is empty", () => { renderTaskForm({ mode: "create", description: "", onPlanningMode: vi.fn(), onSubtaskBreakdown: vi.fn(), }); expect((screen.getByTestId("task-form-plan-button") as HTMLButtonElement).disabled).toBe(true); expect((screen.getByTestId("task-form-subtask-button") as HTMLButtonElement).disabled).toBe(true); }); it("Plan and Subtask buttons are enabled when description has content", () => { renderTaskForm({ mode: "create", description: "A real task", onPlanningMode: vi.fn(), onSubtaskBreakdown: vi.fn(), }); expect((screen.getByTestId("task-form-plan-button") as HTMLButtonElement).disabled).toBe(false); expect((screen.getByTestId("task-form-subtask-button") as HTMLButtonElement).disabled).toBe(false); }); it("Refine button remains near the description textarea", () => { renderTaskForm({ mode: "create", description: "Some text", onPlanningMode: vi.fn(), onSubtaskBreakdown: vi.fn(), }); // Refine button should be rendered (it's inside the description-with-refine wrapper) expect(screen.getByTestId("refine-button")).toBeTruthy(); // But NOT inside the description-actions container const actionsContainer = screen.getByTestId("task-form-description-actions"); expect(actionsContainer.contains(screen.getByTestId("refine-button"))).toBe(false); }); }); describe("TaskForm preset selection (FN-819)", () => { beforeEach(() => { vi.clearAllMocks(); }); it("renders preset dropdown with saved presets from settings", async () => { const { fetchSettings } = await import("../../api"); vi.mocked(fetchSettings).mockResolvedValueOnce({ modelPresets: [ { id: "fast", name: "Fast", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" }, ], autoSelectModelPreset: false, defaultPresetBySize: {}, } as any); renderTaskForm(); await waitFor(() => { expect(fetchSettings).toHaveBeenCalled(); }); const presetSelect = await waitFor(() => { const element = document.getElementById("model-preset") as HTMLSelectElement | null; expect(element).toBeTruthy(); return element as HTMLSelectElement; }); const options = Array.from(presetSelect.options); expect(options.find((o) => o.value === "default")).toBeTruthy(); expect(options.find((o) => o.value === "fast")).toBeTruthy(); expect(options.find((o) => o.textContent === "Fast")).toBeTruthy(); expect(options.find((o) => o.value === "custom")).toBeTruthy(); }); it("selecting a preset applies preset mode and model overrides", async () => { const { fetchSettings } = await import("../../api"); vi.mocked(fetchSettings).mockResolvedValueOnce({ modelPresets: [ { id: "fast", name: "Fast", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" }, ], autoSelectModelPreset: false, defaultPresetBySize: {}, } as any); const onPresetModeChange = vi.fn(); const onSelectedPresetIdChange = vi.fn(); const onExecutorModelChange = vi.fn(); const onValidatorModelChange = vi.fn(); renderTaskForm({ onPresetModeChange, onSelectedPresetIdChange, onExecutorModelChange, onValidatorModelChange, }); await waitFor(() => { expect(fetchSettings).toHaveBeenCalled(); }); const presetSelect = await waitFor(() => { const element = document.getElementById("model-preset") as HTMLSelectElement | null; expect(element).toBeTruthy(); return element as HTMLSelectElement; }); fireEvent.change(presetSelect, { target: { value: "fast" } }); expect(onPresetModeChange).toHaveBeenCalledWith("preset"); expect(onSelectedPresetIdChange).toHaveBeenCalledWith("fast"); expect(onExecutorModelChange).toHaveBeenCalledWith("anthropic/claude-sonnet-4-5"); expect(onValidatorModelChange).toHaveBeenCalledWith("openai/gpt-4o"); }); it("switching to default clears preset and model overrides", async () => { const { fetchSettings } = await import("../../api"); vi.mocked(fetchSettings).mockResolvedValueOnce({ modelPresets: [ { id: "fast", name: "Fast", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5" }, ], autoSelectModelPreset: false, defaultPresetBySize: {}, } as any); const onPresetModeChange = vi.fn(); const onSelectedPresetIdChange = vi.fn(); const onExecutorModelChange = vi.fn(); const onValidatorModelChange = vi.fn(); renderTaskForm({ presetMode: "preset", selectedPresetId: "fast", executorModel: "anthropic/claude-sonnet-4-5", onPresetModeChange, onSelectedPresetIdChange, onExecutorModelChange, onValidatorModelChange, }); await waitFor(() => { expect(fetchSettings).toHaveBeenCalled(); }); const presetSelect = await waitFor(() => { const element = document.getElementById("model-preset") as HTMLSelectElement | null; expect(element).toBeTruthy(); return element as HTMLSelectElement; }); fireEvent.change(presetSelect, { target: { value: "default" } }); expect(onPresetModeChange).toHaveBeenCalledWith("default"); expect(onSelectedPresetIdChange).toHaveBeenCalledWith(""); expect(onExecutorModelChange).toHaveBeenCalledWith(""); expect(onValidatorModelChange).toHaveBeenCalledWith(""); }); it("switching to custom clears preset ID", async () => { const { fetchSettings } = await import("../../api"); vi.mocked(fetchSettings).mockResolvedValueOnce({ modelPresets: [ { id: "fast", name: "Fast", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5" }, ], autoSelectModelPreset: false, defaultPresetBySize: {}, } as any); const onPresetModeChange = vi.fn(); const onSelectedPresetIdChange = vi.fn(); renderTaskForm({ presetMode: "preset", selectedPresetId: "fast", executorModel: "anthropic/claude-sonnet-4-5", onPresetModeChange, onSelectedPresetIdChange, }); await waitFor(() => { expect(fetchSettings).toHaveBeenCalled(); }); const presetSelect = (await screen.findByLabelText("Preset")) as HTMLSelectElement; fireEvent.change(presetSelect, { target: { value: "custom" } }); expect(onPresetModeChange).toHaveBeenCalledWith("custom"); expect(onSelectedPresetIdChange).toHaveBeenCalledWith(""); }); it("Override button exits preset mode", async () => { const { fetchSettings } = await import("../../api"); vi.mocked(fetchSettings).mockResolvedValueOnce({ modelPresets: [ { id: "fast", name: "Fast", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5" }, ], autoSelectModelPreset: false, defaultPresetBySize: {}, } as any); const onPresetModeChange = vi.fn(); renderTaskForm({ presetMode: "preset", selectedPresetId: "fast", executorModel: "anthropic/claude-sonnet-4-5", onPresetModeChange, }); await waitFor(() => { expect(fetchSettings).toHaveBeenCalled(); }); const overrideButton = await screen.findByRole("button", { name: "Override" }); fireEvent.click(overrideButton); expect(onPresetModeChange).toHaveBeenCalledWith("custom"); }); it("disables executor and validator selects when preset mode is active", async () => { const { fetchSettings } = await import("../../api"); vi.mocked(fetchSettings).mockResolvedValueOnce({ modelPresets: [ { id: "fast", name: "Fast", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5" }, ], autoSelectModelPreset: false, defaultPresetBySize: {}, } as any); renderTaskForm({ presetMode: "preset", selectedPresetId: "fast", executorModel: "anthropic/claude-sonnet-4-5", }); await waitFor(() => { expect(fetchSettings).toHaveBeenCalled(); }); const executorSelect = document.getElementById("executor-model") as HTMLSelectElement; const validatorSelect = document.getElementById("validator-model") as HTMLSelectElement; expect(executorSelect?.disabled).toBe(true); expect(validatorSelect?.disabled).toBe(true); }); it("shows preset name as small text when a preset is selected", async () => { const { fetchSettings } = await import("../../api"); vi.mocked(fetchSettings).mockResolvedValueOnce({ modelPresets: [ { id: "fast", name: "Fast", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5" }, ], autoSelectModelPreset: false, defaultPresetBySize: {}, } as any); renderTaskForm({ presetMode: "preset", selectedPresetId: "fast", executorModel: "anthropic/claude-sonnet-4-5", }); await waitFor(() => { expect(screen.getByText("Using preset: Fast")).toBeTruthy(); }); }); }); describe("TaskForm workflow picker (U6/R3)", () => { beforeEach(() => { vi.clearAllMocks(); }); 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, ); } 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(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(); }); 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([{ id: "WF-1", name: "QA" }]); // After the promise resolves, the loading placeholder is replaced by the // populated select containing the fetched workflow option. await waitFor(() => { expect(screen.queryByTestId("task-workflow-loading")).toBeNull(); }); const select = screen.getByTestId("task-workflow-select") as HTMLSelectElement; const optionValues = Array.from(select.options).map((o) => o.value); expect(optionValues).toContain("WF-1"); }); it.each([ ["create", { mode: "create" as const }], ["edit", { mode: "edit" as const, title: "Existing task", onTitleChange: vi.fn() }], ])( "regression: no per-step checkboxes and no fetchWorkflowSteps usage (%s mode)", async (_label, modeProps) => { await mockWorkflows([{ id: "WF-1", name: "QA" }]); renderTaskForm({ onWorkflowIdChange: vi.fn(), ...modeProps }); await waitFor(() => { expect(screen.getByTestId("task-workflow-select")).toBeTruthy(); }); // The old per-step checkbox UI and execution-order controls are gone on // every TaskForm surface (create and edit). 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.) }, ); }); describe("TaskForm focus behavior (FN-1459)", () => { beforeEach(() => { vi.clearAllMocks(); }); it("auto-focuses description textarea in create mode on mount", async () => { renderTaskForm({ mode: "create" }); const textarea = screen.getByRole("textbox", { name: /Description/i }); await waitFor(() => { expect(document.activeElement).toBe(textarea); }); }); it("auto-focuses description textarea in create mode even with initial description", async () => { renderTaskForm({ mode: "create", description: "Pre-filled task" }); const textarea = screen.getByRole("textbox", { name: /Description/i }); await waitFor(() => { expect(document.activeElement).toBe(textarea); }); }); it("auto-focuses title input in edit mode on mount", async () => { renderTaskForm({ mode: "edit", title: "Existing task", onTitleChange: vi.fn(), }); const titleInput = screen.getByLabelText(/Title/i) as HTMLInputElement; await waitFor(() => { expect(document.activeElement).toBe(titleInput); }); }); it("selects title input text in edit mode on mount", async () => { renderTaskForm({ mode: "edit", title: "Existing task", onTitleChange: vi.fn(), }); const titleInput = screen.getByLabelText(/Title/i) as HTMLInputElement; // SelectionStart and SelectionEnd are set when the text is selected await waitFor(() => { // When text is selected, selectionStart should be 0 and selectionEnd should equal the text length expect(titleInput.selectionStart).toBe(0); expect(titleInput.selectionEnd).toBe(titleInput.value.length); }); }); it("does not auto-focus description textarea in edit mode", async () => { renderTaskForm({ mode: "edit", title: "Existing task", onTitleChange: vi.fn(), }); const textarea = screen.getByRole("textbox", { name: /Description/i }); // In edit mode, description should NOT be focused (title input is focused instead) await waitFor(() => { expect(document.activeElement).not.toBe(textarea); }); }); // renderBelowPrimary and hideDependencies slot tests describe("renderBelowPrimary and hideDependencies", () => { it("renders renderBelowPrimary content between primary section and More options toggle", () => { renderTaskForm({ renderBelowPrimary:
Custom content
, }); const injected = screen.getByTestId("injected"); const toggle = screen.getByTestId("task-form-more-options-toggle"); const descriptionArea = screen.getByRole("textbox", { name: /Description/i }); expect(injected).toBeInTheDocument(); // Injected element should appear AFTER the primary section (description area) expect( descriptionArea.compareDocumentPosition(injected) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); // Injected element should appear BEFORE the more-options toggle expect( injected.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); }); it("renders renderBelowModelConfiguration content below model section in More options", async () => { renderTaskForm({ renderBelowModelConfiguration:
Bottom slot content
, githubTrackingEnabled: false, onGithubTrackingEnabledChange: vi.fn(), githubRepoOverride: "", onGithubRepoOverrideChange: vi.fn(), }); const toggle = screen.getByTestId("task-form-more-options-toggle"); fireEvent.click(toggle); await waitFor(() => { expect(toggle).toHaveAttribute("aria-expanded", "true"); }); const modelLabel = screen.getByText("Model Configuration"); // 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"); expect( modelLabel.compareDocumentPosition(injectedBottom) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); expect( injectedBottom.compareDocumentPosition(workflowLabel) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); expect( workflowLabel.compareDocumentPosition(githubTrackingSection) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); }); it("hides dependencies section when hideDependencies is true", async () => { renderTaskForm({ hideDependencies: true }); // Expand "More options" const toggle = screen.getByTestId("task-form-more-options-toggle"); fireEvent.click(toggle); await waitFor(() => { expect(toggle).toHaveAttribute("aria-expanded", "true"); }); // 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(); // 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 () => { renderTaskForm({ hideDependencies: true, dependencies: ["FN-001"], }); const toggle = screen.getByTestId("task-form-more-options-toggle"); await waitFor(() => { expect(toggle).toHaveAttribute("aria-expanded", "false"); }); }); it("renders nothing when renderBelowPrimary is not provided", () => { renderTaskForm({}); // Should not have any unexpected elements - just verify normal rendering works expect(screen.getByRole("textbox", { name: /Description/i })).toBeInTheDocument(); expect(screen.getByTestId("task-form-more-options-toggle")).toBeInTheDocument(); }); }); describe("GitHub tracking controls", () => { it("seeds tracking toggle from project settings default", async () => { const { fetchSettings } = await import("../../api"); vi.mocked(fetchSettings).mockResolvedValueOnce({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {}, githubTrackingEnabledByDefault: true, }); const onGithubTrackingEnabledChange = vi.fn(); renderTaskForm({ githubTrackingEnabled: false, onGithubTrackingEnabledChange, }); await waitFor(() => { expect(onGithubTrackingEnabledChange).toHaveBeenCalledWith(true); }); }); it("renders tracking controls and propagates changes", async () => { const onGithubTrackingEnabledChange = vi.fn(); const onGithubRepoOverrideChange = vi.fn(); renderTaskForm({ githubTrackingEnabled: false, onGithubTrackingEnabledChange, githubRepoOverride: "", onGithubRepoOverrideChange, }); const toggle = await screen.findByLabelText("Enable GitHub issue tracking for this task"); fireEvent.click(toggle); expect(onGithubTrackingEnabledChange).toHaveBeenCalledWith(true); const input = screen.getByLabelText("Repository (owner/repo)"); fireEvent.change(input, { target: { value: "owner/repo" } }); expect(onGithubRepoOverrideChange).toHaveBeenCalledWith("owner/repo"); }); it("shows validation error for invalid repo override", () => { renderTaskForm({ githubTrackingEnabled: true, onGithubTrackingEnabledChange: vi.fn(), githubRepoOverride: "invalid repo", onGithubRepoOverrideChange: vi.fn(), }); expect(screen.getByText("Repository must be in owner/repo format.")).toBeInTheDocument(); }); }); });