From 492f3b4e9be6d432f5cd1ea44432cdc98ef33c0a Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 14 May 2026 01:43:16 +0000 Subject: [PATCH 1/2] fix(cli): scan puppeteer cache for chrome-headless-shell; warn on system-chrome fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CLI's `ensureBrowser` only scanned `~/.cache/hyperframes/chrome` for a managed Chrome binary, which is empty on most clean installs. It then fell through to `findFromSystem()` and picked `/usr/bin/google-chrome`, which the engine receives via `PRODUCER_HEADLESS_SHELL_PATH`. Regular Chrome has dropped `HeadlessExperimental.enable`, so the engine's probe correctly detects this and falls back to screenshot mode — but the user sees no signal, so any perf path depending on chrome-headless-shell silently disappears. Two fixes: 1. Also scan `~/.cache/puppeteer/chrome-headless-shell//...` (the path layout shared with the engine's `resolveHeadlessShellPath`). When chrome-headless-shell is present in either cache it gets picked over system Chrome. 2. When falling back to a non-`chrome-headless-shell` system binary on Linux, emit a single one-time `console.warn` pointing users at `npx @puppeteer/browsers install chrome-headless-shell`. Linux-scoped because the BeginFrame perf path is Linux-only. Tests cover: hyperframes cache hit, puppeteer cache hit (the new path), newest-version preference, system fallback + Linux warn, no warn for direct headless-shell paths, no warn on macOS, one-time warning idempotency. — Vai --- packages/cli/src/browser/manager.test.ts | 202 +++++++++++++++++++++++ packages/cli/src/browser/manager.ts | 108 +++++++++++- 2 files changed, 302 insertions(+), 8 deletions(-) create mode 100644 packages/cli/src/browser/manager.test.ts diff --git a/packages/cli/src/browser/manager.test.ts b/packages/cli/src/browser/manager.test.ts new file mode 100644 index 000000000..f7ea2fbe3 --- /dev/null +++ b/packages/cli/src/browser/manager.test.ts @@ -0,0 +1,202 @@ +/** + * 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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const FAKE_HOME = "/fake/home"; + +// Cache-relative paths (kept in lock-step with manager.ts). +const HF_CACHE = `${FAKE_HOME}/.cache/hyperframes/chrome`; +const PUPPETEER_CACHE = `${FAKE_HOME}/.cache/puppeteer/chrome-headless-shell`; +const PUPPETEER_BINARY = `${PUPPETEER_CACHE}/linux-148.0.7778.97/chrome-headless-shell-linux64/chrome-headless-shell`; +const HF_BINARY = `${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; } /** From c6a98187f55679ba82fd05046d5468f3d8aa4e9f Mon Sep 17 00:00:00 2001 From: Vance Ingalls Date: Thu, 14 May 2026 01:53:24 +0000 Subject: [PATCH 2/2] test(cli): use path.join for fake paths in manager test (Windows CI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version hardcoded `/fake/home/...` literals. On Windows CI `path.join` produces backslash-separated paths, so `existsSync(p)` lookups against the forward-slash literal Set never matched and three cache-hit tests failed. Build the fake paths via `node:path.join` so the test uses the same separator as the production code under test, regardless of host platform. — Vai --- packages/cli/src/browser/manager.test.ts | 29 ++++++++++++++++++------ 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/browser/manager.test.ts b/packages/cli/src/browser/manager.test.ts index f7ea2fbe3..ae826e691 100644 --- a/packages/cli/src/browser/manager.test.ts +++ b/packages/cli/src/browser/manager.test.ts @@ -20,15 +20,30 @@ * `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"; -const FAKE_HOME = "/fake/home"; - -// Cache-relative paths (kept in lock-step with manager.ts). -const HF_CACHE = `${FAKE_HOME}/.cache/hyperframes/chrome`; -const PUPPETEER_CACHE = `${FAKE_HOME}/.cache/puppeteer/chrome-headless-shell`; -const PUPPETEER_BINARY = `${PUPPETEER_CACHE}/linux-148.0.7778.97/chrome-headless-shell-linux64/chrome-headless-shell`; -const HF_BINARY = `${HF_CACHE}/chrome-headless-shell/linux-131.0.6778.85/chrome-headless-shell-linux64/chrome-headless-shell`; +// 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 {