Merge origin/main into feat/codex-oauth-account-usage

This commit is contained in:
Jason
2026-08-03 21:15:48 +08:00
323 changed files with 40750 additions and 10400 deletions
@@ -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();
});
});
+9
View File
@@ -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",
]);
});
});
+2 -9
View File
@@ -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,
}),
}));
+17 -4
View File
@@ -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 }),
},
]);
});
});
});
+63
View File
@@ -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("凭据已失效");
});
});