mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-10-25 17:22:27 +08:00
A successful fetch from a model list header now opens the picker dialog, which already shows how many models came back, so the "fetched N models" toast only repeated it. Claude Code Aggregation, Codex with a model list and OpenCode / MiniMax Code no longer show it there. The fetch buttons that don't open the picker (the Codex default model field, the Claude Code model mapping) keep it, and the empty-list and error toasts are unchanged.
544 lines
17 KiB
TypeScript
544 lines
17 KiB
TypeScript
import type { ComponentProps } from "react";
|
|
import {
|
|
act,
|
|
fireEvent,
|
|
screen,
|
|
waitFor,
|
|
within,
|
|
} from "@testing-library/react";
|
|
import { FormProvider, useForm } from "react-hook-form";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { toast } from "sonner";
|
|
import { renderWithQueryClient as render } from "../utils/testQueryClient";
|
|
import { CodexFormFields } from "@/components/providers/forms/CodexFormFields";
|
|
import {
|
|
copilotGetModels,
|
|
copilotGetModelsForAccount,
|
|
type CopilotModel,
|
|
} from "@/lib/api/copilot";
|
|
import {
|
|
fetchModelsForConfig,
|
|
fetchXaiOauthModels,
|
|
showFetchModelsError,
|
|
type FetchedModel,
|
|
} from "@/lib/api/model-fetch";
|
|
|
|
vi.mock("@/lib/api/copilot", () => ({
|
|
copilotGetModels: vi.fn(),
|
|
copilotGetModelsForAccount: vi.fn(),
|
|
}));
|
|
vi.mock("@/lib/api/model-fetch", () => ({
|
|
fetchModelsForConfig: vi.fn(),
|
|
fetchXaiOauthModels: vi.fn(),
|
|
modelFetchRequestHeaders: vi.fn(),
|
|
showFetchModelsError: vi.fn(),
|
|
}));
|
|
vi.mock("sonner", () => ({
|
|
toast: { error: vi.fn(), success: vi.fn(), info: vi.fn() },
|
|
}));
|
|
vi.mock("@/components/providers/forms/CopilotAuthSection", () => ({
|
|
CopilotAuthSection: () => null,
|
|
}));
|
|
vi.mock("@/components/providers/forms/XaiOAuthSection", () => ({
|
|
XaiOAuthSection: () => null,
|
|
}));
|
|
vi.mock("@/components/providers/forms/shared", async (importOriginal) => {
|
|
const actual =
|
|
await importOriginal<
|
|
typeof import("@/components/providers/forms/shared")
|
|
>();
|
|
return {
|
|
...actual,
|
|
ModelDropdown: ({ models }: { models: FetchedModel[] }) => (
|
|
<output data-testid="model-options">
|
|
{models.map((model) => model.id).join(",")}
|
|
</output>
|
|
),
|
|
};
|
|
});
|
|
|
|
type Props = ComponentProps<typeof CodexFormFields>;
|
|
type ProviderKind = "copilot" | "xai" | "config";
|
|
|
|
function pendingModels() {
|
|
let resolve!: (models: Array<CopilotModel & FetchedModel>) => void;
|
|
let reject!: (reason: Error) => void;
|
|
const promise = new Promise<Array<CopilotModel & FetchedModel>>(
|
|
(res, rej) => {
|
|
resolve = res;
|
|
reject = rej;
|
|
},
|
|
);
|
|
return { promise, resolve, reject };
|
|
}
|
|
|
|
function advertisedModel(id = "model-1"): CopilotModel & FetchedModel {
|
|
return {
|
|
id,
|
|
name: "Model One",
|
|
vendor: "vendor",
|
|
ownedBy: "vendor",
|
|
model_picker_enabled: true,
|
|
supported_endpoints: ["/responses"],
|
|
context_window: 400_000,
|
|
};
|
|
}
|
|
|
|
function makeProps(kind: ProviderKind): Props {
|
|
return {
|
|
isCopilotPreset: kind === "copilot",
|
|
isCopilotAuthenticated: true,
|
|
selectedGitHubAccountId: "github-account",
|
|
isXaiOauthPreset: kind === "xai",
|
|
isXaiOauthAuthenticated: true,
|
|
selectedXaiAccountId: "xai-account",
|
|
codexApiKey: "test-key",
|
|
onApiKeyChange: vi.fn(),
|
|
category: "third_party",
|
|
shouldShowApiKeyLink: false,
|
|
websiteUrl: "",
|
|
shouldShowSpeedTest: false,
|
|
codexBaseUrl: "https://example.com/v1",
|
|
onBaseUrlChange: vi.fn(),
|
|
isFullUrl: false,
|
|
onFullUrlChange: vi.fn(),
|
|
isEndpointModalOpen: false,
|
|
onEndpointModalToggle: vi.fn(),
|
|
autoSelect: false,
|
|
onAutoSelectChange: vi.fn(),
|
|
codexModel: "model-1",
|
|
onModelChange: vi.fn(),
|
|
apiFormat: "openai_chat",
|
|
onApiFormatChange: vi.fn(),
|
|
anthropicAuthField: "ANTHROPIC_AUTH_TOKEN",
|
|
onAnthropicAuthFieldChange: vi.fn(),
|
|
impersonateClaudeCode: false,
|
|
onImpersonateClaudeCodeChange: vi.fn(),
|
|
maxOutputTokens: "",
|
|
onMaxOutputTokensChange: vi.fn(),
|
|
promptCacheRouting: "auto",
|
|
onPromptCacheRoutingChange: vi.fn(),
|
|
catalogModels: [],
|
|
onCatalogModelsChange: vi.fn(),
|
|
speedTestEndpoints: [],
|
|
customUserAgent: "",
|
|
onCustomUserAgentChange: vi.fn(),
|
|
localProxyHeadersOverride: "",
|
|
onLocalProxyHeadersOverrideChange: vi.fn(),
|
|
localProxyBodyOverride: "",
|
|
onLocalProxyBodyOverrideChange: vi.fn(),
|
|
};
|
|
}
|
|
|
|
function Harness(props: Props) {
|
|
const form = useForm();
|
|
return (
|
|
<FormProvider {...form}>
|
|
<CodexFormFields {...props} />
|
|
</FormProvider>
|
|
);
|
|
}
|
|
|
|
const fetchMockFor = (kind: ProviderKind) =>
|
|
kind === "copilot"
|
|
? vi.mocked(copilotGetModelsForAccount)
|
|
: kind === "xai"
|
|
? vi.mocked(fetchXaiOauthModels)
|
|
: vi.mocked(fetchModelsForConfig);
|
|
|
|
// 拉到列表后按钮改叫「选择模型」并自动打开选择弹窗(弹窗外的元素被设为 aria-hidden)。
|
|
const fetchButton = () =>
|
|
screen.getAllByRole("button", {
|
|
name: /^providerForm\.(fetchModels|modelPicker\.title)$/,
|
|
hidden: true,
|
|
})[0];
|
|
const providerKinds: ProviderKind[] = ["copilot", "xai", "config"];
|
|
|
|
describe("Codex model-fetch lifecycle", () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
});
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
it("fetches only models supporting the selected Copilot protocol", async () => {
|
|
const responsesModel = advertisedModel("responses-model");
|
|
const chatModel = {
|
|
...advertisedModel("chat-model"),
|
|
supported_endpoints: ["/chat/completions"],
|
|
};
|
|
vi.mocked(copilotGetModelsForAccount).mockResolvedValue([
|
|
responsesModel,
|
|
chatModel,
|
|
]);
|
|
const props = {
|
|
...makeProps("copilot"),
|
|
copilotApiFormat: "openai_chat" as const,
|
|
};
|
|
render(<Harness {...props} />);
|
|
fireEvent.click(fetchButton());
|
|
await waitFor(() => expect(fetchButton()).toBeEnabled());
|
|
expect(screen.getByTestId("model-options")).toHaveTextContent("chat-model");
|
|
expect(screen.getByTestId("model-options")).not.toHaveTextContent(
|
|
"responses-model",
|
|
);
|
|
expect(props.onModelChange).toHaveBeenCalledWith("chat-model");
|
|
expect(props.onCatalogModelsChange).toHaveBeenCalledWith([
|
|
expect.objectContaining({ model: "chat-model" }),
|
|
]);
|
|
});
|
|
|
|
it("discards a pending model list after the Copilot protocol changes", async () => {
|
|
const pending = pendingModels();
|
|
vi.mocked(copilotGetModelsForAccount).mockReturnValue(pending.promise);
|
|
const props = makeProps("copilot");
|
|
const { rerender } = render(<Harness {...props} />);
|
|
fireEvent.click(fetchButton());
|
|
rerender(<Harness {...props} copilotApiFormat="openai_chat" />);
|
|
await act(async () => pending.resolve([advertisedModel()]));
|
|
expect(props.onCatalogModelsChange).not.toHaveBeenCalled();
|
|
expect(props.onModelChange).not.toHaveBeenCalled();
|
|
expect(toast.success).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each(["gpt-6-astra", "claude-model"])(
|
|
"prefers an available GPT model without replacing a valid selection (%s)",
|
|
async (currentModel) => {
|
|
vi.mocked(copilotGetModelsForAccount).mockResolvedValue([
|
|
advertisedModel("claude-model"),
|
|
advertisedModel("gpt-available"),
|
|
]);
|
|
const props = { ...makeProps("copilot"), codexModel: currentModel };
|
|
render(<Harness {...props} />);
|
|
fireEvent.click(fetchButton());
|
|
await waitFor(() => expect(fetchButton()).toBeEnabled());
|
|
if (currentModel === "claude-model") {
|
|
expect(props.onModelChange).not.toHaveBeenCalled();
|
|
} else {
|
|
expect(props.onModelChange).toHaveBeenCalledWith("gpt-available");
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(providerKinds)(
|
|
"loads %s models through the existing provider route",
|
|
async (kind) => {
|
|
const pending = pendingModels();
|
|
fetchMockFor(kind).mockReturnValue(pending.promise);
|
|
const props = makeProps(kind);
|
|
render(<Harness {...props} />);
|
|
|
|
fireEvent.click(fetchButton());
|
|
expect(fetchButton()).toBeDisabled();
|
|
expect(fetchMockFor(kind)).toHaveBeenCalledTimes(1);
|
|
if (kind === "copilot") {
|
|
expect(copilotGetModelsForAccount).toHaveBeenCalledWith(
|
|
"github-account",
|
|
);
|
|
} else if (kind === "xai") {
|
|
expect(fetchXaiOauthModels).toHaveBeenCalledWith("xai-account");
|
|
} else {
|
|
expect(fetchModelsForConfig).toHaveBeenCalledWith(
|
|
props.codexBaseUrl,
|
|
"test-key",
|
|
false,
|
|
undefined,
|
|
"",
|
|
{ requestHeaders: undefined },
|
|
);
|
|
}
|
|
|
|
await act(async () => pending.resolve([advertisedModel()]));
|
|
await waitFor(() => expect(fetchButton()).toBeEnabled());
|
|
expect(screen.getByTestId("model-options")).toHaveTextContent("model-1");
|
|
// 列表上的按钮拉取成功后直接打开选择弹窗,不再弹成功提示
|
|
expect(await screen.findByRole("dialog")).toBeInTheDocument();
|
|
expect(toast.success).not.toHaveBeenCalled();
|
|
expect(showFetchModelsError).not.toHaveBeenCalled();
|
|
if (kind === "copilot") {
|
|
expect(props.onCatalogModelsChange).toHaveBeenCalledWith([
|
|
{
|
|
model: "model-1",
|
|
displayName: "Model One",
|
|
contextWindow: 400_000,
|
|
supportsParallelToolCalls: false,
|
|
inputModalities: ["text"],
|
|
},
|
|
]);
|
|
} else {
|
|
expect(props.onCatalogModelsChange).not.toHaveBeenCalled();
|
|
}
|
|
},
|
|
);
|
|
|
|
it.each(providerKinds)(
|
|
"keeps the empty-list notification for %s",
|
|
async (kind) => {
|
|
fetchMockFor(kind).mockResolvedValue([]);
|
|
render(<Harness {...makeProps(kind)} />);
|
|
fireEvent.click(fetchButton());
|
|
|
|
await waitFor(() =>
|
|
expect(toast.info).toHaveBeenCalledWith(
|
|
"providerForm.fetchModelsEmpty",
|
|
),
|
|
);
|
|
expect(fetchButton()).toBeEnabled();
|
|
expect(toast.success).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it.each(providerKinds)(
|
|
"reports %s failures and releases loading state",
|
|
async (kind) => {
|
|
const error = new Error("model-list unavailable");
|
|
fetchMockFor(kind).mockRejectedValue(error);
|
|
render(<Harness {...makeProps(kind)} />);
|
|
fireEvent.click(fetchButton());
|
|
|
|
await waitFor(() =>
|
|
expect(showFetchModelsError).toHaveBeenCalledWith(
|
|
error,
|
|
expect.any(Function),
|
|
),
|
|
);
|
|
expect(fetchButton()).toBeEnabled();
|
|
expect(toast.success).not.toHaveBeenCalled();
|
|
expect(console.warn).toHaveBeenCalledWith(
|
|
kind === "copilot"
|
|
? "[Copilot] Failed to fetch models:"
|
|
: kind === "xai"
|
|
? "[XaiOAuth] Failed to fetch models:"
|
|
: "[ModelFetch] Failed:",
|
|
error,
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each(providerKinds)(
|
|
"ignores stale %s results after the request identity changes",
|
|
async (kind) => {
|
|
const pending = pendingModels();
|
|
fetchMockFor(kind).mockReturnValue(pending.promise);
|
|
const props = makeProps(kind);
|
|
const view = render(<Harness {...props} />);
|
|
fireEvent.click(fetchButton());
|
|
view.rerender(
|
|
<Harness
|
|
{...props}
|
|
selectedGitHubAccountId="another-github-account"
|
|
selectedXaiAccountId="another-xai-account"
|
|
codexBaseUrl="https://other.example/v1"
|
|
/>,
|
|
);
|
|
|
|
await act(async () => pending.resolve([advertisedModel()]));
|
|
await waitFor(() => expect(fetchButton()).toBeEnabled());
|
|
expect(screen.queryByTestId("model-options")).not.toBeInTheDocument();
|
|
expect(props.onCatalogModelsChange).not.toHaveBeenCalled();
|
|
expect(toast.success).not.toHaveBeenCalled();
|
|
},
|
|
);
|
|
|
|
it("keeps Copilot filtering, applies reported prompt limits and selects a default model", async () => {
|
|
vi.mocked(copilotGetModels).mockResolvedValue([
|
|
advertisedModel(),
|
|
{
|
|
...advertisedModel("messages-only"),
|
|
supported_endpoints: ["/v1/messages"],
|
|
},
|
|
]);
|
|
const props = {
|
|
...makeProps("copilot"),
|
|
selectedGitHubAccountId: null,
|
|
codexModel: "unavailable",
|
|
catalogModels: [{ model: "model-1", contextWindow: 200_000 }],
|
|
};
|
|
render(<Harness {...props} />);
|
|
fireEvent.click(fetchButton());
|
|
|
|
await waitFor(() =>
|
|
expect(props.onModelChange).toHaveBeenCalledWith("model-1"),
|
|
);
|
|
expect(copilotGetModels).toHaveBeenCalledTimes(1);
|
|
expect(copilotGetModelsForAccount).not.toHaveBeenCalled();
|
|
expect(props.onCatalogModelsChange).toHaveBeenCalledWith([
|
|
expect.objectContaining({ model: "model-1", contextWindow: 400_000 }),
|
|
]);
|
|
expect(screen.getAllByTestId("model-options")[0]).not.toHaveTextContent(
|
|
"messages-only",
|
|
);
|
|
});
|
|
|
|
it("overrides stale managed metadata with live Copilot capabilities", async () => {
|
|
vi.mocked(copilotGetModelsForAccount).mockResolvedValue([
|
|
{
|
|
...advertisedModel(),
|
|
context_window: 922_000,
|
|
supports_parallel_tool_calls: true,
|
|
reasoning_effort: ["none", "low", "medium", "high", "xhigh"],
|
|
},
|
|
]);
|
|
const props = {
|
|
...makeProps("copilot"),
|
|
catalogModels: [
|
|
{
|
|
model: "model-1",
|
|
contextWindow: 1_048_576,
|
|
supportsParallelToolCalls: false,
|
|
reasoningLevels: ["max"],
|
|
baseInstructions: "Keep me",
|
|
},
|
|
],
|
|
};
|
|
render(<Harness {...props} />);
|
|
fireEvent.click(fetchButton());
|
|
|
|
await waitFor(() =>
|
|
expect(props.onCatalogModelsChange).toHaveBeenCalledWith([
|
|
expect.objectContaining({
|
|
model: "model-1",
|
|
contextWindow: 922_000,
|
|
supportsParallelToolCalls: true,
|
|
reasoningLevels: ["none", "low", "medium", "high", "xhigh"],
|
|
baseInstructions: "Keep me",
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("preserves existing managed metadata when Copilot omits it", async () => {
|
|
vi.mocked(copilotGetModelsForAccount).mockResolvedValue([
|
|
{
|
|
...advertisedModel(),
|
|
context_window: undefined,
|
|
},
|
|
]);
|
|
const props = {
|
|
...makeProps("copilot"),
|
|
catalogModels: [
|
|
{
|
|
model: "model-1",
|
|
contextWindow: 222_000,
|
|
supportsParallelToolCalls: true,
|
|
reasoningLevels: ["high", "max"],
|
|
},
|
|
],
|
|
};
|
|
render(<Harness {...props} />);
|
|
fireEvent.click(fetchButton());
|
|
|
|
await waitFor(() =>
|
|
expect(props.onCatalogModelsChange).toHaveBeenCalledWith([
|
|
expect.objectContaining({
|
|
model: "model-1",
|
|
contextWindow: 222_000,
|
|
supportsParallelToolCalls: true,
|
|
reasoningLevels: ["high", "max"],
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it("clears stale reasoning levels when Copilot explicitly reports none", async () => {
|
|
vi.mocked(copilotGetModelsForAccount).mockResolvedValue([
|
|
{
|
|
...advertisedModel(),
|
|
reasoning_effort: [],
|
|
},
|
|
]);
|
|
const props = {
|
|
...makeProps("copilot"),
|
|
catalogModels: [
|
|
{
|
|
model: "model-1",
|
|
reasoningLevels: ["high", "max"],
|
|
},
|
|
],
|
|
};
|
|
render(<Harness {...props} />);
|
|
fireEvent.click(fetchButton());
|
|
|
|
await waitFor(() =>
|
|
expect(props.onCatalogModelsChange).toHaveBeenCalledWith([
|
|
expect.objectContaining({
|
|
model: "model-1",
|
|
reasoningLevels: [],
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
it.each(providerKinds)("preserves %s preflight validation", (kind) => {
|
|
render(
|
|
<Harness
|
|
{...makeProps(kind)}
|
|
isCopilotAuthenticated={false}
|
|
isXaiOauthAuthenticated={false}
|
|
codexApiKey=""
|
|
codexBaseUrl=""
|
|
/>,
|
|
);
|
|
fireEvent.click(fetchButton());
|
|
|
|
expect(fetchMockFor(kind)).not.toHaveBeenCalled();
|
|
expect(fetchButton()).toBeEnabled();
|
|
if (kind === "config") {
|
|
expect(showFetchModelsError).toHaveBeenCalledWith(
|
|
null,
|
|
expect.any(Function),
|
|
{ hasApiKey: false, hasBaseUrl: false },
|
|
);
|
|
} else {
|
|
expect(toast.error).toHaveBeenCalledTimes(1);
|
|
}
|
|
});
|
|
it("removes unchecked catalog rows and moves the default to the new first row", async () => {
|
|
vi.mocked(fetchModelsForConfig).mockResolvedValue([
|
|
{ id: "model-1", ownedBy: null },
|
|
{ id: "model-2", ownedBy: null },
|
|
]);
|
|
const props = {
|
|
...makeProps("config"),
|
|
codexModel: "model-1",
|
|
catalogModels: [
|
|
{ model: "model-1", contextWindow: 200_000 },
|
|
{ model: "model-2", contextWindow: 128_000 },
|
|
],
|
|
};
|
|
render(<Harness {...props} />);
|
|
fireEvent.click(fetchButton());
|
|
|
|
const dialog = await screen.findByRole("dialog");
|
|
fireEvent.click(within(dialog).getByRole("checkbox", { name: "model-1" }));
|
|
fireEvent.click(
|
|
within(dialog).getByRole("button", {
|
|
name: "providerForm.modelPicker.apply",
|
|
}),
|
|
);
|
|
|
|
expect(props.onModelChange).toHaveBeenCalledWith("model-2");
|
|
await waitFor(() =>
|
|
expect(props.onCatalogModelsChange).toHaveBeenLastCalledWith([
|
|
expect.objectContaining({ model: "model-2", contextWindow: 128_000 }),
|
|
]),
|
|
);
|
|
});
|
|
it("keeps the success notification for the default model fetch button without a model list", async () => {
|
|
vi.mocked(fetchModelsForConfig).mockResolvedValue([advertisedModel()]);
|
|
render(
|
|
<Harness {...makeProps("config")} onCatalogModelsChange={undefined} />,
|
|
);
|
|
fireEvent.click(fetchButton());
|
|
|
|
await waitFor(() =>
|
|
expect(toast.success).toHaveBeenCalledWith(
|
|
"providerForm.fetchModelsSuccess",
|
|
),
|
|
);
|
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
|
});
|
|
});
|