Skip to content

Commit 0f743cc

Browse files
committed
Merge pull request #584 from ndycode/claude/audit-65-settings-hub-shared-tests
test: direct suite for settings-hub shared merge/persist helpers
2 parents c23e518 + 8457e39 commit 0f743cc

1 file changed

Lines changed: 252 additions & 0 deletions

File tree

test/settings-hub-shared.test.ts

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const {
4+
loadDashboardDisplaySettingsMock,
5+
saveDashboardDisplaySettingsMock,
6+
savePluginConfigMock,
7+
} = vi.hoisted(() => ({
8+
loadDashboardDisplaySettingsMock: vi.fn(),
9+
saveDashboardDisplaySettingsMock: vi.fn(),
10+
savePluginConfigMock: vi.fn(),
11+
}));
12+
13+
vi.mock("../lib/dashboard-settings.js", async (importOriginal) => ({
14+
...(await importOriginal<typeof import("../lib/dashboard-settings.js")>()),
15+
loadDashboardDisplaySettings: loadDashboardDisplaySettingsMock,
16+
saveDashboardDisplaySettings: saveDashboardDisplaySettingsMock,
17+
getDashboardSettingsPath: () => "/tmp/settings-hub-shared-test/settings.json",
18+
}));
19+
20+
vi.mock("../lib/config.js", async (importOriginal) => ({
21+
...(await importOriginal<typeof import("../lib/config.js")>()),
22+
savePluginConfig: savePluginConfigMock,
23+
}));
24+
25+
import {
26+
applyDashboardDefaultsForKeys,
27+
clampBackendNumber,
28+
cloneDashboardSettings,
29+
copyDashboardSettingValue,
30+
mergeDashboardSettingsForKeys,
31+
persistBackendConfigSelectionForTests,
32+
persistDashboardSettingsSelectionForTests,
33+
} from "../lib/codex-manager/settings-hub/shared.js";
34+
import { DEFAULT_DASHBOARD_DISPLAY_SETTINGS } from "../lib/dashboard-settings.js";
35+
import { backendSettingsEqual } from "../lib/codex-manager/backend-settings-helpers.js";
36+
import type { DashboardDisplaySettings } from "../lib/dashboard-settings.js";
37+
import type { PluginConfig } from "../lib/types.js";
38+
import type { BackendNumberSettingOption } from "../lib/codex-manager/backend-settings-schema.js";
39+
40+
function busyError(): NodeJS.ErrnoException {
41+
const error = new Error("locked") as NodeJS.ErrnoException;
42+
error.code = "EBUSY";
43+
return error;
44+
}
45+
46+
describe("settings hub shared helpers", () => {
47+
beforeEach(() => {
48+
vi.clearAllMocks();
49+
loadDashboardDisplaySettingsMock.mockResolvedValue({});
50+
saveDashboardDisplaySettingsMock.mockResolvedValue(undefined);
51+
savePluginConfigMock.mockResolvedValue(undefined);
52+
});
53+
54+
afterEach(() => {
55+
vi.restoreAllMocks();
56+
});
57+
58+
describe("copyDashboardSettingValue", () => {
59+
it("copies scalars and clones array values", () => {
60+
const source: DashboardDisplaySettings = {
61+
menuShowLastUsed: false,
62+
menuStatuslineFields: ["status", "limits"],
63+
};
64+
const target: DashboardDisplaySettings = {};
65+
copyDashboardSettingValue(target, source, "menuShowLastUsed");
66+
copyDashboardSettingValue(target, source, "menuStatuslineFields");
67+
expect(target.menuShowLastUsed).toBe(false);
68+
expect(target.menuStatuslineFields).toStrictEqual(["status", "limits"]);
69+
// Arrays must be copied, not shared: mutating the target later must
70+
// never write through to the source settings object.
71+
expect(target.menuStatuslineFields).not.toBe(source.menuStatuslineFields);
72+
});
73+
});
74+
75+
describe("applyDashboardDefaultsForKeys", () => {
76+
it("resets only the listed keys and leaves the draft untouched", () => {
77+
const draft: DashboardDisplaySettings = {
78+
menuShowLastUsed: false,
79+
menuShowQuotaSummary: false,
80+
};
81+
const next = applyDashboardDefaultsForKeys(draft, ["menuShowLastUsed"]);
82+
expect(next.menuShowLastUsed).toBe(
83+
DEFAULT_DASHBOARD_DISPLAY_SETTINGS.menuShowLastUsed,
84+
);
85+
expect(next.menuShowQuotaSummary).toBe(false);
86+
expect(draft.menuShowLastUsed).toBe(false);
87+
});
88+
});
89+
90+
describe("mergeDashboardSettingsForKeys", () => {
91+
it("takes only the listed keys from the selection", () => {
92+
const base: DashboardDisplaySettings = {
93+
menuShowLastUsed: false,
94+
menuShowQuotaSummary: false,
95+
};
96+
const selected: DashboardDisplaySettings = {
97+
menuShowLastUsed: true,
98+
menuShowQuotaSummary: true,
99+
};
100+
const merged = mergeDashboardSettingsForKeys(base, selected, [
101+
"menuShowLastUsed",
102+
]);
103+
expect(merged.menuShowLastUsed).toBe(true);
104+
expect(merged.menuShowQuotaSummary).toBe(false);
105+
expect(base.menuShowLastUsed).toBe(false);
106+
});
107+
});
108+
109+
describe("persistDashboardSettingsSelection", () => {
110+
it("re-reads the latest settings and merges only the panel's keys onto them", async () => {
111+
// A concurrent edit to an unrelated key landed on disk after the panel
112+
// loaded: the write must preserve it instead of clobbering with the
113+
// panel's stale view.
114+
loadDashboardDisplaySettingsMock.mockResolvedValue({
115+
menuHighlightCurrentRow: false,
116+
});
117+
const result = await persistDashboardSettingsSelectionForTests(
118+
{ menuShowLastUsed: false, menuHighlightCurrentRow: true },
119+
["menuShowLastUsed"],
120+
"dashboard",
121+
);
122+
expect(saveDashboardDisplaySettingsMock).toHaveBeenCalledTimes(1);
123+
const saved = saveDashboardDisplaySettingsMock.mock
124+
.calls[0]?.[0] as DashboardDisplaySettings;
125+
expect(saved.menuShowLastUsed).toBe(false);
126+
expect(saved.menuHighlightCurrentRow).toBe(false);
127+
expect(result.menuHighlightCurrentRow).toBe(false);
128+
});
129+
130+
it("retries transient write failures through the queued-retry policy", async () => {
131+
saveDashboardDisplaySettingsMock
132+
.mockRejectedValueOnce(busyError())
133+
.mockResolvedValueOnce(undefined);
134+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
135+
const result = await persistDashboardSettingsSelectionForTests(
136+
{ menuShowLastUsed: false },
137+
["menuShowLastUsed"],
138+
"dashboard",
139+
);
140+
expect(saveDashboardDisplaySettingsMock).toHaveBeenCalledTimes(2);
141+
expect(warnSpy).not.toHaveBeenCalled();
142+
expect(result.menuShowLastUsed).toBe(false);
143+
});
144+
145+
it("gives up immediately on a non-retryable error, warning with the fallback", async () => {
146+
saveDashboardDisplaySettingsMock.mockRejectedValue(
147+
new Error("disk gone"),
148+
);
149+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
150+
const selected: DashboardDisplaySettings = { menuShowLastUsed: false };
151+
const result = await persistDashboardSettingsSelectionForTests(
152+
selected,
153+
["menuShowLastUsed"],
154+
"dashboard",
155+
);
156+
expect(warnSpy).toHaveBeenCalledWith(
157+
"Settings save failed (dashboard) after retries: disk gone",
158+
);
159+
// The fallback is the clone-normalized selection (the clone fills
160+
// documented defaults), never the caller's object itself.
161+
expect(result).toStrictEqual(cloneDashboardSettings(selected));
162+
expect(result.menuShowLastUsed).toBe(false);
163+
expect(result).not.toBe(selected);
164+
// Non-retryable: the write must fail on the first attempt, no retries.
165+
expect(saveDashboardDisplaySettingsMock).toHaveBeenCalledTimes(1);
166+
});
167+
168+
it("exhausts all four attempts on persistent EBUSY before falling back", async () => {
169+
saveDashboardDisplaySettingsMock.mockRejectedValue(busyError());
170+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
171+
const result = await persistDashboardSettingsSelectionForTests(
172+
{ menuShowLastUsed: false },
173+
["menuShowLastUsed"],
174+
"dashboard",
175+
);
176+
expect(saveDashboardDisplaySettingsMock).toHaveBeenCalledTimes(4);
177+
expect(warnSpy).toHaveBeenCalledWith(
178+
"Settings save failed (dashboard) after retries: locked",
179+
);
180+
expect(result.menuShowLastUsed).toBe(false);
181+
});
182+
});
183+
184+
describe("persistBackendConfigSelection", () => {
185+
it("saves the backend patch and returns a defensive clone of the selection", async () => {
186+
const selected = {
187+
unsupportedCodexFallbackChain: { "gpt-5.3-codex": ["gpt-5.2"] },
188+
} as PluginConfig;
189+
const result = await persistBackendConfigSelectionForTests(
190+
selected,
191+
"backend",
192+
);
193+
expect(savePluginConfigMock).toHaveBeenCalledTimes(1);
194+
expect(backendSettingsEqual(result, selected)).toBe(true);
195+
expect(result).not.toBe(selected);
196+
expect(result.unsupportedCodexFallbackChain).not.toBe(
197+
selected.unsupportedCodexFallbackChain,
198+
);
199+
});
200+
201+
it("warns and falls back to the selection clone when the save fails", async () => {
202+
savePluginConfigMock.mockRejectedValue(new Error("config locked"));
203+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
204+
const selected = {} as PluginConfig;
205+
const result = await persistBackendConfigSelectionForTests(
206+
selected,
207+
"backend",
208+
);
209+
expect(warnSpy).toHaveBeenCalledWith(
210+
"Settings save failed (backend) after retries: config locked",
211+
);
212+
expect(backendSettingsEqual(result, selected)).toBe(true);
213+
});
214+
215+
it("exhausts all four attempts on persistent EBUSY for the backend path too", async () => {
216+
savePluginConfigMock.mockRejectedValue(busyError());
217+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
218+
const selected = {} as PluginConfig;
219+
const result = await persistBackendConfigSelectionForTests(
220+
selected,
221+
"backend",
222+
);
223+
expect(savePluginConfigMock).toHaveBeenCalledTimes(4);
224+
expect(warnSpy).toHaveBeenCalledWith(
225+
"Settings save failed (backend) after retries: locked",
226+
);
227+
expect(backendSettingsEqual(result, selected)).toBe(true);
228+
});
229+
});
230+
231+
describe("clampBackendNumber", () => {
232+
const option = {
233+
key: "fetchTimeoutMs",
234+
label: "fetch timeout",
235+
min: 10,
236+
max: 100,
237+
step: 5,
238+
unit: "duration",
239+
} as unknown as BackendNumberSettingOption;
240+
241+
it.each([
242+
[5, 10],
243+
[10, 10],
244+
[55.4, 55],
245+
[55.5, 56],
246+
[100, 100],
247+
[250, 100],
248+
])("clamps and rounds %d to %d", (value, expected) => {
249+
expect(clampBackendNumber(option, value)).toBe(expected);
250+
});
251+
});
252+
});

0 commit comments

Comments
 (0)