Skip to content
Source
Sign inSign up

useSettings.test.ts

360 lines13.7 KB

1import { DEFAULT_SERVER_SETTINGS, ProviderInstanceId } from "@t3tools/contracts";
2import { DEFAULT_CLIENT_SETTINGS, type ClientSettings } from "@t3tools/contracts/settings";
3import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
4​
5const persistenceMocks = vi.hoisted(() => ({
6 getClientSettings: vi.fn<() => Promise<ClientSettings | null>>(),
7 setClientSettings: vi.fn<(settings: ClientSettings) => Promise<void>>(),
8}));
9​
10vi.mock("~/localApi", () => ({
11 ensureLocalApi: () => ({ persistence: persistenceMocks }),
12}));
13​
14import {
15 __resetClientSettingsPersistenceForTests,
16 __setClientSettingsForTests,
17 ensureClientSettingsHydrated,
18 getClientSettings,
19 mergeEnvironmentSettings,
20 persistClientSettingsPatch,
21 persistClientSettingsUpdate,
22 prepareModelPreferencesPatch,
23} from "./useSettings";
24import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
25​
26beforeEach(() => {
27 persistenceMocks.getClientSettings.mockReset().mockResolvedValue(null);
28 persistenceMocks.setClientSettings.mockReset().mockResolvedValue(undefined);
29 __resetClientSettingsPersistenceForTests();
30});
31​
32afterEach(() => {
33 vi.restoreAllMocks();
34});
35​
36describe("client settings hydration", () => {
37 const savedSettings = {
38 ...DEFAULT_CLIENT_SETTINGS,
39 timestampFormat: "12-hour" as const,
40 favorites: [{ provider: ProviderInstanceId.make("codex_work"), model: "gpt-5.6" }],
41 };
42 const onboardingCompletedAt = "2026-09-05T12:00:00.000Z";
43 const complete = (current: ClientSettings) => ({ ...current, onboardingCompletedAt });
44​
45 it("waits for saved model preferences before applying the first server edit", async () => {
46 const provider = ProviderInstanceId.make("codex_work");
47 let finishRead!: (settings: ClientSettings) => void;
48 persistenceMocks.getClientSettings.mockReturnValueOnce(
49 new Promise((resolve) => {
50 finishRead = resolve;
51 }),
52 );
53 const patch = prepareModelPreferencesPatch(DEFAULT_SERVER_SETTINGS, {
54 setModelFavorites: [{ provider, model: "new", favorite: true }],
55 setModelsHidden: [{ provider, model: "new", hidden: true }],
56 });
57 expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS);
58 finishRead({
59 ...savedSettings,
60 providerModelPreferences: {
61 [provider]: { hiddenModels: ["saved-hidden"], modelOrder: ["saved-order"] },
62 },
63 });
64 const edited = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, await patch);
65 expect(edited.favorites).toEqual([...savedSettings.favorites, { provider, model: "new" }]);
66 expect(edited.providerModelPreferences?.[provider]).toEqual({
67 hiddenModels: ["saved-hidden", "new"],
68 modelOrder: ["saved-order"],
69 });
70 });
71​
72 // regression: #16816 a failed read could overwrite synced model favorites with defaults
73 it("does not prepare a model preference write after a failed legacy read", async () => {
74 const failure = new Error("storage unavailable");
75 vi.spyOn(console, "error").mockImplementation(() => undefined);
76 persistenceMocks.getClientSettings.mockRejectedValueOnce(failure);
77 await expect(
78 prepareModelPreferencesPatch(DEFAULT_SERVER_SETTINGS, {
79 setProviderModelOrder: { provider: ProviderInstanceId.make("codex_work"), modelOrder: [] },
80 }),
81 ).rejects.toBe(failure);
82 });
83​
84 it("rejects completion after a failed read and preserves saved preferences on retry", async () => {
85 const failure = new Error("storage unavailable");
86 vi.spyOn(console, "error").mockImplementation(() => undefined);
87 persistenceMocks.getClientSettings
88 .mockRejectedValueOnce(failure)
89 .mockResolvedValue(savedSettings);
90​
91 await expect(persistClientSettingsUpdate(complete)).rejects.toBe(failure);
92 expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled();
93 expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS);
94​
95 const completedSettings = { ...savedSettings, onboardingCompletedAt };
96 await expect(persistClientSettingsUpdate(complete)).resolves.toEqual(completedSettings);
97 expect(persistenceMocks.setClientSettings).toHaveBeenCalledExactlyOnceWith(completedSettings);
98 expect(persistenceMocks.getClientSettings).toHaveBeenCalledTimes(2);
99 });
100​
101 it("holds patches until a pending read supplies the saved preferences", async () => {
102 let finishRead!: (settings: ClientSettings) => void;
103 persistenceMocks.getClientSettings.mockImplementationOnce(
104 () =>
105 new Promise((resolve) => {
106 finishRead = resolve;
107 }),
108 );
109 const persisted = new Promise<ClientSettings>((resolve) => {
110 persistenceMocks.setClientSettings.mockImplementationOnce(async (settings) => {
111 resolve(settings);
112 });
113 });
114​
115 const hydration = ensureClientSettingsHydrated();
116 persistClientSettingsPatch({ wordWrap: false });
117 expect(getClientSettings()).toBe(DEFAULT_CLIENT_SETTINGS);
118 expect(persistenceMocks.setClientSettings).not.toHaveBeenCalled();
119​
120 finishRead(savedSettings);
121 await hydration;
122 await expect(persisted).resolves.toEqual({ ...savedSettings, wordWrap: false });
123 expect(persistenceMocks.getClientSettings).toHaveBeenCalledOnce();
124 });
125​
126 it("preserves patch order across hydration and a blocked completion write", async () => {
127 let finishRead!: (settings: ClientSettings) => void;
128 const read = new Promise<ClientSettings>((resolve) => {
129 finishRead = resolve;
130 });
131 persistenceMocks.getClientSettings.mockReturnValue(read);
132 let finishCompletionWrite!: () => void;
133 const blockedWrite = new Promise<void>((resolve) => {
134 finishCompletionWrite = resolve;
135 });
136 let signalCompletionWrite!: () => void;
137 const completionWriteStarted = new Promise<void>((resolve) => {
138 signalCompletionWrite = resolve;
139 });
140 let durableSettings: ClientSettings = savedSettings;
141 const persist = vi
142 .fn<(settings: ClientSettings) => Promise<void>>()
143 .mockImplementationOnce(async (settings) => {
144 signalCompletionWrite();
145 await blockedWrite;
146 durableSettings = settings;
147 })
148 .mockImplementation(async (settings) => {
149 durableSettings = settings;
150 });
151​
152 const completion = persistClientSettingsUpdate(complete, persist);
153 persistClientSettingsPatch({ wordWrap: false }, persist);
154 finishRead(savedSettings);
155 await completionWriteStarted;
156 persistClientSettingsPatch({ wordWrap: true }, persist);
157 const finalWrite = persistClientSettingsUpdate((current) => current, persist);
158​
159 finishCompletionWrite();
160 await completion;
161 await finalWrite;
162​
163 const expected = { ...savedSettings, onboardingCompletedAt, wordWrap: true };
164 expect(getClientSettings()).toEqual(expected);
165 expect(durableSettings).toEqual(expected);
166 });
167});
168​
169describe("persistClientSettingsPatch", () => {
170 it("waits for settings writes in request order", async () => {
171 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
172 let finishFirst!: () => void;
173 let markFirstStarted!: () => void;
174 const firstStarted = new Promise<void>((resolve) => {
175 markFirstStarted = resolve;
176 });
177 persistenceMocks.setClientSettings.mockImplementationOnce(() => {
178 markFirstStarted();
179 return new Promise<void>((resolve) => {
180 finishFirst = resolve;
181 });
182 });
183​
184 const firstSettings = { ...DEFAULT_CLIENT_SETTINGS, snapShotFlash: false };
185 const secondSettings = { ...firstSettings, snapShotPlaySound: false };
186 const first = persistClientSettingsPatch({ snapShotFlash: false });
187 await firstStarted;
188 const second = persistClientSettingsPatch({ snapShotPlaySound: false });
189​
190 expect(persistenceMocks.setClientSettings).toHaveBeenCalledTimes(1);
191 expect(getClientSettings()).toEqual(secondSettings);
192 finishFirst();
193 await Promise.all([first, second]);
194​
195 expect(persistenceMocks.setClientSettings).toHaveBeenNthCalledWith(1, firstSettings);
196 expect(persistenceMocks.setClientSettings).toHaveBeenNthCalledWith(2, secondSettings);
197 });
198​
199 // regression: #35010 a save in one tab reverted settings another tab saved after it loaded
200 it("keeps a setting another tab saved after this tab loaded", async () => {
201 vi.stubGlobal("window", new EventTarget());
202 try {
203 await ensureClientSettingsHydrated();
204 const otherTabSettings = { ...DEFAULT_CLIENT_SETTINGS, timestampFormat: "12-hour" as const };
205 persistenceMocks.getClientSettings.mockResolvedValue(otherTabSettings);
206 window.dispatchEvent(
207 Object.assign(new Event("storage"), { key: "t3code:client-settings:v1" }),
208 );
209​
210 await persistClientSettingsPatch({ wordWrap: false });
211​
212 expect(persistenceMocks.setClientSettings).toHaveBeenLastCalledWith({
213 ...otherTabSettings,
214 wordWrap: false,
215 });
216 } finally {
217 vi.unstubAllGlobals();
218 }
219 });
220});
221​
222describe("persistClientSettingsUpdate", () => {
223 it("publishes the update only after persistence succeeds", async () => {
224 let finishPersistence!: () => void;
225 const persistence = new Promise<void>((resolve) => {
226 finishPersistence = resolve;
227 });
228 const setClientSettings = vi.fn(() => persistence);
229 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
230​
231 const pending = persistClientSettingsUpdate(
232 (current) => ({
233 ...current,
234 timestampFormat: "12-hour",
235 }),
236 setClientSettings,
237 );
238​
239 expect(getClientSettings().timestampFormat).toBe(DEFAULT_CLIENT_SETTINGS.timestampFormat);
240 finishPersistence();
241 await expect(pending).resolves.toMatchObject({ timestampFormat: "12-hour" });
242 expect(getClientSettings().timestampFormat).toBe("12-hour");
243 });
244​
245 it("preserves an optimistic write made while an awaited update persists", async () => {
246 let finishFirstPersistence!: () => void;
247 let durableSettings = DEFAULT_CLIENT_SETTINGS;
248 const firstPersistence = new Promise<void>((resolve) => {
249 finishFirstPersistence = resolve;
250 });
251 const persist = vi
252 .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise<void>>()
253 .mockImplementationOnce((settings) =>
254 firstPersistence.then(() => {
255 durableSettings = settings;
256 }),
257 )
258 .mockImplementation(async (settings) => {
259 durableSettings = settings;
260 });
261 __setClientSettingsForTests(DEFAULT_CLIENT_SETTINGS);
262 const importedProfile = { id: "profile-import", name: "Imported", kind: "persistent" as const };
263​
264 const pending = persistClientSettingsUpdate(
265 (current) => ({
266 ...current,
267 browserProfiles: [...current.browserProfiles, importedProfile],
268 }),
269 persist,
270 );
271 await Promise.resolve();
272 const patch = persistClientSettingsPatch({ wordWrap: false }, persist);
273 finishFirstPersistence();
274 await Promise.all([pending, patch]);
275​
276 expect(persist).toHaveBeenCalledTimes(3);
277 expect(persist.mock.calls[1]?.[0]).toMatchObject({
278 wordWrap: false,
279 });
280 expect(persist.mock.calls[1]?.[0].browserProfiles).toContainEqual(importedProfile);
281 expect(durableSettings.wordWrap).toBe(false);
282 expect(durableSettings.browserProfiles).toContainEqual(importedProfile);
283 expect(getClientSettings().wordWrap).toBe(false);
284 expect(getClientSettings().browserProfiles).toContainEqual(importedProfile);
285 });
286});
287​
288describe("mergeEnvironmentSettings", () => {
289 it("keeps server settlement settings when legacy client data contains retired keys", () => {
290 const serverSettings = {
291 ...DEFAULT_SERVER_SETTINGS,
292 sidebarAutoSettleAfterDays: 14,
293 sidebarAutoSettleOnMerge: false,
294 };
295 const legacyClientSettings = {
296 ...DEFAULT_CLIENT_SETTINGS,
297 sidebarAutoSettleAfterDays: 1,
298 sidebarAutoSettleOnMerge: true,
299 };
300​
301 const settings = mergeEnvironmentSettings(serverSettings, legacyClientSettings);
302​
303 expect(settings.sidebarAutoSettleAfterDays).toBe(14);
304 expect(settings.sidebarAutoSettleOnMerge).toBe(false);
305 });
306});
307​
308describe("onboarding completion persistence", () => {
309 it("keeps onboarding incomplete after a failed save and preserves preferences on retry", async () => {
310 const failure = new Error("disk full");
311 const persist = vi
312 .fn<(settings: typeof DEFAULT_CLIENT_SETTINGS) => Promise<void>>()
313 .mockRejectedValueOnce(failure)
314 .mockResolvedValue(undefined);
315 const existingSettings = {
316 ...DEFAULT_CLIENT_SETTINGS,
317 timestampFormat: "12-hour" as const,
318 favorites: [
319 {
320 provider: ProviderInstanceId.make("codex_work"),
321 model: "gpt-5.6",
322 },
323 ],
324 };
325 __setClientSettingsForTests(existingSettings);
326 const onboardingCompletedAt = "2026-09-01T12:00:00.000Z";
327 const complete = (current: typeof DEFAULT_CLIENT_SETTINGS) => ({
328 ...current,
329 onboardingCompletedAt,
330 });
331​
332 await expect(persistClientSettingsUpdate(complete, persist)).rejects.toBe(failure);
333 expect(getClientSettings()).toBe(existingSettings);
334 expect(getClientSettings().onboardingCompletedAt).toBeNull();
335​
336 const completedSettings = { ...existingSettings, onboardingCompletedAt };
337 await expect(persistClientSettingsUpdate(complete, persist)).resolves.toEqual(
338 completedSettings,
339 );
340 expect(getClientSettings()).toEqual(completedSettings);
341 expect(persist).toHaveBeenLastCalledWith(completedSettings);
342 });
343});
344​
345it("uses the environment's model preferences after migration, including empty lists", () => {
346 const client = {
347 ...DEFAULT_CLIENT_SETTINGS,
348 favorites: [{ provider: ProviderInstanceId.make("codex_work"), model: "old" }],
349 providerModelPreferences: { codex_work: { hiddenModels: ["old"], modelOrder: [] } },
350 };
351 expect(mergeEnvironmentSettings(DEFAULT_SERVER_SETTINGS, client).favorites).toEqual(
352 client.favorites,
353 );
354 const merged = mergeEnvironmentSettings(
355 { ...DEFAULT_SERVER_SETTINGS, favorites: [], providerModelPreferences: {} },
356 client,
357 );
358 expect(merged.favorites).toEqual([]);
359 expect(merged.providerModelPreferences).toEqual({});
360});