Files
CC-Switch/tests/hooks/useCommonConfigSave.test.tsx
Jason 88d5ffba44 fix(codex): move common-config TOML merge off smol-toml to backend toml_edit
updateTomlCommonConfigSnippet re-serialized the whole document through
smol-toml (parse -> deepMerge -> stringify): comments dropped, keys
reordered, and empty parent table headers synthesized -- the
long-standing "config.toml keeps getting reordered" symptom (audit C5,
introduced in 083e48bf).

Replace it with a backend command backed by the same
merge_toml_table_like / remove_toml_table_like used when writing live
configs, so the form preview and the live write share one merge
semantic and user formatting survives edit-time merges. The frontend
sync helper is deleted outright to keep the pattern from coming back.

Making the form operations async exposes them to interleaving, so a
result is discarded unless it is still current when it lands:

- a per-hook sequence number (last operation wins) covers rapid
  toggle/save races where an earlier merge resolves after a later
  removal and would flip the switch back;
- a config-baseline check covers the user hand-editing the TOML while
  a merge is in flight -- the stale result must not clobber their edit.
  The checkbox state self-heals via the existing inference effect.

Regression tests pin both by resolving a suspended merge after a newer
operation / an external edit, plus backend tests locking comment and
key-order preservation, scalar override, and value-matched removal.
2026-07-08 22:46:09 +08:00

179 lines
6.2 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useCodexCommonConfig } from "@/components/providers/forms/hooks/useCodexCommonConfig";
import { useGeminiCommonConfig } from "@/components/providers/forms/hooks/useGeminiCommonConfig";
const getCommonConfigSnippetMock = vi.fn();
const setCommonConfigSnippetMock = vi.fn();
const extractCommonConfigSnippetMock = vi.fn();
const updateTomlCommonConfigSnippetMock = vi.fn();
vi.mock("@/lib/api", () => ({
configApi: {
getCommonConfigSnippet: (...args: unknown[]) =>
getCommonConfigSnippetMock(...args),
setCommonConfigSnippet: (...args: unknown[]) =>
setCommonConfigSnippetMock(...args),
extractCommonConfigSnippet: (...args: unknown[]) =>
extractCommonConfigSnippetMock(...args),
updateTomlCommonConfigSnippet: (...args: unknown[]) =>
updateTomlCommonConfigSnippetMock(...args),
},
}));
describe("common config snippet saving", () => {
beforeEach(() => {
getCommonConfigSnippetMock.mockResolvedValue("");
setCommonConfigSnippetMock.mockResolvedValue(undefined);
extractCommonConfigSnippetMock.mockResolvedValue("");
updateTomlCommonConfigSnippetMock.mockImplementation(
async (configToml: string) => configToml,
);
});
it("does not persist an invalid Codex common config snippet", async () => {
const onConfigChange = vi.fn();
const { result } = renderHook(() =>
useCodexCommonConfig({
codexConfig: "model = \"gpt-5\"",
onConfigChange,
}),
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
let saved = true;
await act(async () => {
saved = await result.current.handleCommonConfigSnippetChange(
"base_url = https://bad.example/v1",
);
});
expect(saved).toBe(false);
expect(setCommonConfigSnippetMock).not.toHaveBeenCalled();
expect(onConfigChange).not.toHaveBeenCalled();
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(() =>
useGeminiCommonConfig({
envValue: "",
onEnvChange,
envStringToObj: () => ({}),
envObjToString: () => "",
}),
);
await waitFor(() => expect(result.current.isLoading).toBe(false));
let saved = false;
act(() => {
saved = result.current.handleCommonConfigSnippetChange(
JSON.stringify({ GEMINI_MODEL: 123 }),
);
});
expect(saved).toBe(false);
expect(setCommonConfigSnippetMock).not.toHaveBeenCalled();
expect(onEnvChange).not.toHaveBeenCalled();
expect(result.current.commonConfigError).toBe(
"geminiConfig.commonConfigInvalidValues",
);
});
});