Files
fusion/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx
gsxdsm 79d4299be2 fix: preserve provider and workflow behavior after migration
Use canonical Anthropic OAuth refresh, keep CLI-backed providers out of API-key auth rows, parse Grok's omitted zero usage, and carry board workflow context into task creation.
2026-07-14 15:07:26 -07:00

2080 lines
90 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import type { ComponentProps } from "react";
import { readFileSync } from "node:fs";
import { NewTaskModal } from "../NewTaskModal";
import type { Task, Column } from "@fusion/core";
import { apiFetchGitHubIssues, apiFetchGitHubPulls, checkDuplicateTasks, fetchAgents, fetchGitRemotes, type BoardWorkflowsPayload } from "../../api";
import { writeBoardWorkflowsCache } from "../../utils/boardWorkflowsCache";
import { writeLastSelectedWorkflowId } from "../../utils/lastSelectedWorkflow";
import { GITHUB_SETUP_WARNING_DELAY_MS, GITHUB_SETUP_WARNING_MISSING_SINCE_KEY } from "../../hooks/useGithubSetupWarningDelay";
import { __test_clearCache as clearSetupReadinessCache } from "../../hooks/useSetupReadiness";
import { scopedKey } from "../../utils/projectStorage";
const newTaskModalCss = readFileSync("app/components/NewTaskModal.css", "utf8");
// Mock lucide-react
vi.mock("lucide-react", () => ({
Sparkles: () => null,
Globe: () => null,
ChevronUp: () => null,
ChevronDown: () => null,
X: () => null,
Bot: () => null,
Maximize2: () => null,
Minimize2: () => null,
Workflow: () => null,
Paperclip: () => null,
ArrowDown: () => null,
ArrowUp: () => null,
Flag: () => null,
TriangleAlert: () => null,
Zap: () => null,
Brain: () => null,
Server: () => null,
Cpu: () => null,
}));
vi.mock("../ProviderIcon", () => ({
ProviderIcon: ({ provider }: { provider: string }) => <span data-testid={`provider-icon-${provider}`} />,
}));
// Mock the api module
vi.mock("../../api", () => ({
uploadAttachment: vi.fn().mockResolvedValue({}),
checkDuplicateTasks: vi.fn().mockResolvedValue([]),
fetchGitRemotes: vi.fn().mockResolvedValue([]),
apiFetchGitHubIssues: vi.fn().mockResolvedValue([]),
apiFetchGitHubPulls: 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: {},
}),
// U6/R3: TaskForm's picker fetches whole workflows; the per-step
// fetchWorkflowSteps + post-create selectTaskWorkflow flow is gone.
fetchWorkflows: vi.fn().mockResolvedValue([]),
fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]),
fetchGlobalSettings: vi.fn().mockResolvedValue({}),
fetchGitBranches: 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),
}));
// FNXC:NewTask 2026-06-22-20:30: viewport mode is switchable so we can exercise both the mobile sheet (default) and the desktop floating window. Defaults to mobile to preserve the existing suite's layout assumptions.
let mockViewportMode: "mobile" | "desktop" = "mobile";
vi.mock("../../hooks/useViewportMode", () => ({
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
getViewportMode: () => mockViewportMode,
isMobileViewport: () => mockViewportMode === "mobile",
useViewportMode: () => mockViewportMode,
}));
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",
};
}
async function chooseWorkflowOption(value: string) {
const trigger = await screen.findByTestId("task-workflow-dropdown-trigger");
fireEvent.click(trigger);
const optionTestId = value === "__none__" ? "task-workflow-option-none" : `task-workflow-option-${value}`;
fireEvent.click(await screen.findByTestId(optionTestId));
}
async function openWorkflowDropdown() {
const trigger = await screen.findByTestId("task-workflow-dropdown-trigger");
fireEvent.click(trigger);
return screen.findByTestId("task-workflow-dropdown-menu");
}
function renderNewTaskModal(props: Partial<ComponentProps<typeof NewTaskModal>> = {}) {
const defaultProps: ComponentProps<typeof NewTaskModal> = {
isOpen: true,
onClose: vi.fn(),
tasks: [] as Task[],
onCreateTask: vi.fn().mockResolvedValue(makeTask("FN-001")),
addToast: vi.fn(),
};
const mergedProps = { ...defaultProps, ...props };
const result = render(<NewTaskModal {...mergedProps} />);
return { ...result, props: mergedProps };
}
describe("NewTaskModal", () => {
beforeEach(() => {
vi.clearAllMocks();
clearSetupReadinessCache();
mockViewportMode = "mobile";
mockConfirm.mockReset();
mockConfirm.mockResolvedValue(true);
vi.mocked(checkDuplicateTasks).mockResolvedValue([]);
vi.mocked(fetchGitRemotes).mockResolvedValue([]);
vi.mocked(apiFetchGitHubIssues).mockResolvedValue([]);
vi.mocked(apiFetchGitHubPulls).mockResolvedValue([]);
window.localStorage.clear();
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,
});
renderNewTaskModal();
// FNXC: NewTaskModal portals to document.body, so query the modal from document (not the render container).
const modal = document.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.getByPlaceholderText("What needs to be done?")).toBeTruthy();
// Without AI-handoff callbacks there is no Plan/Subtask button…
expect(screen.queryByTestId("task-form-plan-button")).toBeNull();
expect(screen.queryByTestId("task-form-subtask-button")).toBeNull();
// …but FNXC:NewTask 2026-06-23-00:10: the inline quick-add action row still renders in create mode to host Attach/Fast/Priority.
expect(screen.getByTestId("task-form-description-actions")).toBeInTheDocument();
// 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();
// FNXC:NewTaskDialogAffordances 2026-06-23-21:47: The regular New Task dialog exposes the screenshot quick-add buttons immediately; detailed selects stay in Advanced.
expect(screen.getByTestId("task-form-inline-create")).toBeVisible();
expect(screen.getByTestId("task-form-inline-attach")).toBeVisible();
expect(screen.getByTestId("task-form-inline-fast")).toBeVisible();
expect(screen.getByTestId("task-form-inline-github")).toBeVisible();
expect(screen.getByTestId("task-form-inline-workflow")).toBeVisible();
expect(screen.getByTestId("task-form-inline-models")).toBeVisible();
expect(screen.getByTestId("task-form-inline-node")).toBeVisible();
expect(screen.getByTestId("task-form-inline-priority")).toBeVisible();
// FNXC:NewTask 2026-06-23-00:10: The DEEP/advanced options now sit behind the collapsed "Advanced" disclosure. Model Configuration / Attachments are NOT shown until the toggle is expanded.
const advancedToggle = screen.getByTestId("task-form-more-options-toggle");
expect(advancedToggle).toHaveTextContent(/Advanced/i);
expect(screen.getByTestId("task-form-more-options")).toHaveAttribute("hidden");
fireEvent.click(advancedToggle);
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();
});
describe("GitHub reference picker", () => {
const originRemote = { name: "origin", owner: "runfusion", repo: "fusion", url: "https://github.com/runfusion/fusion.git" };
const upstreamRemote = { name: "upstream", owner: "octo", repo: "project", url: "https://github.com/octo/project.git" };
const issue = {
number: 12,
title: "Crash on startup",
body: null,
html_url: "https://github.com/runfusion/fusion/issues/12",
labels: [],
};
const pull = {
number: 34,
title: "Fix login",
body: null,
html_url: "https://github.com/runfusion/fusion/pull/34",
headBranch: "fix-login",
baseBranch: "main",
};
async function renderPickerWithData({ remotes = [originRemote], issues = [issue], pulls = [pull], viewport = "mobile" as "mobile" | "desktop" } = {}) {
mockViewportMode = viewport;
vi.mocked(fetchGitRemotes).mockResolvedValueOnce(remotes);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce(issues);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce(pulls);
renderNewTaskModal({ projectId: "project-1" });
await waitFor(() => expect(fetchGitRemotes).toHaveBeenCalledWith("project-1"));
if (remotes.length === 1 || remotes.some((remote) => remote.name === "origin")) {
await waitFor(() => expect(apiFetchGitHubIssues).toHaveBeenCalled());
}
}
it("loads origin remote references and seeds the issue prompt", async () => {
await renderPickerWithData();
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-select")).toBeInTheDocument());
expect(screen.getByText("Issue #12 — Crash on startup")).toBeInTheDocument();
expect(screen.getByText("PR #34 — Fix login")).toBeInTheDocument();
fireEvent.change(screen.getByTestId("new-task-github-reference-select"), { target: { value: "issue:12" } });
await waitFor(() => {
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
expect(textarea.value).toContain("Fetch and read this GitHub issue");
expect(textarea.value).toContain("Source: https://github.com/runfusion/fusion/issues/12");
});
expect(mockConfirm).not.toHaveBeenCalled();
});
it("seeds the PR prompt with review-comment resolution instructions", async () => {
await renderPickerWithData();
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-select")).toBeInTheDocument());
fireEvent.change(screen.getByTestId("new-task-github-reference-select"), { target: { value: "pull:34" } });
await waitFor(() => {
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
expect(textarea.value).toContain("Fetch and read this GitHub pull request");
expect(textarea.value).toContain("resolve or address all actionable PR review comments");
expect(textarea.value).toContain("PR: https://github.com/runfusion/fusion/pull/34");
});
});
it("protects typed descriptions before replacing them with a GitHub prompt", async () => {
await renderPickerWithData();
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-select")).toBeInTheDocument());
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Keep my draft" } });
mockConfirm.mockResolvedValueOnce(false);
fireEvent.change(screen.getByTestId("new-task-github-reference-select"), { target: { value: "issue:12" } });
await waitFor(() => expect(mockConfirm).toHaveBeenCalledWith(expect.objectContaining({ title: "Replace description?" })));
expect(screen.getByPlaceholderText("What needs to be done?")).toHaveValue("Keep my draft");
expect(screen.getByTestId("new-task-github-reference-select")).toHaveValue("");
mockConfirm.mockResolvedValueOnce(true);
fireEvent.change(screen.getByTestId("new-task-github-reference-select"), { target: { value: "issue:12" } });
await waitFor(() => {
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
expect(textarea.value).toContain("Source: https://github.com/runfusion/fusion/issues/12");
});
});
it.each([
{ label: "issues only", issues: [issue], pulls: [], expected: "Issue #12 — Crash on startup", absent: "PR #34 — Fix login" },
{ label: "PRs only", issues: [], pulls: [pull], expected: "PR #34 — Fix login", absent: "Issue #12 — Crash on startup" },
])("renders $label references without an empty dropdown shell", async ({ issues, pulls, expected, absent }) => {
await renderPickerWithData({ issues, pulls });
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-select")).toBeInTheDocument());
expect(screen.getByText(expected)).toBeInTheDocument();
expect(screen.queryByText(absent)).toBeNull();
});
it("keeps duplicate issue and PR numbers distinct", async () => {
await renderPickerWithData({
issues: [{ ...issue, number: 7, html_url: "https://github.com/runfusion/fusion/issues/7" }],
pulls: [{ ...pull, number: 7, html_url: "https://github.com/runfusion/fusion/pull/7" }],
});
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-select")).toBeInTheDocument());
expect(screen.getByText("Issue #7 — Crash on startup")).toBeInTheDocument();
expect(screen.getByText("PR #7 — Fix login")).toBeInTheDocument();
fireEvent.change(screen.getByTestId("new-task-github-reference-select"), { target: { value: "pull:7" } });
await waitFor(() => {
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
expect(textarea.value).toContain("PR: https://github.com/runfusion/fusion/pull/7");
});
fireEvent.change(screen.getByTestId("new-task-github-reference-select"), { target: { value: "issue:7" } });
await waitFor(() => {
const textarea = screen.getByPlaceholderText("What needs to be done?") as HTMLTextAreaElement;
expect(textarea.value).toContain("Source: https://github.com/runfusion/fusion/issues/7");
});
});
it("shows unavailable states without an empty reference dropdown shell", async () => {
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([]);
const noRemoteRender = renderNewTaskModal({ projectId: "project-1" });
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-status")).toHaveTextContent("No GitHub remotes were detected"));
expect(screen.queryByTestId("new-task-github-reference-select")).toBeNull();
noRemoteRender.unmount();
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([upstreamRemote, { ...originRemote, name: "fork" }]);
const { unmount } = renderNewTaskModal({ projectId: "project-1" });
await waitFor(() => expect(screen.getByText("Choose a GitHub remote before selecting an issue or pull request.")).toBeInTheDocument());
expect(screen.getByTestId("new-task-github-remote-select")).toBeInTheDocument();
expect(screen.queryByTestId("new-task-github-reference-select")).toBeNull();
unmount();
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([originRemote]);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([]);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([]);
const emptyRender = renderNewTaskModal({ projectId: "project-1" });
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-status")).toHaveTextContent("No open issues or pull requests"));
expect(screen.queryByTestId("new-task-github-reference-select")).toBeNull();
emptyRender.unmount();
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([originRemote]);
vi.mocked(apiFetchGitHubIssues).mockRejectedValueOnce(new Error("GitHub auth required"));
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([]);
const authErrorRender = renderNewTaskModal({ projectId: "project-1" });
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-status")).toHaveTextContent("GitHub auth required"));
expect(screen.queryByTestId("new-task-github-reference-select")).toBeNull();
authErrorRender.unmount();
vi.mocked(fetchGitRemotes).mockRejectedValueOnce(new Error("Remote network failure"));
renderNewTaskModal({ projectId: "project-1" });
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-status")).toHaveTextContent("Remote network failure"));
expect(screen.queryByTestId("new-task-github-reference-select")).toBeNull();
});
it.each(["desktop", "mobile"] as const)("renders the picker in %s New Task mode", async (viewport) => {
await renderPickerWithData({ viewport });
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-picker")).toBeInTheDocument());
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-select")).toBeEnabled());
});
it("ignores stale remote responses after the project changes", async () => {
let resolveOldRemotes: (remotes: Array<typeof originRemote>) => void = () => {};
vi.mocked(fetchGitRemotes)
.mockReturnValueOnce(new Promise((resolve) => { resolveOldRemotes = resolve; }))
.mockResolvedValueOnce([originRemote]);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([issue]);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([]);
const { props, rerender } = renderNewTaskModal({ projectId: "old-project" });
rerender(<NewTaskModal {...props} projectId="new-project" />);
resolveOldRemotes([{ ...upstreamRemote, name: "stale" }]);
await waitFor(() => expect(fetchGitRemotes).toHaveBeenCalledWith("new-project"));
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-remote")).toHaveTextContent("origin: runfusion/fusion"));
expect(screen.queryByText(/stale:/)).toBeNull();
});
it("renders the picker in the mobile keyboard-open layout", async () => {
mockUseMobileKeyboard.mockReturnValue({
keyboardOpen: true,
keyboardOverlap: 250,
viewportHeight: 400,
viewportOffsetTop: 50,
});
await renderPickerWithData({ viewport: "mobile" });
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-picker")).toBeInTheDocument());
expect(screen.getByTestId("new-task-github-reference-select")).toBeEnabled();
expect(document.querySelector(".new-task-modal")?.getAttribute("style")).toContain("--keyboard-overlap: 250px");
});
it("keeps the mobile sheet hit-testable when the transparent overlay passes through clicks", async () => {
await renderPickerWithData({ viewport: "mobile" });
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-picker")).toBeInTheDocument());
expect(document.querySelector(".new-task-modal--floating")).toBeNull();
expect(newTaskModalCss).toContain("FNXC:NewTaskMobileAffordances 2026-06-25");
expect(newTaskModalCss).toMatch(/\.new-task-modal\s*\{[^}]*pointer-events:\s*auto;/s);
});
it("keeps GitHub, dependency, and agent popups reachable in the mobile keyboard sheet", async () => {
mockUseMobileKeyboard.mockReturnValue({
keyboardOpen: true,
keyboardOverlap: 250,
viewportHeight: 400,
viewportOffsetTop: 50,
});
vi.mocked(fetchAgents).mockResolvedValueOnce([
{ id: "agent-exec", name: "Executor Bot", role: "executor", state: "idle" } as any,
]);
await renderPickerWithData({ viewport: "mobile" });
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-select")).toBeEnabled());
expect(screen.getByTestId("new-task-github-reference-picker")).toBeVisible();
expect(screen.getByText("Issue #12 — Crash on startup")).toBeInTheDocument();
expect(screen.getByText("PR #34 — Fix login")).toBeInTheDocument();
expect(screen.getByTestId("task-form-inline-github")).toBeVisible();
expect(screen.getByTestId("task-form-inline-models")).toBeVisible();
expect(screen.getByTestId("task-form-inline-node")).toBeVisible();
expect(screen.getByTestId("task-form-inline-priority")).toBeVisible();
expect(screen.getByRole("button", { name: "Create Task" })).toBeVisible();
fireEvent.click(screen.getByTestId("dep-trigger"));
expect(screen.getByPlaceholderText("Search tasks…")).toBeVisible();
expect(screen.getByText("No available tasks")).toBeVisible();
const depDropdown = document.querySelector(".new-task-quick-fields .dep-dropdown");
expect(depDropdown).not.toBeNull();
expect(depDropdown?.closest(".new-task-modal")).toBeTruthy();
fireEvent.click(screen.getByTestId("new-task-agent-button"));
await waitFor(() => expect(screen.getByTestId("agent-option-agent-exec")).toBeVisible());
const agentDropdown = document.querySelector(".new-task-quick-fields .agent-picker-dropdown");
expect(agentDropdown).not.toBeNull();
expect(agentDropdown?.closest(".new-task-modal")).toBeTruthy();
expect(document.querySelector(".new-task-modal")?.getAttribute("style")).toContain("--keyboard-overlap: 250px");
});
it("renders mobile GitHub data states without clipping or absent picker controls", async () => {
vi.mocked(fetchGitRemotes).mockImplementationOnce(() => new Promise(() => undefined));
const loadingRender = renderNewTaskModal({ projectId: "project-1" });
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-status")).toHaveTextContent("Loading GitHub remotes"));
expect(screen.getByTestId("new-task-github-reference-picker")).toBeVisible();
loadingRender.unmount();
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([upstreamRemote, { ...originRemote, name: "fork" }]);
const multipleRemoteRender = renderNewTaskModal({ projectId: "project-1" });
await waitFor(() => expect(screen.getByTestId("new-task-github-remote-select")).toBeVisible());
expect(screen.getByText("Choose a GitHub remote before selecting an issue or pull request.")).toBeVisible();
multipleRemoteRender.unmount();
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([originRemote]);
vi.mocked(apiFetchGitHubIssues).mockImplementationOnce(() => new Promise(() => undefined));
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([]);
const referenceLoadingRender = renderNewTaskModal({ projectId: "project-1" });
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-status")).toHaveTextContent("Loading open issues and pull requests"));
expect(screen.getByTestId("new-task-github-reference-picker")).toBeVisible();
referenceLoadingRender.unmount();
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([originRemote]);
vi.mocked(apiFetchGitHubIssues).mockRejectedValueOnce(new Error("GitHub auth required"));
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([]);
const authErrorRender = renderNewTaskModal({ projectId: "project-1" });
await waitFor(() => expect(screen.getByTestId("new-task-github-reference-status")).toHaveTextContent("GitHub auth required"));
expect(screen.getByTestId("new-task-github-reference-picker")).toBeVisible();
authErrorRender.unmount();
vi.mocked(fetchGitRemotes).mockResolvedValueOnce([originRemote]);
vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([{ ...issue, number: 7 }]);
vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([{ ...pull, number: 7 }]);
renderNewTaskModal({ projectId: "project-1" });
await waitFor(() => expect(screen.getByText("Issue #7 — Crash on startup")).toBeVisible());
expect(screen.getByText("PR #7 — Fix login")).toBeVisible();
expect(screen.getByTestId("new-task-github-reference-select")).toBeVisible();
});
});
it("exposes New Task dialog quick-add affordance parity when AI handoff callbacks are supplied", () => {
renderNewTaskModal({
onPlanningMode: vi.fn(),
onSubtaskBreakdown: vi.fn(),
});
const advancedSection = screen.getByTestId("task-form-more-options");
expect(advancedSection).toHaveAttribute("hidden");
// Empty description state: description-gated actions are disabled/absent, while configuration chips stay immediately usable.
expect(screen.getByTestId("task-form-inline-create")).toBeDisabled();
expect(screen.getByTestId("task-form-plan-button")).toBeDisabled();
expect(screen.queryByTestId("refine-button")).toBeNull();
expect(screen.getByTestId("task-form-inline-fast")).toBeVisible();
expect(screen.getByTestId("task-form-inline-github")).toBeVisible();
expect(screen.getByTestId("task-form-inline-workflow")).toBeVisible();
expect(screen.getByTestId("task-form-inline-models")).toBeVisible();
expect(screen.getByTestId("task-form-inline-node")).toBeVisible();
expect(screen.getByTestId("dep-trigger")).toBeVisible();
expect(screen.getByTestId("task-form-inline-attach")).toBeVisible();
expect(screen.getByTestId("new-task-agent-button")).toBeVisible();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Create parity coverage" } });
// Populated description state: the complete screenshot affordance set is visible without opening Advanced.
expect(screen.getByTestId("task-form-inline-create")).toBeEnabled();
expect(screen.getAllByTestId("task-form-plan-button")).toHaveLength(1);
expect(screen.getByTestId("task-form-plan-button")).toBeEnabled();
expect(screen.getByTestId("refine-button")).toBeVisible();
expect(screen.getByTestId("dep-trigger")).toBeVisible();
expect(screen.getByTestId("new-task-agent-button")).toBeVisible();
expect(screen.getByTestId("task-form-inline-attach")).toBeVisible();
expect(screen.getByTestId("task-form-inline-fast")).toBeVisible();
expect(screen.getByTestId("task-form-inline-github")).toBeVisible();
expect(screen.getByTestId("task-form-inline-workflow")).toBeVisible();
expect(screen.getByTestId("task-form-inline-models")).toBeVisible();
expect(screen.getByTestId("task-form-inline-node")).toBeVisible();
expect(screen.getByTestId("task-form-inline-priority")).toBeVisible();
// Detailed editors remain present only inside Advanced, not duplicated as visible siblings.
expect(advancedSection).toContainElement(screen.getByTestId("task-form-execution-mode-select"));
expect(advancedSection).toContainElement(screen.getByTestId("task-form-github-tracking"));
expect(advancedSection).toContainElement(screen.getByTestId("task-priority-select"));
expect(advancedSection).toContainElement(screen.getByTestId("task-node-select"));
expect(advancedSection).toHaveAttribute("hidden");
});
it("keeps the detailed Fast/standard execution-mode select inside Advanced", () => {
renderNewTaskModal();
const advancedSection = screen.getByTestId("task-form-more-options");
const select = screen.getByTestId("task-form-execution-mode-select") as HTMLSelectElement;
expect(advancedSection).toContainElement(select);
expect(advancedSection).toHaveAttribute("hidden");
expect(select).toHaveValue("standard");
expect(Array.from(select.options).map((option) => option.value)).toEqual(["standard", "fast"]);
});
it("includes executionMode fast in the create payload when Fast is selected", async () => {
const { props } = renderNewTaskModal();
fireEvent.click(screen.getByTestId("task-form-inline-fast"));
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Fast parity task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ executionMode: "fast" }),
);
});
await waitFor(() => {
expect(screen.getByTestId("task-form-execution-mode-select")).toHaveValue("standard");
});
});
it("promoted GitHub, workflow, model, node, deps, agent, attach, and create controls are functional", async () => {
const { fetchWorkflows } = await import("../../api");
vi.mocked(fetchWorkflows).mockResolvedValueOnce([
{
id: "WF-quick",
name: "Quick Lane",
description: "",
kind: "workflow",
ir: { version: "v1", name: "Quick Lane", nodes: [], edges: [] },
layout: {},
createdAt: "",
updatedAt: "",
} as any,
]);
const clickSpy = vi.spyOn(HTMLInputElement.prototype, "click").mockImplementation(() => undefined);
const { props } = renderNewTaskModal({ tasks: [makeTask("FN-777")] });
fireEvent.click(screen.getByTestId("task-form-inline-github"));
expect(screen.getByTestId("task-form-inline-github")).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByTestId("task-form-inline-fast"));
expect(screen.getByTestId("task-form-inline-fast")).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByTestId("task-form-inline-attach"));
expect(clickSpy).toHaveBeenCalled();
fireEvent.click(screen.getByTestId("dep-trigger"));
expect(screen.getByPlaceholderText("Search tasks…")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("new-task-agent-button"));
await waitFor(() => expect(screen.getByText("No agents available")).toBeInTheDocument());
fireEvent.click(screen.getByTestId("task-form-inline-workflow"));
await waitFor(() => expect(screen.getByTestId("task-form-more-options")).not.toHaveAttribute("hidden"));
expect(await screen.findByTestId("task-workflow-dropdown-trigger")).toBeInTheDocument();
expect(screen.getByTestId("task-workflow-dropdown-menu")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
expect(screen.getByTestId("task-form-more-options")).toHaveAttribute("hidden");
fireEvent.click(screen.getByTestId("task-form-inline-models"));
await waitFor(() => expect(screen.getByTestId("task-form-more-options")).not.toHaveAttribute("hidden"));
expect(screen.getByText(/Model Configuration/i)).toBeInTheDocument();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
expect(screen.getByTestId("task-form-more-options")).toHaveAttribute("hidden");
fireEvent.click(screen.getByTestId("task-form-inline-node"));
await waitFor(() => expect(screen.getByTestId("task-form-more-options")).not.toHaveAttribute("hidden"));
expect(screen.getByTestId("task-node-select")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Promoted controls create task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
executionMode: "fast",
githubTracking: { enabled: true },
}),
);
});
clickSpy.mockRestore();
});
it("omits executionMode from the create payload when Standard is selected", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Standard parity task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledTimes(1);
});
const payload = vi.mocked(props.onCreateTask).mock.calls[0][0] as Record<string, unknown>;
expect(payload).not.toHaveProperty("executionMode");
});
// FNXC:PlannerOversight 2026-07-04-00:00: leaving the selector on "Inherit from workflow" ("") must omit plannerOversightLevel from the create payload so the task falls back to the workflow's effective value.
it("omits plannerOversightLevel from the create payload when left on Inherit", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Inherit oversight task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledTimes(1);
});
const payload = vi.mocked(props.onCreateTask).mock.calls[0][0] as Record<string, unknown>;
expect(payload).not.toHaveProperty("plannerOversightLevel");
});
it("includes the selected plannerOversightLevel in the create payload", async () => {
const { props } = renderNewTaskModal();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
const select = await screen.findByTestId("planner-oversight-level-select");
fireEvent.change(select, { target: { value: "steer" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Steer oversight task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ plannerOversightLevel: "steer" }),
);
});
});
it("resets executionMode to standard after canceling and discarding changes", async () => {
const { props, rerender } = renderNewTaskModal();
fireEvent.change(screen.getByTestId("task-form-execution-mode-select"), { target: { value: "fast" } });
await waitFor(() => {
expect(screen.getByTestId("task-form-execution-mode-select")).toHaveValue("fast");
});
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
await waitFor(() => {
expect(mockConfirm).toHaveBeenCalledWith({
title: "Discard Changes",
message: "You have unsaved changes. Discard them?",
danger: true,
});
});
rerender(<NewTaskModal {...props} isOpen={false} />);
rerender(<NewTaskModal {...props} isOpen={true} />);
expect(screen.getByTestId("task-form-execution-mode-select")).toHaveValue("standard");
});
it("hands trimmed descriptions to planning and subtask callbacks without discard confirmation", () => {
const onPlanningMode = vi.fn();
const onSubtaskBreakdown = vi.fn();
const { unmount, props } = renderNewTaskModal({
onPlanningMode,
onSubtaskBreakdown,
});
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: " Break this down " } });
fireEvent.click(screen.getByTestId("task-form-plan-button"));
expect(props.onClose).toHaveBeenCalledTimes(1);
expect(mockConfirm).not.toHaveBeenCalled();
expect(onPlanningMode).toHaveBeenCalledWith("Break this down");
expect(onSubtaskBreakdown).not.toHaveBeenCalled();
unmount();
renderNewTaskModal({
onPlanningMode,
onSubtaskBreakdown,
});
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: " Split into subtasks " } });
fireEvent.click(screen.getByTestId("task-form-subtask-button"));
expect(onSubtaskBreakdown).toHaveBeenCalledWith("Split into subtasks");
expect(onPlanningMode).toHaveBeenCalledTimes(1);
});
it("disables Plan and Subtask handoff buttons until a description is present", () => {
renderNewTaskModal({
onPlanningMode: vi.fn(),
onSubtaskBreakdown: vi.fn(),
});
const planButton = screen.getByTestId("task-form-plan-button");
const subtaskButton = screen.getByTestId("task-form-subtask-button");
expect(planButton).toBeDisabled();
expect(subtaskButton).toBeDisabled();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Ready to plan" } });
expect(planButton).not.toBeDisabled();
expect(subtaskButton).not.toBeDisabled();
});
// FNXC:NewTask 2026-06-23-00:10: The New Task dialog NO LONGER force-opens TaskForm's advanced controls. The DEEP/advanced options (model selectors, workflow picker, etc.) are collapsed behind a disclosure relabeled "Advanced"; the common quick-add buttons (Attach/Fast/Priority) are surfaced inline next to Plan and are always visible.
it("keeps deep options behind a collapsed 'Advanced' disclosure while surfacing inline quick-add buttons", () => {
renderNewTaskModal();
// The disclosure toggle exists, reads "Advanced", and starts collapsed (section hidden).
const advancedToggle = screen.getByTestId("task-form-more-options-toggle");
expect(advancedToggle).toHaveTextContent(/Advanced/i);
expect(advancedToggle).toHaveAttribute("aria-expanded", "false");
// Deep options live inside the collapsed (hidden) section, so they are not shown to the user.
const advancedSection = screen.getByTestId("task-form-more-options");
expect(advancedSection).toHaveAttribute("hidden");
expect(advancedSection).toContainElement(screen.getByText(/Model Configuration/i));
expect(advancedSection).toContainElement(screen.getByText("Workflow"));
// Inline quick-add buttons (Attach/Fast/Priority) ARE visible without expanding (outside the hidden section).
expect(screen.getByTestId("task-form-inline-attach")).toBeInTheDocument();
expect(screen.getByTestId("task-form-inline-fast")).toBeInTheDocument();
expect(screen.getByTestId("task-form-inline-priority")).toBeInTheDocument();
expect(screen.getByTestId("dep-trigger")).toBeInTheDocument();
// Expanding the disclosure reveals the deep options.
fireEvent.click(advancedToggle);
expect(advancedToggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByTestId("task-form-more-options")).not.toHaveAttribute("hidden");
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
expect(screen.getByText("Workflow")).toBeTruthy();
});
it("shows dependencies and agent picker by default", () => {
renderNewTaskModal();
// Both dep-trigger and agent button should be visible by default (quick-fields).
expect(screen.getByTestId("dep-trigger")).toBeInTheDocument();
expect(screen.getByTestId("new-task-agent-button")).toBeInTheDocument();
// The "Advanced" disclosure is collapsed by default.
expect(screen.getByTestId("task-form-more-options-toggle")).toHaveTextContent(/Advanced/i);
expect(screen.getByTestId("task-form-more-options")).toHaveAttribute("hidden");
});
it("renders dependencies before attachments in form order (quick-fields before Advanced)", () => {
renderNewTaskModal();
const dependenciesLabel = screen.getByText("Dependencies");
// Expand the Advanced disclosure so the Attachments group renders.
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
const attachmentsLabel = screen.getByText("Attachments");
// Dependencies (in quick-fields) appears before Attachments (in the Advanced section).
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.getByPlaceholderText("What needs to be done?");
await waitFor(() => {
expect(document.activeElement).toBe(textarea);
});
});
it("seeds the description when opened with an initial description", () => {
renderNewTaskModal({ initialDescription: "File: README.md\n\nComment:\nFollow up" });
expect(screen.getByPlaceholderText("What needs to be done?")).toHaveValue("File: README.md\n\nComment:\nFollow up");
expect(screen.getByRole("button", { name: "Create Task" })).not.toBeDisabled();
});
it("does not clobber user edits when initialDescription changes while open", () => {
const { rerender, props } = renderNewTaskModal({ initialDescription: "Seeded description" });
const descTextarea = screen.getByPlaceholderText("What needs to be done?");
fireEvent.change(descTextarea, { target: { value: "User edited text" } });
rerender(<NewTaskModal {...props} initialDescription="Different seed" />);
expect(screen.getByPlaceholderText("What needs to be done?")).toHaveValue("User edited text");
});
it("creates task with description when submitted", async () => {
const { props } = renderNewTaskModal();
const descTextarea = screen.getByPlaceholderText("What needs to be done?");
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",
}),
);
});
});
describe("optional workflow steps (U4)", () => {
const WF = {
id: "wf-x",
name: "Custom",
kind: "workflow" as const,
description: "",
ir: { version: "v1" as const, name: "Custom", nodes: [], edges: [] },
layout: {},
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
};
const STEP = {
templateId: "browser-verification",
name: "Browser Verification",
description: "Verify web application functionality using browser automation",
icon: "globe",
phase: "pre-merge" as const,
defaultOn: false,
};
it("includes a toggled-on optional step in the create payload", async () => {
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
vi.mocked(fetchWorkflows).mockResolvedValue([WF]);
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([STEP]);
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), {
target: { value: "Verify the login page" },
});
await chooseWorkflowOption("wf-x");
const trigger = await screen.findByTestId("task-form-inline-optional-steps");
expect(trigger).toHaveTextContent("Steps: none");
fireEvent.click(trigger);
fireEvent.click(await screen.findByTestId("wf-optional-steps-dropdown-option-browser-verification"));
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ enabledWorkflowSteps: ["browser-verification"] }),
);
});
});
it("keeps exactly one inline optional-steps trigger when Advanced is expanded", async () => {
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
vi.mocked(fetchWorkflows).mockResolvedValue([WF]);
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([STEP]);
renderNewTaskModal();
await chooseWorkflowOption("wf-x");
await screen.findByTestId("task-form-inline-optional-steps");
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
expect(screen.getAllByTestId("task-form-inline-optional-steps")).toHaveLength(1);
expect(screen.queryByTestId("task-form-optional-steps")).toBeNull();
});
it("seeds defaultOn steps as pre-enabled and submits them without toggling", async () => {
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
vi.mocked(fetchWorkflows).mockResolvedValue([WF]);
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([{ ...STEP, defaultOn: true }]);
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "task" } });
await chooseWorkflowOption("wf-x");
const trigger = await screen.findByTestId("task-form-inline-optional-steps");
await waitFor(() => expect(trigger).toHaveTextContent("Steps: 1 selected"));
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ enabledWorkflowSteps: ["browser-verification"] }),
);
});
});
it("submits explicit empty optional steps when Fast is created before optional-step metadata loads", async () => {
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
vi.mocked(fetchWorkflows).mockResolvedValue([WF]);
vi.mocked(fetchWorkflowOptionalSteps).mockReturnValue(new Promise(() => undefined) as any);
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "fast before metadata" } });
await chooseWorkflowOption("wf-x");
await waitFor(() => expect(fetchWorkflowOptionalSteps).toHaveBeenCalledWith("wf-x", undefined));
fireEvent.click(screen.getByTestId("task-form-inline-fast"));
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ executionMode: "fast", enabledWorkflowSteps: [] }),
);
});
});
it("persists explicit empty optional steps after Fast clears defaults and allows manual reselection", async () => {
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
vi.mocked(fetchWorkflows).mockResolvedValue([WF]);
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([{ ...STEP, defaultOn: true }]);
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "fast task" } });
await chooseWorkflowOption("wf-x");
const trigger = await screen.findByTestId("task-form-inline-optional-steps");
await waitFor(() => expect(trigger).toHaveTextContent("Steps: 1 selected"));
fireEvent.click(screen.getByTestId("task-form-inline-fast"));
await waitFor(() => expect(trigger).toHaveTextContent("Steps: none"));
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ executionMode: "fast", enabledWorkflowSteps: [] }),
);
});
vi.mocked(props.onCreateTask).mockClear();
vi.mocked(props.onCreateTask).mockResolvedValue(makeTask("FN-002"));
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "fast task with browser" } });
await chooseWorkflowOption("wf-x");
const nextTrigger = await screen.findByTestId("task-form-inline-optional-steps");
fireEvent.click(screen.getByTestId("task-form-inline-fast"));
fireEvent.click(nextTrigger);
fireEvent.click(await screen.findByTestId("wf-optional-steps-dropdown-option-browser-verification"));
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ executionMode: "fast", enabledWorkflowSteps: ["browser-verification"] }),
);
});
});
it("renders no dropdown and omits enabledWorkflowSteps for 'No workflow'", async () => {
const { fetchWorkflows, fetchWorkflowOptionalSteps } = await import("../../api");
vi.mocked(fetchWorkflows).mockResolvedValue([WF]);
vi.mocked(fetchWorkflowOptionalSteps).mockResolvedValue([STEP]);
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "task" } });
// "No workflow" → null selection → no optional-steps fetch, no dropdown.
await chooseWorkflowOption("__none__");
expect(screen.queryByTestId("task-form-inline-optional-steps")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
const call = vi.mocked(props.onCreateTask).mock.calls.at(-1)?.[0];
expect(call).not.toHaveProperty("enabledWorkflowSteps");
});
});
});
it("submits project-default branch selection by default", 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({
branchSelection: { mode: "project-default" },
}),
);
});
});
it("submits existing branch selection with trimmed names", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with branches" } });
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "existing" } });
fireEvent.change(screen.getByLabelText("Branch name"), { 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({
branchSelection: {
mode: "existing",
branchName: "feature/fn-3422",
baseBranch: "main",
},
}),
);
});
});
it("submits auto-new branch selection", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with auto new" } });
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "auto-new" } });
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({
branchSelection: {
mode: "auto-new",
baseBranch: "main",
},
}),
);
});
});
it("requires branch name for custom-new mode", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with branches" } });
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "custom-new" } });
expect(screen.getByRole("button", { name: "Create Task" })).toBeDisabled();
expect(screen.getByText("Branch name is required for this branch strategy.")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
expect(props.onCreateTask).not.toHaveBeenCalled();
});
it("submits custom-new branch selection when branch name exists", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with custom new" } });
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "custom-new" } });
fireEvent.change(screen.getByLabelText("Branch name"), { target: { value: " feature/custom " } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
branchSelection: {
mode: "custom-new",
branchName: "feature/custom",
},
}),
);
});
});
it("requires branch name for shared-group mode", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with shared group" } });
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "shared-group" } });
expect(screen.getByRole("button", { name: "Create Task" })).toBeDisabled();
expect(screen.getByText("Branch name is required for this branch strategy.")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
expect(props.onCreateTask).not.toHaveBeenCalled();
});
it("submits shared-group branch selection when shared branch exists", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task with shared group" } });
fireEvent.change(screen.getByLabelText("Branch strategy"), { target: { value: "shared-group" } });
fireEvent.change(screen.getByLabelText("Shared feature branch"), { target: { value: " feature/shared " } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
branchSelection: {
mode: "shared-group",
branchName: "feature/shared",
},
}),
);
});
});
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.queryByText("GitHub not connected")).toBeNull();
});
const descTextarea = screen.getByPlaceholderText("What needs to be done?");
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("hides GitHub-only setup warning before the project grace period expires", async () => {
const { fetchAuthStatus } = await import("../../api");
vi.mocked(fetchAuthStatus).mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
renderNewTaskModal({ projectId: "proj-grace" });
await waitFor(() => {
expect(screen.queryByText("No AI provider connected")).toBeNull();
expect(screen.queryByText("GitHub not connected")).toBeNull();
});
});
it("suppresses the modal GitHub setup warning after grace because no settings opener is available", async () => {
const { fetchAuthStatus } = await import("../../api");
vi.mocked(fetchAuthStatus).mockResolvedValueOnce({
providers: [
{ id: "anthropic", name: "Anthropic", authenticated: true, type: "oauth" },
{ id: "github", name: "GitHub", authenticated: false, type: "oauth" },
],
});
window.localStorage.setItem(
scopedKey(GITHUB_SETUP_WARNING_MISSING_SINCE_KEY, "proj-expired"),
String(Date.now() - GITHUB_SETUP_WARNING_DELAY_MS),
);
renderNewTaskModal({ projectId: "proj-expired" });
await waitFor(() => {
expect(screen.queryByText("No AI provider connected")).toBeNull();
expect(screen.queryByText("GitHub not connected")).toBeNull();
});
expect(screen.queryByRole("button", { name: "Connect GitHub" })).toBeNull();
});
it("closes modal after successful creation", async () => {
const { props } = renderNewTaskModal();
const descTextarea = screen.getByPlaceholderText("What needs to be done?");
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.getByPlaceholderText("What needs to be done?");
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.getByPlaceholderText("What needs to be done?");
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.getByPlaceholderText("What needs to be done?");
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.getByPlaceholderText("What needs to be done?");
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("checks for duplicates and creates directly when none are found", async () => {
const { props } = renderNewTaskModal({ projectId: "project-alpha" });
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Unique task description" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(checkDuplicateTasks).toHaveBeenCalledWith({ description: "Unique task description" }, "project-alpha");
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ description: "Unique task description" }),
);
});
expect(screen.queryByText("Possible duplicates")).not.toBeInTheDocument();
});
it("shows duplicate warning and does not create when matches are found", async () => {
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
{ id: "FN-301", title: "Title should not display", description: "Existing similar full-dialog task", column: "todo", score: 0.88 },
]);
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "New full-dialog task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
expect(await screen.findByText("Possible duplicates")).toBeInTheDocument();
expect(screen.getByText("Existing similar full-dialog task")).toBeInTheDocument();
expect(screen.queryByText("Title should not display")).not.toBeInTheDocument();
expect(props.onCreateTask).not.toHaveBeenCalled();
});
it("creates with acknowledged duplicate ids after Create anyway", async () => {
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
{ id: "FN-401", title: "Existing title", description: "Existing duplicate description", column: "todo", score: 0.93 },
{ id: "FN-402", title: "Second title", description: "Second duplicate description", column: "in-progress", score: 0.82 },
]);
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Create anyway duplicate" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
fireEvent.click(await screen.findByRole("button", { name: "Create anyway" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
description: "Create anyway duplicate",
acknowledgedDuplicates: ["FN-401", "FN-402"],
}),
);
});
});
it("dismisses duplicate warning on Cancel without creating", async () => {
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
{ id: "FN-501", title: "Existing title", description: "Cancel duplicate description", column: "todo", score: 0.9 },
]);
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Cancel duplicate" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await screen.findByText("Possible duplicates");
fireEvent.click(screen.getAllByRole("button", { name: "Cancel" }).at(-1)!);
await waitFor(() => {
expect(screen.queryByText("Possible duplicates")).not.toBeInTheDocument();
});
expect(props.onCreateTask).not.toHaveBeenCalled();
});
it("opens the selected duplicate task and closes the dialog", async () => {
vi.mocked(checkDuplicateTasks).mockResolvedValueOnce([
{ id: "FN-601", title: "Existing title", description: "Open duplicate description", column: "todo", score: 0.9 },
]);
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Open duplicate" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
fireEvent.click((await screen.findAllByRole("button", { name: "Open" }))[0]);
await waitFor(() => {
expect(window.location.hash).toBe("#/tasks/FN-601");
expect(props.onClose).toHaveBeenCalled();
});
expect(props.onCreateTask).not.toHaveBeenCalled();
});
it("fails open and creates when duplicate check throws", async () => {
vi.mocked(checkDuplicateTasks).mockRejectedValueOnce(new Error("duplicate check unavailable"));
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByRole("textbox"), { target: { value: "Fail open duplicate check" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.addToast).toHaveBeenCalledWith("Duplicate check failed; creating task anyway.", "error");
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ description: "Fail open duplicate check" }),
);
});
});
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.getByPlaceholderText("What needs to be done?");
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.getByPlaceholderText("What needs to be done?");
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.getByPlaceholderText("What needs to be done?"), { 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.getByPlaceholderText("What needs to be done?"), { 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 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("task-workflow-dropdown-trigger")).toBeTruthy();
});
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Inherit default" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
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-dropdown-trigger")).toBeTruthy();
});
await chooseWorkflowOption("WF-1");
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Pick a workflow" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ workflowId: "WF-1" }),
);
});
});
it("submits the workflow supplied by the contextual board opener", async () => {
await mockWorkflows([{ id: "WF-IDEAS", name: "Coding (Ideas)" }]);
const { props } = renderNewTaskModal({ initialWorkflowId: "WF-IDEAS" });
await waitFor(() => {
expect(screen.getByTestId("task-workflow-dropdown-trigger")).toHaveTextContent("Coding (Ideas)");
});
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Stay in Ideas" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(expect.objectContaining({ workflowId: "WF-IDEAS" }));
});
});
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("task-workflow-dropdown-trigger")).toBeTruthy();
});
// Pick a workflow, then switch to "No workflow" to register an explicit null.
await chooseWorkflowOption("WF-1");
await chooseWorkflowOption("__none__");
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "No workflow task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ workflowId: null }),
);
});
});
it("does not render the legacy per-step checkbox UI", async () => {
await mockWorkflows([{ id: "WF-1", name: "QA" }]);
renderNewTaskModal();
await waitFor(() => {
expect(screen.getByTestId("task-workflow-dropdown-trigger")).toBeTruthy();
});
expect(screen.queryByTestId("workflow-step-order")).toBeNull();
expect(document.querySelector('[data-testid^="workflow-step-checkbox-"]')).toBeNull();
});
});
// 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.getByPlaceholderText("What needs to be done?");
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
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
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("auto-merge selection payload", () => {
it("omits autoMerge from payload when default is selected", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task default auto-merge" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.not.objectContaining({ autoMerge: expect.anything() }),
);
});
});
it("includes autoMerge true when Enabled is selected", async () => {
const { props } = renderNewTaskModal();
await waitFor(() => {
expect(screen.getByTestId("task-automerge-select")).toBeTruthy();
});
fireEvent.change(screen.getByTestId("task-automerge-select"), { target: { value: "on" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task auto-merge on" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ autoMerge: true }),
);
});
});
it("includes autoMerge false when Disabled is selected", async () => {
const { props } = renderNewTaskModal();
await waitFor(() => {
expect(screen.getByTestId("task-automerge-select")).toBeTruthy();
});
fireEvent.change(screen.getByTestId("task-automerge-select"), { target: { value: "off" } });
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Task auto-merge off" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({ autoMerge: false }),
);
});
});
});
describe("priority selection payload", () => {
it("includes default normal priority in create payload", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { 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.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.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.getByPlaceholderText("What needs to be done?"), { 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.getByPlaceholderText("What needs to be done?"), { 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.getByPlaceholderText("What needs to be done?"), { 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.getByPlaceholderText("What needs to be done?"), { 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 the Workflow picker in more options", async () => {
renderNewTaskModal();
const workflowLabel = await screen.findByText("Workflow");
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.getByPlaceholderText("What needs to be done?"), { 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);
});
});
});
/*
FNXC:NewTask 2026-06-22-20:30:
On desktop the New Task dialog is a floating, draggable, resizable, NON-BLOCKING window: the overlay is `pointer-events: none` and aria-modal="false" so behind-clicks pass through and never close the dialog (only the header X / Cancel / Escape dismiss). It carries a draggable header handle and resize handles.
*/
describe("workflow dropdown styling", () => {
it("uses tokenized bounded dropdown styles without legacy native-select assumptions", () => {
const workflowRules = Array.from(newTaskModalCss.matchAll(/\.task-workflow[^,{\s]*(?:[^{}]*)\{([^}]*)\}/g))
.map((match) => match[0])
.join("\n");
expect(newTaskModalCss).toContain("FNXC:NewTaskWorkflowDropdown 2026-06-30");
expect(workflowRules).toContain("var(--space-");
expect(workflowRules).toContain("max-width: 100%");
expect(workflowRules).toContain("overflow-y: auto");
expect(workflowRules).not.toMatch(/#[0-9a-fA-F]{3,8}\b|rgb\(/);
expect(newTaskModalCss).toMatch(/@media \(max-width: 768px\)[\s\S]*\.task-form \.dep-dropdown/);
expect(newTaskModalCss).not.toMatch(/task-workflow-select\s*\{/);
});
});
describe("desktop floating window", () => {
beforeEach(() => {
mockViewportMode = "desktop";
});
it("renders a non-blocking (pointer-events: none, aria-modal=false) overlay that does not dismiss on click", () => {
const onClose = vi.fn();
renderNewTaskModal({ onClose });
const overlay = screen.getByTestId("new-task-modal-overlay");
// Non-blocking: click-through overlay, not a modal.
expect(overlay).toHaveClass("new-task-modal-overlay");
expect(overlay).toHaveAttribute("aria-modal", "false");
// A behind-click on the overlay must NOT close the dialog (no overlay click-to-dismiss).
fireEvent.click(overlay);
expect(onClose).not.toHaveBeenCalled();
});
it("exposes a draggable header handle and resize handles", () => {
renderNewTaskModal();
expect(screen.getByTestId("new-task-drag-handle")).toHaveClass("new-task-modal__header--draggable");
// All eight corner/edge resize handles are present.
for (const dir of ["n", "s", "e", "w", "ne", "nw", "se", "sw"]) {
expect(screen.getByTestId(`new-task-resize-${dir}`)).toBeInTheDocument();
}
// The floating panel is the fixed-positioned window.
const panel = document.querySelector(".new-task-modal--floating");
expect(panel).not.toBeNull();
});
it("keeps the floating window touch-draggable with theme-controlled shadow", () => {
const panelRule = newTaskModalCss.match(/\.new-task-modal--floating\s*\{([^}]*)\}/)?.[1] ?? "";
const headerRule = newTaskModalCss.match(/\.new-task-modal__header--draggable\s*\{([^}]*)\}/)?.[1] ?? "";
expect(panelRule).toContain("box-shadow: var(--floating-window-shadow, var(--shadow-lg));");
expect(headerRule).toContain("touch-action: none;");
expect(headerRule).toContain("min-height: 48px;");
expect(newTaskModalCss).not.toContain("var(--shadow-xl)");
});
it("still closes via the header close button (X)", async () => {
const onClose = vi.fn();
renderNewTaskModal({ onClose });
fireEvent.click(screen.getByLabelText("Close"));
await waitFor(() => expect(onClose).toHaveBeenCalledTimes(1));
});
});
});