mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-29 09:37:37 +08:00
Merge main and harden managed Codex account flow
This commit is contained in:
@@ -7,7 +7,9 @@ import type { Provider } from "@/types";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
add: vi.fn(),
|
||||
update: vi.fn(),
|
||||
ensureClaudeDesktopOfficialProvider: vi.fn(),
|
||||
ensureCodexOfficialProvider: vi.fn(),
|
||||
getAll: vi.fn(),
|
||||
updateTrayMenu: vi.fn(),
|
||||
}));
|
||||
@@ -19,8 +21,11 @@ const uuidMocks = vi.hoisted(() => ({
|
||||
vi.mock("@/lib/api", () => ({
|
||||
providersApi: {
|
||||
add: (...args: unknown[]) => apiMocks.add(...args),
|
||||
update: (...args: unknown[]) => apiMocks.update(...args),
|
||||
ensureClaudeDesktopOfficialProvider: (...args: unknown[]) =>
|
||||
apiMocks.ensureClaudeDesktopOfficialProvider(...args),
|
||||
ensureCodexOfficialProvider: (...args: unknown[]) =>
|
||||
apiMocks.ensureCodexOfficialProvider(...args),
|
||||
getAll: (...args: unknown[]) => apiMocks.getAll(...args),
|
||||
updateTrayMenu: (...args: unknown[]) => apiMocks.updateTrayMenu(...args),
|
||||
},
|
||||
@@ -56,9 +61,11 @@ function createWrapper() {
|
||||
|
||||
beforeEach(() => {
|
||||
apiMocks.add.mockReset().mockResolvedValue(true);
|
||||
apiMocks.update.mockReset().mockResolvedValue(true);
|
||||
apiMocks.ensureClaudeDesktopOfficialProvider
|
||||
.mockReset()
|
||||
.mockResolvedValue(true);
|
||||
apiMocks.ensureCodexOfficialProvider.mockReset().mockResolvedValue(true);
|
||||
apiMocks.getAll.mockReset().mockResolvedValue({});
|
||||
apiMocks.updateTrayMenu.mockReset().mockResolvedValue(true);
|
||||
uuidMocks.generateUUID.mockReset().mockReturnValue("generated-uuid");
|
||||
@@ -133,4 +140,106 @@ describe("useAddProviderMutation", () => {
|
||||
expect(apiMocks.add).not.toHaveBeenCalled();
|
||||
expect(persistedProvider).toEqual(seedProvider);
|
||||
});
|
||||
|
||||
it("persists a managed account binding onto the fixed Codex official seed", async () => {
|
||||
const seedProvider: Provider = {
|
||||
id: "codex-official",
|
||||
name: "OpenAI Official",
|
||||
settingsConfig: { auth: {}, config: "" },
|
||||
category: "official",
|
||||
};
|
||||
apiMocks.getAll.mockResolvedValueOnce({
|
||||
"codex-official": seedProvider,
|
||||
});
|
||||
const { wrapper } = createWrapper();
|
||||
const { result } = renderHook(() => useAddProviderMutation("codex"), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
const persistedProvider = await act(async () =>
|
||||
result.current.mutateAsync({
|
||||
name: "OpenAI Official",
|
||||
settingsConfig: { auth: {}, config: "" },
|
||||
category: "official",
|
||||
meta: {
|
||||
authBinding: {
|
||||
source: "managed_account",
|
||||
authProvider: "codex_oauth",
|
||||
accountId: "acct-managed",
|
||||
},
|
||||
},
|
||||
ensureCodexOfficialSeed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(apiMocks.ensureCodexOfficialProvider).toHaveBeenCalledTimes(1);
|
||||
expect(apiMocks.getAll).toHaveBeenCalledWith("codex");
|
||||
expect(apiMocks.add).not.toHaveBeenCalled();
|
||||
expect(apiMocks.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "codex-official",
|
||||
meta: {
|
||||
authBinding: {
|
||||
source: "managed_account",
|
||||
authProvider: "codex_oauth",
|
||||
accountId: "acct-managed",
|
||||
},
|
||||
},
|
||||
}),
|
||||
"codex",
|
||||
);
|
||||
expect(persistedProvider).toEqual(
|
||||
expect.objectContaining({
|
||||
id: "codex-official",
|
||||
meta: expect.objectContaining({
|
||||
authBinding: expect.objectContaining({
|
||||
accountId: "acct-managed",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("clears a prior managed binding when Codex Official uses native login", async () => {
|
||||
const seedProvider: Provider = {
|
||||
id: "codex-official",
|
||||
name: "OpenAI Official",
|
||||
settingsConfig: { auth: {}, config: "" },
|
||||
category: "official",
|
||||
meta: {
|
||||
providerType: "codex_oauth",
|
||||
authBinding: {
|
||||
source: "managed_account",
|
||||
authProvider: "codex_oauth",
|
||||
accountId: "acct-managed",
|
||||
},
|
||||
},
|
||||
};
|
||||
apiMocks.getAll.mockResolvedValueOnce({
|
||||
"codex-official": seedProvider,
|
||||
});
|
||||
const { wrapper } = createWrapper();
|
||||
const { result } = renderHook(() => useAddProviderMutation("codex"), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
const persistedProvider = await act(async () =>
|
||||
result.current.mutateAsync({
|
||||
name: "OpenAI Official",
|
||||
settingsConfig: { auth: {}, config: "" },
|
||||
category: "official",
|
||||
meta: {},
|
||||
ensureCodexOfficialSeed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(apiMocks.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: "codex-official",
|
||||
meta: {},
|
||||
}),
|
||||
"codex",
|
||||
);
|
||||
expect(persistedProvider.meta).toEqual({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useApiKeyState } from "@/components/providers/forms/hooks/useApiKeyState";
|
||||
|
||||
describe("useApiKeyState", () => {
|
||||
it("shows and creates Claude API key for uncategorized edit providers", () => {
|
||||
const onConfigChange = vi.fn();
|
||||
const initialConfig = JSON.stringify({ env: {} }, null, 2);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useApiKeyState({
|
||||
initialConfig,
|
||||
onConfigChange,
|
||||
selectedPresetId: null,
|
||||
category: undefined,
|
||||
appType: "claude",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.current.showApiKey(initialConfig, true)).toBe(true);
|
||||
|
||||
act(() => {
|
||||
result.current.handleApiKeyChange("sk-test");
|
||||
});
|
||||
|
||||
const updated = JSON.parse(onConfigChange.mock.calls.at(-1)?.[0]);
|
||||
expect(updated.env.ANTHROPIC_AUTH_TOKEN).toBe("sk-test");
|
||||
});
|
||||
|
||||
it("keeps official and cloud provider edit behavior conservative", () => {
|
||||
const initialConfig = JSON.stringify({ env: {} }, null, 2);
|
||||
const officialConfigChange = vi.fn();
|
||||
|
||||
const official = renderHook(() =>
|
||||
useApiKeyState({
|
||||
initialConfig,
|
||||
onConfigChange: officialConfigChange,
|
||||
selectedPresetId: null,
|
||||
category: "official",
|
||||
appType: "claude",
|
||||
}),
|
||||
);
|
||||
expect(official.result.current.showApiKey(initialConfig, true)).toBe(false);
|
||||
act(() => {
|
||||
official.result.current.handleApiKeyChange("sk-official");
|
||||
});
|
||||
expect(officialConfigChange).toHaveBeenLastCalledWith(initialConfig);
|
||||
|
||||
const cloudProviderConfigChange = vi.fn();
|
||||
const cloudProvider = renderHook(() =>
|
||||
useApiKeyState({
|
||||
initialConfig,
|
||||
onConfigChange: cloudProviderConfigChange,
|
||||
selectedPresetId: null,
|
||||
category: "cloud_provider",
|
||||
appType: "claude",
|
||||
}),
|
||||
);
|
||||
expect(cloudProvider.result.current.showApiKey(initialConfig, true)).toBe(
|
||||
false,
|
||||
);
|
||||
act(() => {
|
||||
cloudProvider.result.current.handleApiKeyChange("sk-cloud");
|
||||
});
|
||||
expect(cloudProviderConfigChange).toHaveBeenLastCalledWith(initialConfig);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@ import { useGeminiCommonConfig } from "@/components/providers/forms/hooks/useGem
|
||||
const getCommonConfigSnippetMock = vi.fn();
|
||||
const setCommonConfigSnippetMock = vi.fn();
|
||||
const extractCommonConfigSnippetMock = vi.fn();
|
||||
const updateTomlCommonConfigSnippetMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
configApi: {
|
||||
@@ -15,6 +16,8 @@ vi.mock("@/lib/api", () => ({
|
||||
setCommonConfigSnippetMock(...args),
|
||||
extractCommonConfigSnippet: (...args: unknown[]) =>
|
||||
extractCommonConfigSnippetMock(...args),
|
||||
updateTomlCommonConfigSnippet: (...args: unknown[]) =>
|
||||
updateTomlCommonConfigSnippetMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -23,6 +26,9 @@ describe("common config snippet saving", () => {
|
||||
getCommonConfigSnippetMock.mockResolvedValue("");
|
||||
setCommonConfigSnippetMock.mockResolvedValue(undefined);
|
||||
extractCommonConfigSnippetMock.mockResolvedValue("");
|
||||
updateTomlCommonConfigSnippetMock.mockImplementation(
|
||||
async (configToml: string) => configToml,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not persist an invalid Codex common config snippet", async () => {
|
||||
@@ -36,9 +42,9 @@ describe("common config snippet saving", () => {
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
|
||||
let saved = false;
|
||||
act(() => {
|
||||
saved = result.current.handleCommonConfigSnippetChange(
|
||||
let saved = true;
|
||||
await act(async () => {
|
||||
saved = await result.current.handleCommonConfigSnippetChange(
|
||||
"base_url = https://bad.example/v1",
|
||||
);
|
||||
});
|
||||
@@ -49,6 +55,99 @@ describe("common config snippet saving", () => {
|
||||
expect(result.current.commonConfigError).toContain("invalid value");
|
||||
});
|
||||
|
||||
it("discards stale toggle results when a newer toggle finishes first", async () => {
|
||||
getCommonConfigSnippetMock.mockResolvedValue(
|
||||
"[tui]\nnotifications = true\n",
|
||||
);
|
||||
|
||||
const onConfigChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
useCodexCommonConfig({
|
||||
codexConfig: 'model = "gpt-5"',
|
||||
onConfigChange,
|
||||
initialData: { settingsConfig: { config: 'model = "gpt-5"' } },
|
||||
initialEnabled: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
await waitFor(() => expect(result.current.useCommonConfig).toBe(false));
|
||||
|
||||
// 第一次调用(勾选 on 的 merge)挂起,第二次(取消勾选的剥离)立即返回:
|
||||
// 模拟后端乱序完成
|
||||
let resolveMerge: ((value: string) => void) | undefined;
|
||||
updateTomlCommonConfigSnippetMock
|
||||
.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveMerge = resolve;
|
||||
}),
|
||||
)
|
||||
.mockImplementationOnce(async (configToml: string) => configToml);
|
||||
|
||||
await act(async () => {
|
||||
const mergePending = result.current.handleCommonConfigToggle(true);
|
||||
const removeDone = result.current.handleCommonConfigToggle(false);
|
||||
await removeDone;
|
||||
// on 的合并结果此时才姗姗来迟——必须被序号守卫丢弃
|
||||
resolveMerge?.('model = "gpt-5"\n\n[tui]\nnotifications = true\n');
|
||||
await mergePending;
|
||||
});
|
||||
|
||||
// 用户最后一次操作是 off:过期的 on 结果不得翻转开关或改写配置
|
||||
expect(result.current.useCommonConfig).toBe(false);
|
||||
const lastConfig = onConfigChange.mock.calls.at(-1)?.[0] as string;
|
||||
expect(lastConfig).not.toContain("[tui]");
|
||||
});
|
||||
|
||||
it("discards async merge results when the user edited the config while in flight", async () => {
|
||||
getCommonConfigSnippetMock.mockResolvedValue(
|
||||
"[tui]\nnotifications = true\n",
|
||||
);
|
||||
|
||||
const initialData = { settingsConfig: { config: 'model = "gpt-5"' } };
|
||||
const onConfigChange = vi.fn();
|
||||
const { result, rerender } = renderHook(
|
||||
({ config }: { config: string }) =>
|
||||
useCodexCommonConfig({
|
||||
codexConfig: config,
|
||||
onConfigChange,
|
||||
initialData,
|
||||
initialEnabled: false,
|
||||
}),
|
||||
{ initialProps: { config: 'model = "gpt-5"' } },
|
||||
);
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
||||
await waitFor(() => expect(result.current.useCommonConfig).toBe(false));
|
||||
|
||||
let resolveMerge: ((value: string) => void) | undefined;
|
||||
updateTomlCommonConfigSnippetMock.mockImplementationOnce(
|
||||
() =>
|
||||
new Promise<string>((resolve) => {
|
||||
resolveMerge = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
let togglePending: Promise<void> = Promise.resolve();
|
||||
act(() => {
|
||||
togglePending = result.current.handleCommonConfigToggle(true);
|
||||
});
|
||||
|
||||
// merge 在飞期间,用户在编辑器里手动改了 config(不经过 hook,
|
||||
// 序号不变,只有 codexConfig prop 变化)
|
||||
rerender({ config: 'model = "gpt-6-user-edit"' });
|
||||
|
||||
await act(async () => {
|
||||
resolveMerge?.('model = "gpt-5"\n\n[tui]\nnotifications = true\n');
|
||||
await togglePending;
|
||||
});
|
||||
|
||||
// 基于陈旧基线的合并结果必须被丢弃,不得覆盖用户的手动编辑
|
||||
expect(onConfigChange).not.toHaveBeenCalled();
|
||||
expect(result.current.useCommonConfig).toBe(false);
|
||||
});
|
||||
|
||||
it("does not persist an invalid Gemini common config snippet", async () => {
|
||||
const onEnvChange = vi.fn();
|
||||
const { result } = renderHook(() =>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ describe("useModelState", () => {
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL_NAME: "DeepSeek V4 Pro",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-k2",
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL_NAME: "Kimi K2",
|
||||
CLAUDE_CODE_SUBAGENT_MODEL: "subagent-model[1M]",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -34,6 +35,7 @@ describe("useModelState", () => {
|
||||
expect(result.current.defaultOpusModelName).toBe("Kimi K2");
|
||||
expect(result.current.defaultHaikuModel).toBe("legacy-small");
|
||||
expect(result.current.defaultHaikuModelName).toBe("legacy-small");
|
||||
expect(result.current.subagentModel).toBe("subagent-model[1M]");
|
||||
});
|
||||
|
||||
it("writes and clears role display-name env fields without changing model mapping", () => {
|
||||
@@ -94,6 +96,42 @@ describe("useModelState", () => {
|
||||
expect(result.current.defaultSonnetModelName).toBe("deepseek-v4-pro");
|
||||
});
|
||||
|
||||
it("writes and clears the Claude Code subagent model env field", () => {
|
||||
let latestConfig = JSON.stringify({
|
||||
env: {
|
||||
ANTHROPIC_MODEL: "fallback-model",
|
||||
},
|
||||
});
|
||||
const onConfigChange = vi.fn((config: string) => {
|
||||
latestConfig = config;
|
||||
});
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useModelState({
|
||||
settingsConfig: latestConfig,
|
||||
onConfigChange,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
result.current.handleModelChange(
|
||||
"CLAUDE_CODE_SUBAGENT_MODEL",
|
||||
"subagent-model[1M]",
|
||||
);
|
||||
});
|
||||
|
||||
let env = JSON.parse(latestConfig).env;
|
||||
expect(env.ANTHROPIC_MODEL).toBe("fallback-model");
|
||||
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBe("subagent-model[1M]");
|
||||
|
||||
act(() => {
|
||||
result.current.handleModelChange("CLAUDE_CODE_SUBAGENT_MODEL", "");
|
||||
});
|
||||
|
||||
env = JSON.parse(latestConfig).env;
|
||||
expect(env.CLAUDE_CODE_SUBAGENT_MODEL).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes Claude Code 1M markers for UI toggles", () => {
|
||||
expect(hasClaudeOneMMarker("deepseek-v4-pro[1m]")).toBe(true);
|
||||
expect(hasClaudeOneMMarker("deepseek-v4-pro [1M] ")).toBe(true);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { useOpencodeFormState } from "@/components/providers/forms/hooks/useOpencodeFormState";
|
||||
|
||||
const renderOpencodeFormState = (
|
||||
initialSettingsConfig: Record<string, unknown>,
|
||||
) => {
|
||||
let settingsConfig = JSON.stringify(initialSettingsConfig);
|
||||
const onSettingsConfigChange = vi.fn((nextConfig: string) => {
|
||||
settingsConfig = nextConfig;
|
||||
});
|
||||
|
||||
const hook = renderHook(() =>
|
||||
useOpencodeFormState({
|
||||
appId: "opencode",
|
||||
initialData: { settingsConfig: initialSettingsConfig },
|
||||
onSettingsConfigChange,
|
||||
getSettingsConfig: () => settingsConfig,
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
...hook,
|
||||
onSettingsConfigChange,
|
||||
getSettingsConfig: () => settingsConfig,
|
||||
};
|
||||
};
|
||||
|
||||
describe("useOpencodeFormState", () => {
|
||||
it("hydrates provider headers from options", () => {
|
||||
const { result } = renderOpencodeFormState({
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
headers: {
|
||||
"HTTP-Referer": "https://cc-switch.app",
|
||||
"X-Title": "CC Switch",
|
||||
},
|
||||
},
|
||||
models: {},
|
||||
});
|
||||
|
||||
expect(result.current.opencodeHeaders).toEqual({
|
||||
"HTTP-Referer": "https://cc-switch.app",
|
||||
"X-Title": "CC Switch",
|
||||
});
|
||||
});
|
||||
|
||||
it("writes provider headers to options", () => {
|
||||
const { result, getSettingsConfig } = renderOpencodeFormState({
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {},
|
||||
models: {},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpencodeHeadersChange({
|
||||
"X-Title": "CC Switch",
|
||||
});
|
||||
});
|
||||
|
||||
expect(JSON.parse(getSettingsConfig()).options.headers).toEqual({
|
||||
"X-Title": "CC Switch",
|
||||
});
|
||||
});
|
||||
|
||||
it("removes options.headers when all provider headers are removed", () => {
|
||||
const { result, getSettingsConfig } = renderOpencodeFormState({
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
headers: {
|
||||
"X-Title": "CC Switch",
|
||||
},
|
||||
},
|
||||
models: {},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpencodeHeadersChange({});
|
||||
});
|
||||
|
||||
expect(JSON.parse(getSettingsConfig()).options.headers).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves legitimate headers whose names start with header-", () => {
|
||||
const { result, getSettingsConfig } = renderOpencodeFormState({
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
headers: {
|
||||
"header-version": "v1",
|
||||
"X-Title": "Old",
|
||||
},
|
||||
},
|
||||
models: {},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpencodeHeadersChange({
|
||||
"header-version": "v1",
|
||||
"X-Title": "New",
|
||||
});
|
||||
});
|
||||
|
||||
expect(JSON.parse(getSettingsConfig()).options.headers).toEqual({
|
||||
"header-version": "v1",
|
||||
"X-Title": "New",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves legitimate options whose names start with option-", () => {
|
||||
const { result, getSettingsConfig } = renderOpencodeFormState({
|
||||
npm: "@ai-sdk/openai-compatible",
|
||||
options: {
|
||||
"option-mode": "legacy",
|
||||
timeout: 100,
|
||||
},
|
||||
models: {},
|
||||
});
|
||||
|
||||
act(() => {
|
||||
result.current.handleOpencodeExtraOptionsChange({
|
||||
"option-mode": "legacy",
|
||||
timeout: "200",
|
||||
"draft-option:123": "",
|
||||
});
|
||||
});
|
||||
|
||||
expect(JSON.parse(getSettingsConfig()).options).toEqual({
|
||||
"option-mode": "legacy",
|
||||
timeout: 200,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -242,6 +242,124 @@ describe("useProviderActions", () => {
|
||||
expect(switchProviderMutateAsync).toHaveBeenCalledWith(provider.id);
|
||||
});
|
||||
|
||||
it("warns when switching a Codex Anthropic-format provider without proxy", async () => {
|
||||
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
||||
const { wrapper } = createWrapper();
|
||||
const provider = createProvider({
|
||||
category: "custom",
|
||||
meta: { apiFormat: "anthropic" },
|
||||
});
|
||||
|
||||
const { result } = renderHook(() => useProviderActions("codex", false), {
|
||||
wrapper,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchProvider(provider);
|
||||
});
|
||||
|
||||
expect(toastWarningMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Anthropic Messages"),
|
||||
);
|
||||
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("allows the built-in Codex official provider during takeover", async () => {
|
||||
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
||||
const { wrapper } = createWrapper();
|
||||
const provider = createProvider({
|
||||
id: "codex-official",
|
||||
category: "official",
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useProviderActions("codex", true, true),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchProvider(provider);
|
||||
});
|
||||
|
||||
expect(switchProviderMutateAsync).toHaveBeenCalledWith("codex-official");
|
||||
expect(toastErrorMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("continues blocking other official providers during takeover", async () => {
|
||||
const { wrapper } = createWrapper();
|
||||
const provider = createProvider({
|
||||
id: "claude-official",
|
||||
category: "official",
|
||||
});
|
||||
|
||||
const { result } = renderHook(
|
||||
() => useProviderActions("claude", true, true),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchProvider(provider);
|
||||
});
|
||||
|
||||
expect(switchProviderMutateAsync).not.toHaveBeenCalled();
|
||||
expect(toastErrorMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not grant routing capability to a UUID Codex official copy", async () => {
|
||||
const { wrapper } = createWrapper();
|
||||
const provider = createProvider({
|
||||
id: "generated-uuid",
|
||||
category: "official",
|
||||
});
|
||||
const { result } = renderHook(
|
||||
() => useProviderActions("codex", true, true),
|
||||
{ wrapper },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.switchProvider(provider);
|
||||
});
|
||||
|
||||
expect(switchProviderMutateAsync).not.toHaveBeenCalled();
|
||||
expect(toastErrorMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should sync plugin config when switching Claude provider with integration enabled", async () => {
|
||||
switchProviderMutateAsync.mockResolvedValueOnce(undefined);
|
||||
settingsApiGetMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -81,6 +81,7 @@ describe("useProxyStatus", () => {
|
||||
claude: false,
|
||||
codex: false,
|
||||
gemini: false,
|
||||
grokbuild: false,
|
||||
opencode: false,
|
||||
openclaw: false,
|
||||
});
|
||||
@@ -115,4 +116,4 @@ describe("useProxyStatus", () => {
|
||||
{ closeButton: true },
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user