mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
Merge main and harden managed Codex account flow
This commit is contained in:
@@ -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]",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -51,8 +51,8 @@ vi.mock("@/hooks/useSkills", () => ({
|
||||
{
|
||||
directory: "shared-skill",
|
||||
name: "Shared Skill",
|
||||
description: "Imported from Claude",
|
||||
foundIn: ["claude"],
|
||||
description: "Imported from Grok Build",
|
||||
foundIn: ["grokbuild"],
|
||||
path: "/tmp/shared-skill",
|
||||
},
|
||||
],
|
||||
@@ -82,8 +82,8 @@ describe("UnifiedSkillsPanel", () => {
|
||||
{
|
||||
directory: "shared-skill",
|
||||
name: "Shared Skill",
|
||||
description: "Imported from Claude",
|
||||
foundIn: ["claude"],
|
||||
description: "Imported from Grok Build",
|
||||
foundIn: ["grokbuild"],
|
||||
path: "/tmp/shared-skill",
|
||||
},
|
||||
],
|
||||
@@ -116,5 +116,18 @@ describe("UnifiedSkillsPanel", () => {
|
||||
expect(screen.getByText("Shared Skill")).toBeInTheDocument();
|
||||
expect(screen.getByText("/tmp/shared-skill")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
screen.getByText("skills.importSelected").click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(importSkillsMock).toHaveBeenCalledWith([
|
||||
{
|
||||
directory: "shared-skill",
|
||||
apps: expect.objectContaining({ grokbuild: true }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,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(),
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user