Skip to content
Source
Sign inSign up

DesktopAppSettings.test.ts

500 lines18.9 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 * as Effect from "effect/Effect";
5import * as FileSystem from "effect/FileSystem";
6import * as Layer from "effect/Layer";
7import * as PlatformError from "effect/PlatformError";
8import * as Schema from "effect/Schema";
9import * as Tracer from "effect/Tracer";
10​
11import * as DesktopConfig from "../app/DesktopConfig.ts";
12import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
13import * as DesktopAppSettings from "./DesktopAppSettings.ts";
14​
15const DesktopSettingsPatch = Schema.Struct({
16 linuxPasswordStore: Schema.optionalKey(
17 Schema.Literals(["auto", "gnome-libsecret", "kwallet", "kwallet5", "kwallet6"]),
18 ),
19 mainWindowBounds: Schema.optionalKey(
20 Schema.NullOr(
21 Schema.Struct({
22 x: Schema.Number,
23 y: Schema.Number,
24 width: Schema.Number,
25 height: Schema.Number,
26 }),
27 ),
28 ),
29 mainWindowMaximized: Schema.optionalKey(Schema.Boolean),
30 serverExposureMode: Schema.optionalKey(Schema.Literals(["local-only", "network-accessible"])),
31 tailscaleServeEnabled: Schema.optionalKey(Schema.Boolean),
32 tailscaleServePort: Schema.optionalKey(Schema.Number),
33 updateChannel: Schema.optionalKey(Schema.Literals(["latest", "nightly"])),
34 updateChannelConfiguredByUser: Schema.optionalKey(Schema.Boolean),
35 wslBackendEnabled: Schema.optionalKey(Schema.Boolean),
36 wslMode: Schema.optionalKey(Schema.Literals(["local", "wsl"])),
37 wslDistro: Schema.optionalKey(Schema.NullOr(Schema.String)),
38 wslOnly: Schema.optionalKey(Schema.Boolean),
39});
40​
41const decodeDesktopSettingsPatch = Schema.decodeEffect(Schema.fromJsonString(DesktopSettingsPatch));
42const encodeDesktopSettingsPatch = Schema.encodeEffect(Schema.fromJsonString(DesktopSettingsPatch));
43​
44function layerEnvironment(baseDir: string, appVersion = "0.0.17") {
45 return DesktopEnvironment.layer({
46 dirname: "/repo/apps/desktop/src",
47 homeDirectory: baseDir,
48 platform: "darwin",
49 processArch: "x64",
50 appVersion,
51 appPath: "/repo",
52 isPackaged: true,
53 resourcesPath: "/missing/resources",
54 runningUnderArm64Translation: false,
55 }).pipe(
56 Layer.provide(
57 Layer.mergeAll(
58 NodeServices.layer,
59 NodeCrypto.layer,
60 DesktopConfig.layerTest({ T3CODE_HOME: baseDir }),
61 ),
62 ),
63 );
65​
66const withSettings = <A, E, R>(
67 effect: Effect.Effect<
68 A,
69 E,
70 R | DesktopAppSettings.DesktopAppSettings | DesktopEnvironment.DesktopEnvironment
71 >,
72 options?: { readonly appVersion?: string },
73) =>
74 Effect.gen(function* () {
75 const fileSystem = yield* FileSystem.FileSystem;
76 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
77 prefix: "t3-desktop-settings-test-",
78 });
79 return yield* effect.pipe(
80 Effect.provide(
81 DesktopAppSettings.layer.pipe(
82 Layer.provideMerge(layerEnvironment(baseDir, options?.appVersion)),
83 Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)),
84 ),
85 ),
86 );
87 }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)), Effect.scoped);
88​
89function writeSettingsPatch(patch: typeof DesktopSettingsPatch.Type) {
90 return Effect.gen(function* () {
91 const environment = yield* DesktopEnvironment.DesktopEnvironment;
92 const fileSystem = yield* FileSystem.FileSystem;
93 const encoded = yield* encodeDesktopSettingsPatch(patch);
94 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
95 yield* fileSystem.writeFileString(environment.desktopSettingsPath, `${encoded}\n`);
96 });
98​
99describe("DesktopSettings", () => {
100 it.effect("records one bounds operation span with its input attributes", () =>
101 Effect.gen(function* () {
102 const spans: Array<Tracer.Span> = [];
103 const tracer = Tracer.make({
104 span: (options) => {
105 const span = new Tracer.NativeSpan(options);
106 spans.push(span);
107 return span;
108 },
109 });
110 yield* withSettings(
111 Effect.gen(function* () {
112 const settings = yield* DesktopAppSettings.DesktopAppSettings;
113 yield* settings.setMainWindowBounds({ x: 10, y: 20, width: 900, height: 600 }, true);
114 }),
115 ).pipe(Effect.provideService(Tracer.Tracer, tracer));
116 const operationSpans = spans.filter(
117 (span) => span.name === "desktop.settings.setMainWindowBounds",
118 );
119 assert.lengthOf(operationSpans, 1);
120 assert.deepEqual(Object.fromEntries(operationSpans[0]!.attributes), {
121 x: 10,
122 y: 20,
123 width: 900,
124 height: 600,
125 isMaximized: true,
126 });
127 assert.equal(operationSpans[0]!.status._tag, "Ended");
128 }),
129 );
130​
131 it.effect(
132 "persists disabling and re-enabling local execution without clearing backend settings",
133 () =>
134 withSettings(
135 Effect.gen(function* () {
136 const settings = yield* DesktopAppSettings.DesktopAppSettings;
137 yield* settings.setWslBackendEnabled(true);
138 yield* settings.setWslDistro("Ubuntu");
139 yield* settings.setServerExposureMode("network-accessible");
140 const before = yield* settings.get;
141 assert.isTrue((yield* settings.setLocalEnvironmentEnabled(false)).changed);
142 assert.deepEqual(yield* settings.load, { ...before, localEnvironmentEnabled: false });
143 assert.isFalse((yield* settings.setLocalEnvironmentEnabled(false)).changed);
144 yield* settings.setLocalEnvironmentEnabled(true);
145 assert.deepEqual(yield* settings.load, before);
146 }),
147 ),
148 );
149​
150 it.effect("falls back to defaults when the settings file is malformed", () =>
151 withSettings(
152 Effect.gen(function* () {
153 const environment = yield* DesktopEnvironment.DesktopEnvironment;
154 const fileSystem = yield* FileSystem.FileSystem;
155 const settings = yield* DesktopAppSettings.DesktopAppSettings;
156 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
157 yield* fileSystem.writeFileString(environment.desktopSettingsPath, "{not-json");
158​
159 assert.deepEqual(yield* settings.load, DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS);
160 }),
161 ),
162 );
163​
164 it.effect("keeps saved preferences when a read fails before a bounds save", () =>
165 withSettings(
166 Effect.gen(function* () {
167 const environment = yield* DesktopEnvironment.DesktopEnvironment;
168 const fileSystem = yield* FileSystem.FileSystem;
169 yield* writeSettingsPatch({ serverExposureMode: "network-accessible" });
170 const cause = PlatformError.systemError({
171 _tag: "PermissionDenied",
172 module: "FileSystem",
173 method: "readFileString",
174 pathOrDescriptor: environment.desktopSettingsPath,
175 });
176 let failRead = true;
177 const settings = yield* DesktopAppSettings.make.pipe(
178 Effect.provideService(
179 FileSystem.FileSystem,
180 FileSystem.FileSystem.of({
181 ...fileSystem,
182 readFileString: (path) =>
183 Effect.suspend(() => {
184 if (failRead && path === environment.desktopSettingsPath) {
185 failRead = false;
186 return Effect.fail(cause);
187 }
188 return fileSystem.readFileString(path);
189 }),
190 }),
191 ),
192 );
193​
194 yield* Effect.exit(settings.load);
195 yield* Effect.exit(
196 settings.setMainWindowBounds({ x: 10, y: 20, width: 900, height: 600 }, false),
197 );
198​
199 const persisted = yield* decodeDesktopSettingsPatch(
200 yield* fileSystem.readFileString(environment.desktopSettingsPath),
201 );
202 assert.equal(persisted.serverExposureMode, "network-accessible");
203 }),
204 ),
205 );
206​
207 it.effect("loads lenient persisted desktop settings JSON", () =>
208 withSettings(
209 Effect.gen(function* () {
210 const environment = yield* DesktopEnvironment.DesktopEnvironment;
211 const fileSystem = yield* FileSystem.FileSystem;
212 const settings = yield* DesktopAppSettings.DesktopAppSettings;
213 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
214 yield* fileSystem.writeFileString(
215 environment.desktopSettingsPath,
216 `{
217 // JSONC-style comments and trailing commas match server settings parsing.
218 "serverExposureMode": "network-accessible",
219 "tailscaleServeEnabled": true,
220 "tailscaleServePort": 8443,
221 "mainWindowBounds": { "x": 120, "y": 80, "width": 1280, "height": 900 },
222 }\n`,
223 );
224​
225 assert.deepEqual(yield* settings.load, {
226 linuxPasswordStore: "auto",
227 localEnvironmentEnabled: true,
228 mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 },
229 mainWindowMaximized: false,
230 serverExposureMode: "network-accessible",
231 tailscaleServeEnabled: true,
232 tailscaleServePort: 8443,
233 updateChannel: "latest",
234 updateChannelConfiguredByUser: false,
235 wslBackendEnabled: false,
236 wslOnly: false,
237 wslDistro: null,
238 } satisfies DesktopAppSettings.DesktopSettings);
239 }),
240 ),
241 );
242​
243 it.effect("keeps Local environment off when another value in the file is unreadable", () =>
244 withSettings(
245 Effect.gen(function* () {
246 const environment = yield* DesktopEnvironment.DesktopEnvironment;
247 const fileSystem = yield* FileSystem.FileSystem;
248 const settings = yield* DesktopAppSettings.DesktopAppSettings;
249 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
250 yield* fileSystem.writeFileString(
251 environment.desktopSettingsPath,
252 `{ "localEnvironmentEnabled": false, "serverExposureMode": "tailnet" }\n`,
253 );
254​
255 assert.isFalse((yield* settings.load).localEnvironmentEnabled);
256 yield* settings.setMainWindowBounds({ x: 10, y: 20, width: 800, height: 600 }, false);
257 assert.include(
258 yield* fileSystem.readFileString(environment.desktopSettingsPath),
259 `"localEnvironmentEnabled":false`,
260 );
261 }),
262 ),
263 );
264​
265 it.effect("rejects window bounds that do not satisfy the domain schema", () =>
266 withSettings(
267 Effect.gen(function* () {
268 const settings = yield* DesktopAppSettings.DesktopAppSettings;
269 for (const bounds of [
270 { x: 10.5, y: 20, width: 400, height: 360 },
271 { x: 10, y: 20, width: 399, height: 360 },
272 { x: 10, y: 20, width: 400, height: 359 },
273 ]) {
274 yield* writeSettingsPatch({
275 mainWindowBounds: bounds,
276 mainWindowMaximized: true,
277 serverExposureMode: "network-accessible",
278 });
279​
280 const loaded = yield* settings.load;
281 assert.isNull(loaded.mainWindowBounds);
282 assert.isFalse(loaded.mainWindowMaximized);
283 assert.equal(loaded.serverExposureMode, "network-accessible");
284 }
285 }),
286 ),
287 );
288​
289 it.effect("persists sparse desktop settings documents", () =>
290 withSettings(
291 Effect.gen(function* () {
292 const environment = yield* DesktopEnvironment.DesktopEnvironment;
293 const fileSystem = yield* FileSystem.FileSystem;
294 const settings = yield* DesktopAppSettings.DesktopAppSettings;
295​
296 yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 400, height: 360 }, true);
297 yield* settings.setServerExposureMode("network-accessible");
298​
299 const persisted = yield* decodeDesktopSettingsPatch(
300 yield* fileSystem.readFileString(environment.desktopSettingsPath),
301 );
302 assert.deepEqual(persisted, {
303 mainWindowBounds: { x: -1200, y: 40, width: 400, height: 360 },
304 mainWindowMaximized: true,
305 serverExposureMode: "network-accessible",
306 } satisfies typeof DesktopSettingsPatch.Type);
307​
308 const restored = yield* Effect.gen(function* () {
309 const freshSettings = yield* DesktopAppSettings.DesktopAppSettings;
310 return yield* freshSettings.load;
311 }).pipe(Effect.provide(DesktopAppSettings.layer));
312 assert.deepEqual(restored.mainWindowBounds, persisted.mainWindowBounds);
313 assert.isTrue(restored.mainWindowMaximized);
314 }),
315 ),
316 );
317​
318 it.effect("saves through a symlinked settings file without replacing the link", () =>
319 withSettings(
320 Effect.gen(function* () {
321 const environment = yield* DesktopEnvironment.DesktopEnvironment;
322 const fileSystem = yield* FileSystem.FileSystem;
323 const settings = yield* DesktopAppSettings.DesktopAppSettings;
324 const dotfiles = yield* fileSystem.makeTempDirectoryScoped({
325 prefix: "t3-desktop-settings-dotfiles-",
326 });
327 const linkedSettingsPath = `${dotfiles}/desktop-settings.json`;
328 yield* fileSystem.writeFileString(linkedSettingsPath, "{}\n");
329 yield* fileSystem.makeDirectory(environment.stateDir, { recursive: true });
330 yield* fileSystem.symlink(linkedSettingsPath, environment.desktopSettingsPath);
331​
332 yield* settings.setServerExposureMode("network-accessible");
333​
334 assert.equal(
335 yield* fileSystem.readLink(environment.desktopSettingsPath),
336 linkedSettingsPath,
337 );
338 const persisted = yield* decodeDesktopSettingsPatch(
339 yield* fileSystem.readFileString(linkedSettingsPath),
340 );
341 assert.equal(persisted.serverExposureMode, "network-accessible");
342 }),
343 ),
344 );
345​
346 it.effect("removes its temporary file when replacing the settings file fails", () =>
347 withSettings(
348 Effect.gen(function* () {
349 const environment = yield* DesktopEnvironment.DesktopEnvironment;
350 const fileSystem = yield* FileSystem.FileSystem;
351 const settings = yield* DesktopAppSettings.DesktopAppSettings;
352 yield* fileSystem.makeDirectory(`${environment.desktopSettingsPath}/occupied`, {
353 recursive: true,
354 });
355​
356 const error = yield* settings.setServerExposureMode("network-accessible").pipe(Effect.flip);
357​
358 assert.equal(error.operation, "replace-settings-file");
359 assert.deepEqual(
360 (yield* fileSystem.readDirectory(environment.stateDir)).filter((name) =>
361 name.endsWith(".tmp"),
362 ),
363 [],
364 );
365 }),
366 ),
367 );
368​
369 it.effect("migrates legacy implicit update channels to the runtime default", () =>
370 withSettings(
371 Effect.gen(function* () {
372 const settings = yield* DesktopAppSettings.DesktopAppSettings;
373 yield* writeSettingsPatch({
374 serverExposureMode: "local-only",
375 updateChannel: "latest",
376 });
377​
378 assert.deepEqual(yield* settings.load, {
379 linuxPasswordStore: "auto",
380 localEnvironmentEnabled: true,
381 mainWindowBounds: null,
382 mainWindowMaximized: false,
383 serverExposureMode: "local-only",
384 tailscaleServeEnabled: false,
385 tailscaleServePort: 443,
386 updateChannel: "nightly",
387 updateChannelConfiguredByUser: false,
388 wslBackendEnabled: false,
389 wslOnly: false,
390 wslDistro: null,
391 } satisfies DesktopAppSettings.DesktopSettings);
392 }),
393 { appVersion: "0.0.17-nightly.20260415.1" },
394 ),
395 );
396​
397 it.effect("preserves explicit stable update channel on nightly builds", () =>
398 withSettings(
399 Effect.gen(function* () {
400 const settings = yield* DesktopAppSettings.DesktopAppSettings;
401 yield* writeSettingsPatch({
402 serverExposureMode: "local-only",
403 updateChannel: "latest",
404 updateChannelConfiguredByUser: true,
405 });
406​
407 assert.deepEqual(yield* settings.load, {
408 linuxPasswordStore: "auto",
409 localEnvironmentEnabled: true,
410 mainWindowBounds: null,
411 mainWindowMaximized: false,
412 serverExposureMode: "local-only",
413 tailscaleServeEnabled: false,
414 tailscaleServePort: 443,
415 updateChannel: "latest",
416 updateChannelConfiguredByUser: true,
417 wslBackendEnabled: false,
418 wslOnly: false,
419 wslDistro: null,
420 } satisfies DesktopAppSettings.DesktopSettings);
421 }),
422 { appVersion: "0.0.17-nightly.20260415.1" },
423 ),
424 );
425​
426 it.effect("persists wsl backend toggle and normalizes invalid distro names", () =>
427 withSettings(
428 Effect.gen(function* () {
429 const settings = yield* DesktopAppSettings.DesktopAppSettings;
430 const enable = yield* settings.setWslBackendEnabled(true);
431 assert.isTrue(enable.changed);
432 assert.equal(enable.settings.wslBackendEnabled, true);
433​
434 const distro = yield* settings.setWslDistro("Ubuntu-22.04");
435 assert.isTrue(distro.changed);
436 assert.equal(distro.settings.wslDistro, "Ubuntu-22.04");
437​
438 const reloaded = yield* settings.load;
439 assert.equal(reloaded.wslBackendEnabled, true);
440 assert.equal(reloaded.wslDistro, "Ubuntu-22.04");
441​
442 const reject = yield* settings.setWslDistro("bad name!");
443 assert.equal(reject.settings.wslDistro, null);
444​
445 const noop = yield* settings.setWslDistro(null);
446 assert.isFalse(noop.changed);
447 }),
448 ),
449 );
450​
451 it.effect("applies WSL Windows fallback with persisted and volatile updates", () =>
452 withSettings(
453 Effect.gen(function* () {
454 const settings = yield* DesktopAppSettings.DesktopAppSettings;
455 yield* settings.setWslBackendEnabled(true);
456 yield* settings.setWslOnly(true);
457​
458 const persistedFallback = yield* settings.applyWslWindowsFallback;
459 assert.isTrue(persistedFallback.changed);
460 assert.equal(persistedFallback.settings.wslBackendEnabled, false);
461 assert.equal(persistedFallback.settings.wslOnly, false);
462​
463 const persistedReload = yield* settings.load;
464 assert.equal(persistedReload.wslBackendEnabled, false);
465 assert.equal(persistedReload.wslOnly, false);
466​
467 yield* settings.setWslBackendEnabled(true);
468 yield* settings.setWslOnly(true);
469​
470 const volatileFallback = yield* settings.applyWslWindowsFallbackInMemory;
471 assert.isTrue(volatileFallback.changed);
472 assert.equal(volatileFallback.settings.wslBackendEnabled, false);
473 assert.equal(volatileFallback.settings.wslOnly, false);
474​
475 const current = yield* settings.get;
476 assert.equal(current.wslBackendEnabled, false);
477 assert.equal(current.wslOnly, false);
478​
479 const diskReload = yield* settings.load;
480 assert.equal(diskReload.wslBackendEnabled, true);
481 assert.equal(diskReload.wslOnly, true);
482 }),
483 ),
484 );
485​
486 it.effect("migrates legacy wslMode=wsl to wslBackendEnabled on load", () =>
487 withSettings(
488 Effect.gen(function* () {
489 const settings = yield* DesktopAppSettings.DesktopAppSettings;
490 yield* writeSettingsPatch({
491 wslMode: "wsl",
492 wslDistro: "Ubuntu-22.04",
493 });
494 const loaded = yield* settings.load;
495 assert.equal(loaded.wslBackendEnabled, true);
496 assert.equal(loaded.wslDistro, "Ubuntu-22.04");
497 }),
498 ),
499 );
500});