1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import {
4 ClientSettingsSchema,
5 DEFAULT_CLIENT_SETTINGS,
6 type ClientSettings,
7} from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as PlatformError from "effect/PlatformError";
13import * as Schema from "effect/Schema";
14
15import * as DesktopConfig from "../app/DesktopConfig.ts";
16import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
17import * as DesktopClientSettings from "./DesktopClientSettings.ts";
18
19const clientSettings: ClientSettings = {
20 ...DEFAULT_CLIENT_SETTINGS,
21 notificationMode: "notifications-and-sound",
22 inAppNotificationsEnabled: true,
23 appearanceContrast: 100,
24 browserDefaultViewport: { _tag: "preset", width: 1024, height: 600, presetId: "nest-hub" },
25 browserDefaultZoomFactor: 1.25,
26 browserDefaultAppearance: "dark",
27 browserRecordingFrameRate: 60,
28 browserLinkTarget: "app",
29 browserAutoShowFloatingPreview: false,
30 browserProfiles: [{ id: "work", name: "Work", kind: "persistent" }],
31 browserDefaultProfileId: "work",
32 confirmQuit: "double-click",
33 confirmThreadArchive: true,
34 confirmThreadDelete: false,
35 confirmThreadUnpin: false,
36 contextWindowMeterEnabled: false,
37 composerCollapseOnScroll: true,
38 dismissedProviderUpdateNotificationKeys: [],
39 diffFilesCollapsed: true,
40 diffIgnoreWhitespace: true,
41 diffLayout: "stacked",
42 environmentIdentificationMode: "artwork",
43 favorites: [],
44 fontFamilyCode: "",
45 fontFamilyComposer: "",
46 fontFamilySans: "",
47 fontFamilyTerminal: "",
48 fontSizeCode: 13,
49 fontSizeInterface: 16,
50 fontSizePrompt: 14,
51 fontSizeTerminal: 12,
52 fontSmoothing: true,
53 glassOpacity: 80,
54 onboardingCompletedAt: null,
55 panelAnimationDurationMs: 0,
56 planModeEnabled: false,
57 proactivePanelsEnabled: true,
58 showSkillsInSlashMenu: false,
59 persistComposerContextStrip: true,
60 providerModelPreferences: {},
61 sidebarProjectGroupingMode: "repository_path",
62 sidebarProjectGroupingOverrides: {
63 "environment-1:/tmp/project-a": "separate",
64 },
65 sidebarProjectSortOrder: "manual",
66 sidebarThreadSortOrder: "created_at",
67 sidebarThreadPreviewCount: 6,
68 legacySidebarEnabled: false,
69 sidebarWorkingShelfEnabled: false,
70 loadBalancingEnabled: false,
71 loadBalancingWeights: { "environment-1": 75, "environment-2": 0 },
72 pullRequestMergeMethodOverrides: {},
73 timestampFormat: "24-hour",
74 wordWrap: true,
75};
76
77const decodeClientSettingsJson = Schema.decodeEffect(Schema.fromJsonString(ClientSettingsSchema));
78const decodeRecordJson = Schema.decodeEffect(
79 Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)),
80);
81function layer(baseDir: string) {
82 const layerEnvironment = DesktopEnvironment.layer({
83 dirname: "/repo/apps/desktop/src",
84 homeDirectory: baseDir,
85 platform: "darwin",
86 processArch: "x64",
87 appVersion: "1.2.3",
88 appPath: "/repo",
89 isPackaged: true,
90 resourcesPath: "/missing/resources",
91 runningUnderArm64Translation: false,
92 }).pipe(
93 Layer.provide(
94 Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })),
95 ),
96 );
97
98 return DesktopClientSettings.layer.pipe(
99 Layer.provideMerge(layerEnvironment),
100 Layer.provideMerge(NodeServices.layer),
101 );
102}
103
104const withClientSettings = <A, E, R>(
105 effect: Effect.Effect<A, E, R | DesktopClientSettings.DesktopClientSettings>,
106) =>
107 Effect.gen(function* () {
108 const fileSystem = yield* FileSystem.FileSystem;
109 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
110 prefix: "t3-desktop-client-settings-test-",
111 });
112 return yield* effect.pipe(Effect.provide(layer(baseDir)));
113 }).pipe(Effect.provide(NodeServices.layer), Effect.scoped);
114
115describe("DesktopClientSettings", () => {
116 it.effect("returns none when no client settings file exists", () =>
117 withClientSettings(
118 Effect.gen(function* () {
119 const settings = yield* DesktopClientSettings.DesktopClientSettings;
120 assert.isTrue(Option.isNone(yield* settings.get));
121 }),
122 ),
123 );
124
125 it.effect("persists and reloads client settings", () =>
126 withClientSettings(
127 Effect.gen(function* () {
128 const environment = yield* DesktopEnvironment.DesktopEnvironment;
129 const fileSystem = yield* FileSystem.FileSystem;
130 const settings = yield* DesktopClientSettings.DesktopClientSettings;
131 yield* settings.set(clientSettings);
132
133 assert.deepEqual(yield* settings.get, Option.some(clientSettings));
134 assert.deepEqual(
135 yield* decodeClientSettingsJson(
136 yield* fileSystem.readFileString(environment.clientSettingsPath),
137 ),
138 clientSettings,
139 );
140 assert.isFalse(
141 Object.hasOwn(
142 yield* decodeRecordJson(
143 yield* fileSystem.readFileString(environment.clientSettingsPath),
144 ),
145 "settings",
146 ),
147 );
148 }),
149 ),
150 );
151
152 it.effect("saves through a symlinked client settings file without replacing the link", () =>
153 withClientSettings(
154 Effect.gen(function* () {
155 const environment = yield* DesktopEnvironment.DesktopEnvironment;
156 const fileSystem = yield* FileSystem.FileSystem;
157 const settings = yield* DesktopClientSettings.DesktopClientSettings;
158 const dotfiles = yield* fileSystem.makeTempDirectoryScoped({
159 prefix: "t3-desktop-client-settings-dotfiles-",
160 });
161 const linkedSettingsPath = `${dotfiles}/client-settings.json`;
162 yield* fileSystem.writeFileString(linkedSettingsPath, "{}\n");
163 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
164 yield* fileSystem.symlink(linkedSettingsPath, environment.clientSettingsPath);
165
166 yield* settings.set(clientSettings);
167
168 assert.equal(
169 yield* fileSystem.readLink(environment.clientSettingsPath),
170 linkedSettingsPath,
171 );
172 assert.deepEqual(
173 yield* decodeClientSettingsJson(yield* fileSystem.readFileString(linkedSettingsPath)),
174 clientSettings,
175 );
176 }),
177 ),
178 );
179
180 it.effect.each([
181 { label: "permission", reason: "PermissionDenied" },
182 { label: "I/O", reason: "Unknown" },
183 ] as const)("preserves saved preferences across $label read failures and retries", (failure) =>
184 withClientSettings(
185 Effect.gen(function* () {
186 const environment = yield* DesktopEnvironment.DesktopEnvironment;
187 const fileSystem = yield* FileSystem.FileSystem;
188 const settings = yield* DesktopClientSettings.DesktopClientSettings;
189 const savedSettings = {
190 ...clientSettings,
191 onboardingCompletedAt: "2026-09-05T12:00:00.000Z",
192 };
193 yield* settings.set(savedSettings);
194 const savedContents = yield* fileSystem.readFileString(environment.clientSettingsPath);
195 const cause = PlatformError.systemError({
196 _tag: failure.reason,
197 module: "FileSystem",
198 method: "readFileString",
199 pathOrDescriptor: environment.clientSettingsPath,
200 });
201 let failRead = true;
202 const retryableSettings = yield* DesktopClientSettings.make.pipe(
203 Effect.provideService(
204 FileSystem.FileSystem,
205 FileSystem.FileSystem.of({
206 ...fileSystem,
207 readFileString: (path) =>
208 Effect.suspend(() =>
209 failRead ? Effect.fail(cause) : fileSystem.readFileString(path),
210 ),
211 }),
212 ),
213 );
214
215 const error = yield* retryableSettings.get.pipe(Effect.flip);
216 assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError);
217 assert.equal(error.operation, "read-file");
218 assert.equal(error.path, environment.clientSettingsPath);
219 assert.strictEqual(error.cause, cause);
220 assert.equal(
221 yield* fileSystem.readFileString(environment.clientSettingsPath),
222 savedContents,
223 );
224
225 failRead = false;
226 assert.deepEqual(yield* retryableSettings.get, Option.some(savedSettings));
227 }),
228 ),
229 );
230
231 it.effect("reports the failed client settings write operation and path", () =>
232 withClientSettings(
233 Effect.gen(function* () {
234 const environment = yield* DesktopEnvironment.DesktopEnvironment;
235 const fileSystem = yield* FileSystem.FileSystem;
236 const settings = yield* DesktopClientSettings.DesktopClientSettings;
237 yield* fileSystem.makeDirectory(environment.clientSettingsPath, { recursive: true });
238
239 const error = yield* settings.set(clientSettings).pipe(Effect.flip);
240 assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsWriteError);
241 assert.equal(error.operation, "replace-settings-file");
242 assert.equal(error.path, environment.clientSettingsPath);
243 assert.instanceOf(error.cause, PlatformError.PlatformError);
244 assert.isString(error.cause.stack);
245 assert.equal(
246 error.message,
247 `Desktop client settings write failed during replace-settings-file at ${environment.clientSettingsPath}.`,
248 );
249 assert.notInclude(error.message, error.cause.message);
250 }),
251 ),
252 );
253
254 it.effect("loads lenient direct client settings documents", () =>
255 withClientSettings(
256 Effect.gen(function* () {
257 const environment = yield* DesktopEnvironment.DesktopEnvironment;
258 const fileSystem = yield* FileSystem.FileSystem;
259 const settings = yield* DesktopClientSettings.DesktopClientSettings;
260 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
261 yield* fileSystem.writeFileString(
262 environment.clientSettingsPath,
263 `{
264 // Matches server settings parsing.
265 "timestampFormat": "24-hour",
266 }\n`,
267 );
268
269 const persisted = yield* settings.get;
270 assert.isTrue(Option.isSome(persisted));
271 if (Option.isSome(persisted)) {
272 assert.equal(persisted.value.timestampFormat, "24-hour");
273 }
274 }),
275 ),
276 );
277
278 it.effect("loads legacy wrapped client settings documents", () =>
279 withClientSettings(
280 Effect.gen(function* () {
281 const environment = yield* DesktopEnvironment.DesktopEnvironment;
282 const fileSystem = yield* FileSystem.FileSystem;
283 const settings = yield* DesktopClientSettings.DesktopClientSettings;
284 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
285 yield* fileSystem.writeFileString(
286 environment.clientSettingsPath,
287 `{
288 "settings": {
289 "timestampFormat": "12-hour"
290 }
291 }\n`,
292 );
293
294 const persisted = yield* settings.get;
295 assert.isTrue(Option.isSome(persisted));
296 if (Option.isSome(persisted)) {
297 assert.equal(persisted.value.timestampFormat, "12-hour");
298 }
299 }),
300 ),
301 );
302
303 it.effect("loads defaults from empty client settings documents", () =>
304 withClientSettings(
305 Effect.gen(function* () {
306 const environment = yield* DesktopEnvironment.DesktopEnvironment;
307 const fileSystem = yield* FileSystem.FileSystem;
308 const settings = yield* DesktopClientSettings.DesktopClientSettings;
309 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
310 yield* fileSystem.writeFileString(environment.clientSettingsPath, "{}\n");
311
312 assert.deepEqual(yield* settings.get, Option.some(yield* decodeClientSettingsJson("{}")));
313 }),
314 ),
315 );
316
317 it.effect.each([
318 { label: "malformed JSON", contents: "{not-json" },
319 { label: "invalid direct settings", contents: '{"fontSizeCode":"large"}' },
320 { label: "invalid legacy settings", contents: '{"settings":{"fontSizeCode":"large"}}' },
321 ])("reports $label without treating the settings file as absent", (document) =>
322 withClientSettings(
323 Effect.gen(function* () {
324 const environment = yield* DesktopEnvironment.DesktopEnvironment;
325 const fileSystem = yield* FileSystem.FileSystem;
326 const settings = yield* DesktopClientSettings.DesktopClientSettings;
327 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
328 yield* fileSystem.writeFileString(environment.clientSettingsPath, document.contents);
329
330 const error = yield* settings.get.pipe(Effect.flip);
331 assert.instanceOf(error, DesktopClientSettings.DesktopClientSettingsReadError);
332 assert.equal(error.operation, "decode-document");
333 assert.equal(error.path, environment.clientSettingsPath);
334 assert.instanceOf(error.cause, Schema.SchemaError);
335 assert.equal(
336 yield* fileSystem.readFileString(environment.clientSettingsPath),
337 document.contents,
338 );
339 }),
340 ),
341 );
342});