diff --git a/packages/cli/src/browser/manager.test.ts b/packages/cli/src/browser/manager.test.ts new file mode 100644 index 000000000..ae826e691 --- /dev/null +++ b/packages/cli/src/browser/manager.test.ts @@ -0,0 +1,217 @@ +/** + * Browser-binary resolution tests for `findBrowser()`. + * + * The CLI's `ensureBrowser` is responsible for picking the Chrome binary the + * engine will be launched with. There are two real-world failure modes this + * suite guards against: + * + * 1. `chrome-headless-shell` is installed in the puppeteer cache (the + * directory the engine itself reads), but the CLI used to only scan its + * own `~/.cache/hyperframes/chrome` cache — leaving the engine without a + * headless-shell binary and silently disabling the BeginFrame capture + * path. + * 2. The CLI falls back to system Chrome (`/usr/bin/google-chrome`) on + * Linux, which still launches successfully but has dropped + * `HeadlessExperimental.enable` — again disabling the BeginFrame path + * with no user-visible signal. + * + * Each test stubs filesystem + `@puppeteer/browsers` access using `vi.doMock` + * + dynamic import (the same pattern other modules in this package use, e.g. + * `background-removal/manager.test.ts`) so we don't touch the real + * `HOME` cache. + */ +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Use `path.join` so the fake paths line up with whatever separator Node's +// real `path.join` produces in `manager.ts` on the host running the test +// (forward slashes on Linux/macOS, backslashes on Windows CI). Hardcoded +// `/fake/home/...` literals would fail on Windows because the set lookup +// would never match the `\\`-joined real paths. +const FAKE_HOME = join("/", "fake", "home"); +const HF_CACHE = join(FAKE_HOME, ".cache", "hyperframes", "chrome"); +const PUPPETEER_CACHE = join(FAKE_HOME, ".cache", "puppeteer", "chrome-headless-shell"); +const PUPPETEER_BINARY = join( + PUPPETEER_CACHE, + "linux-148.0.7778.97", + "chrome-headless-shell-linux64", + "chrome-headless-shell", +); +const HF_BINARY = join( + HF_CACHE, + "chrome-headless-shell", + "linux-131.0.6778.85", + "chrome-headless-shell-linux64", + "chrome-headless-shell", +); +const SYSTEM_CHROME = "/usr/bin/google-chrome"; + +interface FsMockOptions { + existing: ReadonlySet; + /** map of dir path -> entries returned by readdirSync */ + dirs?: Record; +} + +function installFsMocks({ existing, dirs }: FsMockOptions) { + vi.doMock("node:fs", () => ({ + existsSync: (p: string) => existing.has(p), + readdirSync: (p: string) => { + const entries = dirs?.[p]; + if (!entries) throw new Error(`ENOENT: readdirSync mock had no entry for ${p}`); + return entries; + }, + rmSync: () => {}, + })); + vi.doMock("node:os", () => ({ + homedir: () => FAKE_HOME, + platform: () => "linux", + arch: () => "x64", + })); +} + +function installPuppeteerBrowsersMock( + opts: { + installedInHfCache?: Array<{ browser: string; executablePath: string }>; + } = {}, +) { + vi.doMock("@puppeteer/browsers", () => ({ + Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" }, + detectBrowserPlatform: () => "linux", + getInstalledBrowsers: vi.fn().mockResolvedValue(opts.installedInHfCache ?? []), + install: vi.fn(), + })); +} + +describe("findBrowser — cache resolution", () => { + const origPlatform = process.platform; + + beforeEach(() => { + vi.resetModules(); + // Force Linux for the system-fallback warning assertions. The + // `Object.defineProperty` dance is needed because `process.platform` is a + // getter on Node — direct assignment is silently a no-op. + Object.defineProperty(process, "platform", { value: "linux", configurable: true }); + delete process.env["HYPERFRAMES_BROWSER_PATH"]; + }); + + afterEach(() => { + Object.defineProperty(process, "platform", { value: origPlatform, configurable: true }); + vi.restoreAllMocks(); + vi.doUnmock("node:fs"); + vi.doUnmock("node:os"); + vi.doUnmock("@puppeteer/browsers"); + }); + + it("resolves to the hyperframes-managed cache when present", async () => { + installFsMocks({ existing: new Set([HF_CACHE, HF_BINARY]) }); + installPuppeteerBrowsersMock({ + installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }], + }); + + const { findBrowser } = await import("./manager.js"); + const result = await findBrowser(); + + expect(result).toEqual({ executablePath: HF_BINARY, source: "cache" }); + }); + + it("falls back to the puppeteer-managed cache when hyperframes cache is empty", async () => { + // Empty hyperframes cache, populated puppeteer cache — the regression + // scenario from the hf#677 spike. + installFsMocks({ + existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY]), + dirs: { [PUPPETEER_CACHE]: ["linux-148.0.7778.97"] }, + }); + installPuppeteerBrowsersMock(); + + const { findBrowser } = await import("./manager.js"); + const result = await findBrowser(); + + expect(result).toEqual({ executablePath: PUPPETEER_BINARY, source: "cache" }); + }); + + it("picks the newest version when multiple chrome-headless-shell builds are cached", async () => { + const olderBinary = `${PUPPETEER_CACHE}/linux-131.0.6778.85/chrome-headless-shell-linux64/chrome-headless-shell`; + installFsMocks({ + existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY, olderBinary]), + dirs: { [PUPPETEER_CACHE]: ["linux-131.0.6778.85", "linux-148.0.7778.97"] }, + }); + installPuppeteerBrowsersMock(); + + const { findBrowser } = await import("./manager.js"); + const result = await findBrowser(); + + expect(result?.executablePath).toBe(PUPPETEER_BINARY); + }); + + it("falls back to system Chrome and warns on Linux when no cache has headless-shell", async () => { + installFsMocks({ existing: new Set([SYSTEM_CHROME]) }); + installPuppeteerBrowsersMock(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js"); + _resetSystemFallbackWarnForTests(); + const result = await findBrowser(); + + expect(result).toEqual({ executablePath: SYSTEM_CHROME, source: "system" }); + expect(warnSpy).toHaveBeenCalledTimes(1); + const message = warnSpy.mock.calls[0]?.[0]; + expect(message).toContain(SYSTEM_CHROME); + expect(message).toContain("HeadlessExperimental"); + expect(message).toContain("chrome-headless-shell"); + }); + + it("does NOT warn when the system path happens to be chrome-headless-shell", async () => { + // HYPERFRAMES_BROWSER_PATH-style override pointing directly at a + // headless-shell binary should NOT trigger the system-Chrome warning. The + // warning is gated on the binary name, not the path source. + const directShell = "/opt/chrome-headless-shell/chrome-headless-shell"; + installFsMocks({ existing: new Set([directShell]) }); + installPuppeteerBrowsersMock(); + process.env["HYPERFRAMES_BROWSER_PATH"] = directShell; + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js"); + _resetSystemFallbackWarnForTests(); + const result = await findBrowser(); + + expect(result?.executablePath).toBe(directShell); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("does NOT warn on macOS when falling back to system Chrome", async () => { + // macOS Chrome still works fine for the screenshot path and the perf + // claims around BeginFrame are Linux-only — keep the warning Linux-scoped + // so darwin users don't get spammed about a "fix" that doesn't apply. + Object.defineProperty(process, "platform", { value: "darwin", configurable: true }); + const darwinChrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; + installFsMocks({ existing: new Set([darwinChrome]) }); + vi.doMock("@puppeteer/browsers", () => ({ + Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" }, + detectBrowserPlatform: () => "mac_arm", + getInstalledBrowsers: vi.fn().mockResolvedValue([]), + install: vi.fn(), + })); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js"); + _resetSystemFallbackWarnForTests(); + const result = await findBrowser(); + + expect(result?.executablePath).toBe(darwinChrome); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it("only warns once across repeated findBrowser() calls", async () => { + installFsMocks({ existing: new Set([SYSTEM_CHROME]) }); + installPuppeteerBrowsersMock(); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js"); + _resetSystemFallbackWarnForTests(); + await findBrowser(); + await findBrowser(); + await findBrowser(); + + expect(warnSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/cli/src/browser/manager.ts b/packages/cli/src/browser/manager.ts index ebbea3400..cffc099af 100644 --- a/packages/cli/src/browser/manager.ts +++ b/packages/cli/src/browser/manager.ts @@ -1,11 +1,17 @@ import { execSync, spawnSync } from "node:child_process"; -import { existsSync, rmSync } from "node:fs"; +import { existsSync, readdirSync, rmSync } from "node:fs"; +import { basename } from "node:path"; import { homedir } from "node:os"; import { join } from "node:path"; import { Browser, detectBrowserPlatform, getInstalledBrowsers, install } from "@puppeteer/browsers"; const CHROME_VERSION = "131.0.6778.85"; const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "chrome"); +// Puppeteer's managed cache — where `@puppeteer/browsers install +// chrome-headless-shell` (and `puppeteer install`) drop binaries. The engine's +// `resolveHeadlessShellPath` scans the same directory; the CLI must look here +// too or it silently picks system Chrome over a perfectly good headless-shell. +const PUPPETEER_CACHE_DIR = join(homedir(), ".cache", "puppeteer", "chrome-headless-shell"); /** Override browser path via --browser-path flag. Takes priority over env var. */ let _browserPathOverride: string | undefined; @@ -67,19 +73,101 @@ function findFromEnv(): BrowserResult | undefined { } async function findFromCache(): Promise { - if (!existsSync(CACHE_DIR)) { - return undefined; + // 1) Hyperframes-managed cache (populated by `clearBrowser` + `install` below). + if (existsSync(CACHE_DIR)) { + const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR }); + const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL); + if (match) { + return { executablePath: match.executablePath, source: "cache" }; + } } - const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR }); - const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL); - if (match) { - return { executablePath: match.executablePath, source: "cache" }; + // 2) Puppeteer's managed cache — where `npx @puppeteer/browsers install + // chrome-headless-shell` lands, and where `puppeteer install` from a project + // that depends on full `puppeteer` (not `puppeteer-core`) lands. The engine + // already reads from here (`resolveHeadlessShellPath`); without this branch + // the CLI would skip past a perfectly good chrome-headless-shell and fall + // through to `findFromSystem()`, picking regular Chrome which has dropped + // `HeadlessExperimental.enable` and disables the perf-optimized capture + // path. + const fromPuppeteer = findFromPuppeteerCache(); + if (fromPuppeteer) { + return fromPuppeteer; } return undefined; } +function findFromPuppeteerCache(): BrowserResult | undefined { + if (!existsSync(PUPPETEER_CACHE_DIR)) return undefined; + let versions: string[]; + try { + versions = readdirSync(PUPPETEER_CACHE_DIR).sort().reverse(); // newest first + } catch { + return undefined; + } + for (const version of versions) { + // Same shape as `resolveHeadlessShellPath` in engine/browserManager.ts — + // keep them aligned. If puppeteer ever changes the on-disk layout the two + // need to move together. + const candidates = [ + join(PUPPETEER_CACHE_DIR, version, "chrome-headless-shell-linux64", "chrome-headless-shell"), + join( + PUPPETEER_CACHE_DIR, + version, + "chrome-headless-shell-mac-arm64", + "chrome-headless-shell", + ), + join(PUPPETEER_CACHE_DIR, version, "chrome-headless-shell-mac-x64", "chrome-headless-shell"), + join( + PUPPETEER_CACHE_DIR, + version, + "chrome-headless-shell-win64", + "chrome-headless-shell.exe", + ), + ]; + for (const binary of candidates) { + if (existsSync(binary)) { + return { executablePath: binary, source: "cache" }; + } + } + } + return undefined; +} + +/** + * True iff the binary at `executablePath` is `chrome-headless-shell` (i.e. the + * Chromium build that still exposes `HeadlessExperimental.enable` / + * `beginFrame`). Regular Chrome and `chromium` have dropped those domains, so + * the engine's perf-optimized BeginFrame capture path silently degrades to + * screenshot mode when those are used. + */ +function isHeadlessShellBinary(executablePath: string): boolean { + const name = basename(executablePath).toLowerCase(); + return name === "chrome-headless-shell" || name === "chrome-headless-shell.exe"; +} + +/** + * Emit a one-time warning when the CLI selects a non-headless-shell binary on + * Linux. Idempotent across repeated `findBrowser()` calls so a long-running + * `hyperframes studio` process doesn't get spammed. + */ +let _warnedSystemFallback = false; +function warnSystemFallbackOnce(executablePath: string): void { + if (_warnedSystemFallback) return; + if (process.platform !== "linux") return; + if (isHeadlessShellBinary(executablePath)) return; + _warnedSystemFallback = true; + console.warn( + `[hyperframes] Using system Chrome at ${executablePath}; HeadlessExperimental.beginFrame is unavailable in regular Chrome builds, so the perf-optimized capture path falls back to screenshot mode. Install chrome-headless-shell for the optimized path:\n npx @puppeteer/browsers install chrome-headless-shell`, + ); +} + +/** Test-only: reset the one-shot warn latch. */ +export function _resetSystemFallbackWarnForTests(): void { + _warnedSystemFallback = false; +} + function findFromSystem(): BrowserResult | undefined { for (const p of SYSTEM_CHROME_PATHS) { if (existsSync(p)) { @@ -108,7 +196,11 @@ export async function findBrowser(): Promise { const fromCache = await findFromCache(); if (fromCache) return fromCache; - return findFromSystem(); + const fromSystem = findFromSystem(); + if (fromSystem) { + warnSystemFallbackOnce(fromSystem.executablePath); + } + return fromSystem; } /**