Files
kandev/apps/web/components/task-create-dialog-form-body.test.tsx
T
2026-09-13 15:25:16 +01:00

531 lines
18 KiB
TypeScript

import { createRef, type ComponentProps } from "react";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
CreateEditSelectors,
DialogPromptSection,
WorkflowSection,
} from "./task-create-dialog-form-body";
import type { DialogFormState, TaskFormInputsHandle } from "./task-create-dialog-types";
import type { TaskCreateLaunchPreview } from "./task-create-dialog-launch-preview";
afterEach(cleanup);
vi.mock("@/components/workflow-selector-row", () => ({
WorkflowSelectorRow: ({ selectedWorkflowId }: { selectedWorkflowId: string | null }) => (
<button type="button">Workflow selector {selectedWorkflowId ?? "none"}</button>
),
}));
vi.mock("@/components/task-create-dialog-dependencies", () => ({
TaskCreateDependencies: ({ value }: { value: string[] }) => (
<button type="button" data-testid="task-create-dependencies-trigger">
{value.length > 0 ? `${value.length} dependencies` : "No dependency"}
</button>
),
}));
// Capture every render of TaskFormInputs so passthrough-related assertions can
// inspect the props that DialogPromptSection forwards to the textarea.
const taskFormInputsCalls: Array<Record<string, unknown>> = [];
vi.mock("@/components/task-create-dialog-selectors", () => ({
TaskFormInputs: (props: Record<string, unknown>) => {
taskFormInputsCalls.push(props);
return (
<textarea
data-testid="task-description-textarea"
placeholder={(props.placeholder as string | undefined) ?? "default placeholder"}
disabled={Boolean(props.disabled)}
/>
);
},
}));
const workflow = { id: "wf-1", name: "Development" };
function renderWorkflowSection(effectiveWorkflowId: string | null) {
return render(
<WorkflowSection
isCreateMode={true}
isTaskStarted={false}
workflows={[workflow]}
snapshots={{}}
effectiveWorkflowId={effectiveWorkflowId}
onWorkflowChange={() => {}}
agentProfiles={[]}
/>,
);
}
describe("WorkflowSection workflow rendering", () => {
it("keeps the selector reachable when no effective workflow is selected", () => {
renderWorkflowSection(null);
expect(screen.getByRole("button", { name: /workflow selector none/i })).toBeTruthy();
});
it("does not show redundant selector for a selected single workflow without overrides", () => {
renderWorkflowSection("wf-1");
expect(screen.queryByRole("button", { name: /workflow selector wf-1/i })).toBeNull();
expect(screen.queryByTestId("task-create-dependencies-trigger")).toBeNull();
});
});
describe("WorkflowSection", () => {
const secondWorkflow = { id: "wf-2", name: "Support" };
function renderWorkflowSection(
effectiveWorkflowId: string | null,
workflows = [workflow, secondWorkflow],
) {
return render(
<WorkflowSection
isCreateMode
isTaskStarted={false}
workflows={workflows}
snapshots={{}}
effectiveWorkflowId={effectiveWorkflowId}
onWorkflowChange={() => {}}
agentProfiles={[]}
/>,
);
}
it("renders the workflow selector without an inline dependency slot", () => {
renderWorkflowSection("wf-1");
expect(screen.getByRole("button", { name: /workflow selector wf-1/i })).toBeTruthy();
expect(screen.queryByTestId("task-create-dependencies-trigger")).toBeNull();
});
it("does not render a workflow row for a single workflow without overrides", () => {
renderWorkflowSection("wf-1", [workflow]);
expect(screen.queryByRole("button", { name: /workflow selector wf-1/i })).toBeNull();
expect(screen.queryByTestId("task-create-dependencies-trigger")).toBeNull();
});
it("keeps the workflow section independent from advanced dependencies", () => {
renderWorkflowSection("wf-1");
expect(screen.getByRole("button", { name: /workflow selector wf-1/i })).toBeTruthy();
expect(screen.queryByTestId("task-create-dependency-slot")).toBeNull();
});
});
function makeFs(): DialogFormState {
return {
blockedBy: [],
setBlockedBy: () => undefined,
taskName: "",
autopilot: false,
setAutopilot: () => {},
priority: "medium",
setPriority: () => {},
setTaskName: () => {},
hasTitle: false,
setHasTitle: () => {},
hasDescription: false,
setHasDescription: () => {},
hasPendingAttachmentUploads: false,
setHasPendingAttachmentUploads: () => {},
draftDescription: "",
openCycle: 0,
currentDefaults: { name: "", description: "" },
descriptionInputRef: createRef<TaskFormInputsHandle>(),
repositories: [],
repositoriesDirty: false,
setRepositories: () => {},
setRepositoriesDirty: () => {},
addRepository: () => {},
removeRepository: () => {},
updateRepository: () => {},
agentProfileId: "",
setAgentProfileId: () => {},
executorId: "",
setExecutorId: () => {},
executorProfileId: "",
setExecutorProfileId: () => {},
setExecutorProfileIdFromSeed: () => {},
seededExecutorProfileId: null,
discoveredRepositories: [],
setDiscoveredRepositories: () => {},
discoverReposLoading: false,
setDiscoverReposLoading: () => {},
discoverReposLoaded: false,
setDiscoverReposLoaded: () => {},
selectedWorkflowId: null,
setSelectedWorkflowId: () => {},
fetchedSteps: null,
setFetchedSteps: () => {},
isCreatingSession: false,
setIsCreatingSession: () => {},
isCreatingTask: false,
setIsCreatingTask: () => {},
useRemote: false,
setUseRemote: () => {},
remoteRepos: [],
setRemoteRepos: () => {},
addRemoteRepo: () => {},
removeRemoteRepo: () => {},
updateRemoteRepo: () => {},
branchesByUrl: {
branches: () => [],
loading: () => false,
error: () => undefined,
ensure: () => undefined,
clear: () => undefined,
},
prInfoByUrl: {
info: () => undefined,
loading: () => false,
settled: () => true,
error: () => undefined,
ensure: () => undefined,
clear: () => undefined,
},
githubUrlError: null,
setGitHubUrlError: () => {},
workflowAgentProfileId: "",
setWorkflowAgentProfileId: () => {},
clearDraft: () => {},
freshBranchEnabled: false,
setFreshBranchEnabled: () => {},
currentLocalBranch: "",
setCurrentLocalBranch: () => {},
currentLocalBranchLoading: false,
setCurrentLocalBranchLoading: () => {},
noRepository: false,
setNoRepository: () => {},
preferLocalExecutor: false,
setPreferLocalExecutor: () => {},
workspacePath: "",
setWorkspacePath: () => {},
};
}
describe("DialogPromptSection launch preview", () => {
it("forwards the launch preview to the prompt composer", () => {
taskFormInputsCalls.length = 0;
const launchPreview: TaskCreateLaunchPreview = {
stepId: "step-1",
stepName: "In Progress",
stepPrompt: "Run {{task_prompt}}",
};
render(
<DialogPromptSection
isSessionMode={false}
isTaskStarted={false}
initialDescription="Original prompt"
fs={makeFs()}
handleKeyDown={(() => {}) as never}
launchPreview={launchPreview}
/>,
);
expect(taskFormInputsCalls.at(-1)?.launchPreview).toEqual(launchPreview);
});
});
describe("DialogPromptSection (CLI-mode parity)", () => {
it("keeps a started task prompt locked", () => {
taskFormInputsCalls.length = 0;
const view = render(
<DialogPromptSection
isSessionMode={false}
isTaskStarted={true}
initialDescription="Original prompt"
fs={makeFs()}
handleKeyDown={(() => {}) as never}
/>,
);
expect((view.getByTestId("task-description-textarea") as HTMLTextAreaElement).disabled).toBe(
true,
);
view.unmount();
});
it("keeps the prompt textarea enabled when the selected profile is passthrough", () => {
taskFormInputsCalls.length = 0;
render(
<DialogPromptSection
isSessionMode={false}
isTaskStarted={false}
initialDescription=""
fs={makeFs()}
handleKeyDown={(() => {}) as never}
descriptionPlaceholder="Write a prompt for the agent..."
/>,
);
const textarea = screen.getByTestId("task-description-textarea") as HTMLTextAreaElement;
expect(textarea.disabled).toBe(false);
expect(textarea.placeholder).toBe("Write a prompt for the agent...");
const last = taskFormInputsCalls.at(-1)!;
expect(last.disabled).toBe(false);
expect(last.placeholder).toBe("Write a prompt for the agent...");
});
it("does not render the legacy 'Prompt ignored — passthrough mode active' warning", () => {
const { container } = render(
<DialogPromptSection
isSessionMode={false}
isTaskStarted={false}
initialDescription="hello"
fs={makeFs()}
handleKeyDown={(() => {}) as never}
/>,
);
expect(container.textContent).not.toMatch(/prompt ignored/i);
expect(container.textContent).not.toMatch(/passthrough mode/i);
});
it("allows Jira/Linear import in passthrough (CLI) mode", () => {
taskFormInputsCalls.length = 0;
render(
<DialogPromptSection
isSessionMode={false}
isTaskStarted={false}
initialDescription=""
fs={makeFs()}
handleKeyDown={(() => {}) as never}
workspaceId="ws-1"
onJiraImport={() => {}}
onLinearImport={() => {}}
/>,
);
const last = taskFormInputsCalls.at(-1)!;
expect((last.jiraImport as { disabled: boolean } | undefined)?.disabled).toBe(false);
expect((last.linearImport as { disabled: boolean } | undefined)?.disabled).toBe(false);
});
it("forwards onComposerSubmit to TaskFormInputs", () => {
taskFormInputsCalls.length = 0;
const onComposerSubmit = () => true;
render(
<DialogPromptSection
isSessionMode={false}
isTaskStarted={false}
initialDescription=""
fs={makeFs()}
handleKeyDown={(() => {}) as never}
onComposerSubmit={onComposerSubmit}
/>,
);
const last = taskFormInputsCalls.at(-1)!;
expect(last.onComposerSubmit).toBe(onComposerSubmit);
});
});
const SELECTOR_TEST_ID = "agent-selector-stub";
const AgentSelectorStub = () => (
<button type="button" data-testid="agent-selector-stub">
selector
</button>
);
const ExecutorSelectorStub = () => <button type="button">executor</button>;
const createEditSelectorsBaseProps: Omit<
ComponentProps<typeof CreateEditSelectors>,
"agentCompatState"
> = {
isTaskStarted: false,
agentProfiles: [{ id: "agent-1", label: "Codex", agent_name: "codex" } as never],
agentProfilesLoading: false,
agentProfileOptions: [],
agentProfileId: "",
onAgentProfileChange: () => {},
isCreatingSession: false,
executorProfileOptions: [],
executorProfileId: "exec-profile-1",
onExecutorProfileChange: () => {},
executorsLoading: false,
AgentSelectorComponent: AgentSelectorStub,
ExecutorProfileSelectorComponent: ExecutorSelectorStub,
workflowAgentLocked: false,
executorProfileName: "Docker",
selectedAgentProfileName: null,
effectiveWorkflowName: null,
runnerEditable: true,
runnerIneligibleReason: "eligible",
};
describe("CreateEditSelectors", () => {
const WORKFLOW_NAME = "Development";
const EXECUTOR_NAME = "Fly";
const EMPTY_STATE_TEST_ID = "agent-profile-empty-state";
const baseProps = createEditSelectorsBaseProps;
// @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.4
it("links credential setup to the selected executor profile", () => {
render(<CreateEditSelectors {...baseProps} agentCompatState="none-compatible" />);
expect(screen.getByTestId(EMPTY_STATE_TEST_ID).textContent).toContain(
"No compatible agent profiles",
);
expect(screen.getByRole("link", { name: /configure credentials/i }).getAttribute("href")).toBe(
"/settings/executors/exec-profile-1",
);
expect(screen.queryByTestId(SELECTOR_TEST_ID)).toBeNull();
});
// @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.1
// @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.6
it("keeps the selector and names the incompatible agent when another agent is compatible", () => {
render(
<CreateEditSelectors
{...baseProps}
agentProfileId="agent-1"
agentCompatState="selected-incompatible"
selectedAgentProfileName="OpenCode"
executorProfileName={EXECUTOR_NAME}
/>,
);
expect(screen.getByTestId(SELECTOR_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(EMPTY_STATE_TEST_ID)).toBeNull();
const note = screen.getByTestId("agent-profile-incompatible-note").textContent ?? "";
expect(note).toContain("OpenCode");
expect(note).toContain(EXECUTOR_NAME);
expect(screen.getByRole("link", { name: /configure credentials/i }).getAttribute("href")).toBe(
"/settings/executors/exec-profile-1",
);
});
it("keeps the selector and shows an unavailable note while replacement is pending", () => {
render(
<CreateEditSelectors
{...baseProps}
agentProfileId="agent-1"
agentCompatState={"selected-unavailable" as never}
selectedAgentProfileName="Disabled agent"
executorProfileName={EXECUTOR_NAME}
/>,
);
expect(screen.getByTestId(SELECTOR_TEST_ID)).toBeTruthy();
expect(screen.queryByTestId(EMPTY_STATE_TEST_ID)).toBeNull();
expect(screen.getByTestId("agent-profile-unavailable-note").textContent).toContain(
"Disabled agent",
);
});
// @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.5
it("names the workflow, agent, and executor when the workflow locks an incompatible agent", () => {
render(
<CreateEditSelectors
{...baseProps}
agentProfileId="agent-1"
agentCompatState="selected-incompatible"
workflowAgentLocked={true}
selectedAgentProfileName="OpenCode"
effectiveWorkflowName={WORKFLOW_NAME}
executorProfileName={EXECUTOR_NAME}
/>,
);
const note = screen.getByTestId("agent-profile-incompatible-note").textContent ?? "";
expect(note).toContain(WORKFLOW_NAME);
expect(note).toContain("OpenCode");
expect(note).toContain(EXECUTOR_NAME);
expect(screen.queryByTestId(SELECTOR_TEST_ID)).toBeNull();
expect(screen.queryByTestId(EMPTY_STATE_TEST_ID)).toBeNull();
expect(screen.getByRole("link", { name: /configure credentials/i }).getAttribute("href")).toBe(
"/settings/executors/exec-profile-1",
);
});
});
// REQ-TASKS-RUNNER-SWITCH-004: runner-editability gating is independent of
// the agent-compatibility rendering exercised above, split into its own
// block to keep each describe's setup focused.
describe("CreateEditSelectors — runner editability (REQ-TASKS-RUNNER-SWITCH-004)", () => {
const RUNNER_NOTE_TEST_ID = "runner-ineligible-note";
const baseProps = createEditSelectorsBaseProps;
// AC-TASKS-RUNNER-SWITCH-004.3: runner editability is independent of
// isTaskStarted — the previous state-only gate must no longer govern it.
it("shows the executor selector for a started task that is still runner-editable", () => {
render(
<CreateEditSelectors
{...baseProps}
agentCompatState="compatible"
isTaskStarted={true}
runnerEditable={true}
/>,
);
expect(screen.getByRole("button", { name: "executor" })).toBeTruthy();
expect(screen.queryByTestId(SELECTOR_TEST_ID)).toBeNull();
expect(screen.queryByTestId(RUNNER_NOTE_TEST_ID)).toBeNull();
});
// AC-TASKS-RUNNER-SWITCH-004.2
it("presents the projected reason instead of the selector when not runner-editable", () => {
render(
<CreateEditSelectors
{...baseProps}
agentCompatState="compatible"
runnerEditable={false}
runnerIneligibleReason="session_exists"
/>,
);
expect(screen.queryByRole("button", { name: "executor" })).toBeNull();
expect(screen.getByTestId(RUNNER_NOTE_TEST_ID).textContent).toBeTruthy();
});
// AC-TASKS-RUNNER-SWITCH-004.4b: never an empty reason or a raw code — an
// unrecognized reason renders the same copy as evaluation_unavailable.
it("falls back to the evaluation-unavailable copy for an unrecognized reason code", () => {
render(
<CreateEditSelectors
{...baseProps}
agentCompatState="compatible"
runnerEditable={false}
runnerIneligibleReason="some_future_reason_this_dialog_predates"
/>,
);
const unrecognized = screen.getByTestId(RUNNER_NOTE_TEST_ID).textContent;
cleanup();
render(
<CreateEditSelectors
{...baseProps}
agentCompatState="compatible"
runnerEditable={false}
runnerIneligibleReason="evaluation_unavailable"
/>,
);
const known = screen.getByTestId(RUNNER_NOTE_TEST_ID).textContent;
expect(unrecognized).toBeTruthy();
expect(unrecognized).toBe(known);
});
// AC-TASKS-RUNNER-SWITCH-004.2: the reason is presented unconditionally
// whenever runner_editable is false — a started task is not a carve-out.
// session_exists is itself one of the ineligibility reasons, so this is
// the common shape for any task with a primary session, not an edge case.
it("shows the ineligible reason for a started, runner-ineligible task instead of rendering nothing", () => {
render(
<CreateEditSelectors
{...baseProps}
agentCompatState="compatible"
isTaskStarted={true}
runnerEditable={false}
runnerIneligibleReason="session_exists"
/>,
);
expect(screen.queryByRole("button", { name: "executor" })).toBeNull();
expect(screen.queryByTestId(SELECTOR_TEST_ID)).toBeNull();
expect(screen.getByTestId(RUNNER_NOTE_TEST_ID).textContent).toBeTruthy();
});
});