mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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.
This commit is contained in:
@@ -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(() =>
|
||||
|
||||
Reference in New Issue
Block a user