97140168d8
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
345 lines
11 KiB
TypeScript
345 lines
11 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import type { ExecutorProfile } from "@/lib/types/http";
|
|
import {
|
|
computeAgentCompatState,
|
|
computeHasRepositorySelection,
|
|
computeSelectedRepoCount,
|
|
filterCompatibleAgentProfiles,
|
|
} from "./task-create-dialog-computed";
|
|
import type { DialogFormState } from "@/components/task-create-dialog-types";
|
|
import type { AgentProfileOption } from "@/lib/state/slices/settings/types";
|
|
|
|
const URL_A = "github.com/a/b";
|
|
const URL_B = "github.com/c/d";
|
|
|
|
// Minimal fs stub for the two pure helpers below. Each function only reads
|
|
// `noRepository`, `useRemote`, `remoteRepos`, and `repositories`, so we cast a
|
|
// partial through `unknown` to avoid having to materialise the full
|
|
// DialogFormState surface in tests.
|
|
function fsStub(overrides: {
|
|
noRepository?: boolean;
|
|
useRemote?: boolean;
|
|
remoteRepos?: Array<{ url?: string; branch?: string }>;
|
|
repositories?: Array<{ repositoryId?: string; localPath?: string; branch?: string }>;
|
|
}): DialogFormState {
|
|
return {
|
|
noRepository: false,
|
|
useRemote: false,
|
|
remoteRepos: [],
|
|
repositories: [],
|
|
...overrides,
|
|
} as unknown as DialogFormState;
|
|
}
|
|
|
|
describe("computeHasRepositorySelection", () => {
|
|
it("returns true in no-repository mode", () => {
|
|
expect(computeHasRepositorySelection(fsStub({ noRepository: true }))).toBe(true);
|
|
});
|
|
|
|
it("returns true when any workspace/local row is set", () => {
|
|
expect(computeHasRepositorySelection(fsStub({ repositories: [{ repositoryId: "r-1" }] }))).toBe(
|
|
true,
|
|
);
|
|
expect(
|
|
computeHasRepositorySelection(fsStub({ repositories: [{ localPath: "/tmp/repo" }] })),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("Remote mode: returns true when ANY row has a non-empty URL", () => {
|
|
// First row empty, second row populated — the previous implementation
|
|
// only inspected `remoteRepos[0]` and would have returned false here.
|
|
expect(
|
|
computeHasRepositorySelection(
|
|
fsStub({
|
|
useRemote: true,
|
|
remoteRepos: [{ url: "" }, { url: "github.com/acme/site" }],
|
|
}),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("Remote mode: returns true when only the first row is populated", () => {
|
|
expect(
|
|
computeHasRepositorySelection(
|
|
fsStub({
|
|
useRemote: true,
|
|
remoteRepos: [{ url: "github.com/acme/site" }],
|
|
}),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("Remote mode: returns false when every row is empty / whitespace", () => {
|
|
expect(
|
|
computeHasRepositorySelection(
|
|
fsStub({
|
|
useRemote: true,
|
|
remoteRepos: [{ url: "" }, { url: " " }],
|
|
}),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("returns false on the empty form", () => {
|
|
expect(computeHasRepositorySelection(fsStub({}))).toBe(false);
|
|
});
|
|
|
|
it("Remote mode: ignores hidden workspace/local rows", () => {
|
|
// Toggle-back preserves the workspace/local rows when useRemote flips on
|
|
// — they're hidden from the UI and must not count toward the selection
|
|
// gate. Otherwise the submit button stays enabled in Remote mode even
|
|
// when the user hasn't filled in any URLs.
|
|
expect(
|
|
computeHasRepositorySelection(
|
|
fsStub({
|
|
useRemote: true,
|
|
repositories: [{ repositoryId: "r-1" }],
|
|
remoteRepos: [{ url: "" }],
|
|
}),
|
|
),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("computeSelectedRepoCount", () => {
|
|
it("counts workspace/local rows", () => {
|
|
expect(
|
|
computeSelectedRepoCount(
|
|
fsStub({ repositories: [{ repositoryId: "r-1" }, { localPath: "/x" }] }),
|
|
),
|
|
).toBe(2);
|
|
});
|
|
|
|
it("ignores empty workspace rows", () => {
|
|
// Half-filled rows (no repositoryId, no localPath) shouldn't count
|
|
// toward the multi-repo gate even though they exist in the array.
|
|
expect(computeSelectedRepoCount(fsStub({ repositories: [{ branch: "main" }] }))).toBe(0);
|
|
});
|
|
|
|
it("Remote mode: counts non-empty URL rows only", () => {
|
|
// Two remote URLs — without the local rows, this alone trips the gate.
|
|
expect(
|
|
computeSelectedRepoCount(
|
|
fsStub({
|
|
useRemote: true,
|
|
remoteRepos: [{ url: URL_A }, { url: URL_B }],
|
|
}),
|
|
),
|
|
).toBe(2);
|
|
});
|
|
|
|
it("Remote mode: ignores hidden workspace/local rows", () => {
|
|
// useRemote=true hides the workspace/local list — those rows must not
|
|
// contribute to the count even if they're still in the form state from
|
|
// a prior mode (toggle-back preserves them).
|
|
expect(
|
|
computeSelectedRepoCount(
|
|
fsStub({
|
|
useRemote: true,
|
|
repositories: [{ repositoryId: "r-1" }, { localPath: "/tmp/x" }],
|
|
remoteRepos: [{ url: URL_A }],
|
|
}),
|
|
),
|
|
).toBe(1);
|
|
});
|
|
|
|
it("Remote mode: returns 0 when only hidden workspace/local rows exist", () => {
|
|
expect(
|
|
computeSelectedRepoCount(
|
|
fsStub({
|
|
useRemote: true,
|
|
repositories: [{ repositoryId: "r-1" }],
|
|
remoteRepos: [],
|
|
}),
|
|
),
|
|
).toBe(0);
|
|
});
|
|
|
|
it("returns 0 in no-repository mode regardless of stale rows", () => {
|
|
expect(
|
|
computeSelectedRepoCount(
|
|
fsStub({
|
|
noRepository: true,
|
|
repositories: [{ repositoryId: "r-1" }],
|
|
remoteRepos: [{ url: URL_A }],
|
|
useRemote: true,
|
|
}),
|
|
),
|
|
).toBe(0);
|
|
});
|
|
|
|
it("ignores remote rows when useRemote is false", () => {
|
|
// The Remote toggle gates the count — switching modes shouldn't smuggle
|
|
// the URL rows into the local count.
|
|
expect(
|
|
computeSelectedRepoCount(
|
|
fsStub({
|
|
useRemote: false,
|
|
remoteRepos: [{ url: URL_A }, { url: URL_B }],
|
|
}),
|
|
),
|
|
).toBe(0);
|
|
});
|
|
|
|
it("trips the multi-repo gate (>1) on a 2-row Remote selection", () => {
|
|
// Multi-row Remote selection must trigger the same executor gating as
|
|
// a 2-workspace-repo task — the previous implementation only counted
|
|
// workspace/local rows, so this would have stayed at 0 even with two
|
|
// populated URLs.
|
|
const count = computeSelectedRepoCount(
|
|
fsStub({
|
|
useRemote: true,
|
|
remoteRepos: [{ url: "https://github.com/a/b" }, { url: "https://github.com/c/d" }],
|
|
}),
|
|
);
|
|
expect(count).toBe(2);
|
|
expect(count > 1).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("filterCompatibleAgentProfiles", () => {
|
|
it("removes disabled raw profiles before compatibility and autopick", () => {
|
|
const profiles = [
|
|
{
|
|
id: "enabled",
|
|
label: "Agent • Enabled",
|
|
agent_id: "a1",
|
|
agent_name: "mock-agent",
|
|
cli_passthrough: false,
|
|
enabled: true,
|
|
},
|
|
{
|
|
id: "disabled",
|
|
label: "Agent • Disabled",
|
|
agent_id: "a1",
|
|
agent_name: "mock-agent",
|
|
cli_passthrough: false,
|
|
enabled: false,
|
|
},
|
|
] satisfies AgentProfileOption[];
|
|
|
|
expect(
|
|
filterCompatibleAgentProfiles(
|
|
profiles,
|
|
{ executor_type: "local", config: {} } as unknown as ExecutorProfile,
|
|
true,
|
|
[],
|
|
).map((p) => p.id),
|
|
).toEqual(["enabled"]);
|
|
});
|
|
});
|
|
|
|
describe("computeAgentCompatState", () => {
|
|
const sprites = {
|
|
id: "exec-sprites",
|
|
executor_type: "sprites",
|
|
config: {},
|
|
} as unknown as ExecutorProfile;
|
|
function profile(id: string, enabled = true): AgentProfileOption {
|
|
return {
|
|
id,
|
|
label: id,
|
|
agent_id: `agent-${id}`,
|
|
agent_name: id,
|
|
cli_passthrough: false,
|
|
enabled,
|
|
};
|
|
}
|
|
const claude = profile("claude");
|
|
const cursor = profile("cursor");
|
|
function state(
|
|
overrides: Partial<Parameters<typeof computeAgentCompatState>[0]> = {},
|
|
): ReturnType<typeof computeAgentCompatState> {
|
|
return computeAgentCompatState({
|
|
selectedExecutorProfile: sprites,
|
|
compatibleAgentProfiles: [cursor],
|
|
selectedAgentProfileId: claude.id,
|
|
selectedAgentProfile: claude,
|
|
workflowAgentLocked: false,
|
|
dynamicRoutingEnabled: true,
|
|
...overrides,
|
|
});
|
|
}
|
|
|
|
it("is compatible while no executor profile is selected", () => {
|
|
expect(state({ selectedExecutorProfile: null, compatibleAgentProfiles: [] })).toBe(
|
|
"compatible",
|
|
);
|
|
});
|
|
|
|
// @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.4
|
|
it("is none-compatible when the compatible list is empty and nothing locks the agent", () => {
|
|
expect(state({ compatibleAgentProfiles: [] })).toBe("none-compatible");
|
|
expect(
|
|
state({
|
|
compatibleAgentProfiles: [],
|
|
selectedAgentProfileId: "",
|
|
selectedAgentProfile: null,
|
|
}),
|
|
).toBe("none-compatible");
|
|
});
|
|
|
|
it("is compatible while nothing is selected and a compatible profile exists", () => {
|
|
expect(state({ selectedAgentProfileId: "", selectedAgentProfile: null })).toBe("compatible");
|
|
});
|
|
|
|
// @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.6
|
|
it("is selected-incompatible, never none-compatible, when the selection is absent from a non-empty list", () => {
|
|
expect(state()).toBe("selected-incompatible");
|
|
});
|
|
|
|
// @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.3
|
|
it("is compatible when the selection is in the list", () => {
|
|
expect(state({ compatibleAgentProfiles: [claude, cursor] })).toBe("compatible");
|
|
});
|
|
|
|
// @covers AC-TASKS-TASK-CREATE-AGENT-COMPATIBILITY-001.5
|
|
it("keeps the workflow-locked state even when nothing else is compatible", () => {
|
|
expect(state({ compatibleAgentProfiles: [], workflowAgentLocked: true })).toBe(
|
|
"selected-incompatible",
|
|
);
|
|
});
|
|
|
|
it("treats a disabled locked profile as no compatible agent, not a credential problem", () => {
|
|
const disabled = profile("disabled", false);
|
|
expect(
|
|
state({
|
|
selectedAgentProfileId: disabled.id,
|
|
selectedAgentProfile: disabled,
|
|
workflowAgentLocked: true,
|
|
}),
|
|
).toBe("none-compatible");
|
|
});
|
|
|
|
it("keeps an unlocked disabled selection distinct when an alternative exists", () => {
|
|
const disabled = profile("disabled", false);
|
|
expect(
|
|
state({
|
|
selectedAgentProfileId: disabled.id,
|
|
selectedAgentProfile: disabled,
|
|
workflowAgentLocked: false,
|
|
compatibleAgentProfiles: [cursor],
|
|
}),
|
|
).toBe("selected-unavailable");
|
|
});
|
|
|
|
it("keeps an unlocked dynamic selection distinct when routing is disabled", () => {
|
|
const dynamic = { ...profile("dynamic"), kind: "dynamic" } satisfies AgentProfileOption;
|
|
expect(
|
|
state({
|
|
selectedAgentProfileId: dynamic.id,
|
|
selectedAgentProfile: dynamic,
|
|
workflowAgentLocked: false,
|
|
dynamicRoutingEnabled: false,
|
|
compatibleAgentProfiles: [cursor],
|
|
}),
|
|
).toBe("selected-unavailable");
|
|
});
|
|
|
|
it("treats an unknown selected id as a credential problem when a compatible profile exists", () => {
|
|
expect(state({ selectedAgentProfileId: "gone", selectedAgentProfile: null })).toBe(
|
|
"selected-incompatible",
|
|
);
|
|
});
|
|
});
|