FN-8710: cover credential instances in model selector saves
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>
This commit is contained in:
@@ -35,6 +35,10 @@ async function selectDropdownOption(user: ReturnType<typeof userEvent.setup>, la
|
||||
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();
|
||||
@@ -57,6 +61,7 @@ describe("ModelSelectorTab", () => {
|
||||
...task,
|
||||
modelProvider: "pi-claude-cli",
|
||||
modelId: "claude-sonnet-5",
|
||||
credentialInstanceId: null,
|
||||
});
|
||||
|
||||
render(
|
||||
@@ -82,10 +87,12 @@ describe("ModelSelectorTab", () => {
|
||||
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,
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -97,12 +104,16 @@ describe("ModelSelectorTab", () => {
|
||||
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",
|
||||
});
|
||||
|
||||
@@ -119,21 +130,25 @@ describe("ModelSelectorTab", () => {
|
||||
...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,
|
||||
@@ -168,6 +183,7 @@ describe("ModelSelectorTab", () => {
|
||||
expect(mockUpdateTask).toHaveBeenNthCalledWith(1, "FN-7398", {
|
||||
modelProvider: "pi-claude-cli",
|
||||
modelId: "claude-sonnet-5",
|
||||
credentialInstanceId: null,
|
||||
}, "project-alpha");
|
||||
});
|
||||
|
||||
@@ -176,6 +192,7 @@ describe("ModelSelectorTab", () => {
|
||||
expect(mockUpdateTask).toHaveBeenNthCalledWith(2, "FN-7398", {
|
||||
validatorModelProvider: "pi-claude-cli",
|
||||
validatorModelId: "claude-sonnet-5",
|
||||
validatorCredentialInstanceId: null,
|
||||
}, "project-alpha");
|
||||
});
|
||||
|
||||
@@ -184,6 +201,7 @@ describe("ModelSelectorTab", () => {
|
||||
expect(mockUpdateTask).toHaveBeenNthCalledWith(3, "FN-7398", {
|
||||
planningModelProvider: "pi-claude-cli",
|
||||
planningModelId: "claude-sonnet-5",
|
||||
planningCredentialInstanceId: null,
|
||||
}, "project-alpha");
|
||||
});
|
||||
|
||||
@@ -192,6 +210,7 @@ describe("ModelSelectorTab", () => {
|
||||
expect(mockUpdateTask).toHaveBeenNthCalledWith(4, "FN-7398", {
|
||||
mergerModelProvider: "pi-claude-cli",
|
||||
mergerModelId: "claude-sonnet-5",
|
||||
mergerCredentialInstanceId: null,
|
||||
}, "project-alpha");
|
||||
});
|
||||
|
||||
@@ -234,11 +253,13 @@ describe("ModelSelectorTab", () => {
|
||||
const task = makeTask({
|
||||
modelProvider: "pi-claude-cli",
|
||||
modelId: "claude-sonnet-5",
|
||||
credentialInstanceId: "executor-stale",
|
||||
});
|
||||
mockUpdateTask.mockResolvedValueOnce({
|
||||
...task,
|
||||
modelProvider: null,
|
||||
modelId: null,
|
||||
credentialInstanceId: null,
|
||||
});
|
||||
|
||||
render(
|
||||
@@ -257,6 +278,53 @@ describe("ModelSelectorTab", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1784,6 +1784,9 @@ describe("TaskDetailModal", () => {
|
||||
models: availableModels,
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
providerInstances: {
|
||||
anthropic: { instances: [{ id: "anthropic-primary", isDefault: true }, { id: "anthropic-secondary", isDefault: false }] },
|
||||
},
|
||||
});
|
||||
|
||||
const initialTask = makeTask({ id: "FN-001", column: "triage", title: "Model sync test" });
|
||||
@@ -1791,15 +1794,22 @@ describe("TaskDetailModal", () => {
|
||||
...initialTask,
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
credentialInstanceId: null,
|
||||
};
|
||||
const updatedAfterExecutorInstance: Task = {
|
||||
...updatedAfterExecutor,
|
||||
credentialInstanceId: "anthropic-secondary",
|
||||
};
|
||||
const updatedAfterValidator: Task = {
|
||||
...updatedAfterExecutor,
|
||||
...updatedAfterExecutorInstance,
|
||||
validatorModelProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
validatorCredentialInstanceId: null,
|
||||
};
|
||||
|
||||
mockUpdateTask
|
||||
.mockResolvedValueOnce(updatedAfterExecutor)
|
||||
.mockResolvedValueOnce(updatedAfterExecutorInstance)
|
||||
.mockResolvedValueOnce(updatedAfterValidator);
|
||||
|
||||
const addToast = vi.fn();
|
||||
@@ -1842,30 +1852,55 @@ describe("TaskDetailModal", () => {
|
||||
expect(mockUpdateTask).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"FN-001",
|
||||
expect.objectContaining({
|
||||
{
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
}),
|
||||
credentialInstanceId: null,
|
||||
},
|
||||
"project-alpha",
|
||||
);
|
||||
});
|
||||
|
||||
await user.click(screen.getByLabelText("Reviewer Model"));
|
||||
await user.click(screen.getByText("GPT-4o"));
|
||||
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(
|
||||
2,
|
||||
"FN-001",
|
||||
{
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
credentialInstanceId: "anthropic-secondary",
|
||||
},
|
||||
"project-alpha",
|
||||
);
|
||||
expect(onTaskUpdated).toHaveBeenCalledWith(expect.objectContaining({
|
||||
modelProvider: "anthropic",
|
||||
modelId: "claude-sonnet-4-5",
|
||||
credentialInstanceId: "anthropic-secondary",
|
||||
}));
|
||||
});
|
||||
|
||||
await user.keyboard("{Escape}");
|
||||
await user.click(screen.getByLabelText("Reviewer Model"));
|
||||
await user.click(screen.getByText("GPT-4o"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateTask).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
"FN-001",
|
||||
{
|
||||
validatorModelProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
validatorCredentialInstanceId: null,
|
||||
},
|
||||
"project-alpha",
|
||||
);
|
||||
expect(onTaskUpdated).toHaveBeenCalledWith(expect.objectContaining({
|
||||
validatorModelProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
validatorCredentialInstanceId: null,
|
||||
}));
|
||||
expect(addToast).not.toHaveBeenCalledWith(expect.any(String), "error");
|
||||
});
|
||||
@@ -1892,6 +1927,9 @@ describe("TaskDetailModal", () => {
|
||||
],
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
providerInstances: {
|
||||
anthropic: { instances: [{ id: "anthropic-primary", isDefault: true }, { id: "anthropic-secondary", isDefault: false }] },
|
||||
},
|
||||
});
|
||||
mockUpdateTask.mockRejectedValueOnce(new Error("Task not found"));
|
||||
|
||||
@@ -1904,6 +1942,7 @@ describe("TaskDetailModal", () => {
|
||||
title: "Scoped reviewer failure",
|
||||
validatorModelProvider: "anthropic",
|
||||
validatorModelId: "claude-haiku-5",
|
||||
validatorCredentialInstanceId: "anthropic-primary",
|
||||
})}
|
||||
projectId="project-alpha"
|
||||
onClose={noop}
|
||||
@@ -1924,11 +1963,15 @@ describe("TaskDetailModal", () => {
|
||||
expect(mockUpdateTask).toHaveBeenCalledWith("FN-001", {
|
||||
validatorModelProvider: "openai",
|
||||
validatorModelId: "gpt-4o",
|
||||
validatorCredentialInstanceId: null,
|
||||
}, "project-alpha");
|
||||
expect(addToast).toHaveBeenCalledTimes(1);
|
||||
expect(addToast).toHaveBeenCalledWith("Task not found", "error");
|
||||
expect(screen.getByLabelText("Reviewer Model")).toHaveTextContent("Claude Haiku 5");
|
||||
});
|
||||
|
||||
await user.click(screen.getByLabelText("Reviewer Model"));
|
||||
expect(await screen.findByTestId("custom-model-dropdown-credential-instance")).toHaveValue("anthropic-primary");
|
||||
});
|
||||
|
||||
it("renders Save and Cancel in the modal footer, not inside the edit form body", () => {
|
||||
|
||||
Reference in New Issue
Block a user