| 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Sink from "effect/Sink"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import { ChildProcessSpawner } from "effect/process"; |
| 7 | |
| 8 | import * as PreviewBrowserHost from "./PreviewBrowserHost.ts"; |
| 9 | |
| 10 | // What `ldd` printed for the pinned headless shell in a bare debian:trixie-slim container. |
| 11 | const LDD_IN_BARE_DEBIAN = `\tlinux-vdso.so.1 (0x00007ffd2d1f8000) |
| 12 | \tlibdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f0c1b6a1000) |
| 13 | \tlibglib-2.0.so.0 => not found |
| 14 | \tlibnss3.so => not found |
| 15 | \tlibX11.so.6 => not found |
| 16 | \tlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f0c1b4c0000) |
| 17 | `; |
| 18 | |
| 19 | // Chrome's own abort on Ubuntu 26.04 with unprivileged user namespaces restricted. |
| 20 | const SANDBOX_ABORT = |
| 21 | "[1005/163106.285700:FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:129] No usable sandbox! If you are running on Ubuntu 23.10+ or another Linux distro that has disabled unprivileged user namespaces with AppArmor, see https://chromium.googlesource.com/"; |
| 22 | |
| 23 | // What the real loader prints when the browser's `--version` cannot start it. |
| 24 | const LOADER_ERROR = |
| 25 | "chrome-headless-shell: error while loading shared libraries: libglib-2.0.so.0: cannot open shared object file: No such file or directory"; |
| 26 | |
| 27 | const hostReporting = (output: { readonly version: string; readonly ldd: string }) => { |
| 28 | const commands: Array<string> = []; |
| 29 | const spawner = ChildProcessSpawner.make((command) => |
| 30 | Effect.sync(() => { |
| 31 | const { command: name, args } = command as unknown as { |
| 32 | readonly command: string; |
| 33 | readonly args: ReadonlyArray<string>; |
| 34 | }; |
| 35 | commands.push([name, ...args].join(" ")); |
| 36 | return ChildProcessSpawner.makeHandle({ |
| 37 | pid: ChildProcessSpawner.ProcessId(1), |
| 38 | exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(0)), |
| 39 | isRunning: Effect.succeed(false), |
| 40 | kill: () => Effect.void, |
| 41 | unref: Effect.succeed(Effect.void), |
| 42 | stdin: Sink.drain, |
| 43 | stdout: Stream.empty, |
| 44 | stderr: Stream.empty, |
| 45 | all: Stream.make(new TextEncoder().encode(name === "ldd" ? output.ldd : output.version)), |
| 46 | getInputFd: () => Sink.drain, |
| 47 | getOutputFd: () => Stream.empty, |
| 48 | }); |
| 49 | }), |
| 50 | ); |
| 51 | return { spawner, commands }; |
| 52 | }; |
| 53 | |
| 54 | const diagnose = (input: { |
| 55 | platform: NodeJS.Platform; |
| 56 | output: string; |
| 57 | ldd: string; |
| 58 | version?: string; |
| 59 | }) => { |
| 60 | const { spawner, commands } = hostReporting({ |
| 61 | ldd: input.ldd, |
| 62 | version: input.version ?? LOADER_ERROR, |
| 63 | }); |
| 64 | return PreviewBrowserHost.diagnoseLaunchFailure({ |
| 65 | executable: "/home/me/.t3/tools/chrome-headless-shell/linux64/154/chrome-headless-shell", |
| 66 | output: input.output, |
| 67 | setupCommand: "sudo t3 browser setup", |
| 68 | }).pipe( |
| 69 | Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), |
| 70 | Effect.provideService(HostProcess.Platform, input.platform), |
| 71 | Effect.map((error) => ({ error, commands })), |
| 72 | ); |
| 73 | }; |
| 74 | |
| 75 | describe("diagnoseLaunchFailure", () => { |
| 76 | it.effect("turns Chrome's sandbox abort into the AppArmor steps", () => |
| 77 | Effect.gen(function* () { |
| 78 | const { error, commands } = yield* diagnose({ |
| 79 | platform: "linux", |
| 80 | output: SANDBOX_ABORT, |
| 81 | ldd: "", |
| 82 | }); |
| 83 | expect(error?._tag).toBe("PreviewBrowserSandboxError"); |
| 84 | expect(error?.message).toContain("Run `sudo t3 browser setup` on the host"); |
| 85 | expect(commands).toEqual([]); |
| 86 | }), |
| 87 | ); |
| 88 | |
| 89 | it.effect("names every library the loader cannot find", () => |
| 90 | Effect.gen(function* () { |
| 91 | const { error } = yield* diagnose({ |
| 92 | platform: "linux", |
| 93 | output: "error while loading shared libraries: libglib-2.0.so.0", |
| 94 | ldd: LDD_IN_BARE_DEBIAN, |
| 95 | }); |
| 96 | expect(error).toMatchObject({ |
| 97 | _tag: "PreviewBrowserLibrariesError", |
| 98 | libraries: ["libglib-2.0.so.0", "libnss3.so", "libX11.so.6"], |
| 99 | }); |
| 100 | expect(error?.message).toContain("Run `sudo t3 browser setup` on the host"); |
| 101 | }), |
| 102 | ); |
| 103 | |
| 104 | it.effect("trusts the browser's own start over ldd", () => |
| 105 | Effect.gen(function* () { |
| 106 | // NixOS: ldd lists libraries that nix-ld still provides to the real loader. |
| 107 | const { error } = yield* diagnose({ |
| 108 | platform: "linux", |
| 109 | output: "crashed", |
| 110 | ldd: LDD_IN_BARE_DEBIAN, |
| 111 | version: "Google Chrome for Testing 154.0.8037.92\n", |
| 112 | }); |
| 113 | expect(error).toBeUndefined(); |
| 114 | }), |
| 115 | ); |
| 116 | |
| 117 | it.effect("leaves other failures alone", () => |
| 118 | Effect.gen(function* () { |
| 119 | const linux = yield* diagnose({ |
| 120 | platform: "linux", |
| 121 | output: "crashed", |
| 122 | ldd: "\tlibc.so.6 => /lib/libc.so.6\n", |
| 123 | version: "Google Chrome for Testing 154.0.8037.92\n", |
| 124 | }); |
| 125 | expect(linux.error).toBeUndefined(); |
| 126 | const mac = yield* diagnose({ platform: "darwin", output: "crashed", ldd: "" }); |
| 127 | expect(mac.error).toBeUndefined(); |
| 128 | expect(mac.commands).toEqual([]); |
| 129 | }), |
| 130 | ); |
| 131 | }); |