Files
cc-switch/tests/components/McodeProviderForm.test.tsx
T
小七 2625bcf192 fix(opencode): preserve untouched extra-option value types on edit (#7841)
The extra-options row editor keeps every value as display text, and any
row change re-parsed all rows with JSON.parse. Untouched string values
such as "123", "false" or "null" were silently turned into numbers,
booleans or null on save, in both the OpenCode and MiniMax Code forms.

Merge row edits into the stored options instead: a row whose text still
matches the stored value's display form keeps the original value and
type, and a renamed key inherits its old value. Renaming an option onto
a key that already exists is rejected, matching the model field editor.

Fixes #7833
2026-10-10 12:05:05 +08:00

309 lines
9.9 KiB
TypeScript

import type { ReactElement } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import {
fireEvent,
render as rtlRender,
screen,
waitFor,
} from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { McodeProviderForm } from "@/components/providers/forms/McodeProviderForm";
import { mcodeProviderPresets } from "@/config/mcodeProviderPresets";
import { piProviderPresets } from "@/config/piProviderPresets";
import { setProviders } from "../msw/state";
import { createTestQueryClient } from "../utils/testQueryClient";
vi.mock("@/components/JsonEditor", () => ({
default: ({
id,
value,
onChange,
}: {
id: string;
value: string;
onChange: (value: string) => void;
}) => (
<textarea
id={id}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
),
}));
const render = (ui: ReactElement) =>
rtlRender(
<QueryClientProvider client={createTestQueryClient()}>
{ui}
</QueryClientProvider>,
);
const original = {
kind: "custom",
api: "anthropic-messages",
enabled: true,
options: {
baseURL: "https://api.example.com/anthropic",
apiKey: "local-test-key",
headers: { "X-Test": "keep" },
},
models: {
model: {
name: "Model",
limit: { context: 200000 },
thinking: { effort: "high" },
},
},
futureSetting: { keep: true },
};
describe("McodeProviderForm", () => {
it.each([true, false])(
"edits native fields while preserving model options and unknown settings (explicit API: %s)",
async (explicitApi) => {
const submit = vi.fn();
const settings: Record<string, unknown> = { ...original };
if (!explicitApi) delete settings.api;
render(
<McodeProviderForm
appId="mcode"
providerId="existing"
initialData={{ name: "Existing", settingsConfig: settings }}
submitLabel="Save"
onSubmit={submit}
onCancel={() => {}}
/>,
);
fireEvent.change(screen.getByLabelText("provider.name"), {
target: { value: "Renamed" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
const saved = JSON.parse(submit.mock.calls[0][0].settingsConfig);
expect(saved).toEqual({ ...settings, name: "Renamed" });
expect(submit.mock.calls[0][0].providerKey).toBe("existing");
expect(screen.getByLabelText("API Key")).toHaveAttribute(
"type",
"password",
);
},
);
it("keeps untouched extra option values verbatim when another option is edited", async () => {
const submit = vi.fn();
render(
<McodeProviderForm
appId="mcode"
providerId="existing"
initialData={{
name: "Existing",
settingsConfig: {
kind: "custom",
api: "anthropic-messages",
enabled: true,
options: {
baseURL: "https://api.example.com/anthropic",
apiKey: "local-test-key",
name: "123",
timeout: 100,
},
models: { model: { name: "Model" } },
},
}}
submitLabel="Save"
onSubmit={submit}
onCancel={() => {}}
/>,
);
const valueInputs = screen.getAllByPlaceholderText("600000");
fireEvent.change(valueInputs[valueInputs.length - 1], {
target: { value: "200" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
const saved = JSON.parse(submit.mock.calls[0][0].settingsConfig);
expect(saved.options).toMatchObject({ name: "123", timeout: 200 });
});
it("keeps both rows with their types when a rename collides with an existing key", async () => {
const submit = vi.fn();
render(
<McodeProviderForm
appId="mcode"
providerId="existing"
initialData={{
name: "Existing",
settingsConfig: {
kind: "custom",
api: "anthropic-messages",
enabled: true,
options: {
baseURL: "https://api.example.com/mcode",
apiKey: "local-test-key",
target: "100",
source: 100,
},
models: { model: { name: "Model" } },
},
}}
submitLabel="Save"
onSubmit={submit}
onCancel={() => {}}
/>,
);
const keyInputs = screen.getAllByPlaceholderText("timeout");
fireEvent.change(keyInputs[keyInputs.length - 1], {
target: { value: "target" },
});
fireEvent.blur(keyInputs[keyInputs.length - 1]);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
const saved = JSON.parse(submit.mock.calls[0][0].settingsConfig);
expect(saved.options).toMatchObject({ target: "100", source: 100 });
});
it("keeps a rejected save open and displays the failure", async () => {
render(
<McodeProviderForm
appId="mcode"
initialData={{ name: "Existing", settingsConfig: original }}
submitLabel="Save"
onSubmit={async () => {
throw new Error("configuration busy");
}}
onCancel={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "Save" }));
expect(await screen.findByRole("alert")).toHaveTextContent(
"configuration busy",
);
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
});
it("uses the shared preset and model editors and saves native API fields", async () => {
const submit = vi.fn();
render(
<McodeProviderForm
appId="mcode"
submitLabel="Save"
onSubmit={submit}
onCancel={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "MiniMax" }));
fireEvent.change(screen.getByLabelText("API Key"), {
target: { value: "test-key" },
});
fireEvent.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
const saved = JSON.parse(submit.mock.calls[0][0].settingsConfig);
expect(saved.api).toBe("openai-completions");
expect(saved.options.apiKey).toBe("test-key");
expect(saved).not.toHaveProperty("npm");
expect(saved.models).toHaveProperty("MiniMax-M3");
expect(submit.mock.calls[0][0].providerKey).toBe("cc-switch-mini-max");
});
it("requires a well-formed, unused provider key for new providers", async () => {
setProviders("mcode", {
taken: { id: "taken", name: "Taken", settingsConfig: original },
});
const submit = vi.fn();
render(
<McodeProviderForm
appId="mcode"
submitLabel="Save"
onSubmit={submit}
onCancel={() => {}}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "MiniMax" }));
fireEvent.change(screen.getByLabelText("API Key"), {
target: { value: "test-key" },
});
const key = screen.getByLabelText(/opencode\.providerKey/);
const save = screen.getByRole("button", { name: "Save" });
expect(key).toHaveValue("cc-switch-mini-max");
expect(save).toBeEnabled();
fireEvent.change(key, { target: { value: "" } });
expect(save).toBeDisabled();
fireEvent.change(key, { target: { value: "-bad-" } });
expect(screen.getByText("opencode.providerKeyInvalid")).toBeInTheDocument();
expect(save).toBeDisabled();
fireEvent.change(key, { target: { value: "taken" } });
expect(
await screen.findByText("opencode.providerKeyDuplicate"),
).toBeInTheDocument();
expect(save).toBeDisabled();
fireEvent.change(key, { target: { value: "My Key!" } });
expect(key).toHaveValue("mykey");
fireEvent.click(save);
await waitFor(() => expect(submit).toHaveBeenCalledOnce());
expect(submit.mock.calls[0][0].providerKey).toBe("mykey");
});
it("locks the provider key when editing", () => {
render(
<McodeProviderForm
appId="mcode"
providerId="existing"
initialData={{ name: "Existing", settingsConfig: original }}
submitLabel="Save"
onSubmit={vi.fn()}
onCancel={() => {}}
/>,
);
const key = screen.getByLabelText(/opencode\.providerKey/);
expect(key).toHaveValue("existing");
expect(key).toBeDisabled();
expect(
screen.getByText("opencode.providerKeyLockedHint"),
).toBeInTheDocument();
});
it("keeps invalid JSON drafts from breaking the structured fields", () => {
render(
<McodeProviderForm
appId="mcode"
initialData={{ name: "Existing", settingsConfig: original }}
submitLabel="Save"
onSubmit={vi.fn()}
onCancel={() => {}}
/>,
);
fireEvent.change(screen.getByLabelText("provider.configJson"), {
target: { value: '{"options":{"baseURL":42}}' },
});
expect(screen.getByRole("button", { name: "Save" })).toBeDisabled();
fireEvent.change(screen.getByLabelText("provider.configJson"), {
target: { value: JSON.stringify(original) },
});
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
});
it("borrows the MiniMax preset without changing Pi or copying Pi compatibility fields", () => {
const pi = piProviderPresets.find((preset) => preset.name === "MiniMax")!;
const mcode = mcodeProviderPresets.find(
(preset) => preset.name === "MiniMax",
)!;
expect(mcode.settingsConfig.options.baseURL).toBe(
pi.settingsConfig.baseUrl,
);
expect(Object.keys(mcode.settingsConfig.models)).toContain("MiniMax-M3");
expect(mcode.settingsConfig).not.toHaveProperty("compat");
expect(pi.settingsConfig.models).toBeInstanceOf(Array);
expect(
mcodeProviderPresets.every((preset) =>
[
"anthropic-messages",
"openai-completions",
"openai-responses",
].includes(preset.settingsConfig.api),
),
).toBe(true);
});
});