From 180f368af1dd6b4d949d182805644077feaa81bc Mon Sep 17 00:00:00 2001 From: James Russo Date: Wed, 1 Jul 2026 18:49:48 -0700 Subject: [PATCH] fix(cli): detect missing Chrome libs & ffmpeg on Linux/WSL in doctor (#1841) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WSL first-render success (34.7%) is dominated by a downloaded chrome-headless-shell that launches into `libnss3.so: cannot open shared object file` — doctor/preflight only checked the binary exists, never that it can load its libraries. - New linuxDeps.ts: /etc/os-release distro detection (Debian/Fedora/Arch/Alpine) + WSL detection, per-distro Chrome dep set, ldd-based shared-lib probe. - preflight.checkChrome downgrades a found-but-unlaunchable Chrome to a render-blocking error with the exact per-distro install command. - Distro-aware ffmpeg hints; launch failures converted to actionable guidance pointing at `hyperframes doctor` (skipped on ARM64). - Detect + print remediation (no auto-install). Render-reliability workstream P1-4. Success measured on PostHog dashboard 1783183. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/cli/src/browser/ffmpeg.ts | 9 +- packages/cli/src/browser/linuxDeps.test.ts | 181 +++++++++++ packages/cli/src/browser/linuxDeps.ts | 350 +++++++++++++++++++++ packages/cli/src/browser/preflight.test.ts | 78 +++++ packages/cli/src/browser/preflight.ts | 43 ++- packages/cli/src/commands/render.ts | 10 + 6 files changed, 665 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/browser/linuxDeps.test.ts create mode 100644 packages/cli/src/browser/linuxDeps.ts diff --git a/packages/cli/src/browser/ffmpeg.ts b/packages/cli/src/browser/ffmpeg.ts index 6db32e3d8..9c0670ca1 100644 --- a/packages/cli/src/browser/ffmpeg.ts +++ b/packages/cli/src/browser/ffmpeg.ts @@ -2,6 +2,7 @@ import { execSync } from "node:child_process"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; +import { detectLinuxDistro, ffmpegInstallCommand } from "./linuxDeps.js"; export const FFMPEG_PATH_ENV = "HYPERFRAMES_FFMPEG_PATH"; export const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH"; @@ -62,8 +63,12 @@ export function getFFmpegInstallHint(): string { switch (process.platform) { case "darwin": return "brew install ffmpeg"; - case "linux": - return "sudo apt install ffmpeg"; + case "linux": { + // Distro-aware so WSL/Fedora/Arch/Alpine users get a command that + // actually works instead of a Debian-only `apt` line. + const distro = detectLinuxDistro(); + return ffmpegInstallCommand(distro.family); + } case "win32": return "Download the 64-bit Windows build from https://ffmpeg.org/download.html#build-windows and add its bin/ directory to PATH."; default: diff --git a/packages/cli/src/browser/linuxDeps.test.ts b/packages/cli/src/browser/linuxDeps.test.ts new file mode 100644 index 000000000..945b8b2e4 --- /dev/null +++ b/packages/cli/src/browser/linuxDeps.test.ts @@ -0,0 +1,181 @@ +// fallow-ignore-file code-duplication +import { describe, it, expect } from "vitest"; +import { + chromeDepsInstallCommand, + chromeLaunchRemediation, + distroFamilyFromOsRelease, + distroLabel, + ffmpegInstallCommand, + isSharedLibLaunchError, + parseLddMissingLibs, + parseOsRelease, +} from "./linuxDeps.js"; + +describe("parseOsRelease", () => { + it("parses quoted and unquoted key=value pairs", () => { + const parsed = parseOsRelease( + ['NAME="Ubuntu"', "ID=ubuntu", 'ID_LIKE="debian"', 'PRETTY_NAME="Ubuntu 22.04.3 LTS"'].join( + "\n", + ), + ); + expect(parsed["ID"]).toBe("ubuntu"); + expect(parsed["ID_LIKE"]).toBe("debian"); + expect(parsed["PRETTY_NAME"]).toBe("Ubuntu 22.04.3 LTS"); + }); + + it("ignores comments and blank lines", () => { + const parsed = parseOsRelease("# a comment\n\nID=fedora\n"); + expect(parsed).toEqual({ ID: "fedora" }); + }); +}); + +describe("distroFamilyFromOsRelease", () => { + it("maps Ubuntu/Debian derivatives to debian", () => { + expect(distroFamilyFromOsRelease("ubuntu", "debian")).toBe("debian"); + expect(distroFamilyFromOsRelease("debian")).toBe("debian"); + expect(distroFamilyFromOsRelease("linuxmint", "ubuntu debian")).toBe("debian"); + }); + + it("maps RHEL family to fedora", () => { + expect(distroFamilyFromOsRelease("fedora")).toBe("fedora"); + expect(distroFamilyFromOsRelease("rocky", "rhel centos fedora")).toBe("fedora"); + expect(distroFamilyFromOsRelease("amzn")).toBe("fedora"); + }); + + it("maps Arch derivatives to arch", () => { + expect(distroFamilyFromOsRelease("arch")).toBe("arch"); + expect(distroFamilyFromOsRelease("manjaro", "arch")).toBe("arch"); + }); + + it("maps Alpine to alpine", () => { + expect(distroFamilyFromOsRelease("alpine")).toBe("alpine"); + }); + + it("returns unknown for unrecognized ids", () => { + expect(distroFamilyFromOsRelease("void")).toBe("unknown"); + expect(distroFamilyFromOsRelease(undefined, undefined)).toBe("unknown"); + }); +}); + +describe("chromeDepsInstallCommand", () => { + it("emits an apt-get line with libnss3 for debian", () => { + const cmd = chromeDepsInstallCommand("debian"); + expect(cmd).toContain("apt-get install -y"); + expect(cmd).toContain("libnss3"); + expect(cmd).toContain("libatk1.0-0"); + }); + + it("emits a dnf line with nss for fedora", () => { + const cmd = chromeDepsInstallCommand("fedora"); + expect(cmd).toContain("dnf install -y"); + expect(cmd).toContain("nss"); + }); + + it("emits a pacman line for arch", () => { + expect(chromeDepsInstallCommand("arch")).toContain("pacman -S"); + }); + + it("emits an apk line for alpine", () => { + expect(chromeDepsInstallCommand("alpine")).toContain("apk add"); + }); + + it("gives generic guidance for unknown distros", () => { + expect(chromeDepsInstallCommand("unknown").toLowerCase()).toContain("nss"); + }); +}); + +describe("ffmpegInstallCommand", () => { + it("uses the distro package manager", () => { + expect(ffmpegInstallCommand("debian")).toBe( + "sudo apt-get update && sudo apt-get install -y ffmpeg", + ); + expect(ffmpegInstallCommand("fedora")).toBe("sudo dnf install -y ffmpeg"); + expect(ffmpegInstallCommand("arch")).toBe("sudo pacman -S --needed ffmpeg"); + expect(ffmpegInstallCommand("alpine")).toBe("sudo apk add ffmpeg"); + }); + + it("gives generic guidance for unknown distros", () => { + expect(ffmpegInstallCommand("unknown").toLowerCase()).toContain("ffmpeg"); + }); +}); + +describe("parseLddMissingLibs", () => { + it("collects libraries reported as not found", () => { + const output = [ + "\tlinux-vdso.so.1 (0x00007fff...)", + "\tlibnss3.so => not found", + "\tlibatk-1.0.so.0 => not found", + "\tlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)", + ].join("\n"); + const result = parseLddMissingLibs(output); + expect(result.ok).toBe(false); + expect(result.missing).toEqual(["libnss3.so", "libatk-1.0.so.0"]); + expect(result.probeUnavailable).toBe(false); + }); + + it("reports ok when every library resolves", () => { + const output = "\tlibc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f...)"; + const result = parseLddMissingLibs(output); + expect(result.ok).toBe(true); + expect(result.missing).toEqual([]); + }); + + it("does not false-positive on a resolved path containing 'not found'", () => { + // A directory literally named "not found" must not trip the missing-lib + // detection — only the `=> not found` marker counts. + const output = "\tlibfoo.so.1 => /opt/not found/libfoo.so.1 (0x00007f...)"; + const result = parseLddMissingLibs(output); + expect(result.ok).toBe(true); + expect(result.missing).toEqual([]); + }); +}); + +describe("distroLabel", () => { + it("returns WSL when running under WSL", () => { + expect(distroLabel({ family: "debian", prettyName: "Ubuntu", isWsl: true })).toBe("WSL"); + }); + + it("returns the pretty name off WSL", () => { + expect(distroLabel({ family: "fedora", prettyName: "Fedora Linux 40", isWsl: false })).toBe( + "Fedora Linux 40", + ); + }); + + it("falls back to Linux when no pretty name", () => { + expect(distroLabel({ family: "unknown", isWsl: false })).toBe("Linux"); + }); +}); + +describe("isSharedLibLaunchError", () => { + it("matches the libnss3 cannot-open message", () => { + expect( + isSharedLibLaunchError( + "libnss3.so: cannot open shared object file: No such file or directory", + ), + ).toBe(true); + }); + + it("matches the dynamic-linker phrasing", () => { + expect(isSharedLibLaunchError("error while loading shared libraries: libatk-1.0.so.0")).toBe( + true, + ); + }); + + it("does not match unrelated errors", () => { + expect(isSharedLibLaunchError("Composition has zero duration")).toBe(false); + }); +}); + +describe("chromeLaunchRemediation", () => { + it("returns undefined for non-launch errors", () => { + expect(chromeLaunchRemediation("Composition HTML is empty")).toBeUndefined(); + }); + + // Platform-dependent output (Linux distro detection) is covered by the + // preflight tests that mock `platform()`; here we only assert the non-Linux / + // non-launch short-circuits, which are deterministic on the macOS CI host. + it("returns undefined off Linux even for a launch failure", () => { + if (process.platform === "linux") return; + expect(chromeLaunchRemediation("Failed to launch the browser process")).toBeUndefined(); + }); +}); diff --git a/packages/cli/src/browser/linuxDeps.ts b/packages/cli/src/browser/linuxDeps.ts new file mode 100644 index 000000000..2cc1ec8da --- /dev/null +++ b/packages/cli/src/browser/linuxDeps.ts @@ -0,0 +1,350 @@ +// fallow-ignore-file code-duplication +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync } from "node:fs"; +import { detectWSL } from "../telemetry/platform.js"; + +/** + * Linux/WSL Chrome & ffmpeg dependency detection and remediation. + * + * WSL first-render success is only ~34.7% (vs ~53% local_agent) — the dominant + * cause is a downloaded `chrome-headless-shell` that launches into + * `libnss3.so: cannot open shared object file` (and friends) because the + * headless Chromium shared-library set is not installed. A binary existing on + * disk is NOT sufficient; `doctor`/preflight must verify it can actually load + * its shared libraries and, when it can't, print the exact per-distro install + * command. + * + * Design decision: detect + print precise remediation, do NOT auto-install. + * Auto-install needs sudo + network and is surprising in an agent/CI context; + * remediation is a copy-paste line the user (or their provisioning script) runs. + */ + +export type LinuxDistroFamily = "debian" | "fedora" | "arch" | "alpine" | "unknown"; + +export interface LinuxDistroInfo { + /** Package-manager family used to pick the install command. */ + family: LinuxDistroFamily; + /** `ID` from /etc/os-release (e.g. "ubuntu", "debian", "fedora"), if any. */ + id?: string; + /** Human-readable name from /etc/os-release `PRETTY_NAME`, if any. */ + prettyName?: string; + /** True when running under Windows Subsystem for Linux. */ + isWsl: boolean; +} + +/** + * Full per-distro package list that provides the headless Chrome dependency + * set. Kept as the complete set (not just the missing ones) so the remediation + * line is a single deterministic, copy-pasteable command that fixes the whole + * class of failure in one shot rather than one library at a time. + * + * The headless Chrome shared-library set (libnss3, libnspr4, libatk, + * at-spi2, cups, libdrm, libxkbcommon, gbm, pango, cairo, alsa, ...) surfaces + * at launch as `error while loading shared libraries: : cannot open shared + * object file`, which Puppeteer wraps as `Failed to launch the browser process`. + * `ldd` reports the exact missing `.so`; the package list below is what provides + * them. + */ +const DISTRO_PACKAGES: Record, string[]> = { + debian: [ + "libnss3", + "libnspr4", + "libatk1.0-0", + "libatk-bridge2.0-0", + "libcups2", + "libdrm2", + "libxkbcommon0", + "libatspi2.0-0", + "libxcomposite1", + "libxdamage1", + "libxfixes3", + "libxrandr2", + "libgbm1", + "libpango-1.0-0", + "libcairo2", + "libasound2", + ], + fedora: [ + "nss", + "nspr", + "atk", + "at-spi2-atk", + "cups-libs", + "libdrm", + "libxkbcommon", + "at-spi2-core", + "libXcomposite", + "libXdamage", + "libXfixes", + "libXrandr", + "mesa-libgbm", + "pango", + "cairo", + "alsa-lib", + ], + arch: [ + "nss", + "nspr", + "atk", + "at-spi2-atk", + "libcups", + "libdrm", + "libxkbcommon", + "at-spi2-core", + "libxcomposite", + "libxdamage", + "libxfixes", + "libxrandr", + "mesa", + "pango", + "cairo", + "alsa-lib", + ], + alpine: [ + "nss", + "nspr", + "atk", + "at-spi2-atk", + "cups-libs", + "libdrm", + "libxkbcommon", + "at-spi2-core", + "libxcomposite", + "libxdamage", + "libxfixes", + "libxrandr", + "mesa-gbm", + "pango", + "cairo", + "alsa-lib", + ], +}; + +const INSTALL_PREFIX: Record, string> = { + debian: "sudo apt-get update && sudo apt-get install -y", + fedora: "sudo dnf install -y", + arch: "sudo pacman -S --needed", + alpine: "sudo apk add", +}; + +/** + * Map an /etc/os-release `ID` / `ID_LIKE` to a package-manager family. + * Exported for direct unit testing without touching the filesystem. + */ +export function distroFamilyFromOsRelease(id?: string, idLike?: string): LinuxDistroFamily { + const haystack = `${id ?? ""} ${idLike ?? ""}`.toLowerCase(); + if (/\b(debian|ubuntu|linuxmint|pop|elementary|raspbian|kali)\b/.test(haystack)) return "debian"; + if (/\b(fedora|rhel|centos|rocky|almalinux|amzn|ol)\b/.test(haystack)) return "fedora"; + if (/\b(arch|manjaro|endeavouros|garuda)\b/.test(haystack)) return "arch"; + if (/\b(alpine)\b/.test(haystack)) return "alpine"; + return "unknown"; +} + +/** Parse the shell-style key=value contents of /etc/os-release. */ +export function parseOsRelease(contents: string): Record { + const out: Record = {}; + for (const rawLine of contents.split("\n")) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq < 0) continue; + const key = line.slice(0, eq).trim(); + let value = line.slice(eq + 1).trim(); + // Strip surrounding single/double quotes. + if (value.length >= 2 && (value[0] === '"' || value[0] === "'") && value.at(-1) === value[0]) { + value = value.slice(1, -1); + } + if (key) out[key] = value; + } + return out; +} + +/** + * Detect the running Linux distribution family and WSL status. Reads + * /etc/os-release; falls back to "unknown" when it can't be read or matched. + */ +export function detectLinuxDistro(): LinuxDistroInfo { + const isWsl = detectWSL(); + try { + const contents = readFileSync("/etc/os-release", "utf-8"); + const parsed = parseOsRelease(contents); + const family = distroFamilyFromOsRelease(parsed["ID"], parsed["ID_LIKE"]); + return { + family, + ...(parsed["ID"] ? { id: parsed["ID"] } : {}), + ...(parsed["PRETTY_NAME"] ? { prettyName: parsed["PRETTY_NAME"] } : {}), + isWsl, + }; + } catch { + return { family: "unknown", isWsl }; + } +} + +/** + * Build the exact command to install the full Chrome shared-library set for a + * distro family, or a generic pointer for unknown families. + */ +export function chromeDepsInstallCommand(family: LinuxDistroFamily): string { + if (family === "unknown") { + return "Install the Chrome headless dependencies for your distro (nss, atk, at-spi2, cups, libdrm, libxkbcommon, gbm, pango, cairo, alsa), then re-run."; + } + return `${INSTALL_PREFIX[family]} ${DISTRO_PACKAGES[family].join(" ")}`; +} + +/** + * Per-distro ffmpeg install command (ffprobe ships in the same `ffmpeg` package + * on every family we support). + */ +export function ffmpegInstallCommand(family: LinuxDistroFamily): string { + if (family === "unknown") { + return "Install ffmpeg (which includes ffprobe) via your distro package manager, then re-run."; + } + return `${INSTALL_PREFIX[family]} ffmpeg`; +} + +/** + * Human-readable environment label for a detected distro — "WSL" when running + * under WSL, else the /etc/os-release PRETTY_NAME, else "Linux". Shared so the + * preflight check and the render-failure remediation name the environment + * identically for the same machine. + */ +export function distroLabel(distro: LinuxDistroInfo): string { + if (distro.isWsl) return "WSL"; + return distro.prettyName ?? "Linux"; +} + +export interface SharedLibProbeResult { + /** True when the probe ran and every required library resolved. */ + ok: boolean; + /** Shared libs reported by `ldd` as "not found". Empty when ok. */ + missing: string[]; + /** + * True when the probe itself could not run (no `ldd`, non-Linux, exec + * failure). Distinct from `ok:false` — we can't conclude libs are missing, so + * callers should not fabricate a false "Chrome broken" error. + */ + probeUnavailable: boolean; +} + +/** + * True when a child-process error indicates the process was killed (e.g. by the + * `timeout` option → SIGTERM), whose stdout is only a partial capture. + */ +function isKilledExecError(err: unknown): boolean { + if (typeof err !== "object" || err === null) return false; + const e = err as NodeJS.ErrnoException & { killed?: boolean; signal?: string | null }; + return e.killed === true || e.signal != null; +} + +/** Extract captured stdout from a child-process error, if present, as a string. */ +function execErrorStdout(err: unknown): string | undefined { + if (typeof err !== "object" || err === null || !("stdout" in err)) return undefined; + const stdout = (err as { stdout?: string | Buffer | null }).stdout; + if (stdout == null) return undefined; + return stdout.toString(); +} + +/** + * Run `ldd ` and report any shared libraries the dynamic linker + * cannot resolve (lines containing "=> not found"). This is the check that + * catches the `libnss3.so: cannot open shared object file` launch failure + * BEFORE a render is attempted — a binary can exist on disk yet be unlaunchable. + * + * Only meaningful on Linux. Returns `probeUnavailable:true` on any platform + * where `ldd` isn't applicable or the probe can't run, so callers treat it as + * "inconclusive" rather than "missing". + */ +export function probeChromeSharedLibs(chromeBinaryPath: string): SharedLibProbeResult { + if (process.platform !== "linux") { + return { ok: true, missing: [], probeUnavailable: true }; + } + if (!existsSync(chromeBinaryPath)) { + return { ok: true, missing: [], probeUnavailable: true }; + } + let output: string; + try { + // ldd exits non-zero when libs are missing but still prints the resolution + // table to stdout, so capture stdout even on failure. + output = execFileSync("ldd", [chromeBinaryPath], { + encoding: "utf-8", + timeout: 5000, + }); + } catch (err) { + // A timed-out/killed ldd leaves only a partial resolution table, which we + // must NOT parse as authoritative (would report spurious missing libs). + // Anything other than a clean non-zero exit with full stdout is + // inconclusive. + if (isKilledExecError(err)) { + return { ok: true, missing: [], probeUnavailable: true }; + } + const stdout = execErrorStdout(err); + if (stdout == null) { + // `ldd` not installed / not executable — we cannot conclude anything. + return { ok: true, missing: [], probeUnavailable: true }; + } + output = stdout; + } + return parseLddMissingLibs(output); +} + +/** + * Parse `ldd` output into the set of unresolved libraries. Exported so the + * parsing (the actual logic) is unit-tested without spawning a real process. + * + * A line like `libnss3.so => not found` means the loader can't find it. + */ +export function parseLddMissingLibs(lddOutput: string): SharedLibProbeResult { + const missing: string[] = []; + for (const rawLine of lddOutput.split("\n")) { + const line = rawLine.trim(); + // Match the exact unresolved marker `=> not found` — NOT a bare "not found" + // substring, which would false-positive on a resolved lib whose path + // happens to contain that text (e.g. `libfoo.so => /opt/not found/...`). + if (!/=>\s*not found\b/.test(line)) continue; + const soname = line.split("=>")[0]?.trim(); + if (soname) missing.push(soname); + } + return { ok: missing.length === 0, missing, probeUnavailable: false }; +} + +/** + * True when an error message is the "Chrome couldn't load its shared libraries" + * launch failure — the exact class `doctor` remediates. Matches both the raw + * dynamic-linker text and Puppeteer's wrapper. + */ +export function isSharedLibLaunchError(message: string): boolean { + return ( + /cannot open shared object file/i.test(message) || + /error while loading shared libraries/i.test(message) || + /lib[\w.+-]*\.so[\w.]*: cannot open/i.test(message) + ); +} + +/** + * Turn a cryptic Chrome launch failure into actionable, per-distro guidance. + * Returns the remediation block to show the user, or `undefined` when the error + * isn't a shared-library/launch failure this module can help with. + */ +export function chromeLaunchRemediation(errorMessage: string): string | undefined { + const isLaunchFailure = + /Failed to launch the browser process/i.test(errorMessage) || + isSharedLibLaunchError(errorMessage); + if (!isLaunchFailure) return undefined; + if (process.platform !== "linux") return undefined; + // On Linux ARM64 the render browser is system Chromium (see + // ensureLinuxArmBrowser in manager.ts), so a launch failure there is almost + // always a missing/mis-pathed chromium — NOT the headless-shell .so set. The + // shared-lib install line would be wrong advice, so defer to that path's own + // guidance instead of emitting it here. + if (process.arch === "arm64") return undefined; + + const distro = detectLinuxDistro(); + const lines: string[] = []; + lines.push( + `Chrome could not launch on ${distroLabel(distro)} — this is almost always missing system libraries (e.g. libnss3).`, + ); + lines.push("Install the Chrome headless dependencies:"); + lines.push(` ${chromeDepsInstallCommand(distro.family)}`); + lines.push("Then verify with: npx hyperframes doctor"); + return lines.join("\n"); +} diff --git a/packages/cli/src/browser/preflight.test.ts b/packages/cli/src/browser/preflight.test.ts index 2ad91b29e..0d1d56b40 100644 --- a/packages/cli/src/browser/preflight.test.ts +++ b/packages/cli/src/browser/preflight.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { parseToolVersion, runEnvironmentChecks } from "./preflight.js"; import * as manager from "./manager.js"; +import * as linuxDeps from "./linuxDeps.js"; describe("runEnvironmentChecks", () => { const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH; @@ -102,6 +103,83 @@ describe("runEnvironmentChecks", () => { }); }); +describe("runEnvironmentChecks — Chrome shared libraries (Linux/WSL)", () => { + const originalPlatform = process.platform; + + beforeEach(() => { + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + }); + + afterEach(() => { + Object.defineProperty(process, "platform", { value: originalPlatform, configurable: true }); + vi.restoreAllMocks(); + }); + + it("downgrades a found Chrome to a render-blocking error when libs are missing", async () => { + vi.spyOn(manager, "findBrowser").mockResolvedValue({ + executablePath: "/root/.cache/hyperframes/chrome-headless-shell", + source: "cache", + }); + vi.spyOn(linuxDeps, "probeChromeSharedLibs").mockReturnValue({ + ok: false, + missing: ["libnss3.so", "libatk-1.0.so.0"], + probeUnavailable: false, + }); + vi.spyOn(linuxDeps, "detectLinuxDistro").mockReturnValue({ + family: "debian", + id: "ubuntu", + prettyName: "Ubuntu 22.04.3 LTS", + isWsl: true, + }); + + const result = await runEnvironmentChecks({ includeBrowser: true }); + const chrome = result.outcomes.find((o) => o.name === "Chrome"); + + expect(chrome).toMatchObject({ + ok: false, + level: "error", + title: "Chrome cannot launch (missing system libraries)", + }); + expect(chrome?.detail).toContain("WSL"); + expect(chrome?.detail).toContain("libnss3.so"); + expect(chrome?.hint).toContain("apt-get install -y"); + expect(chrome?.hint).toContain("libnss3"); + // A lib-broken Chrome must NOT be handed to the render pipeline as usable. + expect(result.browser).toBeUndefined(); + }); + + it("keeps Chrome ok when the shared-lib probe passes", async () => { + vi.spyOn(manager, "findBrowser").mockResolvedValue({ + executablePath: "/usr/bin/chromium", + source: "system", + }); + vi.spyOn(linuxDeps, "probeChromeSharedLibs").mockReturnValue({ + ok: true, + missing: [], + probeUnavailable: false, + }); + + const result = await runEnvironmentChecks({ includeBrowser: true }); + expect(result.outcomes.find((o) => o.name === "Chrome")).toMatchObject({ ok: true }); + expect(result.browser?.executablePath).toBe("/usr/bin/chromium"); + }); + + it("keeps Chrome ok when the probe is inconclusive (no ldd)", async () => { + vi.spyOn(manager, "findBrowser").mockResolvedValue({ + executablePath: "/usr/bin/chromium", + source: "system", + }); + vi.spyOn(linuxDeps, "probeChromeSharedLibs").mockReturnValue({ + ok: false, + missing: [], + probeUnavailable: true, + }); + + const result = await runEnvironmentChecks({ includeBrowser: true }); + expect(result.outcomes.find((o) => o.name === "Chrome")).toMatchObject({ ok: true }); + }); +}); + describe("parseToolVersion", () => { it("extracts ffprobe versions with Windows build suffixes", () => { expect(parseToolVersion("ffprobe version 7.1.1-essentials_build-www.gyan.dev Copyright")).toBe( diff --git a/packages/cli/src/browser/preflight.ts b/packages/cli/src/browser/preflight.ts index efe32a9cf..712ae9c16 100644 --- a/packages/cli/src/browser/preflight.ts +++ b/packages/cli/src/browser/preflight.ts @@ -9,6 +9,12 @@ import { findFFprobe, getFFmpegInstallHint, } from "./ffmpeg.js"; +import { + chromeDepsInstallCommand, + detectLinuxDistro, + distroLabel, + probeChromeSharedLibs, +} from "./linuxDeps.js"; import { getFreeDiskMb } from "../telemetry/system.js"; export type EnvironmentCheckLevel = "ok" | "warn" | "error"; @@ -118,16 +124,45 @@ function checkFFprobe(): EnvironmentCheckOutcome { }; } +/** + * A Chrome binary can exist on disk yet be unlaunchable because its system + * shared libraries (libnss3, libatk, ...) aren't installed — the dominant WSL + * first-render failure. When that's the case, downgrade the "found" outcome to + * a render-blocking error carrying the exact per-distro install command, so the + * user hits it in `doctor`/pre-flight instead of a cryptic + * `Failed to launch the browser process` mid-render. No-op off Linux and when + * `ldd` can't run (probe inconclusive). + */ +function chromeSharedLibOutcome( + executablePath: string, + found: EnvironmentCheckOutcome, +): EnvironmentCheckOutcome { + if (process.platform !== "linux") return found; + const probe = probeChromeSharedLibs(executablePath); + if (probe.probeUnavailable || probe.ok) return found; + + const distro = detectLinuxDistro(); + return { + name: "Chrome", + ok: false, + level: "error", + title: "Chrome cannot launch (missing system libraries)", + detail: `Chrome at ${executablePath} is missing shared libraries on ${distroLabel(distro)}: ${probe.missing.join(", ")}`, + hint: chromeDepsInstallCommand(distro.family), + path: executablePath, + }; +} + async function checkChrome(browserPath?: string): Promise { if (browserPath) { if (existsSync(browserPath)) { - return { + return chromeSharedLibOutcome(browserPath, { name: "Chrome", ok: true, level: "ok", detail: `explicit: ${browserPath}`, path: browserPath, - }; + }); } return { name: "Chrome", @@ -151,13 +186,13 @@ async function checkChrome(browserPath?: string): Promise