1import { describe, expect, it } from "@effect/vitest";
2import * as HostProcess from "@t3tools/shared/HostProcess";
3import * as Effect from "effect/Effect";
4import * as Sink from "effect/Sink";
5import * as Stream from "effect/Stream";
6import { ChildProcessSpawner } from "effect/process";
7
8import * as PreviewBrowserHost from "./PreviewBrowserHost.ts";
9
10// What `ldd` printed for the pinned headless shell in a bare debian:trixie-slim container.
11const 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.
20const 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.
24const 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
27const 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
54const 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
75describe("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});