Files
fusion/packages/dashboard/app/__tests__/settings-sections.test.tsx
gsxdsm 41998a6c75 FN-7828: fix cramped padding on notifications failure-mode settings card
Wraps the failure-notification mode fields in the shared .notification-provider-body wrapper so the card's padding/gutters match sibling provider cards on desktop and mobile.

- Wrap failure-notification mode select and delay input in .notification-provider-body within NotificationsSection.tsx
- Add FNXC:SettingsLayout comment documenting why .notification-provider-body is needed (parent .notification-provider-card has no own padding)
- Add regression test asserting the failure-notification mode field nests inside .notification-provider-body, itself inside .notification-provider-card
- Add changeset (patch) documenting the fix

Files changed:
 .changeset/fuzzy-notification-padding.md           |  7 +++
 .../app/__tests__/settings-sections.test.tsx       | 18 ++++++++
 .../settings/sections/NotificationsSection.tsx     | 50 ++++++++++++----------
 3 files changed, 53 insertions(+), 22 deletions(-)

Fusion-Task-Id: FN-7828

Fusion-Task-Lineage: 734c652d-400e-4cb1-b6a9-3a001f0b10b7

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
2026-07-11 18:44:57 -07:00

797 lines
33 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, 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(fetchWorkflow).mockReset();
vi.mocked(fetchWorkflowSettingValues).mockReset();
vi.mocked(updateWorkflowSettingValues).mockReset();
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
scopeBanner={null}
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 />);
const toggle = screen.getByText("Hide AI session notification banners")
.closest("label")!
.querySelector("input[type=checkbox]") 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("emits the absolute file-browser path toggle via setForm", () => {
function GeneralHost() {
const [form, setForm] = useState({ allowAbsoluteFileBrowserPaths: false } as SettingsFormState);
return (
<GeneralSection
scopeBanner={null}
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
scopeBanner={null}
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
scopeBanner={null}
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
scopeBanner={null}
form={{ ntfyEnabled: false } as SettingsFormState}
setForm={vi.fn()}
testNotificationLoading={{}}
testNotificationResult={{}}
onTestProviderNotification={vi.fn()}
/>,
);
expect(screen.queryByLabelText("ntfy Topic")).not.toBeInTheDocument();
rerender(
<NotificationsSection
scopeBanner={null}
form={{ ntfyEnabled: true } as SettingsFormState}
setForm={vi.fn()}
testNotificationLoading={{}}
testNotificationResult={{}}
onTestProviderNotification={vi.fn()}
/>,
);
expect(screen.getByLabelText("ntfy Topic")).toBeInTheDocument();
});
});
describe("SecretsSection", () => {
it("renders the scope banner, title, and the SecretsView card", () => {
render(
<SecretsSection scopeBanner={<div data-testid="scope-banner" />} addToast={vi.fn()} />,
);
expect(screen.getByTestId("scope-banner")).toBeInTheDocument();
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
scopeBanner={null}
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);
});
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
scopeBanner={null}
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
scopeBanner={null}
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();
});
});
describe("GlobalModelsSection", () => {
it("wires global model lane thinking overrides", () => {
const updateLaneThinkingValue = vi.fn();
render(
<GlobalModelsSection
scopeBanner={null}
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
scopeBanner={null}
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
scopeBanner={null}
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("wires project model lane thinking overrides and reset", () => {
const updateLaneThinkingValue = vi.fn();
const resetLaneThinkingValue = vi.fn();
const resetLaneValue = vi.fn();
render(
<ProjectModelsSection
scopeBanner={null}
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
scopeBanner={null}
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(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("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
scopeBanner={null}
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
scopeBanner={null}
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
scopeBanner={null}
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
scopeBanner={null}
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 scopeBanner={null} 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
scopeBanner={null}
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);
});
});