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("凭据已失效");
|
||||
});
|
||||
});
|
||||
@@ -10,13 +10,8 @@ const expectedChatPresets = new Map<
|
||||
string,
|
||||
{ baseUrl: string; contextWindows: Record<string, number> }
|
||||
>([
|
||||
[
|
||||
"火山Agentplan",
|
||||
{
|
||||
baseUrl: "https://ark.cn-beijing.volces.com/api/coding/v3",
|
||||
contextWindows: { "ark-code-latest": 256000 },
|
||||
},
|
||||
],
|
||||
// 火山Agentplan(国内站 coding/v3)已切原生 Responses,见下方 native 清单;
|
||||
// BytePlus 国际站未核实,保持 Chat 路由
|
||||
[
|
||||
"BytePlus",
|
||||
{
|
||||
@@ -24,16 +19,6 @@ const expectedChatPresets = new Map<
|
||||
contextWindows: { "ark-code-latest": 256000 },
|
||||
},
|
||||
],
|
||||
[
|
||||
"DeepSeek",
|
||||
{
|
||||
baseUrl: "https://api.deepseek.com",
|
||||
contextWindows: {
|
||||
"deepseek-v4-flash": 1000000,
|
||||
"deepseek-v4-pro": 1000000,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
"Zhipu GLM",
|
||||
{
|
||||
@@ -59,7 +44,7 @@ const expectedChatPresets = new Map<
|
||||
"Kimi",
|
||||
{
|
||||
baseUrl: "https://api.moonshot.cn/v1",
|
||||
contextWindows: { "kimi-k2.7-code": 262144 },
|
||||
contextWindows: { "kimi-k2.7-code": 262144, "kimi-k3": 1048576 },
|
||||
},
|
||||
],
|
||||
[
|
||||
@@ -166,11 +151,29 @@ describe("Codex Chat provider presets", () => {
|
||||
string,
|
||||
{ contextWindows: Record<string, number> }
|
||||
>([
|
||||
// 官方 Codex 文档确认 Coding Plan /api/coding/v3 支持 Responses API
|
||||
["火山Agentplan", { contextWindows: { "ark-code-latest": 256000 } }],
|
||||
[
|
||||
"DouBaoSeed",
|
||||
{ contextWindows: { "doubao-seed-2-1-pro-260628": 262144 } },
|
||||
],
|
||||
["Bailian", { contextWindows: { "qwen3-coder-plus": 1048576 } }],
|
||||
// 腾讯 TokenHub 官方 Codex 文档确认 hy3 原生 Responses(2026-07-14)
|
||||
[
|
||||
"Tencent Hunyuan",
|
||||
{ contextWindows: { hy3: 256000, "hy3-preview": 256000 } },
|
||||
],
|
||||
// DeepSeek 官方 Codex 文档确认 deepseek-v4-flash 原生 Responses;
|
||||
// catalog 由后端按 deepseek.com host 镜像官方 models.json 生成
|
||||
[
|
||||
"DeepSeek",
|
||||
{
|
||||
contextWindows: {
|
||||
"deepseek-v4-flash": 1048576,
|
||||
"deepseek-v4-pro": 1048576,
|
||||
},
|
||||
},
|
||||
],
|
||||
["Longcat", { contextWindows: { "LongCat-2.0": 1048576 } }],
|
||||
["MiniMax", { contextWindows: { "MiniMax-M3": 1000000 } }],
|
||||
["MiniMax en", { contextWindows: { "MiniMax-M3": 1000000 } }],
|
||||
|
||||
@@ -20,7 +20,7 @@ describe("AWS Bedrock OpenCode Provider Presets", () => {
|
||||
expect(variants.length).toBeGreaterThan(0);
|
||||
|
||||
const opusModel = variants.find((v) =>
|
||||
v.id.includes("anthropic.claude-opus-4-8"),
|
||||
v.id.includes("anthropic.claude-opus-5"),
|
||||
);
|
||||
expect(opusModel).toBeDefined();
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ describe("SubRouter provider presets", () => {
|
||||
expect(preset?.endpointCandidates).toEqual(["https://subrouter.ai/v1"]);
|
||||
expect(preset?.auth).toEqual({ OPENAI_API_KEY: "" });
|
||||
expect(preset?.config).toContain('name = "subrouter"');
|
||||
expect(preset?.config).toContain('model = "gpt-5.5"');
|
||||
expect(preset?.config).toContain('model = "gpt-5.6-sol"');
|
||||
expect(preset?.config).toContain('base_url = "https://subrouter.ai/v1"');
|
||||
expect(preset?.config).toContain('wire_api = "responses"');
|
||||
});
|
||||
@@ -53,11 +53,11 @@ describe("SubRouter provider presets", () => {
|
||||
expect(preset).toBeDefined();
|
||||
expect(preset?.baseURL).toBe("https://subrouter.ai/v1beta");
|
||||
expect(preset?.endpointCandidates).toEqual(["https://subrouter.ai/v1beta"]);
|
||||
expect(preset?.model).toBe("gemini-3.5-flash");
|
||||
expect(preset?.model).toBe("gemini-3.6-flash");
|
||||
|
||||
const env = (preset?.settingsConfig as { env: Record<string, string> }).env;
|
||||
expect(env.GOOGLE_GEMINI_BASE_URL).toBe("https://subrouter.ai/v1beta");
|
||||
expect(env.GEMINI_MODEL).toBe("gemini-3.5-flash");
|
||||
expect(env.GEMINI_MODEL).toBe("gemini-3.6-flash");
|
||||
});
|
||||
|
||||
it("uses OpenAI-compatible config for OpenCode", () => {
|
||||
@@ -71,7 +71,7 @@ describe("SubRouter provider presets", () => {
|
||||
"https://subrouter.ai/v1",
|
||||
);
|
||||
expect(preset?.settingsConfig.options?.apiKey).toBe("");
|
||||
expect(preset?.settingsConfig.models).toHaveProperty("gpt-5.5");
|
||||
expect(preset?.settingsConfig.models).toHaveProperty("gpt-5.6-sol");
|
||||
});
|
||||
|
||||
it("uses OpenAI completions config for OpenClaw without hardcoded pricing", () => {
|
||||
@@ -84,13 +84,13 @@ describe("SubRouter provider presets", () => {
|
||||
expect(preset?.settingsConfig.baseUrl).toBe("https://subrouter.ai/v1");
|
||||
expect(preset?.settingsConfig.api).toBe("openai-completions");
|
||||
expect(model).toMatchObject({
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
id: "gpt-5.6-sol",
|
||||
name: "GPT-5.6 Sol",
|
||||
contextWindow: 400000,
|
||||
});
|
||||
expect(model).not.toHaveProperty("cost");
|
||||
expect(preset?.suggestedDefaults?.model).toEqual({
|
||||
primary: "subrouter/gpt-5.5",
|
||||
primary: "subrouter/gpt-5.6-sol",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,7 +107,7 @@ describe("SubRouter provider presets", () => {
|
||||
api_mode: "chat_completions",
|
||||
});
|
||||
expect(preset?.suggestedDefaults?.model).toEqual({
|
||||
default: "gpt-5.5",
|
||||
default: "gpt-5.6-sol",
|
||||
provider: "subrouter",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,8 +23,8 @@ describe("TheRouter OpenCode and OpenClaw presets", () => {
|
||||
expect(preset?.settingsConfig.options?.setCacheKey).toBe(true);
|
||||
expect(models).toHaveProperty("openai/gpt-5.3-codex");
|
||||
expect(models).toHaveProperty("anthropic/claude-sonnet-5");
|
||||
expect(models).toHaveProperty("google/gemini-3.5-flash");
|
||||
expect(models["google/gemini-3.5-flash"]?.name).toBe("Gemini 3.5 Flash");
|
||||
expect(models).toHaveProperty("google/gemini-3.6-flash");
|
||||
expect(models["google/gemini-3.6-flash"]?.name).toBe("Gemini 3.6 Flash");
|
||||
});
|
||||
|
||||
it("uses OpenAI completions config for OpenClaw", () => {
|
||||
@@ -45,20 +45,20 @@ describe("TheRouter OpenCode and OpenClaw presets", () => {
|
||||
"anthropic/claude-sonnet-5",
|
||||
"openai/gpt-5.3-codex",
|
||||
"openai/gpt-5.2",
|
||||
"google/gemini-3.5-flash",
|
||||
"google/gemini-3.6-flash",
|
||||
]),
|
||||
);
|
||||
expect(
|
||||
openClawModels.find((model) => model.id === "google/gemini-3.5-flash"),
|
||||
openClawModels.find((model) => model.id === "google/gemini-3.6-flash"),
|
||||
).toMatchObject({
|
||||
name: "Gemini 3.5 Flash",
|
||||
name: "Gemini 3.6 Flash",
|
||||
cost: { input: 1.5, output: 9, cacheRead: 0.15 },
|
||||
});
|
||||
expect(preset?.suggestedDefaults?.model).toEqual({
|
||||
primary: "therouter/anthropic/claude-sonnet-5",
|
||||
fallbacks: [
|
||||
"therouter/openai/gpt-5.2",
|
||||
"therouter/google/gemini-3.5-flash",
|
||||
"therouter/google/gemini-3.6-flash",
|
||||
],
|
||||
});
|
||||
});
|
||||
@@ -67,13 +67,13 @@ describe("TheRouter OpenCode and OpenClaw presets", () => {
|
||||
const googleModels = OPENCODE_PRESET_MODEL_VARIANTS["@ai-sdk/google"];
|
||||
const ids = googleModels.map((model) => model.id);
|
||||
const geminiFlashModels = googleModels.filter(
|
||||
(model) => model.id === "gemini-3.5-flash",
|
||||
(model) => model.id === "gemini-3.6-flash",
|
||||
);
|
||||
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
expect(geminiFlashModels).toHaveLength(1);
|
||||
expect(geminiFlashModels[0]).toMatchObject({
|
||||
name: "Gemini 3.5 Flash",
|
||||
name: "Gemini 3.6 Flash",
|
||||
variants: {
|
||||
minimal: expect.any(Object),
|
||||
low: expect.any(Object),
|
||||
|
||||
@@ -24,7 +24,7 @@ describe("TheRouter provider presets", () => {
|
||||
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe(
|
||||
"anthropic/claude-sonnet-5",
|
||||
);
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("anthropic/claude-opus-4.8");
|
||||
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("anthropic/claude-opus-5");
|
||||
});
|
||||
|
||||
it("uses the OpenAI-compatible v1 endpoint for Codex", () => {
|
||||
@@ -60,10 +60,10 @@ describe("TheRouter provider presets", () => {
|
||||
expect(preset?.category).toBe("aggregator");
|
||||
expect(preset?.endpointCandidates).toEqual(["https://api.therouter.ai"]);
|
||||
expect(preset?.baseURL).toBe("https://api.therouter.ai");
|
||||
expect(preset?.model).toBe("gemini-3.5-flash");
|
||||
expect(preset?.model).toBe("gemini-3.6-flash");
|
||||
|
||||
const env = (preset?.settingsConfig as { env: Record<string, string> }).env;
|
||||
expect(env.GOOGLE_GEMINI_BASE_URL).toBe("https://api.therouter.ai");
|
||||
expect(env.GEMINI_MODEL).toBe("gemini-3.5-flash");
|
||||
expect(env.GEMINI_MODEL).toBe("gemini-3.6-flash");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import en from "@/i18n/locales/en.json";
|
||||
import ja from "@/i18n/locales/ja.json";
|
||||
import zhTW from "@/i18n/locales/zh-TW.json";
|
||||
import zh from "@/i18n/locales/zh.json";
|
||||
|
||||
const requiredKeys = [
|
||||
"manualInstallCommands",
|
||||
"updateAllTools",
|
||||
"currentVersion",
|
||||
"latestVersion",
|
||||
"updateAvailableShort",
|
||||
"toolInstall",
|
||||
"toolUpdate",
|
||||
"toolReady",
|
||||
"toolActionDone",
|
||||
"toolActionPartial",
|
||||
"toolActionFailed",
|
||||
"toolNotRunnable",
|
||||
"toolActionVersionUnchangedTitle",
|
||||
"toolActionVersionUnchanged",
|
||||
"toolActionInstalledNotRunnable",
|
||||
"installedNotRunnable",
|
||||
"toolCheckEnv",
|
||||
"toolDiagnose",
|
||||
"toolDiagnosing",
|
||||
"toolConflictTitle",
|
||||
"toolConflictHint",
|
||||
"toolConflictDefault",
|
||||
"toolConflictNotRunnable",
|
||||
"toolDiagnoseNoConflict",
|
||||
"toolDiagnoseFailed",
|
||||
"toolUpgradeConfirmTitle",
|
||||
"toolUpgradeConfirmHint",
|
||||
"toolUpgradeWillRun",
|
||||
"toolUpgradeConfirmBtn",
|
||||
"toolUpgradeUnanchoredHint",
|
||||
] as const;
|
||||
|
||||
type SettingsTranslations = Record<string, unknown>;
|
||||
|
||||
const locales = [
|
||||
["en", en.settings],
|
||||
["ja", ja.settings],
|
||||
["zh", zh.settings],
|
||||
["zh-TW", zhTW.settings],
|
||||
] as const;
|
||||
|
||||
function interpolationVariables(value: string): string[] {
|
||||
return Array.from(
|
||||
value.matchAll(/\{\{([^}]+)\}\}/g),
|
||||
([, name]) => name,
|
||||
).sort();
|
||||
}
|
||||
|
||||
describe("About tool management locale coverage", () => {
|
||||
it.each(locales)(
|
||||
"defines every tool management key in %s",
|
||||
(_locale, settings) => {
|
||||
const missing = requiredKeys.filter((key) => {
|
||||
const value = (settings as SettingsTranslations)[key];
|
||||
return typeof value !== "string" || value.trim().length === 0;
|
||||
});
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(locales.slice(1))(
|
||||
"preserves interpolation variables in %s",
|
||||
(_locale, settings) => {
|
||||
for (const key of requiredKeys) {
|
||||
const expected = en.settings[key];
|
||||
const actual = (settings as SettingsTranslations)[key];
|
||||
|
||||
expect(typeof actual).toBe("string");
|
||||
expect(interpolationVariables(actual as string)).toEqual(
|
||||
interpolationVariables(expected),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import en from "@/i18n/locales/en.json";
|
||||
import ja from "@/i18n/locales/ja.json";
|
||||
import zhTW from "@/i18n/locales/zh-TW.json";
|
||||
import zh from "@/i18n/locales/zh.json";
|
||||
|
||||
const requiredKeys = [
|
||||
"xaiOauth.authStatus",
|
||||
"xaiOauth.accountCount",
|
||||
"xaiOauth.reauthRequired",
|
||||
"xaiOauth.notAuthenticated",
|
||||
"xaiOauth.selectAccount",
|
||||
"xaiOauth.selectAccountPlaceholder",
|
||||
"xaiOauth.useDefaultAccount",
|
||||
"xaiOauth.accounts",
|
||||
"xaiOauth.defaultAccount",
|
||||
"xaiOauth.expired",
|
||||
"xaiOauth.setAsDefault",
|
||||
"xaiOauth.removeAccount",
|
||||
"xaiOauth.addOrReauth",
|
||||
"xaiOauth.login",
|
||||
"xaiOauth.waitingForAuth",
|
||||
"xaiOauth.enterCode",
|
||||
"xaiOauth.retry",
|
||||
"xaiOauth.logoutAll",
|
||||
"xaiOauth.loginRequired",
|
||||
"managedAuth.selectedAccountNeedsReauth",
|
||||
"managedAuth.selectedAccountUnavailable",
|
||||
"providerForm.providerKeyStatusLoading",
|
||||
"settings.authCenter.xaiOauthDescription",
|
||||
] as const;
|
||||
|
||||
type TranslationTree = Record<string, unknown>;
|
||||
|
||||
function readTranslation(tree: TranslationTree, path: string): unknown {
|
||||
return path.split(".").reduce<unknown>((value, segment) => {
|
||||
if (typeof value !== "object" || value === null) return undefined;
|
||||
return (value as TranslationTree)[segment];
|
||||
}, tree);
|
||||
}
|
||||
|
||||
describe("xAI OAuth locale coverage", () => {
|
||||
it.each([
|
||||
["zh", zh],
|
||||
["zh-TW", zhTW],
|
||||
["en", en],
|
||||
["ja", ja],
|
||||
])("defines every required key in %s", (_locale, translations) => {
|
||||
const missing = requiredKeys.filter((key) => {
|
||||
const value = readTranslation(translations, key);
|
||||
return typeof value !== "string" || value.trim().length === 0;
|
||||
});
|
||||
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { claudeDesktopProviderPresets } from "@/config/claudeDesktopProviderPresets";
|
||||
import { providerPresets } from "@/config/claudeProviderPresets";
|
||||
import { codexProviderPresets } from "@/config/codexProviderPresets";
|
||||
import {
|
||||
extractCodexBaseUrl,
|
||||
extractCodexModelName,
|
||||
extractCodexWireApi,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
|
||||
describe("xAI OAuth provider presets", () => {
|
||||
it("pins the Claude Code preset to managed Responses auth", () => {
|
||||
const preset = providerPresets.find((entry) => entry.name === "xAI (Grok)");
|
||||
expect(preset).toBeDefined();
|
||||
expect(preset).toMatchObject({
|
||||
category: "third_party",
|
||||
apiFormat: "openai_responses",
|
||||
providerType: "xai_oauth",
|
||||
requiresOAuth: true,
|
||||
icon: "xai",
|
||||
});
|
||||
expect((preset!.settingsConfig as any).env).toMatchObject({
|
||||
ANTHROPIC_BASE_URL: "https://api.x.ai/v1",
|
||||
ANTHROPIC_MODEL: "grok-4.5",
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: "grok-4.5",
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: "grok-4.5",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "grok-4.5",
|
||||
});
|
||||
expect((preset!.settingsConfig as any).env).not.toHaveProperty(
|
||||
"ANTHROPIC_API_KEY",
|
||||
);
|
||||
expect((preset!.settingsConfig as any).env).not.toHaveProperty(
|
||||
"ANTHROPIC_AUTH_TOKEN",
|
||||
);
|
||||
});
|
||||
|
||||
it("pins the Claude Desktop preset to proxy Responses mode without 1M", () => {
|
||||
const preset = claudeDesktopProviderPresets.find(
|
||||
(entry) => entry.name === "xAI (Grok)",
|
||||
);
|
||||
expect(preset).toMatchObject({
|
||||
category: "third_party",
|
||||
baseUrl: "https://api.x.ai/v1",
|
||||
mode: "proxy",
|
||||
apiFormat: "openai_responses",
|
||||
providerType: "xai_oauth",
|
||||
requiresOAuth: true,
|
||||
icon: "xai",
|
||||
});
|
||||
expect(preset!.modelRoutes).toEqual([
|
||||
expect.objectContaining({
|
||||
upstreamModel: "grok-4.5",
|
||||
supports1m: false,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("pins the Codex preset to native Responses via API key (no managed OAuth)", () => {
|
||||
const preset = codexProviderPresets.find(
|
||||
(entry) => entry.name === "xAI (Grok)",
|
||||
);
|
||||
expect(preset).toBeDefined();
|
||||
expect(preset).toMatchObject({
|
||||
category: "third_party",
|
||||
apiFormat: "openai_responses",
|
||||
icon: "xai",
|
||||
});
|
||||
// API-key preset: managed-account OAuth is Claude-side only for now.
|
||||
expect(preset).not.toHaveProperty("providerType");
|
||||
expect(preset!.auth).toEqual({ OPENAI_API_KEY: "" });
|
||||
expect(extractCodexBaseUrl(preset!.config)).toBe("https://api.x.ai/v1");
|
||||
expect(extractCodexWireApi(preset!.config)).toBe("responses");
|
||||
expect(extractCodexModelName(preset!.config)).toBe("grok-4.5");
|
||||
expect(preset!.modelCatalog).toEqual([
|
||||
expect.objectContaining({
|
||||
model: "grok-4.5",
|
||||
contextWindow: 500000,
|
||||
supportsParallelToolCalls: true,
|
||||
inputModalities: ["text", "image"],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("pins the Codex OAuth preset to managed native Responses", () => {
|
||||
const preset = codexProviderPresets.find(
|
||||
(entry) => entry.name === "xAI (Grok) OAuth",
|
||||
);
|
||||
expect(preset).toBeDefined();
|
||||
expect(preset).toMatchObject({
|
||||
category: "third_party",
|
||||
apiFormat: "openai_responses",
|
||||
providerType: "xai_oauth",
|
||||
requiresOAuth: true,
|
||||
icon: "xai",
|
||||
});
|
||||
// Managed OAuth: auth.json keeps an empty key; the forwarder injects the
|
||||
// real access token per request and the adapter pins the base URL.
|
||||
expect(preset!.auth).toEqual({ OPENAI_API_KEY: "" });
|
||||
expect(extractCodexBaseUrl(preset!.config)).toBe("https://api.x.ai/v1");
|
||||
expect(extractCodexWireApi(preset!.config)).toBe("responses");
|
||||
expect(extractCodexModelName(preset!.config)).toBe("grok-4.5");
|
||||
expect(preset!.modelCatalog).toEqual([
|
||||
expect.objectContaining({
|
||||
model: "grok-4.5",
|
||||
contextWindow: 500000,
|
||||
supportsParallelToolCalls: true,
|
||||
inputModalities: ["text", "image"],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ const createSettings = (
|
||||
enableClaudePluginIntegration: false,
|
||||
claudeConfigDir: "/claude/custom",
|
||||
codexConfigDir: "/codex/custom",
|
||||
grokConfigDir: "/grok/custom",
|
||||
language: "zh",
|
||||
...overrides,
|
||||
});
|
||||
@@ -68,6 +69,7 @@ describe("useDirectorySettings", () => {
|
||||
if (app === "claude") return "/remote/claude";
|
||||
if (app === "codex") return "/remote/codex";
|
||||
if (app === "gemini") return "/remote/gemini";
|
||||
if (app === "grokbuild") return "/remote/grok";
|
||||
if (app === "opencode") return "/remote/opencode";
|
||||
if (app === "openclaw") return "/remote/openclaw";
|
||||
return "/remote/hermes";
|
||||
@@ -90,6 +92,7 @@ describe("useDirectorySettings", () => {
|
||||
claude: "/remote/claude",
|
||||
codex: "/remote/codex",
|
||||
gemini: "/remote/gemini",
|
||||
grokbuild: "/remote/grok",
|
||||
opencode: "/remote/opencode",
|
||||
openclaw: "/remote/openclaw",
|
||||
hermes: "/remote/hermes",
|
||||
@@ -249,6 +252,7 @@ describe("useDirectorySettings", () => {
|
||||
claude: "/server/claude",
|
||||
codex: "/server/codex",
|
||||
gemini: "/server/gemini",
|
||||
grokbuild: "/server/grok",
|
||||
opencode: "/server/opencode",
|
||||
openclaw: "/server/openclaw",
|
||||
});
|
||||
@@ -257,6 +261,7 @@ describe("useDirectorySettings", () => {
|
||||
expect(result.current.resolvedDirs.claude).toBe("/server/claude");
|
||||
expect(result.current.resolvedDirs.codex).toBe("/server/codex");
|
||||
expect(result.current.resolvedDirs.gemini).toBe("/server/gemini");
|
||||
expect(result.current.resolvedDirs.grokbuild).toBe("/server/grok");
|
||||
expect(result.current.resolvedDirs.opencode).toBe("/server/opencode");
|
||||
expect(result.current.resolvedDirs.openclaw).toBe("/server/openclaw");
|
||||
});
|
||||
|
||||
@@ -264,6 +264,122 @@ describe("useProviderActions", () => {
|
||||
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
|
||||
});
|
||||
|
||||
it("warns for Grok providers that require the Responses router", async () => {
|
||||
switchProviderMutateAsync.mockResolvedValue(undefined);
|
||||
const { wrapper } = createWrapper();
|
||||
const providers = [
|
||||
createProvider({
|
||||
id: "grok-chat",
|
||||
category: "custom",
|
||||
meta: { apiFormat: "openai_chat" },
|
||||
}),
|
||||
createProvider({
|
||||
id: "grok-anthropic",
|
||||
category: "custom",
|
||||
meta: { apiFormat: "anthropic" },
|
||||
}),
|
||||
createProvider({
|
||||
id: "grok-full-url",
|
||||
category: "custom",
|
||||
meta: { isFullUrl: true },
|
||||
}),
|
||||
];
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useProviderActions("grokbuild", false),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
for (const provider of providers) {
|
||||
await act(async () => {
|
||||
await result.current.switchProvider(provider);
|
||||
});
|
||||
}
|
||||
|
||||
expect(toastWarningMock).toHaveBeenCalledTimes(3);
|
||||
expect(switchProviderMutateAsync).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("warns for managed OAuth until the current Code app is taken over", async () => {
|
||||
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
||||
const { wrapper } = createWrapper();
|
||||
const provider = createProvider({
|
||||
category: "custom",
|
||||
meta: {
|
||||
providerType: "codex_oauth",
|
||||
apiFormat: "openai_responses",
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useProviderActions("codex", true, false),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchProvider(provider);
|
||||
});
|
||||
|
||||
expect(toastWarningMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("托管 OAuth"),
|
||||
);
|
||||
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
|
||||
});
|
||||
|
||||
it("does not warn for managed OAuth after the current Code app is taken over", async () => {
|
||||
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
||||
const { wrapper } = createWrapper();
|
||||
const provider = createProvider({
|
||||
category: "custom",
|
||||
meta: {
|
||||
providerType: "codex_oauth",
|
||||
apiFormat: "openai_responses",
|
||||
},
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useProviderActions("codex", true, true),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchProvider(provider);
|
||||
});
|
||||
|
||||
expect(toastWarningMock).not.toHaveBeenCalled();
|
||||
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
|
||||
});
|
||||
|
||||
it("uses proxy process readiness for Claude Desktop routing", async () => {
|
||||
switchProviderMutateAsync.mockResolvedValue(undefined);
|
||||
const { wrapper } = createWrapper();
|
||||
const provider = createProvider({
|
||||
category: "custom",
|
||||
meta: { claudeDesktopMode: "proxy" },
|
||||
});
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ isProxyRunning }) =>
|
||||
useProviderActions("claude-desktop", isProxyRunning, false),
|
||||
{ initialProps: { isProxyRunning: true }, wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchProvider(provider);
|
||||
});
|
||||
expect(toastWarningMock).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ isProxyRunning: false });
|
||||
await act(async () => {
|
||||
await result.current.switchProvider(provider);
|
||||
});
|
||||
|
||||
expect(toastWarningMock).toHaveBeenCalledTimes(1);
|
||||
expect(toastWarningMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Claude Desktop 本地路由模式"),
|
||||
);
|
||||
});
|
||||
|
||||
it("allows the built-in Codex official provider during takeover", async () => {
|
||||
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
||||
const { wrapper } = createWrapper();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { renderHook, act, waitFor } from "@testing-library/react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useProxyStatus } from "@/hooks/useProxyStatus";
|
||||
import { proxyKeys } from "@/lib/query/proxy";
|
||||
import { createTestQueryClient } from "../utils/testQueryClient";
|
||||
|
||||
const toastSuccessMock = vi.fn();
|
||||
@@ -81,6 +82,7 @@ describe("useProxyStatus", () => {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
});
|
||||
@@ -98,12 +100,19 @@ describe("useProxyStatus", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the established proxy query key shapes", () => {
|
||||
expect(proxyKeys.status).toEqual(["proxyStatus"]);
|
||||
expect(proxyKeys.takeoverStatus).toEqual(["proxyTakeoverStatus"]);
|
||||
expect(proxyKeys.globalConfig).toEqual(["globalProxyConfig"]);
|
||||
expect(proxyKeys.appConfig("claude")).toEqual(["appProxyConfig", "claude"]);
|
||||
});
|
||||
|
||||
it("shows interpolated address and port after proxy server starts", async () => {
|
||||
const { wrapper } = createWrapper();
|
||||
const { result } = renderHook(() => useProxyStatus(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
expect(result.current.status).toBeDefined();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
@@ -114,5 +123,12 @@ describe("useProxyStatus", () => {
|
||||
"代理服务已启动 - 127.0.0.1:15721",
|
||||
{ closeButton: true },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.stopProxyServer();
|
||||
});
|
||||
|
||||
expect(invokeMock).toHaveBeenCalledWith("start_proxy_server");
|
||||
expect(invokeMock).toHaveBeenCalledWith("stop_proxy_server");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -218,7 +218,7 @@ describe("App integration with MSW", () => {
|
||||
|
||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||
expect(toastSuccessMock).toHaveBeenCalled();
|
||||
});
|
||||
}, 10_000);
|
||||
|
||||
it("shows toast when auto sync fails in background", async () => {
|
||||
const { default: App } = await import("@/App");
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
const writeErrorLog = vi.hoisted(() =>
|
||||
vi.fn<(message: string, options: { file: string }) => Promise<void>>(() =>
|
||||
Promise.resolve(),
|
||||
),
|
||||
);
|
||||
|
||||
vi.mock("@tauri-apps/plugin-log", () => ({
|
||||
error: writeErrorLog,
|
||||
}));
|
||||
|
||||
import {
|
||||
installGlobalErrorHandlers,
|
||||
redactFrontendLogText,
|
||||
reportFrontendError,
|
||||
} from "@/lib/frontendLogger";
|
||||
|
||||
describe("frontendLogger", () => {
|
||||
beforeEach(() => {
|
||||
writeErrorLog.mockClear();
|
||||
});
|
||||
|
||||
it("redacts URL parameters and named credentials", () => {
|
||||
const redacted = redactFrontendLogText(
|
||||
"https://example.test/path?apiKey=query-secret&name=alice\n" +
|
||||
'api_key: "config secret with spaces"\n' +
|
||||
"Authorization: Bearer bearer-secret\n" +
|
||||
"Authorization: Basic dXNlcjpwYXNz\n" +
|
||||
"Authorization: Token token-secret\n" +
|
||||
" Cookie: session=cookie-secret; preference=private\n" +
|
||||
"ApiKey standalone-secret\n" +
|
||||
"https://user:password@example.test/private",
|
||||
);
|
||||
|
||||
expect(redacted).not.toContain("query-secret");
|
||||
expect(redacted).not.toContain("alice");
|
||||
expect(redacted).not.toContain("config secret with spaces");
|
||||
expect(redacted).not.toContain("secret with spaces");
|
||||
expect(redacted).not.toContain("bearer-secret");
|
||||
expect(redacted).not.toContain("dXNlcjpwYXNz");
|
||||
expect(redacted).not.toContain("token-secret");
|
||||
expect(redacted).not.toContain("standalone-secret");
|
||||
expect(redacted).not.toContain("cookie-secret");
|
||||
expect(redacted).not.toContain("preference=private");
|
||||
expect(redacted).not.toContain("user:password");
|
||||
expect(redacted).toContain("apiKey=[REDACTED]");
|
||||
expect(redacted).toContain('api_key: "[REDACTED]"');
|
||||
|
||||
const escapedMultiline = redactFrontendLogText(
|
||||
'{"detail":"line1\\nsk-ant-api03-escaped-secret"}',
|
||||
);
|
||||
expect(escapedMultiline).not.toContain("sk-ant-api03-escaped-secret");
|
||||
});
|
||||
|
||||
it("writes bounded, redacted errors through the Tauri log plugin", () => {
|
||||
const secret = "sensitive-token";
|
||||
const oversizedDetails = `${"x".repeat(2_000_000)} token=${secret}`;
|
||||
|
||||
reportFrontendError(
|
||||
"window.error",
|
||||
new Error(`failed at https://example.test/?key=${secret}`),
|
||||
oversizedDetails,
|
||||
);
|
||||
|
||||
expect(writeErrorLog).toHaveBeenCalledOnce();
|
||||
const [message, options] = writeErrorLog.mock.calls[0];
|
||||
expect(message).not.toContain(secret);
|
||||
expect(message).toContain("[truncated]");
|
||||
expect(message.length).toBeLessThanOrEqual(12_020);
|
||||
expect(options).toEqual({ file: "frontend" });
|
||||
});
|
||||
|
||||
it("serializes object-shaped rejection reasons without exposing secrets", () => {
|
||||
// 两层脱敏契约:
|
||||
// - 属性名一级:命中敏感名(含复数/数组/嵌套)整个值一律隐藏;
|
||||
// - 值一级:任意位置的“不透明密钥形状”按形状隐藏;
|
||||
// - 文本一级:序列化后 `"name":"value"` 的裸密钥由正则兜底。
|
||||
reportFrontendError("unhandledrejection", {
|
||||
code: 500,
|
||||
message: "auth failed", // 良性文本保留(值含 "auth" 但非 name:value)
|
||||
key: "short-secret", // 裸 key 标量(非不透明) → 属性名层
|
||||
token: "object-secret", // 标量命名 → 属性名层
|
||||
tokens: ["k-9f3a7c2b1e"], // 复数+数组(短值) → 属性名层(去尾 s + 整体隐藏)
|
||||
auth: ["opaque-credential"], // 数组 → 属性名层
|
||||
credential: "AIzaRealCredential123",
|
||||
nested: { detail: "ghp_abcdef123456" }, // 非敏感名,靠不透明形状
|
||||
values: ["eyJhbGciOiJIUzI1NiJ9.cGF5bG9hZA.c2lnbmF0dXJl"], // 数组内不透明形状
|
||||
multiline: "line1\nsk-ant-api03-multiline-secret", // 串内不透明形状
|
||||
session: { activeTab: "providers", scrollPos: 120 }, // 良性状态原样保留
|
||||
});
|
||||
|
||||
const [message] = writeErrorLog.mock.calls[0];
|
||||
expect(message).toContain('"code":500');
|
||||
expect(message).toContain('"message":"auth failed"');
|
||||
expect(message).not.toContain("short-secret");
|
||||
expect(message).not.toContain("object-secret");
|
||||
expect(message).not.toContain("k-9f3a7c2b1e");
|
||||
expect(message).not.toContain("opaque-credential");
|
||||
expect(message).not.toContain("AIzaRealCredential123");
|
||||
expect(message).not.toContain("ghp_abcdef123456");
|
||||
expect(message).not.toContain("eyJhbGciOiJIUzI1NiJ9");
|
||||
expect(message).not.toContain("sk-ant-api03-multiline-secret");
|
||||
expect(message).toContain('"key":"[REDACTED]"');
|
||||
expect(message).toContain('"token":"[REDACTED]"');
|
||||
expect(message).toContain('"tokens":"[REDACTED]"');
|
||||
expect(message).toContain('"auth":"[REDACTED]"');
|
||||
expect(message).toContain('"credential":"[REDACTED]"');
|
||||
expect(message).toContain(
|
||||
'"session":{"activeTab":"providers","scrollPos":120}',
|
||||
);
|
||||
});
|
||||
|
||||
it("applies property-level redaction to stringified-JSON rejection reasons", () => {
|
||||
// 字符串形态的 JSON 不能只靠文本正则——数组/复数/裸标量字段会漏。
|
||||
reportFrontendError(
|
||||
"unhandledrejection",
|
||||
'{"tokens":["k-9f3a7c2b1e"],"auth":["opaque-credential"],"key":"short-secret","keepMe":"visible"}',
|
||||
);
|
||||
|
||||
const [message] = writeErrorLog.mock.calls[0];
|
||||
expect(message).not.toContain("k-9f3a7c2b1e");
|
||||
expect(message).not.toContain("opaque-credential");
|
||||
expect(message).not.toContain("short-secret");
|
||||
expect(message).toContain('"tokens":"[REDACTED]"');
|
||||
expect(message).toContain('"auth":"[REDACTED]"');
|
||||
expect(message).toContain('"key":"[REDACTED]"');
|
||||
expect(message).toContain('"keepMe":"visible"');
|
||||
});
|
||||
|
||||
it("keeps non-JSON error strings as readable text", () => {
|
||||
reportFrontendError("window.error", "plain failure at step 3");
|
||||
|
||||
const [message] = writeErrorLog.mock.calls[0];
|
||||
expect(message).toContain("plain failure at step 3");
|
||||
});
|
||||
|
||||
it("applies property-level redaction to JSON wrapped in an Error message", () => {
|
||||
// throw new Error(JSON.stringify(payload)) 会把凭据藏进 message,
|
||||
// 而 error.stack 第一行原样吐出 message —— 必须先对 message 结构化脱敏。
|
||||
reportFrontendError(
|
||||
"unhandledrejection",
|
||||
new Error(
|
||||
'{"tokens":["k-9f3a7c2b1e"],"key":"short-secret","keepMe":"visible"}',
|
||||
),
|
||||
);
|
||||
|
||||
const [message] = writeErrorLog.mock.calls[0];
|
||||
expect(message).not.toContain("k-9f3a7c2b1e");
|
||||
expect(message).not.toContain("short-secret");
|
||||
expect(message).toContain('"tokens":"[REDACTED]"');
|
||||
expect(message).toContain('"key":"[REDACTED]"');
|
||||
expect(message).toContain('"keepMe":"visible"');
|
||||
});
|
||||
|
||||
it("omits oversized JSON error strings instead of leaking truncated fields", () => {
|
||||
// 合法但超长的 JSON 若先截断再 parse,必成非法 JSON 而退回文本层,数组字段泄漏。
|
||||
const padding = "x".repeat(20_000);
|
||||
reportFrontendError(
|
||||
"unhandledrejection",
|
||||
`{"tokens":["k-9f3a7c2b1e"],"padding":"${padding}"}`,
|
||||
);
|
||||
|
||||
const [message] = writeErrorLog.mock.calls[0];
|
||||
expect(message).not.toContain("k-9f3a7c2b1e");
|
||||
expect(message).toContain("[oversized structured error omitted]");
|
||||
});
|
||||
|
||||
it("omits oversized JSON wrapped in an Error message", () => {
|
||||
const padding = "x".repeat(20_000);
|
||||
reportFrontendError(
|
||||
"unhandledrejection",
|
||||
new Error(`{"tokens":["k-9f3a7c2b1e"],"padding":"${padding}"}`),
|
||||
);
|
||||
|
||||
const [message] = writeErrorLog.mock.calls[0];
|
||||
expect(message).not.toContain("k-9f3a7c2b1e");
|
||||
expect(message).toContain("[oversized structured error omitted]");
|
||||
});
|
||||
|
||||
it("redacts array credentials in prefix+JSON rejection strings", () => {
|
||||
// 前缀 + JSON:`redactStructuredString` 的 startsWith 门被前缀挡掉,退回文本层。
|
||||
// 文本层的容器正则必须在这个统一出口兜住 `"tokens":[...]`。
|
||||
reportFrontendError(
|
||||
"unhandledrejection",
|
||||
'Load failed: {"tokens":["ak_live_7f3d9b21c8e4"]}',
|
||||
);
|
||||
|
||||
const [message] = writeErrorLog.mock.calls[0];
|
||||
expect(message).not.toContain("ak_live_7f3d9b21c8e4");
|
||||
expect(message).toContain("Load failed");
|
||||
});
|
||||
|
||||
it("redacts array credentials in prefix+JSON wrapped in an Error", () => {
|
||||
reportFrontendError(
|
||||
"unhandledrejection",
|
||||
new Error(
|
||||
"Provider provisioning failed: " +
|
||||
JSON.stringify({ apiKeys: ["ak_live_7f3d9b21c8e4"] }),
|
||||
),
|
||||
);
|
||||
|
||||
const [message] = writeErrorLog.mock.calls[0];
|
||||
expect(message).not.toContain("ak_live_7f3d9b21c8e4");
|
||||
});
|
||||
|
||||
it("redacts credentials in double-encoded nested JSON (escaped quotes)", () => {
|
||||
reportFrontendError(
|
||||
"unhandledrejection",
|
||||
new Error(
|
||||
JSON.stringify({
|
||||
status: 400,
|
||||
body: '{"keys":["abcd1234efgh5678ij"]}',
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const [message] = writeErrorLog.mock.calls[0];
|
||||
expect(message).not.toContain("abcd1234efgh5678ij");
|
||||
});
|
||||
|
||||
it("redacts array credentials in a POJO (non-Error) rejection", () => {
|
||||
reportFrontendError("unhandledrejection", {
|
||||
name: "HttpError",
|
||||
message: '{"tokens":["opaqueTokenValue12345"]}',
|
||||
code: 400,
|
||||
});
|
||||
|
||||
const [message] = writeErrorLog.mock.calls[0];
|
||||
expect(message).not.toContain("opaqueTokenValue12345");
|
||||
});
|
||||
|
||||
it("redacts container values only under sensitive keys", () => {
|
||||
// 容器正则只对敏感键生效:普通数组/对象(items/config)与后缀含 key 的词(monkey)不误伤。
|
||||
const redacted = redactFrontendLogText(
|
||||
'{"tokens":["k-secret-1"],"monkey":["visible-a"],"items":["visible-b"],"config":{"theme":"dark"}}',
|
||||
);
|
||||
|
||||
expect(redacted).not.toContain("k-secret-1");
|
||||
expect(redacted).toContain("visible-a");
|
||||
expect(redacted).toContain("visible-b");
|
||||
expect(redacted).toContain('"theme":"dark"');
|
||||
});
|
||||
|
||||
it("preserves native WebKit-style stack frames for JSON-wrapped errors", () => {
|
||||
// macOS/Linux 的 WKWebView 用 `fn@file:line:col` 格式,且 stack 不含 message。
|
||||
// 旧的 `/^\s+at\s/` 过滤会把整段栈丢掉;新实现须补脱敏头并保留原生栈。
|
||||
const err = new Error('{"tokens":["k-9f3a7c2b1e"]}');
|
||||
Object.defineProperty(err, "stack", {
|
||||
value:
|
||||
"handleClick@tauri://localhost/assets/index.js:42:15\n" +
|
||||
"dispatch@tauri://localhost/assets/index.js:99:3",
|
||||
});
|
||||
|
||||
reportFrontendError("unhandledrejection", err);
|
||||
|
||||
const [message] = writeErrorLog.mock.calls[0];
|
||||
expect(message).not.toContain("k-9f3a7c2b1e");
|
||||
expect(message).toContain('"tokens":"[REDACTED]"'); // 脱敏 message 头
|
||||
expect(message).toContain("handleClick@tauri://localhost"); // 原生栈帧保留
|
||||
expect(message).toContain("dispatch@tauri://localhost");
|
||||
});
|
||||
|
||||
it("replaces every occurrence of the raw message in a V8-style stack", () => {
|
||||
// message 若在 stack 里出现多次(eval/匿名帧回显),字面量替换必须全部换掉,零残留。
|
||||
const err = new Error('{"tokens":["k-9f3a7c2b1e"]}');
|
||||
Object.defineProperty(err, "stack", {
|
||||
value:
|
||||
'Error: {"tokens":["k-9f3a7c2b1e"]}\n' +
|
||||
' at eval (eval at <anonymous>, {"tokens":["k-9f3a7c2b1e"]}:1:1)\n' +
|
||||
" at run (index.js:10:5)",
|
||||
});
|
||||
|
||||
reportFrontendError("unhandledrejection", err);
|
||||
|
||||
const [message] = writeErrorLog.mock.calls[0];
|
||||
expect(message).not.toContain("k-9f3a7c2b1e");
|
||||
expect(message).toContain("at run (index.js:10:5)"); // 栈帧保留
|
||||
});
|
||||
|
||||
it("redacts standalone secret shapes in ordinary error text", () => {
|
||||
reportFrontendError(
|
||||
"window.error",
|
||||
new Error("request failed with sk-ant-api03-real-secret"),
|
||||
);
|
||||
|
||||
const [message] = writeErrorLog.mock.calls[0];
|
||||
expect(message).not.toContain("sk-ant-api03-real-secret");
|
||||
expect(message).toContain("[REDACTED]");
|
||||
});
|
||||
|
||||
it("handles circular rejection objects", () => {
|
||||
const reason: Record<string, unknown> = { message: "failed" };
|
||||
reason.self = reason;
|
||||
|
||||
expect(() =>
|
||||
reportFrontendError("unhandledrejection", reason),
|
||||
).not.toThrow();
|
||||
expect(writeErrorLog.mock.calls[0][0]).toContain("[Circular]");
|
||||
});
|
||||
|
||||
it("captures global errors and unhandled rejections and can uninstall", () => {
|
||||
const target = new EventTarget() as unknown as Window;
|
||||
const uninstall = installGlobalErrorHandlers(target);
|
||||
|
||||
const errorEvent = new Event("error") as ErrorEvent;
|
||||
Object.defineProperties(errorEvent, {
|
||||
error: { value: new Error("render failed") },
|
||||
filename: { value: "app.js" },
|
||||
lineno: { value: 10 },
|
||||
colno: { value: 4 },
|
||||
});
|
||||
target.dispatchEvent(errorEvent);
|
||||
|
||||
const rejectionEvent = new Event(
|
||||
"unhandledrejection",
|
||||
) as PromiseRejectionEvent;
|
||||
Object.defineProperty(rejectionEvent, "reason", {
|
||||
value: new Error("request failed"),
|
||||
});
|
||||
target.dispatchEvent(rejectionEvent);
|
||||
|
||||
expect(writeErrorLog).toHaveBeenCalledTimes(2);
|
||||
|
||||
uninstall();
|
||||
target.dispatchEvent(errorEvent);
|
||||
expect(writeErrorLog).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
getModelsDevSyncConfig,
|
||||
updateModelPricingBatch,
|
||||
recordModelsDevSyncResult,
|
||||
} = vi.hoisted(() => ({
|
||||
getModelsDevSyncConfig: vi.fn(),
|
||||
updateModelPricingBatch: vi.fn(),
|
||||
recordModelsDevSyncResult: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api/usage", () => ({
|
||||
usageApi: {
|
||||
getModelsDevSyncConfig,
|
||||
updateModelPricingBatch,
|
||||
recordModelsDevSyncResult,
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
MODELS_DEV_STARTUP_SYNC_INTERVAL_MS,
|
||||
syncModelsDevPricing,
|
||||
} from "@/lib/modelsDevAutoSync";
|
||||
|
||||
const state = {
|
||||
configPath: "C:/Users/test/.cc-switch/model-pricing.json",
|
||||
config: {
|
||||
autoSyncEnabled: true,
|
||||
includeCommonModels: true,
|
||||
selectedModelKeys: ["relay/custom-model"],
|
||||
excludedCommonModelKeys: [],
|
||||
lastSyncAt: null,
|
||||
lastSyncError: null,
|
||||
},
|
||||
};
|
||||
|
||||
describe("syncModelsDevPricing", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
updateModelPricingBatch.mockResolvedValue(2);
|
||||
recordModelsDevSyncResult.mockResolvedValue(undefined);
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
openai: {
|
||||
models: {
|
||||
"gpt-5": {
|
||||
name: "GPT-5",
|
||||
release_date: "2025-08-01",
|
||||
cost: { input: 1, output: 2 },
|
||||
},
|
||||
},
|
||||
},
|
||||
relay: {
|
||||
models: {
|
||||
"custom-model": {
|
||||
name: "Custom Model",
|
||||
release_date: "2025-07-01",
|
||||
cost: { input: 0.5, output: 1 },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("skips network access when automatic sync is disabled", async () => {
|
||||
getModelsDevSyncConfig.mockResolvedValue({
|
||||
...state,
|
||||
config: { ...state.config, autoSyncEnabled: false },
|
||||
});
|
||||
|
||||
const result = await syncModelsDevPricing();
|
||||
|
||||
expect(result.skipped).toBe(true);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(updateModelPricingBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips startup network access when pricing synced within the interval", async () => {
|
||||
// Keep a meaningful margin inside the interval. A 1 ms margin races the
|
||||
// async mocked config lookup and makes this test depend on machine load.
|
||||
const lastSyncAt =
|
||||
Date.now() - MODELS_DEV_STARTUP_SYNC_INTERVAL_MS + 60_000;
|
||||
getModelsDevSyncConfig.mockResolvedValue({
|
||||
...state,
|
||||
config: { ...state.config, lastSyncAt },
|
||||
});
|
||||
|
||||
const result = await syncModelsDevPricing();
|
||||
|
||||
expect(result).toEqual({
|
||||
skipped: true,
|
||||
selected: 0,
|
||||
imported: 0,
|
||||
changed: 0,
|
||||
syncedAt: lastSyncAt,
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
expect(updateModelPricingBatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("imports common and explicitly selected models in one batch", async () => {
|
||||
getModelsDevSyncConfig.mockResolvedValue(state);
|
||||
|
||||
const result = await syncModelsDevPricing();
|
||||
|
||||
expect(updateModelPricingBatch).toHaveBeenCalledTimes(1);
|
||||
expect(updateModelPricingBatch).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ modelId: "gpt-5", inputCostPerMillion: "1" }),
|
||||
expect.objectContaining({
|
||||
modelId: "custom-model",
|
||||
inputCostPerMillion: "0.5",
|
||||
}),
|
||||
]);
|
||||
expect(result).toMatchObject({
|
||||
skipped: false,
|
||||
selected: 2,
|
||||
imported: 2,
|
||||
changed: 2,
|
||||
});
|
||||
expect(recordModelsDevSyncResult).toHaveBeenCalledWith(
|
||||
expect.any(Number),
|
||||
null,
|
||||
);
|
||||
const fetchOptions = vi.mocked(fetch).mock.calls[0]?.[1];
|
||||
expect(fetchOptions).toEqual({ signal: expect.any(AbortSignal) });
|
||||
expect(fetchOptions).not.toHaveProperty("cache");
|
||||
});
|
||||
|
||||
it("stops a startup sync when automatic sync is disabled during download", async () => {
|
||||
getModelsDevSyncConfig.mockResolvedValueOnce(state).mockResolvedValueOnce({
|
||||
...state,
|
||||
config: { ...state.config, autoSyncEnabled: false, lastSyncAt: 123 },
|
||||
});
|
||||
|
||||
const result = await syncModelsDevPricing();
|
||||
|
||||
expect(result).toEqual({
|
||||
skipped: true,
|
||||
selected: 0,
|
||||
imported: 0,
|
||||
changed: 0,
|
||||
syncedAt: 123,
|
||||
});
|
||||
expect(updateModelPricingBatch).not.toHaveBeenCalled();
|
||||
expect(recordModelsDevSyncResult).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the latest model selection after the download completes", async () => {
|
||||
getModelsDevSyncConfig.mockResolvedValueOnce(state).mockResolvedValueOnce({
|
||||
...state,
|
||||
config: {
|
||||
...state.config,
|
||||
includeCommonModels: false,
|
||||
selectedModelKeys: ["relay/custom-model"],
|
||||
},
|
||||
});
|
||||
|
||||
await syncModelsDevPricing();
|
||||
|
||||
expect(updateModelPricingBatch).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ modelId: "custom-model" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses the latest selection for a forced sync even when automatic sync is disabled", async () => {
|
||||
getModelsDevSyncConfig.mockResolvedValueOnce({
|
||||
...state,
|
||||
config: {
|
||||
...state.config,
|
||||
autoSyncEnabled: false,
|
||||
includeCommonModels: false,
|
||||
selectedModelKeys: ["relay/custom-model"],
|
||||
lastSyncAt: Date.now(),
|
||||
},
|
||||
});
|
||||
|
||||
const result = await syncModelsDevPricing(state, true);
|
||||
|
||||
expect(result.skipped).toBe(false);
|
||||
expect(updateModelPricingBatch).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ modelId: "custom-model" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("persists the last error without replacing the previous success time", async () => {
|
||||
const previous = { ...state, config: { ...state.config, lastSyncAt: 123 } };
|
||||
getModelsDevSyncConfig.mockResolvedValue(previous);
|
||||
vi.mocked(fetch).mockRejectedValueOnce(new Error("offline"));
|
||||
|
||||
await expect(syncModelsDevPricing()).rejects.toThrow("offline");
|
||||
expect(recordModelsDevSyncResult).toHaveBeenCalledWith(null, "offline");
|
||||
});
|
||||
});
|
||||
@@ -44,6 +44,7 @@ export const handlers = [
|
||||
http.post(`${TAURI_ENDPOINT}/get_skills_migration_result`, () =>
|
||||
success(null),
|
||||
),
|
||||
http.post(`${TAURI_ENDPOINT}/list_profiles`, () => success([])),
|
||||
http.post(`${TAURI_ENDPOINT}/get_providers`, async ({ request }) => {
|
||||
const { app } = await withJson<{ app: AppId }>(request);
|
||||
return success(getProviders(app));
|
||||
@@ -337,6 +338,7 @@ export const handlers = [
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
}),
|
||||
),
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ const createDefaultProviders = (): ProvidersByApp => ({
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
},
|
||||
grokbuild: {},
|
||||
opencode: {},
|
||||
openclaw: {},
|
||||
hermes: {},
|
||||
@@ -79,6 +80,7 @@ const createDefaultCurrent = (): CurrentProviderState => ({
|
||||
"claude-desktop": "",
|
||||
codex: "codex-1",
|
||||
gemini: "gemini-1",
|
||||
grokbuild: "",
|
||||
opencode: "",
|
||||
openclaw: "",
|
||||
hermes: "",
|
||||
@@ -191,6 +193,7 @@ let mcpConfigs: McpConfigState = {
|
||||
},
|
||||
},
|
||||
gemini: {},
|
||||
grokbuild: {},
|
||||
opencode: {},
|
||||
openclaw: {},
|
||||
hermes: {},
|
||||
@@ -259,6 +262,7 @@ export const resetProviderState = () => {
|
||||
},
|
||||
},
|
||||
gemini: {},
|
||||
grokbuild: {},
|
||||
opencode: {},
|
||||
openclaw: {},
|
||||
hermes: {},
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseDeepLinkConfigPreview } from "@/utils/deepLinkConfigPreview";
|
||||
|
||||
const encodeBase64 = (value: string) =>
|
||||
btoa(String.fromCharCode(...new TextEncoder().encode(value)));
|
||||
|
||||
const encodeUrlSafeBase64WithoutPadding = (value: string) =>
|
||||
encodeBase64(value)
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
|
||||
const grokConfig = `[models]
|
||||
default = "grok-4.5"
|
||||
|
||||
[model."grok-4.5"]
|
||||
model = "grok-4.5"
|
||||
base_url = "https://relay.example/v1"
|
||||
name = "Relay"
|
||||
api_key = "secret-grok-key"
|
||||
api_backend = "responses"
|
||||
context_window = 500000
|
||||
`;
|
||||
|
||||
describe("parseDeepLinkConfigPreview", () => {
|
||||
it("previews direct Grok Build TOML and masks its API key", () => {
|
||||
const preview = parseDeepLinkConfigPreview({
|
||||
app: "grokbuild",
|
||||
config: encodeBase64(grokConfig),
|
||||
configFormat: "toml",
|
||||
});
|
||||
|
||||
expect(preview?.type).toBe("grokbuild");
|
||||
expect(preview?.tomlConfig).toContain("https://relay.example/v1");
|
||||
expect(preview?.tomlConfig).toContain("secr************");
|
||||
expect(preview?.tomlConfig).not.toContain("secret-grok-key");
|
||||
});
|
||||
|
||||
it("previews wrapped Grok Build config JSON", () => {
|
||||
const preview = parseDeepLinkConfigPreview({
|
||||
app: "grokbuild",
|
||||
config: encodeBase64(JSON.stringify({ config: grokConfig })),
|
||||
configFormat: "json",
|
||||
});
|
||||
|
||||
expect(preview?.type).toBe("grokbuild");
|
||||
expect(preview?.tomlConfig).toContain('default = "grok-4.5"');
|
||||
expect(preview?.tomlConfig).not.toContain("secret-grok-key");
|
||||
});
|
||||
|
||||
it("previews URL-safe, unpadded, and space-normalized Grok Build TOML", () => {
|
||||
let config = "";
|
||||
for (let shift = 0; shift < 12; shift += 1) {
|
||||
config = `${grokConfig}\n# ${"p".repeat(shift)}🚀`;
|
||||
const candidate = encodeBase64(config);
|
||||
if (candidate.includes("+") && /=+$/.test(candidate)) break;
|
||||
}
|
||||
const standard = encodeBase64(config);
|
||||
const encoded = encodeUrlSafeBase64WithoutPadding(config);
|
||||
const encodedWithSpaces = standard.replace(/\+/g, " ");
|
||||
expect(standard).toContain("+");
|
||||
expect(standard).toMatch(/=+$/);
|
||||
expect(encoded).toContain("-");
|
||||
expect(encoded).not.toMatch(/=$/);
|
||||
expect(encodedWithSpaces).toContain(" ");
|
||||
|
||||
const urlSafePreview = parseDeepLinkConfigPreview({
|
||||
app: "grokbuild",
|
||||
config: encoded,
|
||||
configFormat: "toml",
|
||||
});
|
||||
const spaceNormalizedPreview = parseDeepLinkConfigPreview({
|
||||
app: "grokbuild",
|
||||
config: encodedWithSpaces,
|
||||
configFormat: "toml",
|
||||
});
|
||||
|
||||
for (const preview of [urlSafePreview, spaceNormalizedPreview]) {
|
||||
expect(preview?.type).toBe("grokbuild");
|
||||
expect(preview?.tomlConfig).toContain("https://relay.example/v1");
|
||||
expect(preview?.tomlConfig).not.toContain("secret-grok-key");
|
||||
}
|
||||
});
|
||||
|
||||
it("masks authentication headers in nested TOML", () => {
|
||||
const config = `${grokConfig}
|
||||
[mcp.servers.example]
|
||||
url = "https://mcp.example"
|
||||
headers = { Authorization = "Bearer top-secret", Cookie = "session=secret", credential = "credential-secret", auth = "auth-secret", safe_header = "visible" }
|
||||
`;
|
||||
const preview = parseDeepLinkConfigPreview({
|
||||
app: "grokbuild",
|
||||
config: encodeBase64(config),
|
||||
configFormat: "toml",
|
||||
});
|
||||
|
||||
expect(preview?.tomlConfig).not.toContain("Bearer top-secret");
|
||||
expect(preview?.tomlConfig).not.toContain("session=secret");
|
||||
expect(preview?.tomlConfig).not.toContain("credential-secret");
|
||||
expect(preview?.tomlConfig).not.toContain("auth-secret");
|
||||
expect(preview?.tomlConfig).toContain("visible");
|
||||
});
|
||||
|
||||
it("inherits sensitivity through nested tables and arrays of tables", () => {
|
||||
const config = `${grokConfig}
|
||||
[model."grok-4.5".auth]
|
||||
value = "nested-auth-secret"
|
||||
short = "tiny"
|
||||
empty = ""
|
||||
nested = { value = "inline-nested-secret" }
|
||||
|
||||
[[model."grok-4.5".credentials]]
|
||||
value = "array-table-secret"
|
||||
`;
|
||||
const preview = parseDeepLinkConfigPreview({
|
||||
app: "grokbuild",
|
||||
config: encodeBase64(config),
|
||||
configFormat: "toml",
|
||||
});
|
||||
|
||||
expect(preview?.tomlConfig).not.toContain("nested-auth-secret");
|
||||
expect(preview?.tomlConfig).not.toContain("inline-nested-secret");
|
||||
expect(preview?.tomlConfig).not.toContain("array-table-secret");
|
||||
expect(preview?.tomlConfig).toContain("nest************");
|
||||
expect(preview?.tomlConfig).toContain("inli************");
|
||||
expect(preview?.tomlConfig).toContain("arra************");
|
||||
expect(preview?.tomlConfig).toContain('short = "****"');
|
||||
expect(preview?.tomlConfig).toContain('empty = ""');
|
||||
});
|
||||
|
||||
it("also masks secrets in Codex TOML previews", () => {
|
||||
const preview = parseDeepLinkConfigPreview({
|
||||
app: "codex",
|
||||
config: encodeBase64(
|
||||
JSON.stringify({
|
||||
auth: { OPENAI_API_KEY: "secret-auth-key" },
|
||||
config: 'experimental_bearer_token = "secret-config-key"',
|
||||
}),
|
||||
),
|
||||
configFormat: "json",
|
||||
});
|
||||
|
||||
expect(preview?.type).toBe("codex");
|
||||
expect(preview?.tomlConfig).not.toContain("secret-config-key");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user