Skip to content
Source
Sign inSign up

DesktopClientSettings.test.ts

291 lines10.6 KB

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