Files
fusion/packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx
gsxdsm c8cb740b93 FN-6026: use model dropdowns for workflow lane settings
Use the shared model picker for workflow lane settings instead of raw provider/model text entry.

- replace built-in workflow model provider/model string fields with combined dropdown controls in the Values tab
- fetch available models and save or clear paired provider/model workflow values together, including fallback lanes
- add regression coverage for dropdown rendering, selection, clearing, empty registries, and paired-key validation errors
- update dashboard and settings documentation to describe the shared workflow model picker behavior

Files changed:
 docs/dashboard-guide.md                            |   4 +-
 docs/settings-reference.md                         |  22 +-
 packages/dashboard/app/components/WorkflowSettingsPanel.tsx       | 241 +++++++++++++++++++--
 packages/dashboard/app/components/__tests__/WorkflowSettingsPanel.test.tsx       | 133 +++++++++++-
 4 files changed, 369 insertions(+), 31 deletions(-)

Fusion-Task-Id: FN-6026

Fusion-Task-Lineage: 7f41120f-f764-4011-9aa5-a323e0aa64d6
2026-06-08 13:32:58 -07:00

445 lines
20 KiB
TypeScript

// @vitest-environment jsdom
/**
* WorkflowSettingsPanel (U6, R5) — declaration authoring + per-project value
* editing. Mirrors the WorkflowFieldsPanel test harness: a small stateful host
* drives the controlled `settings`/`onChange` declaration props the way
* WorkflowNodeEditor does. The value-endpoint api functions are mocked so the
* Values tab can be exercised without a server (the panel never talks to the
* store directly — only through `fetchWorkflowSettingValues` /
* `updateWorkflowSettingValues`).
*/
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, waitFor, cleanup, within } from "@testing-library/react";
import { useState } from "react";
import * as jestDomMatchers from "@testing-library/jest-dom/matchers";
expect.extend(jestDomMatchers);
// Keep the real module (type re-exports, ApiRequestError, every other helper)
// and override only the model/value endpoint functions.
vi.mock("../../api", async () => {
const actual = await vi.importActual<typeof import("../../api")>("../../api");
return {
...actual,
fetchModels: vi.fn(),
fetchWorkflowSettingValues: vi.fn(),
updateWorkflowSettingValues: vi.fn(),
};
});
import * as apiModule from "../../api";
import type { WorkflowSettingDefinition, WorkflowSettingValuesPayload } from "../../api";
import { ApiRequestError } from "../../api";
import { WorkflowSettingsPanel } from "../WorkflowSettingsPanel";
const mockFetchModels = vi.mocked(apiModule.fetchModels);
const mockFetchValues = vi.mocked(apiModule.fetchWorkflowSettingValues);
const mockUpdateValues = vi.mocked(apiModule.updateWorkflowSettingValues);
function payload(over: Partial<WorkflowSettingValuesPayload> = {}): WorkflowSettingValuesPayload {
return { stored: {}, effective: {}, orphaned: [], ...over };
}
function Host({
initial,
workflowId = "wf-1",
readOnly = false,
projectId = "proj-1",
onState,
}: {
initial: WorkflowSettingDefinition[];
workflowId?: string;
readOnly?: boolean;
projectId?: string;
onState?: (s: WorkflowSettingDefinition[]) => void;
}) {
const [settings, setSettings] = useState<WorkflowSettingDefinition[]>(initial);
return (
<WorkflowSettingsPanel
workflowId={workflowId}
settings={settings}
readOnly={readOnly}
projectId={projectId}
addToast={() => {}}
onChange={(next) => {
setSettings(next);
onState?.(next);
}}
/>
);
}
const openValues = () => fireEvent.click(screen.getByTestId("wf-settings-tab-values"));
const openDefinitions = () => fireEvent.click(screen.getByTestId("wf-settings-tab-definitions"));
const modelResponse = {
models: [
{ provider: "openai", id: "gpt-5", name: "GPT-5", reasoning: true, contextWindow: 128000 },
{ provider: "anthropic", id: "claude-sonnet", name: "Claude Sonnet", reasoning: true, contextWindow: 200000 },
],
favoriteProviders: [],
favoriteModels: [],
};
beforeEach(() => {
mockFetchModels.mockResolvedValue(modelResponse);
mockFetchValues.mockResolvedValue(payload());
mockUpdateValues.mockResolvedValue(payload());
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("WorkflowSettingsPanel — Definitions tab", () => {
it("renders the empty state and adds a default string setting", () => {
let latest: WorkflowSettingDefinition[] = [];
render(<Host initial={[]} onState={(s) => (latest = s)} />);
expect(screen.getByText(/No settings declared yet/i)).toBeInTheDocument();
fireEvent.click(screen.getByText("Add setting").closest("button")!);
expect(latest).toHaveLength(1);
expect(latest[0].type).toBe("string");
expect(latest[0].name).toBe("New setting");
});
it("declares a setting of each supported type", () => {
let latest: WorkflowSettingDefinition[] = [];
render(<Host initial={[{ id: "s1", name: "S1", type: "string" }]} onState={(s) => (latest = s)} />);
openDefinitions();
const typeSelect = within(screen.getByTestId("wf-setting-s1")).getByDisplayValue("string");
for (const ty of ["text", "number", "boolean", "enum", "multi-enum"]) {
fireEvent.change(typeSelect, { target: { value: ty } });
expect(latest[0].type).toBe(ty);
}
});
it("seeds options when switching to enum", () => {
let latest: WorkflowSettingDefinition[] = [];
render(<Host initial={[{ id: "s1", name: "S1", type: "string" }]} onState={(s) => (latest = s)} />);
openDefinitions();
const typeSelect = within(screen.getByTestId("wf-setting-s1")).getByDisplayValue("string");
fireEvent.change(typeSelect, { target: { value: "enum" } });
expect(latest[0].options).toHaveLength(1);
expect(screen.getByTestId("wf-setting-options-s1")).toBeInTheDocument();
});
it("surfaces a duplicate-id error via the toast (remove+add id edit)", () => {
const addToast = vi.fn();
function H() {
const [settings, setSettings] = useState<WorkflowSettingDefinition[]>([
{ id: "alpha", name: "A", type: "string" },
{ id: "beta", name: "B", type: "string" },
]);
return (
<WorkflowSettingsPanel
workflowId="wf-1"
settings={settings}
readOnly={false}
projectId="proj-1"
addToast={addToast}
onChange={setSettings}
/>
);
}
render(<H />);
openDefinitions();
const betaItem = screen.getByTestId("wf-setting-beta");
fireEvent.click(within(betaItem).getByText("Edit id"));
const idInput = within(betaItem).getByLabelText("Setting id");
fireEvent.change(idInput, { target: { value: "alpha" } });
fireEvent.blur(idInput);
expect(addToast).toHaveBeenCalledWith(expect.stringMatching(/already exists/i), "error");
});
it("built-in workflows render declarations read-only", () => {
render(<Host initial={[{ id: "s1", name: "S1", type: "string" }]} readOnly />);
expect(screen.getByTestId("wf-settings-tab-values")).toHaveAttribute("aria-selected", "true");
fireEvent.click(screen.getByTestId("wf-settings-tab-definitions"));
expect(screen.getByText(/declarations are read-only/i)).toBeInTheDocument();
const nameInput = within(screen.getByTestId("wf-setting-s1")).getByLabelText("Setting name");
expect(nameInput).toBeDisabled();
// The "Add setting" button is disabled for built-ins.
expect(screen.getByText("Add setting").closest("button")).toBeDisabled();
});
});
describe("WorkflowSettingsPanel — Values tab", () => {
const decls: WorkflowSettingDefinition[] = [
{ id: "timeout-ms", name: "Timeout", type: "number", default: 1000 },
{ id: "new-sessions", name: "New sessions", type: "boolean", default: false },
{ id: "label", name: "Label", type: "string" },
];
it("loads values on open and shows the customized indicator for stored keys", async () => {
mockFetchValues.mockResolvedValue(
payload({ stored: { "timeout-ms": 5000 }, effective: { "timeout-ms": 5000, "new-sessions": false } }),
);
render(<Host initial={decls} />);
openValues();
await waitFor(() => expect(mockFetchValues).toHaveBeenCalledWith("wf-1", "proj-1"));
await waitFor(() => expect(screen.getByTestId("wf-settings-customized-timeout-ms")).toBeInTheDocument());
// A non-stored key shows no customized indicator.
expect(screen.queryByTestId("wf-settings-customized-new-sessions")).not.toBeInTheDocument();
});
it("groups built-in workflow settings under visible category headings", async () => {
mockFetchValues.mockResolvedValue(payload({ effective: { planningProvider: "openai", planningModelId: "gpt-5" } }));
render(
<Host
readOnly
initial={[
{ id: "planningProvider", name: "Planning provider", type: "string" },
{ id: "planningModelId", name: "Planning model", type: "string" },
{ id: "validatorProvider", name: "Validator provider", type: "string" },
{ id: "requirePlanApproval", name: "Require plan approval", type: "boolean" },
{ id: "workflowStepTimeoutMs", name: "Step timeout", type: "number" },
{ id: "customThing", name: "Custom thing", type: "string" },
]}
/>,
);
await waitFor(() => expect(mockFetchValues).toHaveBeenCalledWith("wf-1", "proj-1"));
expect(within(screen.getByTestId("wf-settings-group-models")).getByText("Models")).toBeInTheDocument();
expect(within(screen.getByTestId("wf-settings-group-review")).getByText("Review & Approval")).toBeInTheDocument();
expect(within(screen.getByTestId("wf-settings-group-steps")).getByText("Step Execution")).toBeInTheDocument();
expect(within(screen.getByTestId("wf-settings-group-advanced")).getByText("Advanced")).toBeInTheDocument();
expect(screen.getByLabelText("Plan/Triage Model")).toBeInTheDocument();
expect(screen.getByLabelText("Reviewer provider")).toBeInTheDocument();
});
it("batches three field edits into exactly ONE patch on Save values", async () => {
mockFetchValues.mockResolvedValue(payload({ effective: { "timeout-ms": 1000, "new-sessions": false } }));
render(<Host initial={decls} />);
openValues();
await waitFor(() => expect(mockFetchValues).toHaveBeenCalled());
fireEvent.change(screen.getByLabelText("Timeout"), { target: { value: "5000" } });
fireEvent.click(screen.getByLabelText("New sessions"));
fireEvent.change(screen.getByLabelText("Label"), { target: { value: "hello" } });
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
expect(mockUpdateValues).toHaveBeenCalledWith(
"wf-1",
{ "timeout-ms": 5000, "new-sessions": true, label: "hello" },
"proj-1",
);
});
it("renders a per-field rejection on the matching row and keeps other edits applied", async () => {
mockFetchValues.mockResolvedValue(payload({ effective: { "timeout-ms": 1000, "new-sessions": false } }));
mockUpdateValues.mockRejectedValueOnce(
new ApiRequestError("rejected", 400, {
rejections: [{ code: "type-mismatch", settingId: "timeout-ms", message: "expects a number" }],
}),
);
render(<Host initial={decls} />);
openValues();
await waitFor(() => expect(mockFetchValues).toHaveBeenCalled());
fireEvent.change(screen.getByLabelText("Timeout"), { target: { value: "5000" } });
fireEvent.click(screen.getByLabelText("New sessions"));
fireEvent.click(screen.getByTestId ? screen.getByTestId("wf-settings-save-values") : screen.getByText("Save values"));
await waitFor(() => expect(screen.getByRole("alert")).toHaveTextContent(/expects a number/i));
// The other edited field keeps its value (write-boundary: nothing persisted,
// all pending edits stay applied so the user can fix + resave).
expect((screen.getByLabelText("New sessions") as HTMLInputElement).checked).toBe(true);
});
it("no active project → requires-project state with no write path", () => {
render(
<WorkflowSettingsPanel
workflowId="wf-1"
settings={decls}
readOnly={false}
projectId={undefined}
addToast={() => {}}
onChange={() => {}}
/>,
);
openValues();
expect(screen.getByText(/Open a project to view and edit/i)).toBeInTheDocument();
expect(screen.queryByTestId("wf-settings-save-values")).not.toBeInTheDocument();
expect(mockFetchValues).not.toHaveBeenCalled();
});
it("shows a stale-context notice when the active project changes after open", async () => {
function H() {
const [pid, setPid] = useState<string | undefined>("proj-1");
return (
<>
<button onClick={() => setPid("proj-2")}>switch</button>
<WorkflowSettingsPanel
workflowId="wf-1"
settings={decls}
readOnly={false}
projectId={pid}
addToast={() => {}}
onChange={() => {}}
/>
</>
);
}
render(<H />);
openValues();
await waitFor(() => expect(mockFetchValues).toHaveBeenCalledWith("wf-1", "proj-1"));
expect(screen.queryByTestId("wf-settings-stale-notice")).not.toBeInTheDocument();
fireEvent.click(screen.getByText("switch"));
await waitFor(() => expect(screen.getByTestId("wf-settings-stale-notice")).toBeInTheDocument());
// Save is disabled under stale context (no writes to the new project).
expect(screen.getByTestId("wf-settings-save-values")).toBeDisabled();
});
it("renders orphaned values in a disclosure and deletes via a null patch", async () => {
mockFetchValues.mockResolvedValue(
payload({ stored: { "old-key": "stale" }, effective: {}, orphaned: [{ id: "old-key", value: "stale" }] }),
);
render(<Host initial={decls} />);
openValues();
await waitFor(() => expect(screen.getByTestId("wf-settings-orphaned")).toBeInTheDocument());
// Expand the disclosure.
fireEvent.click(within(screen.getByTestId("wf-settings-orphaned")).getByRole("button"));
const orphanRow = await screen.findByTestId("wf-settings-orphan-old-key");
expect(orphanRow).toHaveTextContent("old-key");
expect(orphanRow).toHaveTextContent("stale");
fireEvent.click(within(orphanRow).getByLabelText("Delete orphaned value"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledWith("wf-1", { "old-key": null }, "proj-1"));
});
it("clear-to-default emits a null patch for a customized value", async () => {
mockFetchValues.mockResolvedValue(payload({ stored: { "timeout-ms": 5000 }, effective: { "timeout-ms": 5000 } }));
render(<Host initial={decls} />);
openValues();
await waitFor(() => expect(screen.getByTestId("wf-settings-customized-timeout-ms")).toBeInTheDocument());
// The clear/reset affordance lives on the row (SettingsFieldRow onClear).
const row = screen.getByTestId("wf-settings-value-timeout-ms");
const clearBtn = within(row).getByRole("button");
fireEvent.click(clearBtn);
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledWith("wf-1", { "timeout-ms": null }, "proj-1"));
});
const modelDecls: WorkflowSettingDefinition[] = [
{ id: "planningProvider", name: "Planning provider", type: "string" },
{ id: "planningModelId", name: "Planning model", type: "string" },
{ id: "executionProvider", name: "Execution provider", type: "string" },
{ id: "executionModelId", name: "Execution model", type: "string" },
{ id: "validatorProvider", name: "Validator provider", type: "string" },
{ id: "validatorModelId", name: "Validator model", type: "string" },
{ id: "planningFallbackProvider", name: "Planning fallback provider", type: "string" },
{ id: "planningFallbackModelId", name: "Planning fallback model", type: "string" },
{ id: "customModelProvider", name: "Custom model provider", type: "string" },
];
async function openPlanningDropdown() {
const trigger = await screen.findByLabelText("Plan/Triage Model");
fireEvent.click(trigger);
return trigger;
}
it("renders built-in model lane pairs as dropdowns without raw provider/model text inputs", async () => {
mockFetchValues.mockResolvedValue(
payload({
stored: { planningProvider: "openai", planningModelId: "gpt-5" },
effective: { planningProvider: "openai", planningModelId: "gpt-5" },
}),
);
render(<Host initial={modelDecls} readOnly />);
await waitFor(() => expect(mockFetchModels).toHaveBeenCalledTimes(1));
expect(screen.getByLabelText("Plan/Triage Model")).toHaveTextContent("GPT-5");
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
expect(screen.getByLabelText("Reviewer Model")).toBeInTheDocument();
expect(screen.getByLabelText("Planning Fallback Model")).toBeInTheDocument();
expect(screen.queryByRole("textbox", { name: "Plan/Triage provider" })).not.toBeInTheDocument();
expect(screen.queryByRole("textbox", { name: "Plan/Triage model" })).not.toBeInTheDocument();
expect(screen.getByLabelText("Custom model provider")).toBeInTheDocument();
expect(screen.getByTestId("wf-settings-customized-planning")).toBeInTheDocument();
});
it("selecting a workflow model writes provider and model id together", async () => {
render(<Host initial={modelDecls} readOnly />);
await waitFor(() => expect(mockFetchModels).toHaveBeenCalled());
await openPlanningDropdown();
fireEvent.click(await screen.findByRole("option", { name: /Claude Sonnet/i }));
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
expect(mockUpdateValues).toHaveBeenCalledWith(
"wf-1",
{ planningProvider: "anthropic", planningModelId: "claude-sonnet" },
"proj-1",
);
});
it("clearing a workflow model dropdown writes paired null values", async () => {
mockFetchValues.mockResolvedValue(
payload({
stored: { planningProvider: "openai", planningModelId: "gpt-5" },
effective: { planningProvider: "openai", planningModelId: "gpt-5" },
}),
);
render(<Host initial={modelDecls} readOnly />);
await waitFor(() => expect(screen.getByTestId("wf-settings-customized-planning")).toBeInTheDocument());
await openPlanningDropdown();
fireEvent.click(await screen.findByRole("option", { name: /Use inherited\/default model/i }));
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
await waitFor(() => expect(mockUpdateValues).toHaveBeenCalledTimes(1));
expect(mockUpdateValues).toHaveBeenCalledWith(
"wf-1",
{ planningProvider: null, planningModelId: null },
"proj-1",
);
});
it("shows inherited/default dropdown state for undefined values without a customized badge", async () => {
render(<Host initial={modelDecls} readOnly />);
await waitFor(() => expect(mockFetchValues).toHaveBeenCalledWith("wf-1", "proj-1"));
expect(screen.getByLabelText("Plan/Triage Model")).toHaveTextContent("Use inherited/default model");
expect(screen.queryByTestId("wf-settings-customized-planning")).not.toBeInTheDocument();
});
it("keeps known model lanes dropdown-backed when the model registry is empty", async () => {
mockFetchModels.mockResolvedValueOnce({ ...modelResponse, models: [] });
render(<Host initial={modelDecls} readOnly />);
await waitFor(() => expect(mockFetchModels).toHaveBeenCalled());
const trigger = screen.getByLabelText("Plan/Triage Model");
expect(trigger).toBeDisabled();
expect(screen.getAllByText(/No models are available/i).length).toBeGreaterThan(0);
expect(screen.queryByLabelText(/^Plan\/Triage provider$/i)).not.toBeInTheDocument();
});
it("surfaces paired-key rejections on the combined row while preserving pending selection", async () => {
mockUpdateValues.mockRejectedValueOnce(
new ApiRequestError("rejected", 400, {
rejections: [{ code: "type-mismatch", settingId: "planningModelId", message: "model is invalid" }],
}),
);
render(<Host initial={modelDecls} readOnly />);
await waitFor(() => expect(mockFetchModels).toHaveBeenCalled());
await openPlanningDropdown();
fireEvent.click(await screen.findByRole("option", { name: /Claude Sonnet/i }));
fireEvent.click(screen.getByTestId("wf-settings-save-values"));
const row = await screen.findByTestId("wf-settings-value-planning");
expect(within(row).getByRole("alert")).toHaveTextContent("model is invalid");
expect(within(row).getByLabelText("Plan/Triage Model")).toHaveTextContent("Claude Sonnet");
expect(mockUpdateValues).toHaveBeenCalledWith(
"wf-1",
{ planningProvider: "anthropic", planningModelId: "claude-sonnet" },
"proj-1",
);
});
});