Add regression coverage for credential-instance persistence across task model selector lanes. - Assert lane model saves explicitly clear stale credential instances. - Cover selecting an available executor credential instance and replacing it with a new model. - Verify inline task detail updates preserve and restore credential-instance state. Files changed: .../components/__tests__/ModelSelectorTab.test.tsx | 68 ++++++++++++++++++++++ ...lModal.inline-editing-and-integrations.test.tsx | 51 ++++++++++++++-- 2 files changed, 115 insertions(+), 4 deletions(-) Fusion-Task-Id: FN-8710 Fusion-Task-Lineage: 35295b90-2aa7-4258-a170-9aaca537bb7a Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
377 lines
13 KiB
TypeScript
377 lines
13 KiB
TypeScript
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
import userEvent from "@testing-library/user-event";
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { TaskDetail } from "@fusion/core";
|
|
import { SWR_CACHE_KEYS } from "../../utils/swrCache";
|
|
import { ModelSelectorTab } from "../ModelSelectorTab";
|
|
|
|
vi.mock("../../api", () => ({
|
|
fetchModels: vi.fn(),
|
|
updateTask: vi.fn(),
|
|
updateGlobalSettings: vi.fn(),
|
|
}));
|
|
|
|
const { fetchModels, updateTask } = await import("../../api");
|
|
const mockFetchModels = vi.mocked(fetchModels);
|
|
const mockUpdateTask = vi.mocked(updateTask);
|
|
|
|
function makeTask(overrides: Partial<TaskDetail> = {}): TaskDetail {
|
|
return {
|
|
id: "FN-7398",
|
|
title: "Anthropic model selector",
|
|
description: "Verify Claude CLI model selection",
|
|
column: "todo",
|
|
steps: [],
|
|
dependencies: [],
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString(),
|
|
...overrides,
|
|
} as unknown as TaskDetail;
|
|
}
|
|
|
|
async function selectDropdownOption(user: ReturnType<typeof userEvent.setup>, label: string, option: string) {
|
|
await user.click(screen.getByLabelText(label));
|
|
const listbox = await screen.findByRole("listbox");
|
|
await user.click(within(listbox).getByText(option));
|
|
}
|
|
|
|
/*
|
|
FNXC:TaskDetailModels 2026-08-01-17:12:
|
|
Every direct task-model save owns its lane's provider, model, and credential-instance tuple. An absent or stale instance is deliberately persisted as null so task execution cannot retain a credential override after its model selection changes.
|
|
*/
|
|
describe("ModelSelectorTab", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
localStorage.removeItem(SWR_CACHE_KEYS.MODELS);
|
|
mockFetchModels.mockResolvedValue({
|
|
models: [
|
|
{ provider: "pi-claude-cli", id: "claude-sonnet-5", name: "Claude Sonnet 5 (CLI)", reasoning: true, contextWindow: 1_000_000 },
|
|
],
|
|
favoriteProviders: [],
|
|
favoriteModels: [],
|
|
});
|
|
});
|
|
|
|
it("renders and selects Anthropic Claude CLI rows from the shared model catalog", async () => {
|
|
const user = userEvent.setup();
|
|
const addToast = vi.fn();
|
|
const onTaskUpdated = vi.fn();
|
|
const task = makeTask();
|
|
mockUpdateTask.mockResolvedValueOnce({
|
|
...task,
|
|
modelProvider: "pi-claude-cli",
|
|
modelId: "claude-sonnet-5",
|
|
credentialInstanceId: null,
|
|
});
|
|
|
|
render(
|
|
<ModelSelectorTab
|
|
task={task}
|
|
addToast={addToast}
|
|
onTaskUpdated={onTaskUpdated}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText(/No models available/i)).not.toBeInTheDocument();
|
|
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
|
});
|
|
|
|
await user.click(screen.getByLabelText("Executor Model"));
|
|
|
|
const listbox = await screen.findByRole("listbox");
|
|
expect(within(listbox).getByText("pi-claude-cli")).toBeInTheDocument();
|
|
await user.click(within(listbox).getByText("Claude Sonnet 5 (CLI)"));
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenCalledWith("FN-7398", {
|
|
modelProvider: "pi-claude-cli",
|
|
modelId: "claude-sonnet-5",
|
|
credentialInstanceId: null,
|
|
}, undefined);
|
|
expect(onTaskUpdated).toHaveBeenCalledWith(expect.objectContaining({
|
|
modelProvider: "pi-claude-cli",
|
|
modelId: "claude-sonnet-5",
|
|
credentialInstanceId: null,
|
|
}));
|
|
});
|
|
});
|
|
|
|
it("passes the scoped project id for executor, reviewer, planning, and thinking saves", async () => {
|
|
const user = userEvent.setup();
|
|
const addToast = vi.fn();
|
|
const onTaskUpdated = vi.fn();
|
|
const task = makeTask({
|
|
modelProvider: "pi-claude-cli",
|
|
modelId: "claude-haiku-5",
|
|
credentialInstanceId: "executor-stale",
|
|
validatorModelProvider: "pi-claude-cli",
|
|
validatorModelId: "claude-haiku-5",
|
|
validatorCredentialInstanceId: "reviewer-stale",
|
|
planningModelProvider: "pi-claude-cli",
|
|
planningModelId: "claude-haiku-5",
|
|
planningCredentialInstanceId: "planning-stale",
|
|
mergerModelProvider: "pi-claude-cli",
|
|
mergerModelId: "claude-haiku-5",
|
|
mergerCredentialInstanceId: "merger-stale",
|
|
thinkingLevel: "minimal",
|
|
});
|
|
|
|
mockFetchModels.mockResolvedValue({
|
|
models: [
|
|
{ provider: "pi-claude-cli", id: "claude-haiku-5", name: "Claude Haiku 5 (CLI)", reasoning: true, contextWindow: 200_000 },
|
|
{ provider: "pi-claude-cli", id: "claude-sonnet-5", name: "Claude Sonnet 5 (CLI)", reasoning: true, contextWindow: 1_000_000 },
|
|
],
|
|
favoriteProviders: [],
|
|
favoriteModels: [],
|
|
});
|
|
mockUpdateTask
|
|
.mockResolvedValueOnce({
|
|
...task,
|
|
modelProvider: "pi-claude-cli",
|
|
modelId: "claude-sonnet-5",
|
|
credentialInstanceId: null,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
...task,
|
|
validatorModelProvider: "pi-claude-cli",
|
|
validatorModelId: "claude-sonnet-5",
|
|
validatorCredentialInstanceId: null,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
...task,
|
|
planningModelProvider: "pi-claude-cli",
|
|
planningModelId: "claude-sonnet-5",
|
|
planningCredentialInstanceId: null,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
...task,
|
|
mergerModelProvider: "pi-claude-cli",
|
|
mergerModelId: "claude-sonnet-5",
|
|
mergerCredentialInstanceId: null,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
...task,
|
|
thinkingLevel: "high",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
...task,
|
|
validatorThinkingLevel: "high",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
...task,
|
|
planningThinkingLevel: "high",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
...task,
|
|
mergerThinkingLevel: "high",
|
|
});
|
|
|
|
render(
|
|
<ModelSelectorTab
|
|
task={task}
|
|
addToast={addToast}
|
|
onTaskUpdated={onTaskUpdated}
|
|
projectId="project-alpha"
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(screen.getByLabelText("Executor Model")).toBeInTheDocument());
|
|
|
|
await selectDropdownOption(user, "Executor Model", "Claude Sonnet 5 (CLI)");
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenNthCalledWith(1, "FN-7398", {
|
|
modelProvider: "pi-claude-cli",
|
|
modelId: "claude-sonnet-5",
|
|
credentialInstanceId: null,
|
|
}, "project-alpha");
|
|
});
|
|
|
|
await selectDropdownOption(user, "Reviewer Model", "Claude Sonnet 5 (CLI)");
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenNthCalledWith(2, "FN-7398", {
|
|
validatorModelProvider: "pi-claude-cli",
|
|
validatorModelId: "claude-sonnet-5",
|
|
validatorCredentialInstanceId: null,
|
|
}, "project-alpha");
|
|
});
|
|
|
|
await selectDropdownOption(user, "Planning Model", "Claude Sonnet 5 (CLI)");
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenNthCalledWith(3, "FN-7398", {
|
|
planningModelProvider: "pi-claude-cli",
|
|
planningModelId: "claude-sonnet-5",
|
|
planningCredentialInstanceId: null,
|
|
}, "project-alpha");
|
|
});
|
|
|
|
await selectDropdownOption(user, "Merger Model", "Claude Sonnet 5 (CLI)");
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenNthCalledWith(4, "FN-7398", {
|
|
mergerModelProvider: "pi-claude-cli",
|
|
mergerModelId: "claude-sonnet-5",
|
|
mergerCredentialInstanceId: null,
|
|
}, "project-alpha");
|
|
});
|
|
|
|
await user.click(screen.getByRole("button", { name: /Executor Model/ }));
|
|
await user.selectOptions(await screen.findByTestId("custom-model-dropdown-thinking"), "high");
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenNthCalledWith(5, "FN-7398", {
|
|
thinkingLevel: "high",
|
|
}, "project-alpha");
|
|
});
|
|
|
|
await user.click(screen.getByRole("button", { name: /Reviewer Model/ }));
|
|
await user.selectOptions(await screen.findByTestId("custom-model-dropdown-thinking"), "high");
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenNthCalledWith(6, "FN-7398", {
|
|
validatorThinkingLevel: "high",
|
|
}, "project-alpha");
|
|
});
|
|
|
|
await user.click(screen.getByRole("button", { name: /Planning Model/ }));
|
|
await user.selectOptions(await screen.findByTestId("custom-model-dropdown-thinking"), "high");
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenNthCalledWith(7, "FN-7398", {
|
|
planningThinkingLevel: "high",
|
|
}, "project-alpha");
|
|
});
|
|
|
|
await user.click(screen.getByRole("button", { name: /Merger Model/ }));
|
|
await user.selectOptions(await screen.findByTestId("custom-model-dropdown-thinking"), "high");
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenNthCalledWith(8, "FN-7398", {
|
|
mergerThinkingLevel: "high",
|
|
}, "project-alpha");
|
|
});
|
|
expect(addToast).toHaveBeenCalledWith(expect.stringContaining("set to"), "success");
|
|
});
|
|
|
|
it("clears model overrides with the scoped project id", async () => {
|
|
const user = userEvent.setup();
|
|
const task = makeTask({
|
|
modelProvider: "pi-claude-cli",
|
|
modelId: "claude-sonnet-5",
|
|
credentialInstanceId: "executor-stale",
|
|
});
|
|
mockUpdateTask.mockResolvedValueOnce({
|
|
...task,
|
|
modelProvider: null,
|
|
modelId: null,
|
|
credentialInstanceId: null,
|
|
});
|
|
|
|
render(
|
|
<ModelSelectorTab
|
|
task={task}
|
|
addToast={vi.fn()}
|
|
onTaskUpdated={vi.fn()}
|
|
projectId="project-alpha"
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(screen.getByLabelText("Executor Model")).toBeInTheDocument());
|
|
await selectDropdownOption(user, "Executor Model", "Use default");
|
|
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenCalledWith("FN-7398", {
|
|
modelProvider: null,
|
|
modelId: null,
|
|
credentialInstanceId: null,
|
|
}, "project-alpha");
|
|
});
|
|
});
|
|
|
|
it("persists populated instances and clears stale instances with their executor tuple", async () => {
|
|
const user = userEvent.setup();
|
|
const task = makeTask({
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-haiku-5",
|
|
credentialInstanceId: "anthropic-primary",
|
|
});
|
|
mockFetchModels.mockResolvedValue({
|
|
models: [
|
|
{ provider: "anthropic", id: "claude-haiku-5", name: "Claude Haiku 5", reasoning: true, contextWindow: 200_000 },
|
|
{ provider: "anthropic", id: "claude-sonnet-5", name: "Claude Sonnet 5", reasoning: true, contextWindow: 1_000_000 },
|
|
],
|
|
favoriteProviders: [],
|
|
favoriteModels: [],
|
|
providerInstances: {
|
|
anthropic: { instances: [{ id: "anthropic-primary", isDefault: true }, { id: "anthropic-secondary", isDefault: false }] },
|
|
},
|
|
});
|
|
mockUpdateTask
|
|
.mockResolvedValueOnce({ ...task, credentialInstanceId: "anthropic-secondary" })
|
|
.mockResolvedValueOnce({ ...task, modelId: "claude-sonnet-5", credentialInstanceId: null });
|
|
|
|
render(<ModelSelectorTab task={task} addToast={vi.fn()} onTaskUpdated={vi.fn()} projectId="project-alpha" />);
|
|
|
|
await waitFor(() => expect(screen.getByLabelText("Executor Model")).toBeInTheDocument());
|
|
await user.click(screen.getByLabelText("Executor Model"));
|
|
await user.selectOptions(await screen.findByTestId("custom-model-dropdown-credential-instance"), "anthropic-secondary");
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenNthCalledWith(1, "FN-7398", {
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-haiku-5",
|
|
credentialInstanceId: "anthropic-secondary",
|
|
}, "project-alpha");
|
|
});
|
|
|
|
await user.keyboard("{Escape}");
|
|
await selectDropdownOption(user, "Executor Model", "Claude Sonnet 5");
|
|
await waitFor(() => {
|
|
expect(mockUpdateTask).toHaveBeenNthCalledWith(2, "FN-7398", {
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-sonnet-5",
|
|
credentialInstanceId: null,
|
|
}, "project-alpha");
|
|
});
|
|
});
|
|
|
|
it("renders reviewer and planning thinking badges with default fallback and concrete overrides", async () => {
|
|
const task = makeTask({
|
|
thinkingLevel: "medium",
|
|
validatorThinkingLevel: "high",
|
|
planningThinkingLevel: undefined,
|
|
mergerThinkingLevel: "low",
|
|
});
|
|
|
|
render(
|
|
<ModelSelectorTab
|
|
task={task}
|
|
addToast={vi.fn()}
|
|
settings={{ defaultThinkingLevel: "low" } as any}
|
|
/>,
|
|
);
|
|
|
|
await waitFor(() => expect(screen.getByLabelText("Executor Model")).toBeInTheDocument());
|
|
|
|
const badges = screen.getAllByTestId("custom-model-dropdown-thinking-badge");
|
|
expect(badges).toHaveLength(4);
|
|
expect(badges[0]).toHaveTextContent("Medium");
|
|
expect(badges[1]).toHaveTextContent("High");
|
|
expect(badges[2]).toHaveTextContent("Default (low)");
|
|
expect(badges[3]).toHaveTextContent("Low");
|
|
});
|
|
|
|
it("updates from a cached empty catalog to populated Claude CLI rows without remounting", async () => {
|
|
localStorage.setItem(
|
|
SWR_CACHE_KEYS.MODELS,
|
|
JSON.stringify({
|
|
savedAt: Date.now(),
|
|
data: { models: [], favoriteProviders: [], favoriteModels: [] },
|
|
}),
|
|
);
|
|
|
|
render(<ModelSelectorTab task={makeTask()} addToast={vi.fn()} />);
|
|
|
|
expect(screen.getByText(/No models available/i)).toBeInTheDocument();
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByText(/No models available/i)).not.toBeInTheDocument();
|
|
expect(screen.getByLabelText("Executor Model")).toBeInTheDocument();
|
|
});
|
|
});
|
|
});
|