1import {
2 DEFAULT_SERVER_SETTINGS,
3 ProviderDriverKind,
4 ProviderInstanceId,
5} from "@t3tools/contracts";
6import { DEFAULT_CLIENT_SETTINGS, type ClientSettings } from "@t3tools/contracts/settings";
7import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
8
9const persistenceMocks = vi.hoisted(() => ({
10 getClientSettings: vi.fn<() => Promise<ClientSettings | null>>(),
11 setClientSettings: vi.fn<(settings: ClientSettings) => Promise<void>>(),
12}));
13
14vi.mock("~/localApi", () => ({
15 ensureLocalApi: () => ({ persistence: persistenceMocks }),
16}));
17
18import {
19 __resetClientSettingsPersistenceForTests,
20 __setClientSettingsForTests,
21 ensureClientSettingsHydrated,
22 getClientSettings,
23 mergeEnvironmentSettings,
24 persistClientSettingsPatch,
25 persistClientSettingsUpdate,
26 prepareModelPreferencesPatch,
27 resolveEnvironmentIdentificationMode,
28} from "./useSettings";
29import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
30
31beforeEach(() => {
32 persistenceMocks.getClientSettings.mockReset().mockResolvedValue(null);
33 persistenceMocks.setClientSettings.mockReset().mockResolvedValue(undefined);
34 __resetClientSettingsPersistenceForTests();
35});
36
37afterEach(() => {
38 vi.restoreAllMocks();
39});
40
41describe("client settings hydration", () => {
42 const savedSettings = {
43 ...DEFAULT_CLIENT_SETTINGS,
44 timestampFormat: "12-hour" as const,
45 favorites: [{ provider: ProviderInstanceId.make("codex_work"), model: "gpt-5.6" }],
46 };
47 const onboardingCompletedAt = "2026-09-05T12:00:00.000Z";
48 const complete = (current: ClientSettings) => ({ ...current, onboardingCompletedAt });
49
50 it("waits for saved model preferences before applying the first server edit", async () => {
51 const provider = ProviderInstanceId.make("codex_work");
52 let finishRead!: (settings: ClientSettings) => void;
53 persistenceMocks.getClientSettings.mockReturnValueOnce(
54 new Promise((resolve) => {
55 finishRead = resolve;
56 }),
57 );
58 const patch = prepareModelPreferencesPatch(DEFAULT_SERVER_SETTINGS, {
59 setModelFavorites: [{ provider, model: "new", favorite: true }],
60 setModelsHidden: [{ provider, model: "new", hidden: true }],
61 });
62 expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS);
63 finishRead({
64 ...savedSettings,
65 providerModelPreferences: {
66 [provider]: { hiddenModels: ["saved-hidden"], modelOrder: ["saved-order"] },
67 },
68 });
69 const edited = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, await patch);
70 expect(edited.favorites).toEqual([...savedSettings.favorites, { provider, model: "new" }]);
71 expect(edited.providerModelPreferences?.[provider]).toEqual({
72 hiddenModels: ["saved-hidden", "new"],
73 modelOrder: ["saved-order"],
74 });
75 });
76
77 it("does not prepare a model preference write after a failed legacy read", async () => {
78 const failure = new Error("storage unavailable");
79 vi.spyOn(console, "error").mockImplementation(() => undefined);
80 persistenceMocks.getClientSettings.mockRejectedValueOnce(failure);
81 await expect(
82 prepareModelPreferencesPatch(DEFAULT_SERVER_SETTINGS, {
83 setProviderModelOrder: { provider: ProviderInstanceId.make("codex_work"), modelOrder: [] },
84 }),
85 ).rejects.toBe(failure);
86 });
87
88 it("rejects completion after a failed read and preserves saved preferences on retry", async () => {
89 const failure = new Error("storage unavailable");
90 vi.spyOn(console, "error").mockImplementation(() => undefined);
91 persistenceMocks.getClientSettings
92 .mockRejectedValueOnce(failure)
93 .mockResolvedValue(savedSettings);
94
95 await expect(persistClientSettingsUpdate(complete)).rejects.toBe(failure);
96 expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled();
97 expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS);
98
99 const completedSettings = { ...savedSettings, onboardingCompletedAt };
100 await expect(persistClientSettingsUpdate(complete)).resolves.toEqual(completedSettings);
101 expect(persistenceMocks.setClientSettings).toHaveBeenCalledExactlyOnceWith(completedSettings);
102 expect(persistenceMocks.getClientSettings).toHaveBeenCalledTimes(2);
103 });
104
105 it("uses defaults only after storage confirms no saved settings exist", async () => {
106 const completedSettings = { ...DEFAULT_CLIENT_SETTINGS, onboardingCompletedAt };
107
108 await expect(persistClientSettingsUpdate(complete)).resolves.toEqual(completedSettings);
109 expect(persistenceMocks.getClientSettings).toHaveBeenCalledOnce();
110 expect(persistenceMocks.setClientSettings).toHaveBeenCalledExactlyOnceWith(completedSettings);
111 });
112
113 it("holds patches until a pending read supplies the saved preferences", async () => {
114 let finishRead!: (settings: ClientSettings) => void;
115 persistenceMocks.getClientSettings.mockImplementationOnce(
116 () =>
117 new Promise((resolve) => {
118 finishRead = resolve;
119 }),
120 );
121 const persisted = new Promise<ClientSettings>((resolve) => {
122 persistenceMocks.setClientSettings.mockImplementationOnce(async (settings) => {
123 resolve(settings);
124 });
125 });
126
127 const hydration = ensureClientSettingsHydrated();
128 persistClientSettingsPatch({ wordWrap: false });
129 expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS);
130 expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled();
131
132 finishRead(savedSettings);
133 await hydration;
134 await expect(persisted).resolves.toEqual({ ...savedSettings, wordWrap: false });
135 expect(persistenceMocks.getClientSettings).toHaveBeenCalledOnce();
136 });
137
138 it("handles failed patch reads without writing and retries with the saved preferences", async () => {
139 const failure = new Error("storage unavailable");
140 vi.spyOn(console, "error").mockImplementation(() => undefined);
141 persistenceMocks.getClientSettings.mockRejectedValue(failure);
142
143 const hydration = ensureClientSettingsHydrated();
144 persistClientSettingsPatch({ wordWrap: false });
145 await expect(hydration).rejects.toBe(failure);
146 expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled();
147
148 persistenceMocks.getClientSettings.mockResolvedValue(savedSettings);
149 const persisted = new Promise<ClientSettings>((resolve) => {
150 persistenceMocks.setClientSettings.mockImplementationOnce(async (settings) => {
151 resolve(settings);
152 });
153 });
154 persistClientSettingsPatch({ wordWrap: false });
155
156 await expect(persisted).resolves.toEqual({ ...savedSettings, wordWrap: false });
157 });
158
159 it("preserves patch order across hydration and a blocked completion write", async () => {
160 let finishRead!: (settings: ClientSettings) => void;
161 const read = new Promise<ClientSettings>((resolve) => {
162 finishRead = resolve;
163 });
164 persistenceMocks.getClientSettings.mockReturnValue(read);
165 let finishCompletionWrite!: () => void;
166 const blockedWrite = new Promise<void>((resolve) => {
167 finishCompletionWrite = resolve;
168 });
169 let signalCompletionWrite!: () => void;
170 const completionWriteStarted = new Promise<void>((resolve) => {
171 signalCompletionWrite = resolve;
172 });
173 let durableSettings: ClientSettings = savedSettings;
174 const persist = vi
175 .fn<(settings: ClientSettings) => Promise<void>>()
176 .mockImplementationOnce(async (settings) => {
177 signalCompletionWrite();
178 await blockedWrite;
179 durableSettings = settings;
180 })
181 .mockImplementation(async (settings) => {
182 durableSettings = settings;
183 });
184
185 const completion = persistClientSettingsUpdate(complete, persist);
186 persistClientSettingsPatch({ wordWrap: false }, persist);
187 finishRead(savedSettings);
188 await completionWriteStarted;
189 persistClientSettingsPatch({ wordWrap: true }, persist);
190 const finalWrite = persistClientSettingsUpdate((current) => current, persist);
191
192 finishCompletionWrite();
193 await completion;
194 await finalWrite;
195
196 const expected = { ...savedSettings, onboardingCompletedAt, wordWrap: true };
197 expect(getClientSettings()).toEqual(expected);
198 expect(durableSettings).toEqual(expected);
199 });
200});
201
202describe("persistClientSettingsPatch", () => {
203 it("waits for settings writes in request order", async () => {
204 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
205 let finishFirst!: () => void;
206 let markFirstStarted!: () => void;
207 const firstStarted = new Promise<void>((resolve) => {
208 markFirstStarted = resolve;
209 });
210 persistenceMocks.setClientSettings.mockImplementationOnce(() => {
211 markFirstStarted();
212 return new Promise<void>((resolve) => {
213 finishFirst = resolve;
214 });
215 });
216
217 const firstSettings = { ...DEFAULT_CLIENT_SETTINGS, snapShotFlash: false };
218 const secondSettings = { ...firstSettings, snapShotPlaySound: false };
219 const first = persistClientSettingsPatch({ snapShotFlash: false });
220 await firstStarted;
221 const second = persistClientSettingsPatch({ snapShotPlaySound: false });
222
223 expect(persistenceMocks.setClientSettings).toHaveBeenCalledTimes(1);
224 expect(getClientSettings()).toEqual(secondSettings);
225 finishFirst();
226 await Promise.all([first, second]);
227
228 expect(persistenceMocks.setClientSettings).toHaveBeenNthCalledWith(1, firstSettings);
229 expect(persistenceMocks.setClientSettings).toHaveBeenNthCalledWith(2, secondSettings);
230 });
231});
232
233describe("persistClientSettingsUpdate", () => {
234 it("publishes the update only after persistence succeeds", async () => {
235 let finishPersistence!: () => void;
236 const persistence = new Promise<void>((resolve) => {
237 finishPersistence = resolve;
238 });
239 const setClientSettings = vi.fn(() => persistence);
240 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
241
242 const pending = persistClientSettingsUpdate(
243 (current) => ({
244 ...current,
245 timestampFormat: "12-hour",
246 }),
247 setClientSettings,
248 );
249
250 expect(getClientSettings().timestampFormat).toBe(DEFAULT_CLIENT_SETTINGS.timestampFormat);
251 finishPersistence();
252 await expect(pending).resolves.toMatchObject({ timestampFormat: "12-hour" });
253 expect(getClientSettings().timestampFormat).toBe("12-hour");
254 });
255
256 it("keeps the current snapshot and propagates persistence failure", async () => {
257 const failure = new Error("disk full");
258 const setClientSettings = vi.fn().mockRejectedValue(failure);
259 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
260
261 await expect(
262 persistClientSettingsUpdate(
263 (current) => ({ ...current, timestampFormat: "12-hour" }),
264 setClientSettings,
265 ),
266 ).rejects.toBe(failure);
267 expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS);
268 });
269
270 it("preserves an optimistic write made while an awaited update persists", async () => {
271 let finishFirstPersistence!: () => void;
272 let durableSettings = DEFAULT_CLIENT_SETTINGS;
273 const firstPersistence = new Promise<void>((resolve) => {
274 finishFirstPersistence = resolve;
275 });
276 const persist = vi
277 .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise<void>>()
278 .mockImplementationOnce((settings) =>
279 firstPersistence.then(() => {
280 durableSettings = settings;
281 }),
282 )
283 .mockImplementation(async (settings) => {
284 durableSettings = settings;
285 });
286 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
287 const importedProfile = { id: "profile-import", name: "Imported", kind: "persistent" as const };
288
289 const pending = persistClientSettingsUpdate(
290 (current) => ({
291 ...current,
292 browserProfiles: [...current.browserProfiles, importedProfile],
293 }),
294 persist,
295 );
296 await Promise.resolve();
297 const patch = persistClientSettingsPatch({ wordWrap: false }, persist);
298 finishFirstPersistence();
299 await Promise.all([pending, patch]);
300
301 expect(persist).toHaveBeenCalledTimes(3);
302 expect(persist.mock.calls[1]?.[0]).toMatchObject({
303 wordWrap: false,
304 });
305 expect(persist.mock.calls[1]?.[0].browserProfiles).toContainEqual(importedProfile);
306 expect(durableSettings.wordWrap).toBe(false);
307 expect(durableSettings.browserProfiles).toContainEqual(importedProfile);
308 expect(getClientSettings().wordWrap).toBe(false);
309 expect(getClientSettings().browserProfiles).toContainEqual(importedProfile);
310 });
311
312 it("orders an awaited update after an older optimistic write", async () => {
313 let finishOldWrite!: () => void;
314 let durableSettings = DEFAULT_CLIENT_SETTINGS;
315 const oldWrite = new Promise<void>((resolve) => {
316 finishOldWrite = resolve;
317 });
318 const persist = vi
319 .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise<void>>()
320 .mockImplementationOnce((settings) =>
321 oldWrite.then(() => {
322 durableSettings = settings;
323 }),
324 )
325 .mockImplementation(async (settings) => {
326 durableSettings = settings;
327 });
328 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
329
330 void persistClientSettingsPatch({ wordWrap: false }, persist);
331 const importedProfile = { id: "profile-import", name: "Imported", kind: "persistent" as const };
332 const registration = persistClientSettingsUpdate(
333 (current) => ({
334 ...current,
335 browserProfiles: [...current.browserProfiles, importedProfile],
336 }),
337 persist,
338 );
339 await Promise.resolve();
340 expect(persist).toHaveBeenCalledTimes(1);
341
342 finishOldWrite();
343 await registration;
344
345 expect(persist).toHaveBeenCalledTimes(2);
346 expect(durableSettings.wordWrap).toBe(false);
347 expect(durableSettings.browserProfiles).toContainEqual(importedProfile);
348 });
349
350 it("continues the queue after a rejected write", async () => {
351 const failure = new Error("disk full");
352 const persist = vi
353 .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise<void>>()
354 .mockRejectedValueOnce(failure)
355 .mockResolvedValue(undefined);
356 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
357
358 await expect(
359 persistClientSettingsUpdate(
360 (current) => ({ ...current, timestampFormat: "12-hour" }),
361 persist,
362 ),
363 ).rejects.toBe(failure);
364 await expect(
365 persistClientSettingsUpdate((current) => ({ ...current, wordWrap: false }), persist),
366 ).resolves.toMatchObject({ wordWrap: false });
367 });
368});
369
370describe("resolveEnvironmentIdentificationMode", () => {
371 it("keeps identification hidden until client settings hydrate", () => {
372 expect(resolveEnvironmentIdentificationMode({ mode: "artwork", settingsHydrated: false })).toBe(
373 "none",
374 );
375 expect(resolveEnvironmentIdentificationMode({ mode: "pill", settingsHydrated: true })).toBe(
376 "pill",
377 );
378 });
379
380 it("uses a pill instead of artwork with a palette theme", () => {
381 expect(
382 resolveEnvironmentIdentificationMode({
383 mode: "artwork",
384 settingsHydrated: true,
385 paletteThemeActive: true,
386 }),
387 ).toBe("pill");
388 });
389
390 it("respects none with a palette theme", () => {
391 expect(
392 resolveEnvironmentIdentificationMode({
393 mode: "none",
394 settingsHydrated: true,
395 paletteThemeActive: true,
396 }),
397 ).toBe("none");
398 });
399
400 it("keeps artwork when the palette theme opts into it", () => {
401 expect(
402 resolveEnvironmentIdentificationMode({
403 mode: "artwork",
404 settingsHydrated: true,
405 paletteThemeActive: true,
406 paletteThemeAllowsArtwork: true,
407 }),
408 ).toBe("artwork");
409 });
410});
411
412describe("mergeEnvironmentSettings", () => {
413 it("combines the selected environment's server settings with client preferences", () => {
414 const serverSettings = {
415 ...DEFAULT_SERVER_SETTINGS,
416 providerInstances: {
417 [ProviderInstanceId.make("codex_remote")]: {
418 driver: ProviderDriverKind.make("codex"),
419 enabled: true,
420 },
421 },
422 };
423 const clientSettings = {
424 ...DEFAULT_CLIENT_SETTINGS,
425 favorites: [
426 {
427 provider: ProviderInstanceId.make("codex_remote"),
428 model: "gpt-5.4",
429 },
430 ],
431 };
432
433 const settings = mergeEnvironmentSettings(serverSettings, clientSettings);
434
435 expect(settings.providerInstances).toBe(serverSettings.providerInstances);
436 expect(settings.favorites).toBe(clientSettings.favorites);
437 });
438
439 it("keeps server settlement settings when legacy client data contains retired keys", () => {
440 const serverSettings = {
441 ...DEFAULT_SERVER_SETTINGS,
442 sidebarAutoSettleAfterDays: 14,
443 sidebarAutoSettleOnMerge: false,
444 };
445 const legacyClientSettings = {
446 ...DEFAULT_CLIENT_SETTINGS,
447 sidebarAutoSettleAfterDays: 1,
448 sidebarAutoSettleOnMerge: true,
449 };
450
451 const settings = mergeEnvironmentSettings(serverSettings, legacyClientSettings);
452
453 expect(settings.sidebarAutoSettleAfterDays).toBe(14);
454 expect(settings.sidebarAutoSettleOnMerge).toBe(false);
455 });
456});
457
458describe("onboarding completion persistence", () => {
459 it("keeps onboarding incomplete after a failed save and preserves preferences on retry", async () => {
460 const failure = new Error("disk full");
461 const persist = vi
462 .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise<void>>()
463 .mockRejectedValueOnce(failure)
464 .mockResolvedValue(undefined);
465 const existingSettings = {
466 ...DEFAULT_CLIENT_SETTINGS,
467 timestampFormat: "12-hour" as const,
468 favorites: [
469 {
470 provider: ProviderInstanceId.make("codex_work"),
471 model: "gpt-5.6",
472 },
473 ],
474 };
475 __setClientSettingsForTests(existingSettings);
476 const onboardingCompletedAt = "2026-09-01T12:00:00.000Z";
477 const complete = (current: typeof DEFAULT_CLIENT_SETTINGS) => ({
478 ...current,
479 onboardingCompletedAt,
480 });
481
482 await expect(persistClientSettingsUpdate(complete, persist)).rejects.toBe(failure);
483 expect(getClientSettings()).toBe(existingSettings);
484 expect(getClientSettings().onboardingCompletedAt).toBeNull();
485
486 const completedSettings = { ...existingSettings, onboardingCompletedAt };
487 await expect(persistClientSettingsUpdate(complete, persist)).resolves.toEqual(
488 completedSettings,
489 );
490 expect(getClientSettings()).toEqual(completedSettings);
491 expect(persist).toHaveBeenLastCalledWith(completedSettings);
492 });
493});
494
495it("uses the environment's model preferences after migration, including empty lists", () => {
496 const client = {
497 ...DEFAULT_CLIENT_SETTINGS,
498 favorites: [{ provider: ProviderInstanceId.make("codex_work"), model: "old" }],
499 providerModelPreferences: { codex_work: { hiddenModels: ["old"], modelOrder: [] } },
500 };
501 expect(mergeEnvironmentSettings(DEFAULT_SERVER_SETTINGS, client).favorites).toEqual(
502 client.favorites,
503 );
504 const merged = mergeEnvironmentSettings(
505 { ...DEFAULT_SERVER_SETTINGS, favorites: [], providerModelPreferences: {} },
506 client,
507 );
508 expect(merged.favorites).toEqual([]);
509 expect(merged.providerModelPreferences).toEqual({});
510});