1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, describe, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Logger from "effect/Logger";
6import * as PlatformError from "effect/PlatformError";
7import * as Schema from "effect/Schema";
8import * as Sink from "effect/Sink";
9import * as Stream from "effect/Stream";
10import * as ChildProcess from "effect/process/ChildProcess";
11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
12import * as NodeOS from "node:os";
13import { vi } from "vite-plus/test";
14
15import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
16import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts";
17
18vi.mock("node:os", { spy: true });
19
20const textEncoder = new TextEncoder();
21
22const isDesktopShellEnvironmentCommandError = Schema.is(
23 DesktopShellEnvironment.DesktopShellEnvironmentCommandError,
24);
25
26function envOutput(values: Readonly<Record<string, string>>): string {
27 return Object.entries(values)
28 .flatMap(([name, value]) => [
29 `__T3CODE_ENV_${name}_START__`,
30 value,
31 `__T3CODE_ENV_${name}_END__`,
32 ])
33 .join("\n");
34}
35
36function makeProcess(output: string, exitCode = 0): ChildProcessSpawner.ChildProcessHandle {
37 const stdout = output.length === 0 ? Stream.empty : Stream.make(textEncoder.encode(output));
38 return ChildProcessSpawner.makeHandle({
39 pid: ChildProcessSpawner.ProcessId(123),
40 stdout,
41 stderr: Stream.empty,
42 all: stdout,
43 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
44 isRunning: Effect.succeed(false),
45 kill: () => Effect.void,
46 stdin: Sink.drain,
47 getInputFd: () => Sink.drain,
48 getOutputFd: () => Stream.empty,
49 unref: Effect.succeed(Effect.void),
50 });
51}
52
53function withProcessEnv<A, E, R>(
54 env: NodeJS.ProcessEnv,
55 effect: Effect.Effect<A, E, R>,
56): Effect.Effect<A, E, R> {
57 return Effect.acquireUseRelease(
58 Effect.sync(() => {
59 const previous = process.env;
60 process.env = env;
61 return previous;
62 }),
63 () => effect,
64 (previous) =>
65 Effect.sync(() => {
66 process.env = previous;
67 }),
68 );
69}
70
71function runShellEnvironment(input: {
72 readonly env: NodeJS.ProcessEnv;
73 readonly platform: NodeJS.Platform;
74 readonly userShell?: string;
75 readonly handler: (
76 command: ChildProcess.Command,
77 ) => string | { output: string; exitCode: number };
78 readonly failure?: PlatformError.PlatformError;
79}) {
80 const layerEnvironment = Layer.succeed(
81 DesktopEnvironment.DesktopEnvironment,
82 DesktopEnvironment.DesktopEnvironment.of({
83 platform: input.platform,
84 } as DesktopEnvironment.DesktopEnvironment["Service"]),
85 );
86 const layerSpawner = Layer.succeed(
87 ChildProcessSpawner.ChildProcessSpawner,
88 ChildProcessSpawner.make((command) => {
89 if (input.failure !== undefined) return Effect.fail(input.failure);
90 const result = input.handler(command);
91 return Effect.succeed(
92 typeof result === "string"
93 ? makeProcess(result)
94 : makeProcess(result.output, result.exitCode),
95 );
96 }),
97 );
98
99 const program = Effect.gen(function* () {
100 const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment;
101 yield* shellEnvironment.installIntoProcess;
102 }).pipe(
103 Effect.provide(
104 DesktopShellEnvironment.layer.pipe(
105 Layer.provide(Layer.mergeAll(layerEnvironment, NodeServices.layer, layerSpawner)),
106 ),
107 ),
108 );
109
110 return Effect.acquireUseRelease(
111 Effect.sync(() =>
112 vi.spyOn(NodeOS, "userInfo").mockReturnValue({
113 ...NodeOS.userInfo(),
114 shell: input.userShell ?? null,
115 }),
116 ),
117 () => withProcessEnv(input.env, program),
118 (spy) => Effect.sync(() => spy.mockRestore()),
119 );
120}
121
122describe("DesktopShellEnvironment", () => {
123 it.effect("rejects PATH output from a failed shell and tries the next candidate", () =>
124 Effect.gen(function* () {
125 const env: NodeJS.ProcessEnv = { SHELL: "/bin/failing", PATH: "/usr/bin" };
126 const commands: string[] = [];
127
128 yield* runShellEnvironment({
129 env,
130 platform: "linux",
131 userShell: "/usr/local/bin/fish",
132 handler: (command) => {
133 if (command._tag !== "StandardCommand") return "";
134 commands.push(command.command);
135 return command.command === "/bin/failing"
136 ? { output: envOutput({ PATH: "/failed/bin" }), exitCode: 7 }
137 : envOutput({ PATH: "/home/test/.local/bin:/usr/bin" });
138 },
139 }).pipe(Effect.provide(Logger.layer([])));
140
141 assert.deepEqual(commands, ["/bin/failing", "/usr/local/bin/fish"]);
142 assert.equal(env.PATH, "/home/test/.local/bin:/usr/bin");
143 }),
144 );
145
146 it.effect.each(["darwin", "linux"] as const)(
147 "uses the account shell when SHELL is missing on %s",
148 (platform) =>
149 Effect.gen(function* () {
150 const env: NodeJS.ProcessEnv = { PATH: "/usr/bin" };
151 const commands: string[] = [];
152
153 yield* runShellEnvironment({
154 env,
155 platform,
156 userShell: "/usr/local/bin/fish",
157 handler: (command) => {
158 if (command._tag !== "StandardCommand") return "";
159 commands.push(command.command);
160 return envOutput({ PATH: "/home/test/.local/bin:/usr/bin" });
161 },
162 });
163
164 assert.deepEqual(commands, ["/usr/local/bin/fish"]);
165 assert.equal(env.PATH, "/home/test/.local/bin:/usr/bin");
166 }),
167 );
168
169 it.effect("tries the account shell before the platform fallback when SHELL has no PATH", () =>
170 Effect.gen(function* () {
171 const env: NodeJS.ProcessEnv = { SHELL: "/bin/missing", PATH: "/usr/bin" };
172 const commands: string[] = [];
173
174 yield* runShellEnvironment({
175 env,
176 platform: "linux",
177 userShell: "/usr/local/bin/fish",
178 handler: (command) => {
179 if (command._tag !== "StandardCommand") return "";
180 commands.push(command.command);
181 return command.command === "/usr/local/bin/fish"
182 ? envOutput({ PATH: "/home/test/.local/bin:/usr/bin" })
183 : "";
184 },
185 });
186
187 assert.deepEqual(commands, ["/bin/missing", "/usr/local/bin/fish"]);
188 assert.equal(env.PATH, "/home/test/.local/bin:/usr/bin");
189 }),
190 );
191
192 it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () =>
193 Effect.gen(function* () {
194 const env: NodeJS.ProcessEnv = {
195 SHELL: "/bin/zsh",
196 PATH: "/Users/test/.local/bin:/usr/bin",
197 };
198 const commands: ChildProcess.Command[] = [];
199
200 yield* runShellEnvironment({
201 env,
202 platform: "darwin",
203 handler: (command) => {
204 commands.push(command);
205 return envOutput({
206 PATH: "/opt/homebrew/bin:/usr/bin",
207 SSH_AUTH_SOCK: "/tmp/secretive.sock",
208 HOMEBREW_PREFIX: "/opt/homebrew",
209 });
210 },
211 });
212
213 assert.equal(commands.length, 1);
214 assert.equal(commands[0]?._tag === "StandardCommand" ? commands[0].command : "", "/bin/zsh");
215 assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
216 assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock");
217 assert.equal(env.HOMEBREW_PREFIX, "/opt/homebrew");
218 }),
219 );
220
221 it.effect("preserves inherited POSIX values when present", () =>
222 Effect.gen(function* () {
223 const env: NodeJS.ProcessEnv = {
224 SHELL: "/bin/zsh",
225 PATH: "/usr/bin",
226 SSH_AUTH_SOCK: "/tmp/inherited.sock",
227 };
228
229 yield* runShellEnvironment({
230 env,
231 platform: "darwin",
232 handler: () =>
233 envOutput({
234 PATH: "/opt/homebrew/bin:/usr/bin",
235 SSH_AUTH_SOCK: "/tmp/login-shell.sock",
236 }),
237 });
238
239 assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin");
240 assert.equal(env.SSH_AUTH_SOCK, "/tmp/inherited.sock");
241 }),
242 );
243
244 it.effect("hydrates the locale from the login shell on macOS", () =>
245 Effect.gen(function* () {
246 const env: NodeJS.ProcessEnv = {
247 SHELL: "/bin/zsh",
248 PATH: "/usr/bin",
249 };
250
251 yield* runShellEnvironment({
252 env,
253 platform: "darwin",
254 handler: () =>
255 envOutput({
256 PATH: "/opt/homebrew/bin:/usr/bin",
257 LANG: "de_DE.UTF-8",
258 }),
259 });
260
261 assert.equal(env.LANG, "de_DE.UTF-8");
262 }),
263 );
264
265 it.effect("preserves an inherited locale over the login shell on macOS", () =>
266 Effect.gen(function* () {
267 const env: NodeJS.ProcessEnv = {
268 SHELL: "/bin/zsh",
269 PATH: "/usr/bin",
270 LANG: "en_US.UTF-8",
271 };
272
273 yield* runShellEnvironment({
274 env,
275 platform: "darwin",
276 handler: () =>
277 envOutput({
278 PATH: "/opt/homebrew/bin:/usr/bin",
279 LANG: "de_DE.UTF-8",
280 }),
281 });
282
283 assert.equal(env.LANG, "en_US.UTF-8");
284 }),
285 );
286
287 it.effect("does not mix login-shell locale categories into an inherited locale", () =>
288 Effect.gen(function* () {
289 const env: NodeJS.ProcessEnv = {
290 SHELL: "/bin/zsh",
291 PATH: "/usr/bin",
292 LANG: "en_US.UTF-8",
293 };
294
295 yield* runShellEnvironment({
296 env,
297 platform: "darwin",
298 handler: () =>
299 envOutput({
300 PATH: "/opt/homebrew/bin:/usr/bin",
301 LC_ALL: "de_DE.UTF-8",
302 }),
303 });
304
305 assert.equal(env.LANG, "en_US.UTF-8");
306 assert.equal(env.LC_ALL, undefined);
307 }),
308 );
309
310 it.effect("falls back to a UTF-8 LC_CTYPE when no locale is available on macOS", () =>
311 Effect.gen(function* () {
312 const env: NodeJS.ProcessEnv = {
313 SHELL: "/bin/zsh",
314 PATH: "/usr/bin",
315 };
316
317 yield* runShellEnvironment({
318 env,
319 platform: "darwin",
320 handler: () => envOutput({ PATH: "/opt/homebrew/bin:/usr/bin" }),
321 });
322
323 assert.equal(env.LANG, undefined);
324 assert.equal(env.LC_ALL, undefined);
325 assert.equal(env.LC_CTYPE, "en_US.UTF-8");
326 }),
327 );
328
329 it.effect("does not apply the locale fallback on linux", () =>
330 Effect.gen(function* () {
331 const env: NodeJS.ProcessEnv = {
332 SHELL: "/bin/zsh",
333 PATH: "/usr/bin",
334 };
335
336 yield* runShellEnvironment({
337 env,
338 platform: "linux",
339 handler: () => envOutput({ PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin" }),
340 });
341
342 assert.equal(env.LANG, undefined);
343 }),
344 );
345
346 it.effect("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on linux", () =>
347 Effect.gen(function* () {
348 const env: NodeJS.ProcessEnv = {
349 SHELL: "/bin/zsh",
350 PATH: "/usr/bin",
351 };
352
353 yield* runShellEnvironment({
354 env,
355 platform: "linux",
356 handler: () =>
357 envOutput({
358 PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin",
359 SSH_AUTH_SOCK: "/tmp/secretive.sock",
360 }),
361 });
362
363 assert.equal(env.PATH, "/home/linuxbrew/.linuxbrew/bin:/usr/bin");
364 assert.equal(env.SSH_AUTH_SOCK, "/tmp/secretive.sock");
365 }),
366 );
367
368 it.effect("hydrates missing CA trust variables from the login shell", () =>
369 Effect.gen(function* () {
370 const env: NodeJS.ProcessEnv = {
371 SHELL: "/bin/zsh",
372 PATH: "/usr/bin",
373 SSL_CERT_DIR: "/etc/ssl/certs",
374 };
375
376 yield* runShellEnvironment({
377 env,
378 platform: "darwin",
379 handler: () =>
380 envOutput({
381 PATH: "/opt/zerobrew/bin:/usr/bin",
382 SSL_CERT_FILE: "/opt/zerobrew/etc/ca-certificates/cacert.pem",
383 SSL_CERT_DIR: "/opt/zerobrew/etc/ca-certificates",
384 NODE_EXTRA_CA_CERTS: "/Users/me/corp-root.pem",
385 }),
386 });
387
388 assert.equal(env.SSL_CERT_FILE, "/opt/zerobrew/etc/ca-certificates/cacert.pem");
389 assert.equal(env.SSL_CERT_DIR, "/etc/ssl/certs");
390 assert.equal(env.NODE_EXTRA_CA_CERTS, "/Users/me/corp-root.pem");
391 }),
392 );
393
394 it.effect("keeps mise's PATH record with the login shell PATH", () =>
395 Effect.gen(function* () {
396 const env: NodeJS.ProcessEnv = {
397 SHELL: "/bin/bash",
398 PATH: "/usr/bin",
399 };
400
401 yield* runShellEnvironment({
402 env,
403 platform: "linux",
404 handler: () =>
405 envOutput({
406 PATH: "/home/me/.local/bin:/mise/installs/claude/latest:/mise/shims",
407 __MISE_DIFF: "mise-diff",
408 }),
409 });
410
411 assert.equal(env.__MISE_DIFF, "mise-diff");
412 }),
413 );
414
415 it.effect("falls back to launchctl PATH on macOS when shell probing does not return one", () =>
416 Effect.gen(function* () {
417 const env: NodeJS.ProcessEnv = {
418 SHELL: "/opt/homebrew/bin/nu",
419 PATH: "/usr/bin",
420 };
421 const commands: string[] = [];
422
423 yield* runShellEnvironment({
424 env,
425 platform: "darwin",
426 handler: (command) => {
427 if (command._tag !== "StandardCommand") return "";
428 commands.push(command.command);
429 return command.command === "/bin/launchctl" ? "/opt/homebrew/bin:/usr/bin" : "";
430 },
431 });
432
433 assert.deepEqual(commands, ["/opt/homebrew/bin/nu", "/bin/zsh", "/bin/launchctl"]);
434 assert.equal(env.PATH, "/opt/homebrew/bin:/usr/bin");
435 }),
436 );
437
438 it.effect("loads PowerShell profile environment on Windows", () =>
439 Effect.gen(function* () {
440 const env: NodeJS.ProcessEnv = {
441 PATH: "C:\\Windows\\System32",
442 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
443 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
444 USERPROFILE: "C:\\Users\\testuser",
445 };
446
447 yield* runShellEnvironment({
448 env,
449 platform: "win32",
450 handler: (command) => {
451 if (command._tag !== "StandardCommand") return "";
452 const loadProfile = !command.args.includes("-NoProfile");
453 return loadProfile
454 ? envOutput({
455 PATH: "C:\\Profile\\Node;C:\\Windows\\System32",
456 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
457 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
458 })
459 : envOutput({ PATH: 'C:\\Custom\\Bin;C:";C:\\Windows\\System32' });
460 },
461 });
462
463 assert.equal(
464 env.PATH,
465 [
466 "C:\\Profile\\Node",
467 "C:\\Windows\\System32",
468 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
469 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
470 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
471 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
472 "C:\\Users\\testuser\\.local\\bin",
473 "C:\\Users\\testuser\\.bun\\bin",
474 "C:\\Users\\testuser\\scoop\\shims",
475 "C:\\Custom\\Bin",
476 "C:",
477 ].join(";"),
478 );
479 assert.equal(env.FNM_DIR, "C:\\Users\\testuser\\AppData\\Roaming\\fnm");
480 assert.equal(
481 env.FNM_MULTISHELL_PATH,
482 "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
483 );
484 }),
485 );
486
487 it.effect("prefers login-shell desktop session hints over inherited values on linux", () =>
488 Effect.gen(function* () {
489 const env: NodeJS.ProcessEnv = {
490 SHELL: "/bin/zsh",
491 PATH: "/usr/bin",
492 XDG_CURRENT_DESKTOP: "wrong-launcher",
493 XDG_SESSION_DESKTOP: "wrong-launcher",
494 };
495
496 yield* runShellEnvironment({
497 env,
498 platform: "linux",
499 handler: () =>
500 envOutput({
501 PATH: "/home/linuxbrew/.linuxbrew/bin:/usr/bin",
502 XDG_CURRENT_DESKTOP: "KDE",
503 XDG_SESSION_DESKTOP: "KDE",
504 XDG_SESSION_TYPE: "wayland",
505 }),
506 });
507
508 assert.equal(env.XDG_CURRENT_DESKTOP, "KDE");
509 assert.equal(env.XDG_SESSION_DESKTOP, "KDE");
510 assert.equal(env.XDG_SESSION_TYPE, "wayland");
511 }),
512 );
513
514 it.effect("overrides stale dbus session addresses from the login shell", () =>
515 Effect.gen(function* () {
516 const env: NodeJS.ProcessEnv = {
517 SHELL: "/bin/zsh",
518 PATH: "/usr/bin",
519 DBUS_SESSION_BUS_ADDRESS: "unix:path=/tmp/stale-bus",
520 };
521
522 yield* runShellEnvironment({
523 env,
524 platform: "linux",
525 handler: () =>
526 envOutput({
527 PATH: "/usr/bin",
528 DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/1000/bus",
529 }),
530 });
531
532 assert.equal(env.DBUS_SESSION_BUS_ADDRESS, "unix:path=/run/user/1000/bus");
533 }),
534 );
535
536 it.effect("logs command failures with safe probe context and the exact cause", () => {
537 const env: NodeJS.ProcessEnv = {
538 SHELL: "/bin/bash",
539 PATH: "/usr/bin",
540 };
541 const cause = PlatformError.systemError({
542 _tag: "PermissionDenied",
543 module: "ChildProcess",
544 method: "spawn",
545 pathOrDescriptor: "/bin/bash",
546 });
547 const messages: Array<unknown> = [];
548 const logger = Logger.make(({ message }) => {
549 messages.push(message);
550 });
551
552 return runShellEnvironment({
553 env,
554 platform: "linux",
555 handler: () => "",
556 failure: cause,
557 }).pipe(
558 Effect.andThen(
559 Effect.sync(() => {
560 const errors = messages
561 .flatMap((message) => (Array.isArray(message) ? message : [message]))
562 .filter(isDesktopShellEnvironmentCommandError);
563 assert.lengthOf(errors, 1);
564 assert.equal(errors[0]?.probe, "login-shell");
565 assert.equal(errors[0]?.executable, "bash");
566 assert.equal(errors[0]?.argumentCount, 2);
567 assert.notProperty(errors[0] ?? {}, "args");
568 assert.equal(errors[0]?.cause, cause);
569 assert.notInclude(errors[0]?.message ?? "", cause.message);
570 }),
571 ),
572 Effect.provide(Logger.layer([logger], { mergeWithExisting: false })),
573 );
574 });
575});