Files
fusion/packages/dashboard/app/__tests__/settings-sections.test.tsx
gsxdsm 743df98aa4 capacity, part 1: merge pinned at 1, worktrees-off mode, and one dead knob deleted (#2502)
First slice of the capacity simplification. Operator: *"just have two
capacity — overall per project agent count and max worktrees. Remove all
other capacities and counts."* Plus two later additions: **merge is
always 1, fixed**, and **worktrees off ⇒ limit by total agents only**.

Three independently revertable commits. No limiter is added anywhere;
one is deleted, one is made structurally absent, and one is pinned.

---

## 1. Merge concurrency ratcheted at 1 (test-only)

I was asked to add a limiter if merge concurrency could be raised. **It
cannot** — there is no setting, workflow property, pool or trait config
anywhere that raises it, so this adds no code and pins what already
holds.

Serialization lives in the **pump**: `drainMergeQueue`’s `mergeRunning`
re-entrancy latch, `activeMergeTaskId` as a single-slot identity, the
`mergeBodyInFlight` next-generation latch, and one `ProjectEngine` per
projectId.

**Not** in the merge-queue lease, which is a per-task ROW (`primaryKey
[projectId, taskId]`) — two tasks can hold leases simultaneously by
construction, and it has exactly one caller (the worktree-reuse
handoff). Ordinary merges never take it. A lease-level test would have
been describing an invariant that layer has never held.

The second half guards the other direction: a merge-concurrency
*setting* would not fail the pump ratchet — it would sit unread until
someone wired it up.

**Revert-proof:** deleting the latch → `expected 1 times, but got 2
times`; deleting the `finally` → latch-stuck; injecting
`maxConcurrentMerges: 2` → fails naming the key; injecting a
`maxParallelLanes` merge-trait field → fails naming the field. Sources
restored byte-identical after each injection.

## 2. `worktreesEnabled` — off means the worktree limit cannot bind

No worktrees-off mode existed (no
`worktreesEnabled`/`useWorktrees`/`worktreeMode` anywhere — only
worktree *configuration*).

**Why not `maxWorktrees: 0`, which needs no new key:** it deadlocks. `??
4` keeps `0` (not nullish), the gate is `used >= limit`, so `0 >= 0`
holds **on an empty board** and nothing ever dispatches — while the
operator-visible reason reads `gate=maxWorktrees; used=0/0`, a limiter
that looks like it is working while the board is dead. It also needs the
Command Center `{min:1}` clamp relaxed. So `0` costs the gate rewrite
*and* the clamp change *and* encodes a mode as a magic value.

**Off is absence, not a big number.** `resolveWorktreeCapacityLimit`
returns `number | null`; `ConcurrencyGateDiagnostic.maxWorktreesGate` is
now optional, so consulting a worktree limit in OFF mode does not
type-check. A gate holding `Infinity` can start binding again the moment
someone "fixes" a comparison; an absent gate cannot.

That paid for itself immediately: making it nullable surfaced a
**second, independent** worktree gate (`activeWorktrees >= maxWorktrees`
early-return) that a skip-by-convention approach would have missed
silently.

**Scope, deliberately:** this is a statement about *counting*, not
isolation. It does not make concurrent agents safe to share one checkout
and builds nothing toward that — the non-worktree paths that exist today
are fallbacks to the operator’s own tree, one of which caused FN-8600.

**Revert-proof:** a resolver ignoring the flag turns both OFF scheduler
tests red while every ON test stays green — they reuse the *same*
fixture (5 in-progress, limit 4) that pre-existing tests prove blocks,
so the pair moves in opposite directions. Removing `disabled:` reddens
the UI test.

## 3. `maxTriageConcurrent` deleted — it controlled nothing

**Measured: zero enforcement reads.** The only `.maxTriageConcurrent`
reference in the repo was a route echoing it back in `/config`. FN-8453
removed the pool it gated and left the knob shipping in
`DEFAULT_SETTINGS`, the settings type, the section registry, the API
response and six i18n catalogs, doing nothing, for releases.

Historical FNXC comments are **updated, not deleted** — they explain a
real past incident; they now say "planning admission slot" so they stop
implying a live setting. Tombstoned so it cannot return.

`/config` loses a field; safe in-repo since `fetchConfig`’s own return
type never declared it.

---

## Two corrections worth recording

- I earlier reported `maxWorktrees` had **no** Settings UI. Wrong —
`WorktreesSection.tsx:47`; my grep was truncated by `head`. It changed
the placement (toggle beside it, rather than a duplicate key in
Scheduling).
- I planned to assert the queued-reason string is rewritten in OFF mode.
Measured that it is **unreachable**: when `maxConcurrent` binds, the
sweep bails before the per-task reason and logs nothing. The test
asserts absence instead.

Two near-misses caught before commit: a pre-existing FN-7505 guard
caught my *new* key missing a description mapping; and editing i18n via
`json.load/dump` silently dropped unrelated duplicate keys
(`autoUpdateAndRestart` in `fr`) — Python keeps only the last of a
duplicated key. Redone textually, every catalog re-validated.

## Verification

`pnpm lint` clean · core/engine/dashboard/i18n typecheck clean · `pnpm
test:gate` green (309 + 10 + 71) · capacity/worktree suites 11/11 ·
engine merge-invariant + scheduler 45/45 · dashboard settings 114/114.
Rebased onto current main and re-verified.

Nothing was booted at any point.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added a project setting to enable or disable running tasks in
worktrees.
* Disabling worktrees removes worktree capacity limits from task
scheduling.
* The “Max Worktrees” setting is disabled when worktree execution is
turned off.

* **Changes**
* Removed the unused triage concurrency setting from configuration and
dashboard responses.
* Updated scheduling diagnostics and queue messages to reflect disabled
worktree capacity limits.
  * Added localized labels and help text for the new setting.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 18:52:31 -07:00

1110 lines
48 KiB
TypeScript

// @vitest-environment jsdom
/**
* Per-section smoke tests for the extracted SettingsModal sections (U9 / KTD-10).
*
* These pin the section-component contract: each section reads from `form` and
* emits edits via `setForm` (the shell keeps persistence/save-split). We cover
* three representative sections — an Appearance toggle round-trip, a
* Notifications field, and an Experimental flag — following the dashboard
* component-test conventions in settings-primitives.test.tsx.
*/
import { useState } from "react";
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
import { render, screen, fireEvent, cleanup, waitFor, act, within } from "@testing-library/react";
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
import { AppearanceSection } from "../components/settings/sections/AppearanceSection";
import { GeneralSection } from "../components/settings/sections/GeneralSection";
import { NotificationsSection } from "../components/settings/sections/NotificationsSection";
import { ExperimentalSection } from "../components/settings/sections/ExperimentalSection";
import { MovedSettingsStub } from "../components/settings/sections/MovedSettingsStub";
import { ProjectModelsSection } from "../components/settings/sections/ProjectModelsSection";
import { GlobalModelsSection } from "../components/settings/sections/GlobalModelsSection";
import { PromptsSection } from "../components/settings/sections/PromptsSection";
import { SecretsSection } from "../components/settings/sections/SecretsSection";
import { WorktreesSection } from "../components/settings/sections/WorktreesSection";
import type { SettingsFormState } from "../components/settings/sections/context";
import { fetchWorkflow, fetchWorkflows, fetchWorkflowSettingValues, updateWorkflowSettingValues } from "../api";
import type { WorkflowSettingValuesPayload } from "../api";
vi.mock("../components/AgentPromptsManager", () => ({
AgentPromptsManager: () => <div data-testid="agent-prompts-manager" />,
}));
vi.mock("../components/SecretsView", () => ({
SecretsView: () => <div data-testid="secrets-view" />,
}));
vi.mock("../api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../api")>();
return {
...actual,
fetchWorkflows: vi.fn(async () => []),
fetchWorkflow: vi.fn(async () => ({ id: "builtin:coding", name: "Coding", ir: {} })),
fetchWorkflowSettingValues: vi.fn(async () => ({ stored: {}, effective: {}, orphaned: [] })),
fetchProjectDefaultWorkflow: vi.fn(async () => ({ workflowId: null })),
setProjectDefaultWorkflow: vi.fn(async () => ({ workflowId: null })),
fetchGlobalSettings: vi.fn(async () => ({})),
updateWorkflowSettingValues: vi.fn(async () => ({ stored: {}, effective: {}, orphaned: [] })),
};
});
vi.mock("../components/CustomModelDropdown", () => ({
CustomModelDropdown: ({ id, label, value, onChange, menuWidth = "trigger", showThinkingLevel = false, thinkingLevel = "", onThinkingLevelChange, defaultThinkingLevel }: { id?: string; label: string; value?: string; onChange?: (value: string) => void; menuWidth?: "trigger" | "readable"; showThinkingLevel?: boolean; thinkingLevel?: string; onThinkingLevelChange?: (value: string) => void; defaultThinkingLevel?: string }) => (
<div>
<button
type="button"
data-testid={`mock-model-dropdown-${id ?? label}`}
data-menu-width={menuWidth}
data-value={value ?? ""}
data-thinking-visible={showThinkingLevel ? "true" : "false"}
data-thinking-value={thinkingLevel}
data-default-thinking={defaultThinkingLevel ?? ""}
onClick={() => onChange?.("anthropic/claude-sonnet-4-5")}
>
{label}
</button>
<button type="button" data-testid={`mock-model-clear-${id ?? label}`} onClick={() => onChange?.("")}>
clear
</button>
{showThinkingLevel ? (
<button type="button" data-testid={`mock-thinking-${id ?? label}`} onClick={() => onThinkingLevelChange?.(thinkingLevel ? "" : "high")}>
thinking:{thinkingLevel || "inherit"}
</button>
) : null}
</div>
),
}));
expect.extend(jestDomMatchers);
beforeEach(() => {
vi.mocked(fetchWorkflows).mockReset();
vi.mocked(fetchWorkflow).mockReset();
vi.mocked(fetchWorkflowSettingValues).mockReset();
vi.mocked(updateWorkflowSettingValues).mockReset();
vi.mocked(fetchWorkflows).mockResolvedValue([]);
vi.mocked(fetchWorkflow).mockResolvedValue({ id: "builtin:coding", name: "Coding", ir: {} } as never);
vi.mocked(fetchWorkflowSettingValues).mockResolvedValue({ stored: {}, effective: {}, orphaned: [] });
vi.mocked(updateWorkflowSettingValues).mockResolvedValue({ stored: {}, effective: {}, orphaned: [] });
});
afterEach(() => cleanup());
const emptyForm = {} as SettingsFormState;
describe("AppearanceSection", () => {
function AppearanceHost() {
const [hidden, setHidden] = useState(false);
return (
<AppearanceSection
form={emptyForm}
setForm={vi.fn()}
themeMode="dark"
colorTheme="default"
dashboardFontScalePct={100}
sessionBannersHidden={hidden}
setSessionBannersHidden={setHidden}
/>
);
}
it("round-trips the session-banner toggle through its setter", () => {
render(<AppearanceHost />);
/*
FNXC:SettingsStyling 2026-07-15-17:35:
Resolved through the label→control association rather than by walking the DOM. The old `getByText(...).closest("label").querySelector("input")` assumed the label ELEMENT wrapped the checkbox, which was the `checkbox-label` markup's shape; the shared row primitive binds `htmlFor`/`id` instead, so the label is now a sibling of the control and the walk returned null.
`getByLabelText` asserts the binding an assistive technology actually uses, so it survives markup changes and additionally fails if that binding is ever broken — which the DOM walk could not detect.
*/
const toggle = screen.getByLabelText("Hide AI session notification banners") as HTMLInputElement;
expect(toggle.checked).toBe(false);
fireEvent.click(toggle);
expect(toggle.checked).toBe(true);
fireEvent.click(toggle);
expect(toggle.checked).toBe(false);
});
});
describe("GeneralSection", () => {
it("hides deprecated built-ins from the workflow enablement toggles", async () => {
vi.mocked(fetchWorkflows).mockResolvedValue([
{ id: "builtin:coding", name: "Coding", kind: "workflow", ir: {} },
{ id: "builtin:brainstorming", name: "Brainstorming", kind: "workflow", ir: {} },
{ id: "builtin:coding-ideas", name: "Coding (Ideas)", kind: "workflow", ir: {} },
] as never);
render(
<GeneralSection
form={emptyForm}
setForm={vi.fn()}
addToast={vi.fn()}
prefixError={null}
setPrefixError={vi.fn()}
projectTrackingRepoOptions={[]}
projectTrackingRepoLoading={false}
projectTrackingRepoError={null}
/>,
);
await waitFor(() => expect(screen.getByLabelText("Coding")).toBeInTheDocument());
expect(screen.queryByLabelText("Brainstorming")).not.toBeInTheDocument();
/*
FNXC:SettingsWorkflowToggles 2026-07-23-22:05:
PR #2378 restored builtin:coding-ideas (removed from DEPRECATED_BUILTIN_WORKFLOW_IDS),
so Coding (Ideas) is a live selectable built-in again and must render a toggle.
Brainstorming remains the deprecated built-in that must stay hidden.
*/
expect(screen.getByLabelText("Coding (Ideas)")).toBeInTheDocument();
});
it("emits the absolute file-browser path toggle via setForm", () => {
function GeneralHost() {
const [form, setForm] = useState({ allowAbsoluteFileBrowserPaths: false } as SettingsFormState);
return (
<GeneralSection
form={form}
setForm={setForm}
addToast={vi.fn()}
prefixError={null}
setPrefixError={vi.fn()}
projectTrackingRepoOptions={[]}
projectTrackingRepoLoading={false}
projectTrackingRepoError={null}
/>
);
}
render(<GeneralHost />);
const checkbox = screen.getByLabelText(/Allow absolute file-browser paths/i) as HTMLInputElement;
expect(checkbox.checked).toBe(false);
fireEvent.click(checkbox);
expect(checkbox.checked).toBe(true);
});
});
describe("NotificationsSection", () => {
it("emits the chosen failure-notification mode via setForm", () => {
const setForm = vi.fn();
render(
<NotificationsSection
form={emptyForm}
setForm={setForm}
testNotificationLoading={{}}
testNotificationResult={{}}
onTestProviderNotification={vi.fn()}
/>,
);
const select = screen.getByLabelText("Failure notification mode") as HTMLSelectElement;
fireEvent.change(select, { target: { value: "all" } });
expect(setForm).toHaveBeenCalledTimes(1);
const updater = setForm.mock.calls[0][0] as (f: SettingsFormState) => SettingsFormState;
expect(updater(emptyForm)).toMatchObject({ failureNotificationMode: "all" });
});
it("nests the failure-notification mode field inside the padded provider body", () => {
render(
<NotificationsSection
form={emptyForm}
setForm={vi.fn()}
testNotificationLoading={{}}
testNotificationResult={{}}
onTestProviderNotification={vi.fn()}
/>,
);
const select = screen.getByLabelText("Failure notification mode") as HTMLSelectElement;
const providerBody = select.closest(".notification-provider-body");
expect(providerBody).not.toBeNull();
expect(providerBody?.closest(".notification-provider-card")).not.toBeNull();
});
it("shows the ntfy topic field only when ntfy is enabled", () => {
const { rerender } = render(
<NotificationsSection
form={{ ntfyEnabled: false } as SettingsFormState}
setForm={vi.fn()}
testNotificationLoading={{}}
testNotificationResult={{}}
onTestProviderNotification={vi.fn()}
/>,
);
expect(screen.queryByLabelText("ntfy Topic")).not.toBeInTheDocument();
rerender(
<NotificationsSection
form={{ ntfyEnabled: true } as SettingsFormState}
setForm={vi.fn()}
testNotificationLoading={{}}
testNotificationResult={{}}
onTestProviderNotification={vi.fn()}
/>,
);
expect(screen.getByLabelText("ntfy Topic")).toBeInTheDocument();
});
});
describe("SecretsSection", () => {
/*
FNXC:SettingsScope 2026-07-15-18:52:
The scope-banner assertion went with the banner itself: sections no longer take a `scopeBanner` slot, because one section-level scope claim was false wherever a section mixed scopes. Scope now rides on each row's badge.
The rest of the contract — title plus the SecretsView card — is unchanged and still asserted.
*/
it("renders the title and the SecretsView card", () => {
render(
<SecretsSection addToast={vi.fn()} />,
);
expect(screen.getByText("Secrets")).toBeInTheDocument();
expect(screen.getByTestId("secrets-view")).toBeInTheDocument();
});
});
describe("WorktreesSection", () => {
const worktrunkInstall = {
status: "installed",
version: "1.0.0",
installPath: "/tmp/worktrunk",
requesting: false,
requestInstall: vi.fn(),
} as never;
it("renders editable copy-file rows with add, browse, and remove controls", () => {
const onChange = vi.fn();
const onBrowse = vi.fn();
const onRemove = vi.fn();
const onAdd = vi.fn();
render(
<WorktreesSection
form={{ recycleWorktrees: false, worktreeCopyFiles: [".env"] } as SettingsFormState}
setForm={vi.fn()}
gitRemotes={[]}
worktrunkInstall={worktrunkInstall}
worktrunkInstallVerified={true}
onOpenWorktreesDirPicker={vi.fn()}
onWorktreeCopyFileChange={onChange}
onRemoveWorktreeCopyFile={onRemove}
onAddWorktreeCopyFile={onAdd}
onOpenWorktreeCopyFilePicker={onBrowse}
/>,
);
expect(screen.getByText("Files to copy into new worktrees")).toBeInTheDocument();
const input = screen.getByLabelText("File to copy into new worktrees") as HTMLInputElement;
expect(input.value).toBe(".env");
fireEvent.change(input, { target: { value: "config/local.env" } });
expect(onChange).toHaveBeenCalledWith(0, "config/local.env");
fireEvent.click(screen.getByRole("button", { name: "Browse file to copy into new worktrees" }));
expect(onBrowse).toHaveBeenCalledWith(0);
fireEvent.click(screen.getByRole("button", { name: "Remove copied worktree file" }));
expect(onRemove).toHaveBeenCalledWith(0);
fireEvent.click(screen.getByRole("button", { name: "Add file" }));
expect(onAdd).toHaveBeenCalledTimes(1);
});
/*
FNXC:CapacityModel 2026-07-28-13:45:
Capacity is two configurable numbers per project; `worktreeLimitEnabled` decides
whether the SECOND one applies. These pin the UI half of the "inert means
incapable, not ignored" rule: with worktrees off the Max Worktrees control is
DISABLED, so an operator cannot set a number the engine will not consult. A live
control feeding a dead limiter is exactly the confusion this change removes.
*/
const worktreeCapacityProps = {
gitRemotes: [],
worktrunkInstall,
worktrunkInstallVerified: true,
onOpenWorktreesDirPicker: vi.fn(),
onWorktreeCopyFileChange: vi.fn(),
onRemoveWorktreeCopyFile: vi.fn(),
onAddWorktreeCopyFile: vi.fn(),
onOpenWorktreeCopyFilePicker: vi.fn(),
} as never;
it("enables the Max Worktrees control while worktrees are on", () => {
render(
<WorktreesSection
form={{ recycleWorktrees: false, worktreeCopyFiles: [], maxWorktrees: 4, worktreeLimitEnabled: true } as SettingsFormState}
setForm={vi.fn()}
{...worktreeCapacityProps}
/>,
);
expect(screen.getByLabelText("Max Worktrees")).not.toBeDisabled();
});
it("disables the Max Worktrees control while worktrees are off", () => {
render(
<WorktreesSection
form={{ recycleWorktrees: false, worktreeCopyFiles: [], maxWorktrees: 4, worktreeLimitEnabled: false } as SettingsFormState}
setForm={vi.fn()}
{...worktreeCapacityProps}
/>,
);
expect(screen.getByLabelText("Max Worktrees")).toBeDisabled();
});
it("treats an absent worktreeLimitEnabled as on, so existing projects keep their worktree cap", () => {
// Every project row predating this setting has no `worktreeLimitEnabled` key. If
// absence read as OFF, an upgrade would silently drop a limiter operators rely
// on — and the control would grey out with no one having asked for it.
render(
<WorktreesSection
form={{ recycleWorktrees: false, worktreeCopyFiles: [], maxWorktrees: 4 } as SettingsFormState}
setForm={vi.fn()}
{...worktreeCapacityProps}
/>,
);
expect(screen.getByLabelText("Limit concurrent worktrees")).toBeChecked();
expect(screen.getByLabelText("Max Worktrees")).not.toBeDisabled();
});
it("toggling the worktree limit off writes worktreeLimitEnabled false", () => {
const setForm = vi.fn((updater: SettingsFormState | ((prev: SettingsFormState) => SettingsFormState)) =>
typeof updater === "function"
? updater({ recycleWorktrees: false, worktreeLimitEnabled: true } as SettingsFormState)
: updater,
);
render(
<WorktreesSection
form={{ recycleWorktrees: false, worktreeCopyFiles: [], maxWorktrees: 4, worktreeLimitEnabled: true } as SettingsFormState}
setForm={setForm}
{...worktreeCapacityProps}
/>,
);
fireEvent.click(screen.getByLabelText("Limit concurrent worktrees"));
expect(setForm).toHaveBeenCalledTimes(1);
expect(setForm.mock.results[0]?.value).toMatchObject({ worktreeLimitEnabled: false });
});
it("renders and toggles the board worktree grouping checkbox", () => {
const setForm = vi.fn((updater: SettingsFormState | ((prev: SettingsFormState) => SettingsFormState)) => {
if (typeof updater === "function") {
return updater({ recycleWorktrees: false, showWorktreeGrouping: false } as SettingsFormState);
}
return updater;
});
render(
<WorktreesSection
form={{ recycleWorktrees: false, showWorktreeGrouping: false, worktreeCopyFiles: [] } as SettingsFormState}
setForm={setForm}
gitRemotes={[]}
worktrunkInstall={worktrunkInstall}
worktrunkInstallVerified={true}
onOpenWorktreesDirPicker={vi.fn()}
onWorktreeCopyFileChange={vi.fn()}
onRemoveWorktreeCopyFile={vi.fn()}
onAddWorktreeCopyFile={vi.fn()}
onOpenWorktreeCopyFilePicker={vi.fn()}
/>,
);
const checkbox = screen.getByLabelText(/Show worktree grouping on the board/i) as HTMLInputElement;
expect(checkbox.checked).toBe(false);
fireEvent.click(checkbox);
expect(setForm).toHaveBeenCalledTimes(1);
expect(setForm.mock.results[0]?.value).toMatchObject({ showWorktreeGrouping: true });
});
it("keeps an empty copy-file row reachable when the setting is undefined", () => {
render(
<WorktreesSection
form={{ recycleWorktrees: false, worktreeCopyFiles: undefined } as SettingsFormState}
setForm={vi.fn()}
gitRemotes={[]}
worktrunkInstall={worktrunkInstall}
worktrunkInstallVerified={true}
onOpenWorktreesDirPicker={vi.fn()}
onWorktreeCopyFileChange={vi.fn()}
onRemoveWorktreeCopyFile={vi.fn()}
onAddWorktreeCopyFile={vi.fn()}
onOpenWorktreeCopyFilePicker={vi.fn()}
/>,
);
expect(screen.getByLabelText("File to copy into new worktrees")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Browse file to copy into new worktrees" })).toBeInTheDocument();
});
/*
FNXC:TaskPinnedWorktrees 2026-07-16-00:00: recycleWorktrees and worktreeNaming:"task-id" are mutually
exclusive; the UI enforces this bidirectionally so the conflicting state is unreachable.
*/
function renderWorktrees(form: Partial<SettingsFormState>) {
return render(
<WorktreesSection
form={{ worktreeCopyFiles: [], ...form } as SettingsFormState}
setForm={vi.fn()}
gitRemotes={[]}
worktrunkInstall={worktrunkInstall}
worktrunkInstallVerified={true}
onOpenWorktreesDirPicker={vi.fn()}
onWorktreeCopyFileChange={vi.fn()}
onRemoveWorktreeCopyFile={vi.fn()}
onAddWorktreeCopyFile={vi.fn()}
onOpenWorktreeCopyFilePicker={vi.fn()}
/>,
);
}
it("disables the recycle toggle when worktree naming is task-id (mutually exclusive)", () => {
renderWorktrees({ recycleWorktrees: false, worktreeNaming: "task-id" });
const recycle = screen.getByLabelText(/Recycle worktrees/i) as HTMLInputElement;
expect(recycle.disabled).toBe(true);
expect(recycle.checked).toBe(false);
// The naming select stays enabled so the operator can switch away from task-id.
const naming = screen.getByLabelText(/Worktree Naming Style/i) as HTMLSelectElement;
expect(naming.disabled).toBe(false);
expect(naming.value).toBe("task-id");
});
it("disables the naming select when recycling is on (mutually exclusive)", () => {
renderWorktrees({ recycleWorktrees: true, worktreeNaming: "random" });
const naming = screen.getByLabelText(/Worktree Naming Style/i) as HTMLSelectElement;
expect(naming.disabled).toBe(true);
const recycle = screen.getByLabelText(/Recycle worktrees/i) as HTMLInputElement;
expect(recycle.disabled).toBe(false);
expect(recycle.checked).toBe(true);
});
it("leaves both controls editable when neither conflicting value is set", () => {
renderWorktrees({ recycleWorktrees: false, worktreeNaming: "random" });
expect((screen.getByLabelText(/Recycle worktrees/i) as HTMLInputElement).disabled).toBe(false);
expect((screen.getByLabelText(/Worktree Naming Style/i) as HTMLSelectElement).disabled).toBe(false);
});
it("legacy conflict (both set): keeps the recycle toggle enabled+checked so it can be repaired", () => {
// Runtime treats this state as recycling (pinning off); the UI mirrors that and offers an escape hatch —
// turning recycling off re-enables the naming select. Both controls must never lock together.
renderWorktrees({ recycleWorktrees: true, worktreeNaming: "task-id" });
const recycle = screen.getByLabelText(/Recycle worktrees/i) as HTMLInputElement;
expect(recycle.disabled).toBe(false);
expect(recycle.checked).toBe(true);
const naming = screen.getByLabelText(/Worktree Naming Style/i) as HTMLSelectElement;
expect(naming.disabled).toBe(true);
});
});
describe("GlobalModelsSection", () => {
it("wires global model lane thinking overrides", () => {
const updateLaneThinkingValue = vi.fn();
render(
<GlobalModelsSection
form={{ defaultThinkingLevel: "low" } as SettingsFormState}
setForm={vi.fn()}
availableModels={[{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }]}
modelsLoading={false}
globalModelLanes={[{ laneId: "execution", label: "Execution", helperText: "Execution", fallbackOrder: "default", globalProviderKey: "executionGlobalProvider", globalModelKey: "executionGlobalModelId", globalThinkingKey: "executionGlobalThinkingLevel" } as never]}
getLaneThinkingValue={() => "medium"}
updateLaneThinkingValue={updateLaneThinkingValue}
resetLaneThinkingValue={vi.fn()}
favoriteProviders={[]}
favoriteModels={[]}
onToggleFavorite={vi.fn()}
onToggleModelFavorite={vi.fn()}
addToast={vi.fn()}
/>,
);
expect(screen.getByTestId("mock-model-dropdown-global-execution-model")).toHaveAttribute("data-thinking-visible", "true");
expect(screen.getByTestId("mock-model-dropdown-global-execution-model")).toHaveAttribute("data-thinking-value", "medium");
expect(screen.getByTestId("mock-model-dropdown-global-execution-model")).toHaveAttribute("data-default-thinking", "low");
fireEvent.click(screen.getByTestId("mock-thinking-global-execution-model"));
expect(updateLaneThinkingValue).toHaveBeenCalledWith(expect.objectContaining({ laneId: "execution" }), "");
});
it("wires global fallback thinking value and clears it with the fallback model", () => {
function GlobalFallbackHost() {
const [form, setForm] = useState<SettingsFormState>({
defaultThinkingLevel: "medium",
fallbackProvider: "anthropic",
fallbackModelId: "claude-sonnet-4-5",
fallbackThinkingLevel: "high",
} as SettingsFormState);
return (
<GlobalModelsSection
form={form}
setForm={setForm}
availableModels={[{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", reasoning: true }]}
modelsLoading={false}
globalModelLanes={[]}
getLaneThinkingValue={() => ""}
updateLaneThinkingValue={vi.fn()}
resetLaneThinkingValue={vi.fn()}
favoriteProviders={[]}
favoriteModels={[]}
onToggleFavorite={vi.fn()}
onToggleModelFavorite={vi.fn()}
addToast={vi.fn()}
/>
);
}
render(<GlobalFallbackHost />);
expect(screen.getByTestId("mock-model-dropdown-fallbackModel")).toHaveAttribute("data-thinking-visible", "true");
expect(screen.getByTestId("mock-model-dropdown-fallbackModel")).toHaveAttribute("data-thinking-value", "high");
expect(screen.getByTestId("mock-model-dropdown-fallbackModel")).toHaveAttribute("data-default-thinking", "medium");
fireEvent.click(screen.getByTestId("mock-thinking-fallbackModel"));
expect(screen.getByTestId("mock-model-dropdown-fallbackModel")).toHaveAttribute("data-thinking-value", "");
fireEvent.click(screen.getByTestId("mock-thinking-fallbackModel"));
expect(screen.getByTestId("mock-model-dropdown-fallbackModel")).toHaveAttribute("data-thinking-value", "high");
fireEvent.click(screen.getByTestId("mock-model-clear-fallbackModel"));
expect(screen.getByTestId("mock-model-dropdown-fallbackModel")).toHaveAttribute("data-value", "");
expect(screen.getByTestId("mock-model-dropdown-fallbackModel")).toHaveAttribute("data-thinking-value", "");
});
});
describe("ProjectModelsSection", () => {
const models = {
modelLanes: [],
getLaneStatus: () => "inherited" as const,
getLaneValue: () => "",
updateLaneValue: vi.fn(),
resetLaneValue: vi.fn(),
getLaneThinkingValue: () => "",
updateLaneThinkingValue: vi.fn(),
resetLaneThinkingValue: vi.fn(),
availableModels: [],
modelsLoading: false,
favoriteProviders: [],
favoriteModels: [],
onToggleFavorite: vi.fn(),
onToggleModelFavorite: vi.fn(),
editingPresetId: null,
setEditingPresetId: vi.fn(),
presetDraft: null,
setPresetDraft: vi.fn(),
onSavePresetDraft: vi.fn(),
confirmDelete: vi.fn(),
};
it("opts Project Models lane and preset dropdowns into readable menu width", () => {
render(
<ProjectModelsSection
form={{} as SettingsFormState}
setForm={vi.fn()}
models={{
...models,
modelLanes: [
{ laneId: "default", label: "Default", helperText: "Default", fallbackOrder: "global" },
{ laneId: "summarization", label: "Summarization", helperText: "Summarization", fallbackOrder: "global" },
] as never,
availableModels: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }],
presetDraft: { id: "preset", name: "Preset", executorProvider: undefined, executorModelId: undefined, validatorProvider: undefined, validatorModelId: undefined },
}}
addToast={vi.fn()}
/>,
);
expect(screen.getByTestId("mock-model-dropdown-defaultModel")).toHaveAttribute("data-menu-width", "readable");
expect(screen.getByTestId("mock-model-dropdown-summarizationModel")).toHaveAttribute("data-menu-width", "readable");
expect(screen.getByTestId("mock-model-dropdown-preset-executor-model")).toHaveAttribute("data-menu-width", "readable");
expect(screen.getByTestId("mock-model-dropdown-preset-validator-model")).toHaveAttribute("data-menu-width", "readable");
});
it("renders and updates the supported input-language task-definition toggle", () => {
const setForm = vi.fn();
render(
<ProjectModelsSection
form={{ taskDefinitionInInputLanguage: false } as SettingsFormState}
setForm={setForm}
models={models}
addToast={vi.fn()}
/>,
);
const toggle = screen.getByRole("checkbox", { name: "Write task definitions in the operator's input language" });
expect(toggle).not.toBeChecked();
expect(screen.getByText(/supported detectable input languages/i)).toBeInTheDocument();
fireEvent.click(toggle);
expect(setForm).toHaveBeenCalled();
});
it("colocates summarization model controls with AI summarization settings", () => {
render(
<ProjectModelsSection
form={{} as SettingsFormState}
setForm={vi.fn()}
models={{
...models,
modelLanes: [
{ laneId: "default", label: "Default", helperText: "Default", fallbackOrder: "global" },
{ laneId: "merger", label: "Merger", helperText: "Merger", fallbackOrder: "global" },
{ laneId: "summarization", label: "Summarization", helperText: "Summarization", fallbackOrder: "global" },
] as never,
availableModels: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }],
}}
addToast={vi.fn()}
/>,
);
const summarizationSection = screen.getByTestId("project-models-ai-summarization");
const heading = screen.getByRole("heading", { name: "AI Title and Git Commit Message Summarization" });
const autoSummarizeTitles = screen.getByRole("checkbox", { name: "Auto-summarize long descriptions as titles" });
const summarizationDropdown = screen.getByTestId("mock-model-dropdown-summarizationModel");
const fallbackDropdown = screen.getByTestId("mock-model-dropdown-titleSummarizerFallbackModel");
const defaultDropdown = screen.getByTestId("mock-model-dropdown-defaultModel");
const mergerDropdown = screen.getByTestId("mock-model-dropdown-mergerModel");
expect(summarizationSection).toContainElement(heading);
expect(summarizationSection).toContainElement(autoSummarizeTitles);
expect(summarizationSection).toContainElement(summarizationDropdown);
expect(summarizationSection).toContainElement(fallbackDropdown);
expect(defaultDropdown.compareDocumentPosition(heading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(mergerDropdown.compareDocumentPosition(heading) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(heading.compareDocumentPosition(summarizationDropdown) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(heading.compareDocumentPosition(fallbackDropdown) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
});
it("keeps summarization controls behind the available-models guard", () => {
render(
<ProjectModelsSection
form={{} as SettingsFormState}
setForm={vi.fn()}
models={{
...models,
modelLanes: [
{ laneId: "default", label: "Default", helperText: "Default", fallbackOrder: "global" },
{ laneId: "summarization", label: "Summarization", helperText: "Summarization", fallbackOrder: "global" },
] as never,
}}
addToast={vi.fn()}
/>,
);
const summarizationSection = screen.getByTestId("project-models-ai-summarization");
expect(within(summarizationSection).getByText("No models available. Configure authentication first.")).toBeInTheDocument();
expect(within(summarizationSection).queryByTestId("mock-model-dropdown-summarizationModel")).not.toBeInTheDocument();
expect(within(summarizationSection).queryByTestId("mock-model-dropdown-titleSummarizerFallbackModel")).not.toBeInTheDocument();
});
it("wires project model lane thinking overrides and reset", () => {
const updateLaneThinkingValue = vi.fn();
const resetLaneThinkingValue = vi.fn();
const resetLaneValue = vi.fn();
render(
<ProjectModelsSection
form={{ defaultThinkingLevel: "medium" } as SettingsFormState}
setForm={vi.fn()}
models={{
...models,
modelLanes: [
{ laneId: "default", label: "Default", helperText: "Default", fallbackOrder: "global", projectThinkingKey: "defaultThinkingLevelOverride" },
] as never,
getLaneThinkingValue: () => "high",
updateLaneThinkingValue,
resetLaneThinkingValue,
resetLaneValue,
availableModels: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }],
}}
addToast={vi.fn()}
/>,
);
expect(screen.getByTestId("mock-model-dropdown-defaultModel")).toHaveAttribute("data-thinking-visible", "true");
expect(screen.getByTestId("mock-model-dropdown-defaultModel")).toHaveAttribute("data-thinking-value", "high");
expect(screen.getByTestId("mock-model-dropdown-defaultModel")).toHaveAttribute("data-default-thinking", "medium");
fireEvent.click(screen.getByTestId("mock-thinking-defaultModel"));
expect(updateLaneThinkingValue).toHaveBeenCalledWith(expect.objectContaining({ laneId: "default" }), "");
fireEvent.click(screen.getByText(/Reset/));
expect(resetLaneValue).toHaveBeenCalledWith(expect.objectContaining({ laneId: "default" }));
expect(resetLaneThinkingValue).toHaveBeenCalledWith(expect.objectContaining({ laneId: "default" }));
});
it("wires project title-summarizer fallback thinking and clears it with reset", () => {
function ProjectTitleFallbackHost() {
const [form, setForm] = useState<SettingsFormState>({
defaultThinkingLevel: "medium",
titleSummarizerFallbackProvider: "anthropic",
titleSummarizerFallbackModelId: "claude-sonnet-4-5",
titleSummarizerFallbackThinkingLevel: "high",
} as SettingsFormState);
return (
<ProjectModelsSection
form={form}
setForm={setForm}
models={{
...models,
availableModels: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }],
}}
addToast={vi.fn()}
/>
);
}
render(<ProjectTitleFallbackHost />);
const dropdown = screen.getByTestId("mock-model-dropdown-titleSummarizerFallbackModel");
expect(screen.getByTestId("project-models-ai-summarization")).toContainElement(dropdown);
expect(dropdown).toHaveAttribute("data-thinking-visible", "true");
expect(dropdown).toHaveAttribute("data-thinking-value", "high");
expect(dropdown).toHaveAttribute("data-default-thinking", "medium");
fireEvent.click(screen.getByTestId("mock-thinking-titleSummarizerFallbackModel"));
expect(screen.getByTestId("mock-model-dropdown-titleSummarizerFallbackModel")).toHaveAttribute("data-thinking-value", "");
fireEvent.click(screen.getByTestId("mock-thinking-titleSummarizerFallbackModel"));
expect(screen.getByTestId("mock-model-dropdown-titleSummarizerFallbackModel")).toHaveAttribute("data-thinking-value", "high");
fireEvent.click(screen.getByRole("button", { name: "Reset" }));
expect(screen.getByTestId("mock-model-dropdown-titleSummarizerFallbackModel")).toHaveAttribute("data-value", "");
expect(screen.getByTestId("mock-model-dropdown-titleSummarizerFallbackModel")).toHaveAttribute("data-thinking-value", "");
});
it("renders merger fallback directly after merger and resets its complete lane", () => {
function ProjectMergerFallbackHost() {
const [form, setForm] = useState<SettingsFormState>({
mergerFallbackProvider: "anthropic",
mergerFallbackModelId: "claude-sonnet-4-5",
mergerFallbackThinkingLevel: "high",
} as SettingsFormState);
return <ProjectModelsSection
form={form}
setForm={setForm}
models={{
...models,
modelLanes: [
{ laneId: "default", label: "Default", helperText: "Default", fallbackOrder: "global" },
{ laneId: "merger", label: "Merger", helperText: "Merger", fallbackOrder: "global" },
{ laneId: "import-translate", label: "Translate", helperText: "Translate", fallbackOrder: "global" },
] as never,
availableModels: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }],
}}
addToast={vi.fn()}
/>;
}
render(<ProjectMergerFallbackHost />);
const dropdown = screen.getByTestId("mock-model-dropdown-mergerFallbackModel");
expect(dropdown).toHaveAttribute("data-value", "anthropic/claude-sonnet-4-5");
expect(dropdown).toHaveAttribute("data-thinking-value", "high");
const dropdowns = Array.from(document.querySelectorAll("[data-testid^='mock-model-dropdown-']"));
const mergerIndex = dropdowns.indexOf(screen.getByTestId("mock-model-dropdown-mergerModel"));
expect(dropdowns[mergerIndex + 1]).toBe(dropdown);
fireEvent.click(screen.getByRole("button", { name: "Reset" }));
expect(dropdown).toHaveAttribute("data-value", "");
expect(dropdown).toHaveAttribute("data-thinking-value", "");
});
it("renders default workflow model lanes with each fallback directly after its primary", async () => {
vi.mocked(fetchWorkflow).mockResolvedValue({
id: "builtin:coding",
name: "Coding",
ir: {
settings: [
"planning",
"planningFallback",
"execution",
"executionFallback",
"validator",
"validatorFallback",
].flatMap((lane) => [
{ id: `${lane}Provider`, name: `${lane} provider`, type: "string" },
{ id: `${lane}ModelId`, name: `${lane} model`, type: "string" },
{ id: `${lane}ThinkingLevel`, name: `${lane} thinking`, type: "enum" },
]),
},
} as never);
vi.mocked(fetchWorkflowSettingValues).mockResolvedValue({ stored: {}, effective: {}, orphaned: [] });
render(
<ProjectModelsSection
form={{ defaultWorkflowId: "builtin:coding" } as SettingsFormState}
setForm={vi.fn()}
models={{
...models,
availableModels: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }],
}}
projectId="project-1"
addToast={vi.fn()}
/>,
);
await screen.findByTestId("workflow-model-lane-validator-fallback");
const workflowLaneIds = Array.from(document.querySelectorAll<HTMLElement>("[data-testid^='workflow-model-lane-']"))
.map((element) => element.dataset.testid?.replace("workflow-model-lane-", ""));
expect(workflowLaneIds).toEqual([
"planning",
"planning-fallback",
"execution",
"execution-fallback",
"validator",
"validator-fallback",
]);
});
it("wires workflow fallback lane thinking render, persist, and reset", async () => {
let saver: (() => Promise<void>) | null = null;
vi.mocked(fetchWorkflow).mockResolvedValue({
id: "builtin:coding",
name: "Coding",
ir: {
settings: [
{ id: "planningFallbackProvider", name: "Planning fallback provider", type: "string" },
{ id: "planningFallbackModelId", name: "Planning fallback model", type: "string" },
{ id: "planningFallbackThinkingLevel", name: "Planning fallback thinking", type: "enum" },
{ id: "validatorFallbackProvider", name: "Reviewer fallback provider", type: "string" },
{ id: "validatorFallbackModelId", name: "Reviewer fallback model", type: "string" },
{ id: "validatorFallbackThinkingLevel", name: "Reviewer fallback thinking", type: "enum" },
],
},
} as never);
vi.mocked(fetchWorkflowSettingValues).mockResolvedValueOnce({
stored: { validatorFallbackProvider: "anthropic", validatorFallbackModelId: "claude-sonnet-4-5", validatorFallbackThinkingLevel: "high" },
effective: { validatorFallbackProvider: "anthropic", validatorFallbackModelId: "claude-sonnet-4-5", validatorFallbackThinkingLevel: "high" },
orphaned: [],
});
vi.mocked(updateWorkflowSettingValues).mockResolvedValueOnce({
stored: { validatorFallbackProvider: "anthropic", validatorFallbackModelId: "claude-sonnet-4-5", validatorFallbackThinkingLevel: "high" },
effective: { validatorFallbackProvider: "anthropic", validatorFallbackModelId: "claude-sonnet-4-5", validatorFallbackThinkingLevel: "high" },
orphaned: [],
});
render(
<ProjectModelsSection
form={{ defaultWorkflowId: "builtin:coding", defaultThinkingLevel: "medium" } as SettingsFormState}
setForm={vi.fn()}
models={{
...models,
availableModels: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }],
}}
projectId="project-1"
addToast={vi.fn()}
registerWorkflowLaneSaver={(next) => {
saver = next;
}}
/>,
);
const planning = await screen.findByTestId("mock-model-dropdown-workflow-planning-fallback-model");
expect(planning).toHaveAttribute("data-thinking-visible", "true");
expect(planning).toHaveAttribute("data-default-thinking", "medium");
expect(screen.getByTestId("mock-model-dropdown-workflow-validator-fallback-model")).toHaveAttribute("data-thinking-value", "high");
fireEvent.click(screen.getByTestId("mock-thinking-workflow-planning-fallback-model"));
await act(async () => {
await saver!();
});
expect(updateWorkflowSettingValues).toHaveBeenLastCalledWith("builtin:coding", { planningFallbackThinkingLevel: "high" }, "project-1");
fireEvent.click(within(screen.getByTestId("workflow-model-lane-validator-fallback")).getByRole("button", { name: "Reset" }));
await act(async () => {
await saver!();
});
expect(updateWorkflowSettingValues).toHaveBeenLastCalledWith("builtin:coding", {
validatorFallbackProvider: null,
validatorFallbackModelId: null,
validatorFallbackThinkingLevel: null,
}, "project-1");
});
it("opts default workflow model lane dropdowns into readable menu width", async () => {
vi.mocked(fetchWorkflow).mockResolvedValue({
id: "builtin:coding",
name: "Coding",
ir: {
settings: [
{ id: "planningProvider", name: "Planning Provider", type: "string" },
{ id: "planningModelId", name: "Planning Model", type: "string" },
{ id: "planningThinkingLevel", name: "Planning thinking", type: "enum" },
],
},
} as never);
vi.mocked(fetchWorkflowSettingValues).mockResolvedValueOnce({ stored: {}, effective: {}, orphaned: [] });
render(
<ProjectModelsSection
form={{ defaultWorkflowId: "builtin:coding" } as SettingsFormState}
setForm={vi.fn()}
models={{
...models,
availableModels: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }],
}}
projectId="project-1"
addToast={vi.fn()}
/>,
);
expect(await screen.findByTestId("mock-model-dropdown-workflow-planning-model")).toHaveAttribute("data-menu-width", "readable");
});
it("preserves workflow lane edits made while the registered saver is in flight", async () => {
let saver: (() => Promise<void>) | null = null;
let resolveSave!: (value: WorkflowSettingValuesPayload) => void;
vi.mocked(fetchWorkflow).mockResolvedValue({
id: "builtin:coding",
name: "Coding",
ir: {
settings: [
{ id: "planningProvider", name: "Planning Provider", type: "string" },
{ id: "planningModelId", name: "Planning Model", type: "string" },
{ id: "planningThinkingLevel", name: "Planning thinking", type: "enum" },
{ id: "executionProvider", name: "Execution Provider", type: "string" },
{ id: "executionModelId", name: "Execution Model", type: "string" },
{ id: "executionThinkingLevel", name: "Execution thinking", type: "enum" },
],
},
} as never);
vi.mocked(fetchWorkflowSettingValues).mockResolvedValueOnce({ stored: {}, effective: {}, orphaned: [] });
vi.mocked(updateWorkflowSettingValues)
.mockReturnValueOnce(
new Promise<WorkflowSettingValuesPayload>((resolve) => {
resolveSave = resolve;
}),
)
.mockResolvedValueOnce({
stored: { executionProvider: "anthropic", executionModelId: "claude-sonnet-4-5" },
effective: { executionProvider: "anthropic", executionModelId: "claude-sonnet-4-5" },
orphaned: [],
});
render(
<ProjectModelsSection
form={{ defaultWorkflowId: "builtin:coding" } as SettingsFormState}
setForm={vi.fn()}
models={{
...models,
availableModels: [{ provider: "anthropic", id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5" }],
}}
projectId="project-1"
addToast={vi.fn()}
registerWorkflowLaneSaver={(next) => {
saver = next;
}}
/>,
);
const planning = await screen.findByTestId("mock-model-dropdown-workflow-planning-model");
const execution = await screen.findByTestId("mock-model-dropdown-workflow-execution-model");
fireEvent.click(planning);
await waitFor(() => expect(planning).toHaveAttribute("data-value", "anthropic/claude-sonnet-4-5"));
const firstSave = saver!();
await waitFor(() => expect(updateWorkflowSettingValues).toHaveBeenCalledTimes(1));
expect(updateWorkflowSettingValues).toHaveBeenNthCalledWith(
1,
"builtin:coding",
{ planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5" },
"project-1",
);
fireEvent.click(execution);
await waitFor(() => expect(execution).toHaveAttribute("data-value", "anthropic/claude-sonnet-4-5"));
await act(async () => {
resolveSave({
stored: { planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5" },
effective: { planningProvider: "anthropic", planningModelId: "claude-sonnet-4-5" },
orphaned: [],
});
await firstSave;
});
expect(execution).toHaveAttribute("data-value", "anthropic/claude-sonnet-4-5");
await act(async () => {
await saver!();
});
await waitFor(() => expect(updateWorkflowSettingValues).toHaveBeenCalledTimes(2));
expect(updateWorkflowSettingValues).toHaveBeenNthCalledWith(
2,
"builtin:coding",
{ executionProvider: "anthropic", executionModelId: "claude-sonnet-4-5" },
"project-1",
);
});
it("renders PR prompt guidance textareas and emits edits through setForm", () => {
function ProjectModelsHost() {
const [form, setFormState] = useState<SettingsFormState>({
prTitlePromptInstructions: "Keep it short.",
prDescriptionPromptInstructions: "Mention testing.",
} as SettingsFormState);
return (
<ProjectModelsSection
form={form}
setForm={setFormState as never}
models={models}
addToast={vi.fn()}
/>
);
}
render(<ProjectModelsHost />);
const titleField = screen.getByLabelText("PR title prompt guidance") as HTMLTextAreaElement;
const descriptionField = screen.getByLabelText("PR description prompt guidance") as HTMLTextAreaElement;
expect(titleField.value).toBe("Keep it short.");
expect(descriptionField.value).toBe("Mention testing.");
fireEvent.change(titleField, { target: { value: "Use release style." } });
fireEvent.change(descriptionField, { target: { value: "Group by impact." } });
expect(titleField.value).toBe("Use release style.");
expect(descriptionField.value).toBe("Group by impact.");
});
});
describe("PromptsSection", () => {
it("renders the title and mounts AgentPromptsManager", () => {
render(
<PromptsSection form={emptyForm} setForm={vi.fn()} />,
);
expect(screen.getByText("Prompts")).toBeInTheDocument();
expect(screen.getByTestId("agent-prompts-manager")).toBeInTheDocument();
});
});
describe("MovedSettingsStub", () => {
it("renders the message and fires the open-workflow-settings callback", () => {
const onOpen = vi.fn();
render(<MovedSettingsStub message="Step execution moved" onOpenWorkflowSettings={onOpen} />);
expect(screen.getByText("Step execution moved")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Open workflow settings" }));
expect(onOpen).toHaveBeenCalledTimes(1);
});
it("disables the action when no handler is wired", () => {
render(<MovedSettingsStub message="Moved" />);
expect(screen.getByRole("button", { name: "Open workflow settings" })).toBeDisabled();
});
});
describe("ExperimentalSection", () => {
const knownFeatures = { insights: "Insights" };
const legacyAliases: Record<string, string> = { devServer: "devServerView" };
const getCanonicalKey = (k: string) => legacyAliases[k] ?? k;
const isFeatureEnabled = (features: Record<string, boolean>, key: string) => features[key] === true;
// Stateful host so the controlled checkbox actually toggles between renders
// (a bare mock setForm never re-renders, so jsdom reports the bound value).
function ExperimentalHost() {
const [form, setFormState] = useState<SettingsFormState>(
{ experimentalFeatures: {} } as SettingsFormState,
);
return (
<ExperimentalSection
form={form}
setForm={setFormState as never}
knownFeatures={knownFeatures}
legacyAliases={legacyAliases}
getCanonicalKey={getCanonicalKey}
isFeatureEnabled={isFeatureEnabled}
/>
);
}
it("renders a row per known flag and round-trips the canonical key", () => {
render(<ExperimentalHost />);
expect(screen.getByText("Insights")).toBeInTheDocument();
expect(screen.queryByText("Roadmaps")).not.toBeInTheDocument();
const insightsToggle = document.getElementById("experimental-insights") as HTMLInputElement;
expect(insightsToggle.checked).toBe(false);
fireEvent.click(insightsToggle);
expect(insightsToggle.checked).toBe(true);
fireEvent.click(insightsToggle);
expect(insightsToggle.checked).toBe(false);
});
});