mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-04 11:43:57 +08:00
Merge origin/main into feat/codex-oauth-account-usage
This commit is contained in:
@@ -125,4 +125,45 @@ describe("AddProviderDialog", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("新建 Grok Build 自定义供应商时不补默认 Grok 图标", async () => {
|
||||
const handleSubmit = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
mockFormValues = {
|
||||
name: "tes 1",
|
||||
websiteUrl: "",
|
||||
icon: "",
|
||||
iconColor: "",
|
||||
settingsConfig: JSON.stringify({
|
||||
config: `[models]
|
||||
default = "grok-4.5"
|
||||
|
||||
[model."grok-4.5"]
|
||||
model = "grok-4.5"
|
||||
base_url = "https://grok.example.com/v1"
|
||||
name = "tes 1"
|
||||
api_key = "secret"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
render(
|
||||
<AddProviderDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
appId="grokbuild"
|
||||
onSubmit={handleSubmit}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "common.add" }));
|
||||
|
||||
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
|
||||
|
||||
const submitted = handleSubmit.mock.calls[0][0];
|
||||
expect(submitted.icon).toBeUndefined();
|
||||
expect(submitted.iconColor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,6 +30,35 @@ function renderForm(
|
||||
}
|
||||
|
||||
describe("ClaudeDesktopProviderForm", () => {
|
||||
it.each(["github_copilot", "codex_oauth", "xai_oauth"])(
|
||||
"托管 OAuth %s 即使旧数据是 direct 也强制开启模型映射",
|
||||
(providerType) => {
|
||||
renderForm({
|
||||
name: "Managed OAuth Provider",
|
||||
category: "third_party",
|
||||
settingsConfig: {
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: "https://api.example.com",
|
||||
},
|
||||
},
|
||||
meta: {
|
||||
providerType,
|
||||
claudeDesktopMode: "direct",
|
||||
apiFormat: "anthropic",
|
||||
claudeDesktopModelRoutes: {
|
||||
"claude-sonnet-5": { model: "upstream-model" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const modelMappingToggle = screen.getByRole("switch", {
|
||||
name: "需要模型映射",
|
||||
});
|
||||
expect(modelMappingToggle).toBeChecked();
|
||||
expect(modelMappingToggle).toBeDisabled();
|
||||
},
|
||||
);
|
||||
|
||||
it("编辑模型映射的菜单显示名时保持输入框焦点", () => {
|
||||
renderForm({
|
||||
name: "Proxy Provider",
|
||||
@@ -177,7 +206,7 @@ describe("ClaudeDesktopProviderForm", () => {
|
||||
model: "upstream-old",
|
||||
labelOverride: "upstream-old",
|
||||
},
|
||||
"claude-opus-4-8": { model: "upstream-old" },
|
||||
"claude-opus-5": { model: "upstream-old" },
|
||||
"claude-fable-5": { model: "upstream-old" },
|
||||
"claude-haiku-4-5": { model: "upstream-old" },
|
||||
});
|
||||
@@ -185,7 +214,7 @@ describe("ClaudeDesktopProviderForm", () => {
|
||||
[
|
||||
"claude-fable-5",
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-opus-5",
|
||||
"claude-sonnet-5",
|
||||
],
|
||||
);
|
||||
@@ -221,7 +250,7 @@ describe("ClaudeDesktopProviderForm", () => {
|
||||
model: "deepseek-v4-pro",
|
||||
supports1m: true,
|
||||
});
|
||||
expect(routes["claude-opus-4-8"]).toMatchObject({
|
||||
expect(routes["claude-opus-5"]).toMatchObject({
|
||||
model: "deepseek-v4-pro",
|
||||
supports1m: true,
|
||||
});
|
||||
|
||||
@@ -23,6 +23,10 @@ vi.mock("@/components/providers/forms/CopilotAuthSection", () => ({
|
||||
CopilotAuthSection: () => <div />,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/providers/forms/XaiOAuthSection", () => ({
|
||||
XaiOAuthSection: () => <div />,
|
||||
}));
|
||||
|
||||
describe("CodexOAuthSection", () => {
|
||||
beforeEach(() => {
|
||||
mocks.useCodexOauth.mockReturnValue({
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const reportFrontendError = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/lib/frontendLogger", () => ({
|
||||
reportFrontendError,
|
||||
}));
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { FrontendErrorBoundary } from "@/components/FrontendErrorBoundary";
|
||||
|
||||
function ThrowingChild(): React.ReactNode {
|
||||
throw new Error("sensitive render failure");
|
||||
}
|
||||
|
||||
describe("FrontendErrorBoundary", () => {
|
||||
it("reports render failures and replaces the broken tree", () => {
|
||||
const consoleError = vi
|
||||
.spyOn(console, "error")
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
render(
|
||||
<FrontendErrorBoundary>
|
||||
<ThrowingChild />
|
||||
</FrontendErrorBoundary>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button")).toBeInTheDocument();
|
||||
expect(reportFrontendError).toHaveBeenCalledWith(
|
||||
"react.error_boundary",
|
||||
expect.objectContaining({ message: "sensitive render failure" }),
|
||||
expect.any(String),
|
||||
);
|
||||
|
||||
consoleError.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { parse as parseToml } from "smol-toml";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
GrokBuildProviderForm,
|
||||
grokApiBackendFromApiFormat,
|
||||
} from "@/components/providers/forms/GrokBuildProviderForm";
|
||||
|
||||
vi.mock("@/components/JsonEditor", () => ({
|
||||
default: ({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}) => (
|
||||
<textarea
|
||||
aria-label="raw-config"
|
||||
value={value}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
describe("GrokBuildProviderForm", () => {
|
||||
it("offers curated Grok Build presets and applies one", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(
|
||||
<GrokBuildProviderForm
|
||||
submitLabel="Save"
|
||||
onSubmit={() => {}}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 国产官方直连(cn_official)不在 Grok Build 预设列表里
|
||||
expect(screen.queryByRole("button", { name: /BytePlus/ })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /Kimi/ })).toBeNull();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /PatewayAI/ }));
|
||||
|
||||
const baseUrlInput =
|
||||
container.querySelector<HTMLInputElement>("#codexBaseUrl");
|
||||
const nameInput =
|
||||
container.querySelector<HTMLInputElement>('input[name="name"]');
|
||||
expect(baseUrlInput?.value).toBe("https://api.pateway.ai/v1");
|
||||
expect(nameInput?.value).toBe("PatewayAI");
|
||||
});
|
||||
|
||||
it("submits a complete config.toml payload with Grok defaults", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(
|
||||
<GrokBuildProviderForm
|
||||
submitLabel="Save"
|
||||
onSubmit={onSubmit}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
const nameInput =
|
||||
container.querySelector<HTMLInputElement>('input[name="name"]');
|
||||
const baseUrlInput =
|
||||
container.querySelector<HTMLInputElement>("#codexBaseUrl");
|
||||
expect(nameInput).not.toBeNull();
|
||||
expect(baseUrlInput).not.toBeNull();
|
||||
|
||||
fireEvent.change(nameInput!, { target: { value: "Example Relay" } });
|
||||
fireEvent.change(baseUrlInput!, {
|
||||
target: { value: "https://relay.example.com/v1" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("API Key"), {
|
||||
target: { value: "secret-key" },
|
||||
});
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
const submitted = onSubmit.mock.calls[0][0];
|
||||
expect(submitted.icon).toBe("");
|
||||
const settings = JSON.parse(submitted.settingsConfig);
|
||||
const config = parseToml(settings.config) as any;
|
||||
|
||||
expect(config.models.default).toBe("grok-4.5");
|
||||
expect(config.model["grok-4.5"]).toEqual({
|
||||
model: "grok-4.5",
|
||||
base_url: "https://relay.example.com/v1",
|
||||
name: "Example Relay",
|
||||
api_key: "secret-key",
|
||||
api_backend: "responses",
|
||||
context_window: 500000,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps preset API formats into Grok api_backend", async () => {
|
||||
// 预设列表已不含 Chat Completions 条目(国产官方直连被移除),
|
||||
// chat/messages 映射分支由纯函数覆盖
|
||||
expect(grokApiBackendFromApiFormat("openai_chat")).toBe("chat_completions");
|
||||
expect(grokApiBackendFromApiFormat("anthropic")).toBe("messages");
|
||||
expect(grokApiBackendFromApiFormat("openai_responses")).toBe("responses");
|
||||
|
||||
// 组件级接线用带显式 apiFormat 的 Responses 预设验证
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<GrokBuildProviderForm
|
||||
submitLabel="Save"
|
||||
onSubmit={onSubmit}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /APIKEY\.FUN/ }));
|
||||
await user.type(screen.getByLabelText("API Key"), "secret-key");
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
const submitted = onSubmit.mock.calls[0][0];
|
||||
const settings = JSON.parse(submitted.settingsConfig);
|
||||
const config = parseToml(settings.config) as any;
|
||||
expect(submitted.meta.apiFormat).toBe("openai_responses");
|
||||
const selected = config.model[config.models.default];
|
||||
expect(selected.api_backend).toBe("responses");
|
||||
expect(selected.model).toBe("grok-4.5");
|
||||
expect(selected.base_url).toBe("https://api.apikey.fun/v1");
|
||||
});
|
||||
|
||||
it("renders localized validation feedback for malformed TOML", async () => {
|
||||
const onSubmit = vi.fn();
|
||||
render(
|
||||
<GrokBuildProviderForm
|
||||
submitLabel="Save"
|
||||
onSubmit={onSubmit}
|
||||
onCancel={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("raw-config"), {
|
||||
target: { value: "[models" },
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Invalid config\.toml:/)).toBeInTheDocument();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads edit-mode values and does not resubmit stale custom endpoints", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSubmit = vi.fn();
|
||||
const config = `[models]
|
||||
default = "existing-profile"
|
||||
|
||||
[model."existing-profile"]
|
||||
model = "grok-upstream"
|
||||
base_url = "https://existing.example.com/v1"
|
||||
name = "Existing Relay"
|
||||
api_key = "existing-key"
|
||||
api_backend = "responses"
|
||||
context_window = 250000
|
||||
`;
|
||||
const { container } = render(
|
||||
<GrokBuildProviderForm
|
||||
providerId="existing-provider"
|
||||
submitLabel="Save"
|
||||
onSubmit={onSubmit}
|
||||
onCancel={() => {}}
|
||||
initialData={{
|
||||
name: "Existing Relay",
|
||||
settingsConfig: { config },
|
||||
meta: {
|
||||
custom_endpoints: {
|
||||
"https://deleted.example.com/v1": {
|
||||
url: "https://deleted.example.com/v1",
|
||||
addedAt: 1,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>("#grokbuild-profile")?.value,
|
||||
).toBe("existing-profile");
|
||||
expect(
|
||||
container.querySelector<HTMLInputElement>("#codexBaseUrl")?.value,
|
||||
).toBe("https://existing.example.com/v1");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Save" }));
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
expect(onSubmit.mock.calls[0][0].meta.custom_endpoints).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -245,6 +245,7 @@ describe("McpFormModal", () => {
|
||||
claude: true,
|
||||
codex: true,
|
||||
gemini: true,
|
||||
grokbuild: true,
|
||||
},
|
||||
});
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
@@ -390,6 +391,7 @@ type = "stdio"
|
||||
claude: true,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
});
|
||||
expect(onSave).toHaveBeenCalledTimes(1);
|
||||
expect(onSave).toHaveBeenCalledWith();
|
||||
@@ -423,6 +425,12 @@ type = "stdio"
|
||||
expect(geminiCheckbox.checked).toBe(true);
|
||||
fireEvent.click(geminiCheckbox);
|
||||
|
||||
const grokbuildCheckbox = screen.getByLabelText(
|
||||
"mcp.unifiedPanel.apps.grokbuild",
|
||||
) as HTMLInputElement;
|
||||
expect(grokbuildCheckbox.checked).toBe(true);
|
||||
fireEvent.click(grokbuildCheckbox);
|
||||
|
||||
fireEvent.click(screen.getByText("common.add"));
|
||||
|
||||
await waitFor(() => expect(upsertMock).toHaveBeenCalledTimes(1));
|
||||
@@ -432,6 +440,7 @@ type = "stdio"
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
hermes: false,
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
getModelsDevSyncConfig,
|
||||
saveModelsDevSyncConfig,
|
||||
getModelPricing,
|
||||
openAppConfigFolder,
|
||||
syncModelsDevPricing,
|
||||
} = vi.hoisted(() => ({
|
||||
getModelsDevSyncConfig: vi.fn(),
|
||||
saveModelsDevSyncConfig: vi.fn(),
|
||||
getModelPricing: vi.fn(),
|
||||
openAppConfigFolder: vi.fn(),
|
||||
syncModelsDevPricing: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: { count?: number }) =>
|
||||
options?.count == null ? key : `${key}:${options.count}`,
|
||||
i18n: { resolvedLanguage: "en" },
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("sonner", () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/usage", () => ({
|
||||
usageApi: {
|
||||
getModelsDevSyncConfig,
|
||||
saveModelsDevSyncConfig,
|
||||
getModelPricing,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/settings", () => ({
|
||||
settingsApi: { openAppConfigFolder },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/modelsDevAutoSync", () => ({
|
||||
MODELS_DEV_SYNC_CONFIG_QUERY_KEY: ["models-dev-sync-config"],
|
||||
syncModelsDevPricing,
|
||||
}));
|
||||
|
||||
import { ModelsDevAutoSyncPanel } from "@/components/usage/ModelsDevAutoSyncPanel";
|
||||
|
||||
const state = {
|
||||
configPath: "C:/Users/test/.cc-switch/model-pricing.json",
|
||||
config: {
|
||||
autoSyncEnabled: false,
|
||||
includeCommonModels: true,
|
||||
selectedModelKeys: [],
|
||||
excludedCommonModelKeys: [],
|
||||
lastSyncAt: null,
|
||||
lastSyncError: null,
|
||||
},
|
||||
};
|
||||
|
||||
function renderPanel() {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={client}>
|
||||
<ModelsDevAutoSyncPanel />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ModelsDevAutoSyncPanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
getModelsDevSyncConfig.mockResolvedValue(state);
|
||||
saveModelsDevSyncConfig.mockResolvedValue(undefined);
|
||||
getModelPricing.mockResolvedValue([]);
|
||||
openAppConfigFolder.mockResolvedValue(undefined);
|
||||
syncModelsDevPricing.mockResolvedValue({
|
||||
skipped: false,
|
||||
selected: 2,
|
||||
imported: 2,
|
||||
changed: 1,
|
||||
syncedAt: Date.now(),
|
||||
});
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
openai: {
|
||||
name: "OpenAI",
|
||||
models: {
|
||||
"gpt-5": {
|
||||
name: "GPT-5",
|
||||
release_date: "2025-08-01",
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
deepseek: {
|
||||
name: "DeepSeek",
|
||||
models: {
|
||||
"deepseek-chat": {
|
||||
name: "DeepSeek Chat",
|
||||
release_date: "2025-12-01",
|
||||
cost: { input: 0.3, output: 1.2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads automatic sync as disabled by default", async () => {
|
||||
renderPanel();
|
||||
|
||||
expect(
|
||||
await screen.findByText("usage.modelsDevAutoSync.title"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(state.configPath)).toBeInTheDocument();
|
||||
expect(screen.getByRole("switch")).not.toBeChecked();
|
||||
expect(saveModelsDevSyncConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists disabling without showing the overwrite warning", async () => {
|
||||
const enabledState = {
|
||||
...state,
|
||||
config: { ...state.config, autoSyncEnabled: true },
|
||||
};
|
||||
getModelsDevSyncConfig.mockResolvedValue(enabledState);
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(await screen.findByRole("switch"));
|
||||
await waitFor(() =>
|
||||
expect(saveModelsDevSyncConfig).toHaveBeenCalledWith({
|
||||
...enabledState.config,
|
||||
autoSyncEnabled: false,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("usage.modelsDevAutoSync.enableConfirmTitle"),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("warns about price overwrites before enabling automatic sync", async () => {
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(await screen.findByRole("switch"));
|
||||
|
||||
expect(saveModelsDevSyncConfig).not.toHaveBeenCalled();
|
||||
expect(
|
||||
await screen.findByText("usage.modelsDevAutoSync.enableConfirmTitle"),
|
||||
).toBeInTheDocument();
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", {
|
||||
name: "usage.modelsDevAutoSync.enableConfirmAction",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(saveModelsDevSyncConfig).toHaveBeenCalledWith({
|
||||
...state.config,
|
||||
autoSyncEnabled: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps automatic sync disabled when the overwrite warning is cancelled", async () => {
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(await screen.findByRole("switch"));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", { name: "common.cancel" }),
|
||||
);
|
||||
|
||||
expect(saveModelsDevSyncConfig).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("switch")).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("reloads the automatic sync config after reading the local pricing file", async () => {
|
||||
const initialState = {
|
||||
...state,
|
||||
config: { ...state.config, autoSyncEnabled: true },
|
||||
};
|
||||
getModelsDevSyncConfig
|
||||
.mockResolvedValueOnce(initialState)
|
||||
.mockResolvedValue(state);
|
||||
renderPanel();
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: "usage.modelsDevAutoSync.reloadLocalFile",
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getModelsDevSyncConfig).toHaveBeenCalledTimes(2),
|
||||
);
|
||||
expect(getModelPricing).toHaveBeenCalledTimes(1);
|
||||
await waitFor(() => expect(screen.getByRole("switch")).not.toBeChecked());
|
||||
});
|
||||
|
||||
it("opens the searchable multi-select dialog with common models selected", async () => {
|
||||
renderPanel();
|
||||
fireEvent.click(
|
||||
await screen.findByRole("button", {
|
||||
name: "usage.modelsDevAutoSync.configure",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
await screen.findByText("usage.modelsDevAutoSync.configureTitle"),
|
||||
).toBeInTheDocument();
|
||||
expect(await screen.findByText("GPT-5")).toBeInTheDocument();
|
||||
expect(screen.getByText("DeepSeek Chat")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("usage.modelsDevAutoSync.selectedCount:2"),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getAllByText("usage.modelsDevAutoSync.commonBadge"),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,11 @@ import {
|
||||
formatPrice,
|
||||
normalizeModelIdForPricing,
|
||||
} from "@/components/usage/ModelsDevPickerDialog";
|
||||
import {
|
||||
getCommonModelKeys,
|
||||
resolveModelsDevSelection,
|
||||
toModelPricing,
|
||||
} from "@/lib/modelsDevPricing";
|
||||
|
||||
describe("normalizeModelIdForPricing", () => {
|
||||
it("keeps already-normalized ids unchanged", () => {
|
||||
@@ -141,4 +146,183 @@ describe("flattenModels", () => {
|
||||
// 完全没有定价的模型被过滤
|
||||
expect(entries.some((e) => e.modelId === "free-model")).toBe(false);
|
||||
});
|
||||
|
||||
it("filters deprecated and non-text output models while keeping multimodal input models", () => {
|
||||
const entries = flattenModels({
|
||||
acme: {
|
||||
models: {
|
||||
"multimodal-chat": {
|
||||
name: "Multimodal Chat",
|
||||
modalities: {
|
||||
input: ["text", "image", "audio", "video"],
|
||||
output: ["text"],
|
||||
},
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
"legacy-chat": {
|
||||
status: "deprecated",
|
||||
modalities: { output: ["text"] },
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
"speech-model": {
|
||||
modalities: { output: ["audio"] },
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
"mixed-output-model": {
|
||||
modalities: { output: ["text", "audio"] },
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
"movie-generator": {
|
||||
modalities: { output: ["video"] },
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
"fallback-video-model": {
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(entries.map((entry) => entry.modelId)).toEqual(["multimodal-chat"]);
|
||||
});
|
||||
|
||||
it("selects a bounded canonical set of common model families", () => {
|
||||
const openAiModels = Object.fromEntries(
|
||||
Array.from({ length: 7 }, (_, index) => {
|
||||
const version = index + 1;
|
||||
return [
|
||||
`gpt-${version}`,
|
||||
{
|
||||
name: `GPT ${version}`,
|
||||
release_date: `2025-0${version}-01`,
|
||||
cost: { input: version, output: version * 2 },
|
||||
},
|
||||
];
|
||||
}),
|
||||
);
|
||||
const entries = flattenModels({
|
||||
openai: {
|
||||
name: "OpenAI",
|
||||
models: {
|
||||
...openAiModels,
|
||||
"gpt-image-1": {
|
||||
release_date: "2026-01-01",
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
aggregator: {
|
||||
name: "Aggregator",
|
||||
models: {
|
||||
"gpt-7": {
|
||||
release_date: "2026-02-01",
|
||||
cost: { input: 9, output: 18 },
|
||||
},
|
||||
},
|
||||
},
|
||||
anthropic: {
|
||||
name: "Anthropic",
|
||||
models: {
|
||||
"claude-sonnet-5": {
|
||||
release_date: "2026-06-01",
|
||||
cost: { input: 3, output: 15 },
|
||||
},
|
||||
},
|
||||
},
|
||||
deepseek: {
|
||||
name: "DeepSeek",
|
||||
models: {
|
||||
"deepseek-chat": {
|
||||
release_date: "2025-12-01",
|
||||
cost: { input: 0.3, output: 1.2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
xiaomi: {
|
||||
name: "Xiaomi",
|
||||
models: {
|
||||
"mimo-v2.5": {
|
||||
release_date: "2026-04-01",
|
||||
cost: { input: 0.2, output: 1 },
|
||||
},
|
||||
"mimo-v2.5-tts": {
|
||||
release_date: "2026-05-01",
|
||||
cost: { input: 0.1, output: 0.5 },
|
||||
},
|
||||
},
|
||||
},
|
||||
longcat: {
|
||||
name: "LongCat",
|
||||
models: {
|
||||
"LongCat-2.0": {
|
||||
release_date: "2026-03-01",
|
||||
cost: { input: 0.4, output: 1.6 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const common = getCommonModelKeys(entries);
|
||||
expect(common.has("openai/gpt-image-1")).toBe(false);
|
||||
expect(common.has("aggregator/gpt-7")).toBe(false);
|
||||
expect(common.has("openai/gpt-7")).toBe(true);
|
||||
expect(common.has("openai/gpt-1")).toBe(false);
|
||||
expect(common.has("anthropic/claude-sonnet-5")).toBe(true);
|
||||
expect(common.has("deepseek/deepseek-chat")).toBe(true);
|
||||
expect(common.has("xiaomi/mimo-v2.5")).toBe(true);
|
||||
expect(common.has("xiaomi/mimo-v2.5-tts")).toBe(false);
|
||||
expect(common.has("longcat/LongCat-2.0")).toBe(true);
|
||||
});
|
||||
|
||||
it("combines common and explicit selections and deduplicates normalized ids", () => {
|
||||
const entries = flattenModels({
|
||||
openai: {
|
||||
models: {
|
||||
"gpt-5": {
|
||||
name: "GPT-5 Official",
|
||||
release_date: "2025-08-01",
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
relay: {
|
||||
models: {
|
||||
"vendor/GPT-5": {
|
||||
name: "GPT-5 Relay",
|
||||
release_date: "2025-07-01",
|
||||
cost: { input: 9, output: 18 },
|
||||
},
|
||||
"custom-model": {
|
||||
name: "Custom",
|
||||
release_date: "2025-06-01",
|
||||
cost: { input: 0.5, output: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const selected = resolveModelsDevSelection(entries, {
|
||||
autoSyncEnabled: true,
|
||||
includeCommonModels: true,
|
||||
selectedModelKeys: ["relay/vendor/GPT-5", "relay/custom-model"],
|
||||
excludedCommonModelKeys: ["openai/gpt-5"],
|
||||
lastSyncAt: null,
|
||||
lastSyncError: null,
|
||||
});
|
||||
|
||||
expect(selected.map((entry) => entry.key)).toEqual([
|
||||
"relay/vendor/GPT-5",
|
||||
"relay/custom-model",
|
||||
]);
|
||||
|
||||
const pricing = toModelPricing([
|
||||
entries.find((entry) => entry.key === "openai/gpt-5")!,
|
||||
entries.find((entry) => entry.key === "relay/vendor/GPT-5")!,
|
||||
]);
|
||||
expect(pricing).toHaveLength(1);
|
||||
expect(pricing[0]).toMatchObject({
|
||||
modelId: "gpt-5",
|
||||
displayName: "GPT-5 Official",
|
||||
inputCostPerMillion: "1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -61,6 +61,7 @@ type TestPresetEntry = {
|
||||
settingsConfig: Record<string, never>;
|
||||
category: ProviderCategory;
|
||||
primePartner?: boolean;
|
||||
isPartner?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -207,8 +208,9 @@ describe("ProviderPresetSelector pure helpers", () => {
|
||||
|
||||
const original = sortPresetEntries(presetEntries, originalMode, t);
|
||||
expect(original).not.toBe(presetEntries);
|
||||
// original 模式置顶官方分类(alpha),其余保持传入顺序。
|
||||
expect(getIds(original)).toEqual(["alpha", "gamma", "beta", "delta"]);
|
||||
// original 模式置顶官方分类(alpha);其余均非赞助商,按显示名排序
|
||||
// (Beta Gateway < Delta Mirror < Gamma 本地名)。
|
||||
expect(getIds(original)).toEqual(["alpha", "beta", "delta", "gamma"]);
|
||||
|
||||
expect(getIds(sortPresetEntries(presetEntries, nameAscMode, t))).toEqual([
|
||||
"alpha",
|
||||
@@ -229,30 +231,44 @@ describe("ProviderPresetSelector pure helpers", () => {
|
||||
).toEqual(["alpha", "beta", "delta", "gamma"]);
|
||||
});
|
||||
|
||||
it("original 模式按「官方 → 尊享伙伴 → 其余」三段排序,各组内部保序且双重身份不重复", () => {
|
||||
it("original 模式按「官方 → 尊享伙伴 → 赞助商 → 非赞助商」四段排序,前三组保序、末组按显示名,双重身份不重复", () => {
|
||||
// 故意打乱传入顺序,验证:
|
||||
// - official 组置顶(officialOnly、officialPrime 按出现顺序);
|
||||
// - 非官方且 primePartner 的预设居中(primeOnly);
|
||||
// - 其余保持传入顺序(restFirst、restLast);
|
||||
// - 既是 official 又是 primePartner 的预设只归入官方组、不在 prime 组重复。
|
||||
// - 非官方且 primePartner 的预设次之(primeAndPartner);
|
||||
// - 赞助商(isPartner)第三段,保持传入(预设文件)顺序:
|
||||
// partnerZeta 在 partnerAlpha 前,不按字母重排;
|
||||
// - 非赞助商按显示名排序:restAlpha 排到 restZulu 前;
|
||||
// - 既是 official 又是 primePartner 的只归入官方组;
|
||||
// 既是 primePartner 又是 isPartner 的只归入 prime 组、不在赞助商组重复。
|
||||
const mixed: TestPresetEntry[] = [
|
||||
{
|
||||
id: "restFirst",
|
||||
id: "restZulu",
|
||||
preset: {
|
||||
name: "Rest First",
|
||||
websiteUrl: "https://rest-first.example.com",
|
||||
name: "Zulu Rest",
|
||||
websiteUrl: "https://rest-zulu.example.com",
|
||||
settingsConfig: {},
|
||||
category: "third_party",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "primeOnly",
|
||||
id: "partnerZeta",
|
||||
preset: {
|
||||
name: "Prime Only",
|
||||
websiteUrl: "https://prime-only.example.com",
|
||||
name: "Zeta Partner",
|
||||
websiteUrl: "https://partner-zeta.example.com",
|
||||
settingsConfig: {},
|
||||
category: "aggregator",
|
||||
isPartner: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "primeAndPartner",
|
||||
preset: {
|
||||
name: "Prime And Partner",
|
||||
websiteUrl: "https://prime-and-partner.example.com",
|
||||
settingsConfig: {},
|
||||
category: "cn_official",
|
||||
primePartner: true,
|
||||
isPartner: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -275,10 +291,20 @@ describe("ProviderPresetSelector pure helpers", () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "restLast",
|
||||
id: "partnerAlpha",
|
||||
preset: {
|
||||
name: "Rest Last",
|
||||
websiteUrl: "https://rest-last.example.com",
|
||||
name: "Alpha Partner",
|
||||
websiteUrl: "https://partner-alpha.example.com",
|
||||
settingsConfig: {},
|
||||
category: "third_party",
|
||||
isPartner: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "restAlpha",
|
||||
preset: {
|
||||
name: "Alpha Rest",
|
||||
websiteUrl: "https://rest-alpha.example.com",
|
||||
settingsConfig: {},
|
||||
category: "aggregator",
|
||||
},
|
||||
@@ -288,23 +314,27 @@ describe("ProviderPresetSelector pure helpers", () => {
|
||||
expect(getIds(sortPresetEntries(mixed, "original", t))).toEqual([
|
||||
"officialOnly",
|
||||
"officialPrime",
|
||||
"primeOnly",
|
||||
"restFirst",
|
||||
"restLast",
|
||||
"primeAndPartner",
|
||||
"partnerZeta",
|
||||
"partnerAlpha",
|
||||
"restAlpha",
|
||||
"restZulu",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ProviderPresetSelector", () => {
|
||||
it("默认(original 模式)将官方分类置顶,其余保持传入顺序", () => {
|
||||
it("默认(original 模式)将官方分类置顶,非赞助商按显示名排序", () => {
|
||||
renderSelector();
|
||||
|
||||
// 组件内 t() 未配置翻译资源,显示名回退为 key 字面量:
|
||||
// Beta Gateway < Delta Mirror < preset.gamma。
|
||||
expect(getPresetButtonTexts()).toEqual([
|
||||
"providerPreset.custom",
|
||||
"preset.alpha",
|
||||
"preset.gamma",
|
||||
"Beta Gateway",
|
||||
"Delta Mirror",
|
||||
"preset.gamma",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -327,9 +357,9 @@ describe("ProviderPresetSelector", () => {
|
||||
expect(getPresetButtonTexts()).toEqual([
|
||||
"providerPreset.custom",
|
||||
"preset.alpha",
|
||||
"preset.gamma",
|
||||
"Beta Gateway",
|
||||
"Delta Mirror",
|
||||
"preset.gamma",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { FAILOVER_APPS } from "@/components/settings/ProxyTabContent";
|
||||
|
||||
describe("ProxyTabContent failover apps", () => {
|
||||
it("exposes Grok Build alongside the existing failover applications", () => {
|
||||
expect(FAILOVER_APPS.map(({ id }) => id)).toEqual([
|
||||
"claude",
|
||||
"codex",
|
||||
"gemini",
|
||||
"grokbuild",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -22,17 +22,10 @@ vi.mock("react-i18next", () => ({
|
||||
|
||||
vi.mock("@/hooks/useProxyStatus", () => ({
|
||||
useProxyStatus: () => ({
|
||||
status: null,
|
||||
isLoading: false,
|
||||
isRunning: false,
|
||||
isTakeoverActive: false,
|
||||
startWithTakeover: vi.fn(),
|
||||
takeoverStatus: null,
|
||||
startProxyServer: vi.fn(),
|
||||
stopWithRestore: vi.fn(),
|
||||
switchProxyProvider: vi.fn(),
|
||||
checkRunning: vi.fn(),
|
||||
checkTakeoverActive: vi.fn(),
|
||||
isStarting: false,
|
||||
isStopping: false,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -51,8 +51,8 @@ vi.mock("@/hooks/useSkills", () => ({
|
||||
{
|
||||
directory: "shared-skill",
|
||||
name: "Shared Skill",
|
||||
description: "Imported from Claude",
|
||||
foundIn: ["claude"],
|
||||
description: "Imported from Grok Build",
|
||||
foundIn: ["grokbuild"],
|
||||
path: "/tmp/shared-skill",
|
||||
},
|
||||
],
|
||||
@@ -82,8 +82,8 @@ describe("UnifiedSkillsPanel", () => {
|
||||
{
|
||||
directory: "shared-skill",
|
||||
name: "Shared Skill",
|
||||
description: "Imported from Claude",
|
||||
foundIn: ["claude"],
|
||||
description: "Imported from Grok Build",
|
||||
foundIn: ["grokbuild"],
|
||||
path: "/tmp/shared-skill",
|
||||
},
|
||||
],
|
||||
@@ -116,5 +116,18 @@ describe("UnifiedSkillsPanel", () => {
|
||||
expect(screen.getByText("Shared Skill")).toBeInTheDocument();
|
||||
expect(screen.getByText("/tmp/shared-skill")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("skills.importSelected").click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(importSkillsMock).toHaveBeenCalledWith([
|
||||
{
|
||||
directory: "shared-skill",
|
||||
apps: expect.objectContaining({ grokbuild: true }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { XaiOAuthSection } from "@/components/providers/forms/XaiOAuthSection";
|
||||
|
||||
const mockUseXaiOauth = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/components/providers/forms/hooks/useXaiOauth", () => ({
|
||||
useXaiOauth: mockUseXaiOauth,
|
||||
}));
|
||||
|
||||
describe("XaiOAuthSection", () => {
|
||||
beforeEach(() => {
|
||||
mockUseXaiOauth.mockReturnValue({
|
||||
accounts: [
|
||||
{
|
||||
id: "expired-account",
|
||||
login: "expired@example.com",
|
||||
avatar_url: null,
|
||||
authenticated_at: 1,
|
||||
github_domain: "x.ai",
|
||||
requires_reauth: true,
|
||||
},
|
||||
{
|
||||
id: "usable-account",
|
||||
login: "usable@example.com",
|
||||
avatar_url: null,
|
||||
authenticated_at: 2,
|
||||
github_domain: "x.ai",
|
||||
requires_reauth: false,
|
||||
},
|
||||
],
|
||||
defaultAccountId: "usable-account",
|
||||
hasAnyAccount: true,
|
||||
isAuthenticated: true,
|
||||
pollingState: "idle",
|
||||
deviceCode: null,
|
||||
error: null,
|
||||
isPolling: false,
|
||||
isAddingAccount: false,
|
||||
isRemovingAccount: false,
|
||||
isSettingDefaultAccount: false,
|
||||
addAccount: vi.fn(),
|
||||
removeAccount: vi.fn(),
|
||||
setDefaultAccount: vi.fn(),
|
||||
cancelAuth: vi.fn(),
|
||||
logout: vi.fn(),
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a selected account visible when it requires reauthentication", () => {
|
||||
render(
|
||||
<XaiOAuthSection
|
||||
selectedAccountId="expired-account"
|
||||
onAccountSelect={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent(
|
||||
"expired@example.com",
|
||||
);
|
||||
expect(screen.getByRole("combobox")).toHaveTextContent("凭据已失效");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user