import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen, fireEvent, waitFor } from "@testing-library/react"; import { NewTaskModal } from "../NewTaskModal"; import type { Task, Column } from "@fusion/core"; // Mock lucide-react vi.mock("lucide-react", () => ({ Sparkles: () => null, Globe: () => null, ChevronUp: () => null, ChevronDown: () => null, X: () => null, Bot: () => null, Maximize2: () => null, Minimize2: () => null, })); // Mock the api module vi.mock("../../api", () => ({ uploadAttachment: vi.fn().mockResolvedValue({}), 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: [] }), fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {}, }), fetchWorkflowSteps: vi.fn().mockResolvedValue([]), fetchGlobalSettings: vi.fn().mockResolvedValue({}), fetchAgents: vi.fn().mockResolvedValue([]), fetchAuthStatus: vi.fn().mockResolvedValue({ providers: [] }), refineText: vi.fn(), getRefineErrorMessage: vi.fn((err) => err?.message || "Failed to refine text. Please try again."), updateGlobalSettings: vi.fn().mockResolvedValue({}), })); const mockConfirm = vi.fn(); vi.mock("../../hooks/useConfirm", () => ({ useConfirm: () => ({ confirm: mockConfirm }), })); const mockUseMobileKeyboard = vi.fn(); vi.mock("../../hooks/useMobileKeyboard", () => ({ useMobileKeyboard: (...args: unknown[]) => mockUseMobileKeyboard(...args), })); vi.mock("../../hooks/useViewportMode", () => ({ useViewportMode: () => "mobile", })); 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 renderNewTaskModal(props = {}) { const defaultProps = { isOpen: true, onClose: vi.fn(), tasks: [] as Task[], onCreateTask: vi.fn().mockResolvedValue({ id: "FN-001" }), addToast: vi.fn(), }; const mergedProps = { ...defaultProps, ...props }; const result = render(); return { ...result, props: mergedProps }; } describe("NewTaskModal", () => { beforeEach(() => { vi.clearAllMocks(); mockConfirm.mockReset(); mockConfirm.mockResolvedValue(true); mockUseMobileKeyboard.mockReturnValue({ keyboardOpen: false, keyboardOverlap: 0, viewportHeight: null, viewportOffsetTop: 0, }); }); it("applies keyboard CSS variables when mobile keyboard is open", () => { mockUseMobileKeyboard.mockReturnValue({ keyboardOpen: true, keyboardOverlap: 250, viewportHeight: 400, viewportOffsetTop: 50, }); const { container } = renderNewTaskModal(); const modal = container.querySelector(".new-task-modal"); expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true }); expect(modal?.getAttribute("style")).toContain("--keyboard-overlap: 250px"); expect(modal?.getAttribute("style")).toContain("--vv-height: 400px"); expect(modal?.getAttribute("style")).toContain("--vv-offset-top: 50px"); }); it("does not apply keyboard CSS variables when keyboard is closed", () => { const { container } = renderNewTaskModal(); const modal = container.querySelector(".new-task-modal"); expect(mockUseMobileKeyboard).toHaveBeenCalledWith({ enabled: true }); expect(modal?.getAttribute("style") ?? "").not.toContain("--keyboard-overlap"); }); it("renders all form fields when open", async () => { renderNewTaskModal(); expect(screen.getByText("New Task")).toBeTruthy(); expect(screen.getByRole('textbox')).toBeTruthy(); expect(screen.queryByRole("button", { name: "Plan" })).toBeNull(); expect(screen.queryByRole("button", { name: "Subtask" })).toBeNull(); expect(screen.queryByTestId("task-form-description-actions")).toBeNull(); // Dependencies and agent are in quick-fields — visible by default (no toggle needed) expect(screen.getByTestId("dep-trigger")).toBeInTheDocument(); expect(screen.getByTestId("new-task-agent-button")).toBeInTheDocument(); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); await waitFor(() => { expect(screen.getByText(/Model Configuration/i)).toBeTruthy(); expect(screen.getByText(/Attachments/i)).toBeTruthy(); }); expect(screen.getByRole("button", { name: "Create Task" })).toBeTruthy(); expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy(); }); it("shows More options toggle and reveals advanced fields when clicked", async () => { renderNewTaskModal(); 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"); // Dependencies are now in quick-fields (visible by default), so the dep-trigger is present expect(screen.getByTestId("dep-trigger")).toBeInTheDocument(); fireEvent.click(toggle); await waitFor(() => { expect(toggle).toHaveAttribute("aria-expanded", "true"); expect(moreOptions).not.toHaveAttribute("hidden"); }); // Model Configuration, Attachments, and Workflow Steps are revealed expect(screen.getByText(/Model Configuration/i)).toBeTruthy(); expect(screen.getByText(/Attachments/i)).toBeTruthy(); expect(screen.getByText(/Workflow Steps/i)).toBeTruthy(); }); it("shows dependencies and agent picker by default without expanding More options", () => { renderNewTaskModal(); // Both dep-trigger and agent button should be visible by default expect(screen.getByTestId("dep-trigger")).toBeInTheDocument(); expect(screen.getByTestId("new-task-agent-button")).toBeInTheDocument(); // More options should be collapsed expect(screen.getByTestId("task-form-more-options-toggle")).toHaveAttribute("aria-expanded", "false"); }); it("renders dependencies before attachments in form order (quick-fields before More options)", () => { renderNewTaskModal(); const dependenciesLabel = screen.getByText("Dependencies"); // Attachments is inside the collapsed "More options" section, so we need to expand first const toggle = screen.getByTestId("task-form-more-options-toggle"); fireEvent.click(toggle); const attachmentsLabel = screen.getByText("Attachments"); // Dependencies (in quick-fields) appears before Attachments (in More options) expect( dependenciesLabel.compareDocumentPosition(attachmentsLabel) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); }); it("focuses description textarea when modal opens", async () => { renderNewTaskModal(); const textarea = screen.getByRole('textbox'); await waitFor(() => { expect(document.activeElement).toBe(textarea); }); }); it("creates task with description when submitted", async () => { const { props } = renderNewTaskModal(); const descTextarea = screen.getByRole('textbox'); fireEvent.change(descTextarea, { target: { value: "Test description" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.objectContaining({ description: "Test description", }), ); }); }); it("includes branch and baseBranch when provided", async () => { const { props } = renderNewTaskModal(); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with branches" } }); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByLabelText("Working branch"), { target: { value: " feature/fn-3422 " } }); fireEvent.change(screen.getByLabelText("Merge target / base branch"), { target: { value: " main " } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.objectContaining({ branch: "feature/fn-3422", baseBranch: "main", }), ); }); }); it("omits branch and baseBranch when left blank", async () => { const { props } = renderNewTaskModal(); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task without branches" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.objectContaining({ branch: undefined, baseBranch: undefined, }), ); }); }); it("still submits when setup warnings are shown", async () => { const { fetchAuthStatus } = await import("../../api"); vi.mocked(fetchAuthStatus).mockResolvedValueOnce({ providers: [{ id: "github", name: "GitHub", authenticated: false, type: "oauth" }], }); const { props } = renderNewTaskModal(); await waitFor(() => { expect(screen.getByText("No AI provider connected")).toBeTruthy(); expect(screen.getByText("GitHub not connected")).toBeTruthy(); }); const descTextarea = screen.getByRole("textbox"); fireEvent.change(descTextarea, { target: { value: "Submit despite warning" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.objectContaining({ description: "Submit despite warning", }), ); }); }); it("closes modal after successful creation", async () => { const { props } = renderNewTaskModal(); const descTextarea = screen.getByRole('textbox'); fireEvent.change(descTextarea, { target: { value: "Test" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onClose).toHaveBeenCalled(); }); }); it("shows success toast after creation", async () => { const { props } = renderNewTaskModal({ onCreateTask: vi.fn().mockResolvedValue({ id: "FN-042" }), }); const descTextarea = screen.getByRole('textbox'); fireEvent.change(descTextarea, { target: { value: "Test description" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.addToast).toHaveBeenCalledWith("Created FN-042", "success"); }); }); it("confirms before closing with dirty state", async () => { const { props } = renderNewTaskModal(); const descTextarea = screen.getByRole('textbox'); fireEvent.change(descTextarea, { target: { value: "Test description" } }); mockConfirm.mockResolvedValueOnce(false); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); await waitFor(() => { expect(mockConfirm).toHaveBeenCalledWith({ title: "Discard Changes", message: "You have unsaved changes. Discard them?", danger: true, }); }); expect(props.onClose).not.toHaveBeenCalled(); }); it("closes without confirm when state is not dirty", () => { const { props } = renderNewTaskModal(); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); expect(props.onClose).toHaveBeenCalled(); }); it("creates task with title undefined by default", async () => { const { props } = renderNewTaskModal(); const descTextarea = screen.getByRole('textbox'); fireEvent.change(descTextarea, { target: { value: "Only description" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.objectContaining({ title: undefined, description: "Only description", }), ); }); }); it("calls onCreateTask when form is submitted", async () => { const { props } = renderNewTaskModal(); const descTextarea = screen.getByRole('textbox'); fireEvent.change(descTextarea, { target: { value: "Normal task" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.objectContaining({ description: "Normal task", }), ); }); }); it("disables Create Task when description is empty", () => { renderNewTaskModal(); const createButton = screen.getByRole("button", { name: "Create Task" }); expect(createButton).toBeDisabled(); }); it("enables Create Task when description has content", () => { renderNewTaskModal(); const descTextarea = screen.getByRole('textbox'); fireEvent.change(descTextarea, { target: { value: "Some text" } }); const createButton = screen.getByRole("button", { name: "Create Task" }); expect(createButton).not.toBeDisabled(); }); // Preset selection tests (FN-819) describe("model preset selection payload", () => { it("omits modelPresetId from payload when in default mode", async () => { const { props } = renderNewTaskModal(); const descTextarea = screen.getByRole('textbox'); fireEvent.change(descTextarea, { target: { value: "Default mode task" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.objectContaining({ modelPresetId: undefined, }), ); }); }); it("includes modelPresetId and model overrides in payload when preset is selected", async () => { const { fetchSettings } = await import("../../api"); vi.mocked(fetchSettings).mockResolvedValue({ modelPresets: [ { id: "fast", name: "Fast", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5", validatorProvider: "openai", validatorModelId: "gpt-4o" }, ], autoSelectModelPreset: false, defaultPresetBySize: {}, } as any); const { props } = renderNewTaskModal(); // Wait for settings to load and preset dropdown to populate await waitFor(() => { const select = document.getElementById("model-preset") as HTMLSelectElement; expect(select).toBeTruthy(); expect(Array.from(select.options).some((o) => o.value === "fast")).toBe(true); }); // Type a description fireEvent.change(screen.getByRole('textbox'), { target: { value: "Preset task" } }); // Select the preset const select = document.getElementById("model-preset") as HTMLSelectElement; fireEvent.change(select, { target: { value: "fast" } }); // Submit fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.objectContaining({ modelPresetId: "fast", modelProvider: "anthropic", modelId: "claude-sonnet-4-5", validatorModelProvider: "openai", validatorModelId: "gpt-4o", }), ); }); }); it("omits modelPresetId from payload when switching from preset to custom", async () => { const { fetchSettings } = await import("../../api"); vi.mocked(fetchSettings).mockResolvedValue({ modelPresets: [ { id: "fast", name: "Fast", executorProvider: "anthropic", executorModelId: "claude-sonnet-4-5" }, ], autoSelectModelPreset: false, defaultPresetBySize: {}, } as any); const { props } = renderNewTaskModal(); // Wait for settings to load await waitFor(() => { const select = document.getElementById("model-preset") as HTMLSelectElement; expect(Array.from(select.options).some((o) => o.value === "fast")).toBe(true); }); // Type a description fireEvent.change(screen.getByRole('textbox'), { target: { value: "Custom task" } }); // Select a preset first const select = document.getElementById("model-preset") as HTMLSelectElement; fireEvent.change(select, { target: { value: "fast" } }); // Now switch to custom fireEvent.change(select, { target: { value: "custom" } }); // Submit fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.objectContaining({ modelPresetId: undefined, }), ); }); }); }); // 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: "" }, ]); const { props } = renderNewTaskModal(); await waitFor(() => { expect(screen.getByTestId("workflow-step-checkbox-WS-001")).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: "Task with workflow step" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.objectContaining({ enabledWorkflowSteps: ["WS-001"], }), ); }); }); 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: "" }, ]); const { props } = renderNewTaskModal(); await waitFor(() => { expect(screen.getByTestId("workflow-step-checkbox-WS-001")).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" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.objectContaining({ enabledWorkflowSteps: ["WS-001", "WS-002"], }), ); }); }); 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: "" }, ]); 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, }), ); }); }); }); // Review level tests (FN-2241) describe("review level selection payload", () => { it("omits reviewLevel from payload when not selected", async () => { const { props } = renderNewTaskModal(); const descTextarea = screen.getByRole('textbox'); fireEvent.change(descTextarea, { target: { value: "Task without review level" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.objectContaining({ reviewLevel: undefined, }), ); }); }); it("includes reviewLevel in payload when selected", async () => { const { props } = renderNewTaskModal(); // Open more options to access the review level selector fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); await waitFor(() => { expect(screen.getByLabelText("Review")).toBeTruthy(); }); // Select review level 2 (Plan and Code) const select = document.getElementById("review-level") as HTMLSelectElement; fireEvent.change(select, { target: { value: "2" } }); const descTextarea = screen.getByPlaceholderText("What needs to be done?"); fireEvent.change(descTextarea, { target: { value: "Task with review level" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.objectContaining({ reviewLevel: 2, }), ); }); }); it("includes reviewLevel 3 in payload when Full review is selected", async () => { const { props } = renderNewTaskModal(); // Open more options to access the review level selector fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); await waitFor(() => { expect(screen.getByLabelText("Review")).toBeTruthy(); }); // Select review level 3 (Full) const select = document.getElementById("review-level") as HTMLSelectElement; fireEvent.change(select, { target: { value: "3" } }); const descTextarea = screen.getByPlaceholderText("What needs to be done?"); fireEvent.change(descTextarea, { target: { value: "Task with full review" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.objectContaining({ reviewLevel: 3, }), ); }); }); }); describe("priority selection payload", () => { it("includes default normal priority in create payload", async () => { const { props } = renderNewTaskModal(); fireEvent.change(screen.getByRole("textbox"), { target: { value: "Task with default priority" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.objectContaining({ priority: "normal", }), ); }); }); it("includes selected priority and resets back to normal after submit", async () => { const { props } = renderNewTaskModal(); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByTestId("task-priority-select"), { target: { value: "urgent" } }); fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with urgent priority" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.objectContaining({ priority: "urgent", }), ); }); await waitFor(() => { expect(screen.getByTestId("task-priority-select")).toHaveValue("normal"); }); }); it("treats non-default priority as dirty state on cancel", async () => { renderNewTaskModal(); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); fireEvent.change(screen.getByTestId("task-priority-select"), { target: { value: "high" } }); mockConfirm.mockResolvedValueOnce(false); fireEvent.click(screen.getByRole("button", { name: "Cancel" })); await waitFor(() => { expect(mockConfirm).toHaveBeenCalledWith({ title: "Discard Changes", message: "You have unsaved changes. Discard them?", danger: true, }); }); }); }); // Agent assignment tests (FN-1483) describe("agent assignment", () => { it("renders agent picker button", () => { renderNewTaskModal(); expect(screen.getByTestId("new-task-agent-button")).toBeTruthy(); expect(screen.getByText("Assign agent")).toBeTruthy(); }); it("shows dropdown when agent button is clicked", async () => { const { fetchAgents } = await import("../../api"); vi.mocked(fetchAgents).mockResolvedValueOnce([ { id: "agent-1", name: "Executor Bot", role: "executor", state: "active" as const, metadata: {}, createdAt: "", updatedAt: "" }, ]); renderNewTaskModal(); fireEvent.click(screen.getByTestId("new-task-agent-button")); await waitFor(() => { expect(screen.getByText("Select agent")).toBeTruthy(); expect(screen.getByText("Executor Bot")).toBeTruthy(); }); }); it("shows selected agent name in button", async () => { const { fetchAgents } = await import("../../api"); vi.mocked(fetchAgents).mockResolvedValueOnce([ { id: "agent-1", name: "Executor Bot", role: "executor", state: "active" as const, metadata: {}, createdAt: "", updatedAt: "" }, ]); renderNewTaskModal(); fireEvent.click(screen.getByTestId("new-task-agent-button")); await waitFor(() => { expect(screen.getByText("Select agent")).toBeTruthy(); }); fireEvent.click(screen.getByText("Executor Bot")); await waitFor(() => { expect(screen.getByTestId("new-task-agent-button")).toHaveTextContent("Executor Bot"); }); }); it("includes assignedAgentId in payload when agent is selected", async () => { const { fetchAgents } = await import("../../api"); vi.mocked(fetchAgents).mockResolvedValueOnce([ { id: "agent-1", name: "Executor Bot", role: "executor", state: "active" as const, metadata: {}, createdAt: "", updatedAt: "" }, ]); const { props } = renderNewTaskModal(); // Type description fireEvent.change(screen.getByRole('textbox'), { target: { value: "Task with agent" } }); // Open agent picker and select agent fireEvent.click(screen.getByTestId("new-task-agent-button")); await waitFor(() => { expect(screen.getByText("Executor Bot")).toBeTruthy(); }); fireEvent.click(screen.getByText("Executor Bot")); // Submit fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.objectContaining({ assignedAgentId: "agent-1", }), ); }); }); it("omits assignedAgentId from payload when no agent is selected", async () => { const { props } = renderNewTaskModal(); fireEvent.change(screen.getByRole('textbox'), { target: { value: "Task without agent" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.not.objectContaining({ assignedAgentId: expect.anything(), }), ); }); }); it("omits assignedAgentId from payload after clearing selection", async () => { const { fetchAgents } = await import("../../api"); vi.mocked(fetchAgents).mockResolvedValueOnce([ { id: "agent-1", name: "Executor Bot", role: "executor", state: "active" as const, metadata: {}, createdAt: "", updatedAt: "" }, ]); const { props } = renderNewTaskModal(); // Type description fireEvent.change(screen.getByRole('textbox'), { target: { value: "Task with agent" } }); // Open agent picker and select agent fireEvent.click(screen.getByTestId("new-task-agent-button")); await waitFor(() => { expect(screen.getByText("Executor Bot")).toBeTruthy(); }); fireEvent.click(screen.getByText("Executor Bot")); // Open picker again and clear selection fireEvent.click(screen.getByTestId("new-task-agent-button")); await waitFor(() => { expect(screen.getByText("Clear selection")).toBeTruthy(); }); fireEvent.click(screen.getByText("Clear selection")); // Submit fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledWith( expect.not.objectContaining({ assignedAgentId: expect.anything(), }), ); }); }); it("triggers dirty state when agent is selected", async () => { const { fetchAgents } = await import("../../api"); vi.mocked(fetchAgents).mockResolvedValueOnce([ { id: "agent-1", name: "Executor Bot", role: "executor", state: "active" as const, metadata: {}, createdAt: "", updatedAt: "" }, ]); renderNewTaskModal(); // Open agent picker and select agent fireEvent.click(screen.getByTestId("new-task-agent-button")); await waitFor(() => { expect(screen.getByText("Executor Bot")).toBeTruthy(); }); fireEvent.click(screen.getByText("Executor Bot")); // Try to close - should show confirm fireEvent.click(screen.getByRole("button", { name: "Cancel" })); await waitFor(() => { expect(mockConfirm).toHaveBeenCalledWith({ title: "Discard Changes", message: "You have unsaved changes. Discard them?", danger: true, }); }); }); it("resets agent selection after successful task creation", async () => { const { fetchAgents } = await import("../../api"); vi.mocked(fetchAgents).mockResolvedValueOnce([ { id: "agent-1", name: "Executor Bot", role: "executor", state: "active" as const, metadata: {}, createdAt: "", updatedAt: "" }, ]); renderNewTaskModal(); // Type description fireEvent.change(screen.getByRole('textbox'), { target: { value: "Task with agent" } }); // Open agent picker and select agent fireEvent.click(screen.getByTestId("new-task-agent-button")); await waitFor(() => { expect(screen.getByText("Executor Bot")).toBeTruthy(); }); fireEvent.click(screen.getByText("Executor Bot")); // Submit fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(screen.getByTestId("new-task-agent-button")).toHaveTextContent("Assign agent"); }); }); }); describe("GitHub tracking", () => { it("renders GitHub tracking after Workflow Steps in more options", async () => { renderNewTaskModal(); fireEvent.click(screen.getByTestId("task-form-more-options-toggle")); const workflowLabel = await screen.findByText("Workflow Steps"); const githubTrackingSection = screen.getByTestId("task-form-github-tracking"); expect( workflowLabel.compareDocumentPosition(githubTrackingSection) & Node.DOCUMENT_POSITION_FOLLOWING, ).toBe(Node.DOCUMENT_POSITION_FOLLOWING); }); it("seeds tracking toggle from project settings and submits githubTracking payload", async () => { const { fetchSettings } = await import("../../api"); vi.mocked(fetchSettings).mockResolvedValueOnce({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {}, githubTrackingEnabledByDefault: true, }); const { props } = renderNewTaskModal(); fireEvent.change(screen.getByRole("textbox"), { target: { value: "Task with tracking" } }); const toggle = await screen.findByLabelText("Enable GitHub issue tracking for this task"); fireEvent.click(toggle); fireEvent.change(screen.getByLabelText("Repository (owner/repo)"), { target: { value: "acme/repo" } }); fireEvent.click(screen.getByRole("button", { name: "Create Task" })); await waitFor(() => { expect(props.onCreateTask).toHaveBeenCalledTimes(1); }); }); }); });