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("凭据已失效");
});
});
+21 -18
View File
@@ -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 原生 Responses2026-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 } }],
+1 -1
View File
@@ -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),
);
}
},
);
});
+56
View File
@@ -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");
});
+116
View File
@@ -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();
+18 -2
View File
@@ -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");
});
});
});
+1 -1
View File
@@ -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");
+327
View File
@@ -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);
});
});
+199
View File
@@ -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");
});
});
+2
View File
@@ -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,
}),
),
+4
View File
@@ -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: {},
+146
View File
@@ -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");
});
});