Merge main and harden managed Codex account flow

This commit is contained in:
SaladDay
2026-07-18 10:06:58 +00:00
276 changed files with 32101 additions and 3292 deletions
@@ -2,6 +2,7 @@ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AddProviderDialog } from "@/components/providers/AddProviderDialog";
import type { ProviderFormValues } from "@/components/providers/forms/ProviderForm";
import { codexProviderPresets } from "@/config/codexProviderPresets";
vi.mock("@/components/ui/dialog", () => ({
Dialog: ({ children }: { children: React.ReactNode }) => (
@@ -125,4 +126,96 @@ describe("AddProviderDialog", () => {
},
});
});
it("submits the managed account selected from the Codex Official preset", async () => {
const handleSubmit = vi.fn().mockResolvedValue(undefined);
const officialPresetIndex = codexProviderPresets.findIndex(
(preset) =>
preset.category === "official" && preset.providerType === "codex_oauth",
);
expect(officialPresetIndex).toBeGreaterThanOrEqual(0);
mockFormValues = {
name: "OpenAI Official",
websiteUrl: "https://chatgpt.com/codex",
settingsConfig: JSON.stringify({ auth: {}, config: "" }),
presetId: `codex-${officialPresetIndex}`,
presetCategory: "official",
meta: {
providerType: "codex_oauth",
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
},
},
};
render(
<AddProviderDialog
open
onOpenChange={vi.fn()}
appId="codex"
onSubmit={handleSubmit}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "common.add" }));
await waitFor(() => expect(handleSubmit).toHaveBeenCalledTimes(1));
expect(handleSubmit).toHaveBeenCalledWith(
expect.objectContaining({
category: "official",
ensureCodexOfficialSeed: true,
meta: expect.objectContaining({
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
},
}),
}),
);
});
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();
});
});
@@ -173,7 +173,7 @@ describe("ClaudeDesktopProviderForm", () => {
// claude-old 迁移到 Sonnet;留空的 Opus / Fable / Haiku 回填为 Sonnet 的
// 上游模型,保证落库四档齐全,子 agent 调用的各档始终可解析。
expect(submitted.meta.claudeDesktopModelRoutes).toMatchObject({
"claude-sonnet-4-6": {
"claude-sonnet-5": {
model: "upstream-old",
labelOverride: "upstream-old",
},
@@ -186,7 +186,7 @@ describe("ClaudeDesktopProviderForm", () => {
"claude-fable-5",
"claude-haiku-4-5",
"claude-opus-4-8",
"claude-sonnet-4-6",
"claude-sonnet-5",
],
);
});
@@ -217,7 +217,7 @@ describe("ClaudeDesktopProviderForm", () => {
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
const routes = onSubmit.mock.calls[0][0].meta.claudeDesktopModelRoutes;
// 留空的 Opus / Haiku 回填同一上游模型,1M 声明应与 Sonnet 一致。
expect(routes["claude-sonnet-4-6"]).toMatchObject({
expect(routes["claude-sonnet-5"]).toMatchObject({
model: "deepseek-v4-pro",
supports1m: true,
});
@@ -259,8 +259,8 @@ describe("ClaudeDesktopProviderForm", () => {
await waitFor(() => expect(onSubmit).toHaveBeenCalled());
const submitted = onSubmit.mock.calls[0][0];
expect(submitted.meta.claudeDesktopModelRoutes).toMatchObject({
"claude-sonnet-4-6": {
model: "claude-sonnet-4-6",
"claude-sonnet-5": {
model: "claude-sonnet-5",
},
});
});
@@ -85,6 +85,7 @@ const renderCopilotForm = (overrides: Partial<ClaudeFormFieldsProps> = {}) => {
defaultOpusModelName: "",
defaultFableModel: "",
defaultFableModelName: "",
subagentModel: "",
onModelChange: vi.fn(),
speedTestEndpoints: [],
apiFormat: "anthropic",
@@ -173,4 +174,25 @@ describe("ClaudeFormFields", () => {
);
});
});
it("一键设置会同时写入 Subagent 模型", () => {
const onModelChange = vi.fn();
renderCopilotForm({
claudeModel: "shared-model[1M]",
defaultSonnetModel: "",
defaultSonnetModelName: "",
onModelChange,
});
fireEvent.click(
screen.getByRole("button", {
name: "一键设置",
}),
);
expect(onModelChange).toHaveBeenCalledWith(
"CLAUDE_CODE_SUBAGENT_MODEL",
"shared-model[1M]",
);
});
});
+33
View File
@@ -0,0 +1,33 @@
import { render } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
const Panels = ({ innerOpen }: { innerOpen: boolean }) => (
<>
<FullScreenPanel isOpen title="Outer" onClose={() => undefined}>
outer
</FullScreenPanel>
<FullScreenPanel isOpen={innerOpen} title="Inner" onClose={() => undefined}>
inner
</FullScreenPanel>
</>
);
describe("FullScreenPanel body scroll locking", () => {
afterEach(() => {
document.body.style.overflow = "";
});
it("keeps the body locked when a nested panel closes", () => {
document.body.style.overflow = "clip";
const view = render(<Panels innerOpen />);
expect(document.body.style.overflow).toBe("hidden");
view.rerender(<Panels innerOpen={false} />);
expect(document.body.style.overflow).toBe("hidden");
view.unmount();
expect(document.body.style.overflow).toBe("clip");
});
});
@@ -0,0 +1,178 @@
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 } 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 Codex-compatible provider presets and applies one", async () => {
const user = userEvent.setup();
const { container } = render(
<GrokBuildProviderForm
submitLabel="Save"
onSubmit={() => {}}
onCancel={() => {}}
/>,
);
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 Chat Completions presets into Grok api_backend", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(
<GrokBuildProviderForm
submitLabel="Save"
onSubmit={onSubmit}
onCancel={() => {}}
/>,
);
await user.click(screen.getByRole("button", { name: /BytePlus/ }));
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_chat");
expect(config.model[config.models.default].api_backend).toBe(
"chat_completions",
);
});
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();
});
});
@@ -0,0 +1,86 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CodexOAuthSection } from "@/components/providers/forms/CodexOAuthSection";
import { CopilotAuthSection } from "@/components/providers/forms/CopilotAuthSection";
const authMocks = vi.hoisted(() => ({
refetchCodex: vi.fn(),
refetchCopilot: vi.fn(),
}));
const failedStatus = (refetchStatus: () => void) => ({
accounts: [],
defaultAccountId: null,
migrationError: null,
isStatusSuccess: false,
isStatusError: true,
hasAnyAccount: false,
pollingState: "idle" as const,
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(),
refetchStatus,
});
vi.mock("@/components/providers/forms/hooks/useCodexOauth", () => ({
useCodexOauth: () => failedStatus(authMocks.refetchCodex),
}));
vi.mock("@/components/providers/forms/hooks/useCopilotAuth", () => ({
useCopilotAuth: () => failedStatus(authMocks.refetchCopilot),
}));
describe("managed auth status failures", () => {
beforeEach(() => {
authMocks.refetchCodex.mockResolvedValue(undefined);
authMocks.refetchCopilot.mockResolvedValue(undefined);
});
it("shows a retryable error instead of an empty Codex account selector", () => {
const onAccountSelect = vi.fn();
render(
<CodexOAuthSection
mode="select"
selectedAccountId="acct-existing"
onAccountSelect={onAccountSelect}
/>,
);
expect(screen.getByRole("alert")).toHaveTextContent(
"无法加载 ChatGPT 账号状态,请重试。",
);
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
expect(onAccountSelect).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "重试" }));
expect(authMocks.refetchCodex).toHaveBeenCalledTimes(1);
});
it("shows a retryable error instead of an empty Copilot account selector", () => {
const onAccountSelect = vi.fn();
render(
<CopilotAuthSection
mode="select"
selectedAccountId="acct-existing"
onAccountSelect={onAccountSelect}
/>,
);
expect(screen.getByRole("alert")).toHaveTextContent(
"无法加载 GitHub Copilot 账号状态,请重试。",
);
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
expect(onAccountSelect).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "重试" }));
expect(authMocks.refetchCopilot).toHaveBeenCalledTimes(1);
});
});
+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,203 @@
import { fireEvent, render, screen } from "@testing-library/react";
import type { ComponentProps, PropsWithChildren } from "react";
import { useForm } from "react-hook-form";
import { describe, expect, it, vi } from "vitest";
import { OpenCodeFormFields } from "@/components/providers/forms/OpenCodeFormFields";
import { Form } from "@/components/ui/form";
type OpenCodeFormFieldsProps = ComponentProps<typeof OpenCodeFormFields>;
const FormShell = ({ children }: PropsWithChildren) => {
const form = useForm();
return <Form {...form}>{children}</Form>;
};
const renderOpenCodeForm = (
overrides: Partial<OpenCodeFormFieldsProps> = {},
) => {
const props: OpenCodeFormFieldsProps = {
npm: "@ai-sdk/openai-compatible",
onNpmChange: vi.fn(),
apiKey: "sk-test",
onApiKeyChange: vi.fn(),
category: "custom",
shouldShowApiKeyLink: false,
websiteUrl: "",
baseUrl: "https://api.example.com/v1",
onBaseUrlChange: vi.fn(),
headers: {},
onHeadersChange: vi.fn(),
models: {
"kimi-k2": {
name: "Kimi K2",
limit: { context: 1048576, output: 131072 },
},
},
onModelsChange: vi.fn(),
extraOptions: {},
onExtraOptionsChange: vi.fn(),
...overrides,
};
return {
props,
...render(
<FormShell>
<OpenCodeFormFields {...props} />
</FormShell>,
),
};
};
const expandFirstModel = () => {
fireEvent.click(screen.getByRole("button", { name: "Toggle model details" }));
};
describe("OpenCodeFormFields", () => {
it("surfaces existing provider headers", () => {
renderOpenCodeForm({
headers: {
"HTTP-Referer": "https://cc-switch.app",
"X-Title": "CC Switch",
},
});
expect(screen.getByDisplayValue("HTTP-Referer")).toBeInTheDocument();
expect(
screen.getByDisplayValue("https://cc-switch.app"),
).toBeInTheDocument();
expect(screen.getByDisplayValue("X-Title")).toBeInTheDocument();
expect(screen.getByDisplayValue("CC Switch")).toBeInTheDocument();
});
it("updates provider headers", () => {
const onHeadersChange = vi.fn();
renderOpenCodeForm({
headers: { "X-Title": "CC Switch" },
onHeadersChange,
});
fireEvent.change(screen.getByDisplayValue("CC Switch"), {
target: { value: "OpenCode" },
});
expect(onHeadersChange).toHaveBeenCalledWith({
"X-Title": "OpenCode",
});
});
it("shows a blank header name for newly added headers", () => {
const onHeadersChange = vi.fn();
const { rerender, props } = renderOpenCodeForm({ onHeadersChange });
fireEvent.click(screen.getByRole("button", { name: "Add header" }));
const nextHeaders = onHeadersChange.mock.calls[0][0];
const headerKey = Object.keys(nextHeaders)[0];
expect(headerKey).toMatch(/^draft-header:/);
rerender(
<FormShell>
<OpenCodeFormFields {...props} headers={nextHeaders} />
</FormShell>,
);
expect(screen.getByPlaceholderText("X-Title")).toHaveValue("");
});
it("removes provider headers", () => {
const onHeadersChange = vi.fn();
renderOpenCodeForm({
headers: { "X-Title": "CC Switch" },
onHeadersChange,
});
fireEvent.click(screen.getByRole("button", { name: "Remove header" }));
expect(onHeadersChange).toHaveBeenCalledWith({});
});
it("rejects case-insensitive duplicate header names and restores the input", () => {
const onHeadersChange = vi.fn();
renderOpenCodeForm({
headers: { "X-A": "A", "X-B": "B" },
onHeadersChange,
});
const keyInput = screen.getByDisplayValue("X-B");
fireEvent.change(keyInput, { target: { value: "x-a" } });
fireEvent.blur(keyInput);
expect(onHeadersChange).not.toHaveBeenCalled();
expect(keyInput).toHaveValue("X-B");
});
it("surfaces provider options whose names start with option-", () => {
renderOpenCodeForm({
extraOptions: { "option-mode": "legacy" },
});
expect(screen.getByDisplayValue("option-mode")).toBeInTheDocument();
expect(screen.getByDisplayValue("legacy")).toBeInTheDocument();
});
it("surfaces existing model token limits", () => {
renderOpenCodeForm();
expandFirstModel();
expect(screen.getByLabelText("Context")).toHaveValue(1048576);
expect(screen.getByLabelText("Output")).toHaveValue(131072);
});
it("updates model token limits as structured numbers", () => {
const onModelsChange = vi.fn();
renderOpenCodeForm({ onModelsChange });
expandFirstModel();
fireEvent.change(screen.getByLabelText("Context"), {
target: { value: "2000000" },
});
expect(onModelsChange).toHaveBeenCalledWith({
"kimi-k2": {
name: "Kimi K2",
limit: { context: 2000000, output: 131072 },
},
});
});
it("removes model limit when both fields are cleared", () => {
const onModelsChange = vi.fn();
const { rerender, props } = renderOpenCodeForm({ onModelsChange });
expandFirstModel();
fireEvent.change(screen.getByLabelText("Context"), {
target: { value: "" },
});
const withoutContext = {
"kimi-k2": {
name: "Kimi K2",
limit: { output: 131072 },
},
};
expect(onModelsChange).toHaveBeenLastCalledWith(withoutContext);
rerender(
<FormShell>
<OpenCodeFormFields {...props} models={withoutContext} />
</FormShell>,
);
fireEvent.change(screen.getByLabelText("Output"), {
target: { value: "" },
});
expect(onModelsChange).toHaveBeenLastCalledWith({
"kimi-k2": {
name: "Kimi K2",
},
});
});
});
@@ -0,0 +1,150 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClientProvider } from "@tanstack/react-query";
import { describe, expect, it, vi } from "vitest";
import {
ProviderForm,
type ProviderFormValues,
} from "@/components/providers/forms/ProviderForm";
import { createTestQueryClient } from "../utils/testQueryClient";
vi.mock("@/components/providers/forms/CodexOAuthSection", () => ({
CodexOAuthSection: ({
onAccountSelect,
}: {
onAccountSelect?: (accountId: string | null) => void;
}) => (
<div>
<button type="button" onClick={() => onAccountSelect?.("acct-managed")}>
select-managed-account
</button>
<button type="button" onClick={() => onAccountSelect?.(null)}>
select-native-login
</button>
</div>
),
}));
vi.mock("@/components/providers/forms/CodexConfigEditor", () => ({
default: () => <div data-testid="codex-config-editor" />,
}));
vi.mock("@/components/providers/forms/ProviderAdvancedConfig", () => ({
ProviderAdvancedConfig: () => <div data-testid="advanced-config" />,
}));
vi.mock("@/components/providers/forms/hooks", async (importOriginal) => {
const actual =
await importOriginal<typeof import("@/components/providers/forms/hooks")>();
return {
...actual,
useCopilotAuth: () => ({
isAuthenticated: false,
isStatusSuccess: true,
isStatusError: false,
}),
useCodexOauth: () => ({
isAuthenticated: true,
isStatusSuccess: true,
isStatusError: false,
}),
useCodexCommonConfig: () => ({
useCommonConfig: false,
commonConfigSnippet: "",
commonConfigError: null,
handleCommonConfigToggle: vi.fn(),
handleCommonConfigSnippetChange: vi.fn(),
isExtracting: false,
handleExtract: vi.fn(),
clearCommonConfigError: vi.fn(),
}),
useGeminiCommonConfig: () => ({
useCommonConfig: false,
commonConfigSnippet: "",
commonConfigError: null,
handleCommonConfigToggle: vi.fn(),
handleCommonConfigSnippetChange: vi.fn(),
isExtracting: false,
handleExtract: vi.fn(),
clearCommonConfigError: vi.fn(),
}),
};
});
vi.mock("@/lib/query", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/query")>();
return {
...actual,
useSettingsQuery: () => ({
data: { commonConfigConfirmed: true },
}),
};
});
function renderCodexForm(onSubmit: (values: ProviderFormValues) => void) {
const queryClient = createTestQueryClient();
return render(
<QueryClientProvider client={queryClient}>
<ProviderForm
appId="codex"
submitLabel="save-provider"
onSubmit={onSubmit}
onCancel={vi.fn()}
/>
</QueryClientProvider>,
);
}
describe("ProviderForm Codex Official managed account", () => {
it("persists the selected managed account while stripping OAuth secrets", async () => {
const onSubmit = vi.fn();
renderCodexForm(onSubmit);
fireEvent.click(screen.getByRole("button", { name: /OpenAI Official/ }));
fireEvent.click(
await screen.findByRole("button", { name: "select-managed-account" }),
);
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const submitted = onSubmit.mock.calls[0][0] as ProviderFormValues;
expect(submitted).toEqual(
expect.objectContaining({
presetId: "codex-0",
presetCategory: "official",
meta: expect.objectContaining({
providerType: "codex_oauth",
authBinding: {
source: "managed_account",
authProvider: "codex_oauth",
accountId: "acct-managed",
},
}),
}),
);
expect(JSON.parse(submitted.settingsConfig)).toEqual({
auth: {},
config: "",
});
});
it("keeps Official on native browser login when no managed account is selected", async () => {
const onSubmit = vi.fn();
renderCodexForm(onSubmit);
fireEvent.click(screen.getByRole("button", { name: /OpenAI Official/ }));
fireEvent.click(
await screen.findByRole("button", { name: "select-managed-account" }),
);
fireEvent.click(
screen.getByRole("button", { name: "select-native-login" }),
);
fireEvent.click(screen.getByRole("button", { name: "save-provider" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
const submitted = onSubmit.mock.calls[0][0] as ProviderFormValues;
expect(submitted.presetId).toBe("codex-0");
expect(submitted.presetCategory).toBe("official");
expect(submitted.meta?.providerType).toBeUndefined();
expect(submitted.meta?.authBinding).toBeUndefined();
});
});
+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 }),
},
]);
});
});
});
+155
View File
@@ -0,0 +1,155 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
fireEvent,
render,
screen,
waitFor,
within,
} from "@testing-library/react";
import type { ComponentProps } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { UsageDashboard } from "@/components/usage/UsageDashboard";
const useProviderStatsMock = vi.hoisted(() => vi.fn());
const useModelStatsMock = vi.hoisted(() => vi.fn());
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
i18n: {
resolvedLanguage: "en",
language: "en",
},
}),
}));
vi.mock("framer-motion", () => ({
motion: {
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
},
}));
vi.mock("@/hooks/useUsageEventBridge", () => ({
useUsageEventBridge: () => {},
}));
vi.mock("@/lib/query/usage", async () => {
const actual =
await vi.importActual<typeof import("@/lib/query/usage")>(
"@/lib/query/usage",
);
return {
...actual,
useProviderStats: (...args: unknown[]) => useProviderStatsMock(...args),
useModelStats: (...args: unknown[]) => useModelStatsMock(...args),
};
});
vi.mock("@/components/usage/UsageHero", () => ({
UsageHero: () => <div data-testid="usage-hero" />,
}));
vi.mock("@/components/usage/UsageTrendChart", () => ({
UsageTrendChart: () => <div data-testid="usage-trend" />,
}));
vi.mock("@/components/usage/RequestLogTable", () => ({
RequestLogTable: () => <div data-testid="request-log-table" />,
}));
vi.mock("@/components/usage/ProviderStatsTable", () => ({
ProviderStatsTable: () => <div data-testid="provider-stats-table" />,
}));
vi.mock("@/components/usage/ModelStatsTable", () => ({
ModelStatsTable: () => <div data-testid="model-stats-table" />,
}));
vi.mock("@/components/usage/PricingConfigPanel", () => ({
PricingConfigPanel: () => <div data-testid="pricing-config-panel" />,
}));
vi.mock("@/components/usage/UsageDateRangePicker", () => ({
UsageDateRangePicker: () => <button type="button">date-range</button>,
}));
vi.mock("@/components/ui/select", () => ({
Select: ({ value, onValueChange, children }: any) => (
<div data-testid={`select-${value}`}>
{children}
<button type="button" onClick={() => onValueChange?.("5000")}>
choose-5000
</button>
</div>
),
SelectTrigger: ({ children, ...props }: any) => (
<button type="button" {...props}>
{children}
</button>
),
SelectValue: () => null,
SelectContent: ({ children }: any) => <div>{children}</div>,
SelectItem: ({ children, ...props }: any) => <div {...props}>{children}</div>,
}));
const renderDashboard = (props: ComponentProps<typeof UsageDashboard> = {}) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<UsageDashboard {...props} />
</QueryClientProvider>,
);
};
describe("UsageDashboard", () => {
beforeEach(() => {
useProviderStatsMock.mockReset();
useModelStatsMock.mockReset();
useProviderStatsMock.mockReturnValue({ data: [] });
useModelStatsMock.mockReturnValue({ data: [] });
});
it("uses the saved refresh interval when mounted", () => {
renderDashboard({ refreshIntervalMs: 5000 });
expect(screen.getByTestId("select-5000")).toBeInTheDocument();
});
it("persists refresh interval changes", async () => {
const onRefreshIntervalChange = vi.fn().mockResolvedValue(true);
renderDashboard({ onRefreshIntervalChange });
fireEvent.click(
within(screen.getByTestId("select-30000")).getByRole("button", {
name: "choose-5000",
}),
);
await waitFor(() =>
expect(onRefreshIntervalChange).toHaveBeenCalledWith(5000),
);
expect(screen.getByTestId("select-5000")).toBeInTheDocument();
});
it("rolls back optimistic interval changes when persistence fails", async () => {
const onRefreshIntervalChange = vi.fn().mockResolvedValue(false);
renderDashboard({ onRefreshIntervalChange });
fireEvent.click(
within(screen.getByTestId("select-30000")).getByRole("button", {
name: "choose-5000",
}),
);
await waitFor(() =>
expect(onRefreshIntervalChange).toHaveBeenCalledWith(5000),
);
await waitFor(() =>
expect(screen.getByTestId("select-30000")).toBeInTheDocument(),
);
});
});
+31 -12
View File
@@ -10,21 +10,40 @@ describe("Kimi For Coding Provider Preset", () => {
expect(kimiForCoding).toBeDefined();
});
it("should use template placeholder for Claude Code auto-compact window", () => {
// CLAUDE_CODE_MAX_CONTEXT_TOKENS is ignored for claude-* model ids, so the
// preset must route the endpoint's own alias for the context envs to bite
it("should route the kimi-for-coding model id on every tier", () => {
const env = (kimiForCoding!.settingsConfig as any).env;
expect(env).toHaveProperty(
"CLAUDE_CODE_AUTO_COMPACT_WINDOW",
"${CLAUDE_CODE_AUTO_COMPACT_WINDOW}",
);
expect(env).toMatchObject({
ANTHROPIC_MODEL: "kimi-for-coding",
ANTHROPIC_DEFAULT_HAIKU_MODEL: "kimi-for-coding",
ANTHROPIC_DEFAULT_SONNET_MODEL: "kimi-for-coding",
ANTHROPIC_DEFAULT_OPUS_MODEL: "kimi-for-coding",
});
});
it("should expose auto-compact window as editable template value with Kimi default", () => {
const values = (kimiForCoding!.templateValues as any)
?.CLAUDE_CODE_AUTO_COMPACT_WINDOW;
expect(values).toBeDefined();
expect(values.defaultValue).toBe("262144");
expect(values.editorValue).toBe("262144");
expect(values.label).toBe("Auto Compact Window");
// 预设直接钉值,不再暴露表单输入框;要调整的用户直接改 JSON 编辑框
it("should pin the 256K context envs without exposing form fields", () => {
const env = (kimiForCoding!.settingsConfig as any).env;
expect(env.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe("262144");
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe("262144");
expect(kimiForCoding!.templateValues).toBeUndefined();
});
});
describe("Codex Provider Preset", () => {
const codex = providerPresets.find((p) => p.name === "Codex");
it("should include the Codex preset", () => {
expect(codex).toBeDefined();
});
// 预设直接钉 Codex 目录的 372K 窗口(openai/codex#31860),不暴露表单输入框
it("should pin the Codex-catalog 372K window without exposing form fields", () => {
const env = (codex!.settingsConfig as any).env;
expect(env.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe("372000");
expect(env.CLAUDE_CODE_AUTO_COMPACT_WINDOW).toBe("372000");
expect(codex!.templateValues).toBeUndefined();
});
});
@@ -129,6 +129,14 @@ const expectedChatPresets = new Map<
]);
describe("Codex Chat provider presets", () => {
it("enables session-based prompt cache routing for Kimi Coding", () => {
const preset = codexProviderPresets.find(
(item) => item.name === "Kimi For Coding",
);
expect(preset?.promptCacheRouting).toBe("enabled");
});
it("marks migrated Chat Completions presets for local routing", () => {
for (const [name, expected] of expectedChatPresets) {
const preset = codexProviderPresets.find((item) => item.name === name);
@@ -163,7 +171,7 @@ describe("Codex Chat provider presets", () => {
{ contextWindows: { "doubao-seed-2-1-pro-260628": 262144 } },
],
["Bailian", { contextWindows: { "qwen3-coder-plus": 1048576 } }],
["Longcat", { contextWindows: { "LongCat-2.0-Preview": 1048576 } }],
["Longcat", { contextWindows: { "LongCat-2.0": 1048576 } }],
["MiniMax", { contextWindows: { "MiniMax-M3": 1000000 } }],
["MiniMax en", { contextWindows: { "MiniMax-M3": 1000000 } }],
[
+149
View File
@@ -0,0 +1,149 @@
import { describe, expect, it } from "vitest";
import { claudeDesktopProviderPresets } from "@/config/claudeDesktopProviderPresets";
import { providerPresets } from "@/config/claudeProviderPresets";
import { codexProviderPresets } from "@/config/codexProviderPresets";
import { hermesProviderPresets } from "@/config/hermesProviderPresets";
import { openclawProviderPresets } from "@/config/openclawProviderPresets";
import { opencodeProviderPresets } from "@/config/opencodeProviderPresets";
const LONGCAT_MODEL = "LongCat-2.0";
const LONGCAT_DISPLAY_NAME = "LongCat 2.0";
const LONGCAT_OPENAI_BASE_URL = "https://api.longcat.chat/openai/v1";
const LONGCAT_BRAND = "LongCat";
const FLASH_VARIANT = "Flash";
const CHAT_SUFFIX = "Chat";
const REMOVED_LONGCAT_NAMES = [
`${LONGCAT_BRAND}-${FLASH_VARIANT}-${CHAT_SUFFIX}`,
`${LONGCAT_BRAND} ${FLASH_VARIANT} ${CHAT_SUFFIX}`,
`${LONGCAT_MODEL}-Preview`,
`${LONGCAT_DISPLAY_NAME} Preview`,
];
function findLongcatPreset<T extends { name: string }>(presets: T[]): T {
const preset = presets.find((item) => item.name === "Longcat");
expect(preset).toBeDefined();
return preset!;
}
describe("Longcat provider presets", () => {
it("uses the official LongCat 2.0 model for Claude Code", () => {
const preset = findLongcatPreset(providerPresets);
const env = (preset.settingsConfig as { env: Record<string, unknown> }).env;
expect(env).toMatchObject({
ANTHROPIC_MODEL: LONGCAT_MODEL,
ANTHROPIC_SMALL_FAST_MODEL: LONGCAT_MODEL,
ANTHROPIC_DEFAULT_HAIKU_MODEL: LONGCAT_MODEL,
ANTHROPIC_DEFAULT_SONNET_MODEL: LONGCAT_MODEL,
ANTHROPIC_DEFAULT_OPUS_MODEL: LONGCAT_MODEL,
CLAUDE_CODE_MAX_OUTPUT_TOKENS: "131072",
});
});
it("uses the official LongCat 2.0 model for Claude Desktop routes", () => {
const preset = findLongcatPreset(claudeDesktopProviderPresets);
expect(
preset.modelRoutes?.map((route) => ({
upstreamModel: route.upstreamModel,
labelOverride: route.labelOverride,
})),
).toEqual([
{
upstreamModel: LONGCAT_MODEL,
labelOverride: LONGCAT_MODEL,
},
]);
});
it("uses the official LongCat 2.0 model for Hermes", () => {
const preset = findLongcatPreset(hermesProviderPresets);
expect(preset.settingsConfig.models).toEqual([
{ id: LONGCAT_MODEL, name: LONGCAT_DISPLAY_NAME },
]);
expect(preset.suggestedDefaults?.model).toEqual({
default: LONGCAT_MODEL,
provider: "longcat",
});
});
it("uses the official LongCat 2.0 model for OpenCode", () => {
const preset = findLongcatPreset(opencodeProviderPresets);
expect(preset.settingsConfig.options?.baseURL).toBe(
LONGCAT_OPENAI_BASE_URL,
);
expect(preset.templateValues?.baseURL.defaultValue).toBe(
LONGCAT_OPENAI_BASE_URL,
);
expect(preset.templateValues?.baseURL.placeholder).toBe(
LONGCAT_OPENAI_BASE_URL,
);
expect(preset.settingsConfig.models).toEqual({
[LONGCAT_MODEL]: {
name: LONGCAT_DISPLAY_NAME,
options: { thinking: { type: "disabled" } },
},
});
});
it("uses the official LongCat 2.0 model for OpenClaw", () => {
const preset = findLongcatPreset(openclawProviderPresets);
expect(preset.settingsConfig.baseUrl).toBe(LONGCAT_OPENAI_BASE_URL);
expect(preset.templateValues?.baseUrl.defaultValue).toBe(
LONGCAT_OPENAI_BASE_URL,
);
expect(preset.templateValues?.baseUrl.placeholder).toBe(
LONGCAT_OPENAI_BASE_URL,
);
expect(preset.settingsConfig.models).toEqual([
expect.objectContaining({
id: LONGCAT_MODEL,
name: LONGCAT_DISPLAY_NAME,
reasoning: false,
input: ["text"],
contextWindow: 1048576,
maxTokens: 131072,
compat: { maxTokensField: "max_tokens" },
}),
]);
expect(preset.suggestedDefaults?.model).toEqual({
primary: `longcat/${LONGCAT_MODEL}`,
});
expect(preset.suggestedDefaults?.modelCatalog).toEqual({
[`longcat/${LONGCAT_MODEL}`]: { alias: "LongCat" },
});
});
it("uses the official LongCat 2.0 model for Codex", () => {
const preset = findLongcatPreset(codexProviderPresets);
expect(preset.config).toContain(`model = "${LONGCAT_MODEL}"`);
expect(preset.modelCatalog).toEqual([
expect.objectContaining({
model: LONGCAT_MODEL,
displayName: LONGCAT_DISPLAY_NAME,
contextWindow: 1048576,
}),
]);
});
it("does not keep retired or preview Longcat model names", () => {
const longcatPresets = [
findLongcatPreset(providerPresets),
findLongcatPreset(claudeDesktopProviderPresets),
findLongcatPreset(hermesProviderPresets),
findLongcatPreset(opencodeProviderPresets),
findLongcatPreset(openclawProviderPresets),
findLongcatPreset(codexProviderPresets),
];
const serializedPresets = JSON.stringify(longcatPresets);
for (const removedName of REMOVED_LONGCAT_NAMES) {
expect(serializedPresets).not.toContain(removedName);
}
});
});
@@ -22,7 +22,7 @@ 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-4.6");
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");
});
@@ -42,7 +42,7 @@ describe("TheRouter OpenCode and OpenClaw presets", () => {
expect(preset?.settingsConfig.api).toBe("openai-completions");
expect(modelIds).toEqual(
expect.arrayContaining([
"anthropic/claude-sonnet-4.6",
"anthropic/claude-sonnet-5",
"openai/gpt-5.3-codex",
"openai/gpt-5.2",
"google/gemini-3.5-flash",
@@ -55,7 +55,7 @@ describe("TheRouter OpenCode and OpenClaw presets", () => {
cost: { input: 1.5, output: 9, cacheRead: 0.15 },
});
expect(preset?.suggestedDefaults?.model).toEqual({
primary: "therouter/anthropic/claude-sonnet-4.6",
primary: "therouter/anthropic/claude-sonnet-5",
fallbacks: [
"therouter/openai/gpt-5.2",
"therouter/google/gemini-3.5-flash",
@@ -17,12 +17,12 @@ describe("TheRouter provider presets", () => {
expect(env.ANTHROPIC_BASE_URL).toBe("https://api.therouter.ai");
expect(env.ANTHROPIC_AUTH_TOKEN).toBe("");
expect(env.ANTHROPIC_API_KEY).toBe("");
expect(env.ANTHROPIC_MODEL).toBe("anthropic/claude-sonnet-4.6");
expect(env.ANTHROPIC_MODEL).toBe("anthropic/claude-sonnet-5");
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe(
"anthropic/claude-haiku-4.5",
);
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe(
"anthropic/claude-sonnet-4.6",
"anthropic/claude-sonnet-5",
);
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe("anthropic/claude-opus-4.8");
});
+109
View File
@@ -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({});
});
});
+67
View File
@@ -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);
});
});
+102 -3
View File
@@ -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");
});
+38
View File
@@ -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);
+132
View File
@@ -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,
});
});
});
+118
View File
@@ -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({
+2 -1
View File
@@ -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 },
);
});
});
});
+116 -2
View File
@@ -50,7 +50,7 @@ describe("isTransientUsageError", () => {
);
});
it("HTTP 5xx → 瞬时(true);4xx → 非瞬时(false", () => {
it("HTTP 5xx / 429(限流)→ 瞬时(true);其余 4xx → 非瞬时(false", () => {
expect(isTransientUsageError(fail("API error (HTTP 500): oops"))).toBe(
true,
);
@@ -60,12 +60,19 @@ describe("isTransientUsageError", () => {
expect(
isTransientUsageError(fail("API error (HTTP 502): bad gateway")),
).toBe(true);
// 429 是限流:稍后重试即可恢复,归瞬时——且不应清空 keep-last-good 快照
expect(
isTransientUsageError(fail("API error (HTTP 429): rate limited")),
).toBe(false);
).toBe(true);
expect(
isTransientUsageError(fail("HTTP 429 Too Many Requests : x")),
).toBe(true);
expect(
isTransientUsageError(fail("Authentication failed (HTTP 403)")),
).toBe(false);
expect(
isTransientUsageError(fail("Authentication failed (HTTP 401)")),
).toBe(false);
});
it("成功 / 无错误信息 → false", () => {
@@ -173,4 +180,111 @@ describe("resolveDisplayUsage (keep-last-good)", () => {
const r = resolveDisplayUsage(success, 0, null, now);
expect(r.lastGood).toEqual({ data: success, at: now });
});
it("429 限流:窗口内继续展示上次成功,且不清空 lastGood", () => {
const prev: LastGoodUsage = { data: ok(42), at: T0 };
const now = T0 + 1000;
const r = resolveDisplayUsage(
fail("API error (HTTP 429): rate limited"),
now,
prev,
now,
);
expect(r.data).toBe(prev.data); // 掩盖:继续展示上次成功
expect(r.lastGood).toBe(prev); // 快照保留,抖动过去后可继续兜底
});
});
// 订阅系 hooksuseSubscriptionQuota / useCodexOauthQuota)复用同一决策函数,
// 这里用 SubscriptionQuota 的形状锁定泛型行为。
describe("resolveDisplayUsageSubscriptionQuota 形状)", () => {
interface QuotaLike {
success: boolean;
error?: string | null;
credentialStatus: string;
queriedAt: number | null;
}
const quotaOk = (queriedAt: number): QuotaLike => ({
success: true,
error: null,
credentialStatus: "valid",
queriedAt,
});
const quotaFail = (error: string): QuotaLike => ({
success: false,
error,
credentialStatus: "valid",
queriedAt: null,
});
it("瞬时 5xx:窗口内继续展示上次成功的 quota(含其 queriedAt", () => {
const prev = { data: quotaOk(T0), at: T0 };
const now = T0 + 1000;
const r = resolveDisplayUsage(
quotaFail("API error (HTTP 502): bad gateway"),
now,
prev,
now,
);
expect(r.data).toBe(prev.data);
expect(r.data?.queriedAt).toBe(T0); // 展示的相对时间指向旧成功
});
it("确定性失败(token 过期):立即透出并清空 lastGood", () => {
const prev = { data: quotaOk(T0), at: T0 };
const now = T0 + 1000;
const failure = quotaFail("OAuth token has expired");
const r = resolveDisplayUsage(failure, now, prev, now);
expect(r.data).toBe(failure);
expect(r.lastGood).toBeNull();
});
});
// invoke reject(后端 Err:网络/超时/读体中断)时 react-query 保留上次成功 data
// `rejected` 标志让这份"陈旧成功"走同一 keep-last-good 窗口(锚定上次成功时刻
// dataUpdatedAt),而不是无限期被当作新鲜成功展示——否则「彻底断网」反而比
// 「单次 5xx」掩盖更久。
describe("resolveDisplayUsagerejectedreject 保留的旧成功按窗口过期)", () => {
it("窗口内:继续展示旧成功;prevLastGood=null(组件重挂丢 ref)也从 dataUpdatedAt 补种", () => {
const stale = ok(42); // react-query 保留的旧成功,dataUpdatedAt = 其成功时刻
const now = T0 + KEEP_LAST_GOOD_MS - 1;
const r = resolveDisplayUsage(stale, T0, null, now, { rejected: true });
expect(r.data).toBe(stale);
expect(r.lastQueriedAt).toBe(T0); // 相对时间指向上次真实成功
expect(r.lastGood).toEqual({ data: stale, at: T0 });
});
it("超窗(>= keepMs):不再展示旧成功(data 置空由调用方合成失败占位),快照保留不清空", () => {
const stale = ok(42);
const now = T0 + KEEP_LAST_GOOD_MS;
const r = resolveDisplayUsage(stale, T0, null, now, { rejected: true });
expect(r.data).toBeUndefined();
expect(r.lastQueriedAt).toBe(T0);
expect(r.lastGood).toEqual({ data: stale, at: T0 }); // reject 属瞬时,不清快照
});
it("reject 且从无成功(raw=undefined,首次查询即失败):data 为 undefinedlastGood 不变", () => {
const r = resolveDisplayUsage(undefined, 0, null, T0, { rejected: true });
expect(r.data).toBeUndefined();
expect(r.lastQueriedAt).toBeNull();
expect(r.lastGood).toBeNull();
});
it("reject 但保留的是确定性失败结果:照旧立即透出并清空 lastGood(rejected 只作用于成功值)", () => {
const prev: LastGoodUsage = { data: ok(42), at: T0 };
const failure = fail("Authentication failed (HTTP 401)");
const r = resolveDisplayUsage(failure, T0 + 1000, prev, T0 + 1000, {
rejected: true,
});
expect(r.data).toBe(failure);
expect(r.lastGood).toBeNull();
});
it("rejected 未传(默认 false):成功照常作为新鲜结果,既有语义不变", () => {
const success = ok(7);
const now = T0 + KEEP_LAST_GOOD_MS * 3; // 距 T0 很久也无妨:非 reject 的成功就是新鲜的
const r = resolveDisplayUsage(success, now, null, now);
expect(r.data).toBe(success);
expect(r.lastGood).toEqual({ data: success, at: now });
});
});
+1
View File
@@ -337,6 +337,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: {},