| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import { assert, describe, it } from "@effect/vitest"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Logger from "effect/Logger"; |
| 6 | import * as PlatformError from "effect/PlatformError"; |
| 7 | import * as Schema from "effect/Schema"; |
| 8 | import * as Sink from "effect/Sink"; |
| 9 | import * as Stream from "effect/Stream"; |
| 10 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 11 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"; |
| 12 | import * as NodeOS from "node:os"; |
| 13 | import { vi } from "vite-plus/test"; |
| 14 | |
| 15 | import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; |
| 16 | import * as DesktopShellEnvironment from "./DesktopShellEnvironment.ts"; |
| 17 | |
| 18 | vi.mock("node:os", { spy: true }); |
| 19 | |
| 20 | const textEncoder = new TextEncoder(); |
| 21 | |
| 22 | const isDesktopShellEnvironmentCommandError = Schema.is( |
| 23 | DesktopShellEnvironment.DesktopShellEnvironmentCommandError, |
| 24 | ); |
| 25 | |
| 26 | function 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 | |
| 36 | function 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 | |
| 53 | function 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 | |
| 71 | function 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 | |
| 122 | describe("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 | }); |