20308a2d16
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
531 lines
18 KiB
TypeScript
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();
|
|
});
|
|
});
|