fix(cli): scan puppeteer cache for chrome-headless-shell; warn on system-chrome fallback (#821)

## What

Two correctness fixes to `packages/cli/src/browser/manager.ts` so the CLI picks the right Chrome binary for any perf path that depends on `chrome-headless-shell`:

1. **Also scan the puppeteer-managed cache.** `findFromCache` now reads from both `~/.cache/hyperframes/chrome` (the CLI's own managed cache) and `~/.cache/puppeteer/chrome-headless-shell/<version>/<platform-dir>/chrome-headless-shell` (the path layout that the engine's `resolveHeadlessShellPath` already reads from). When `chrome-headless-shell` is present in either cache, it now wins over system Chrome.
2. **Warn when falling through to a non-`chrome-headless-shell` system binary on Linux.** A single one-time `console.warn` explains the perf consequence and points the user at `npx @puppeteer/browsers install chrome-headless-shell`. Linux-scoped because the BeginFrame perf path is Linux-only.

## Why

Discovered in a recent spike on the BeginFrame perf path. On a clean install, the CLI's hyperframes-managed cache (`~/.cache/hyperframes/chrome`) is empty, so `findFromCache` returns `undefined`. The CLI then falls through to `findFromSystem()` and picks `/usr/bin/google-chrome`, exporting it to the engine via `PRODUCER_HEADLESS_SHELL_PATH` in `render.ts`. The engine receives that path, sees it's already set, and skips its own correct `~/.cache/puppeteer/chrome-headless-shell` scan.

Regular Chrome (147+) has dropped `HeadlessExperimental.enable`. The engine's BeginFrame probe correctly catches this and silently falls back to screenshot mode — but the operator sees no signal, so any user who installed `chrome-headless-shell` via `npx @puppeteer/browsers install` (the standard puppeteer flow) silently loses the perf path.

This is a "two codepaths know about 'the chrome we ship' but look in different places" bug. The fix collapses them.

## How

- `findFromCache` now consults both caches. Hyperframes-managed cache wins when both contain a binary (preserves existing behavior).
- New `findFromPuppeteerCache` mirrors `resolveHeadlessShellPath` from `packages/engine/src/services/browserManager.ts` — same path layout, same newest-first version sort. A comment in both files notes they need to move together if puppeteer ever changes the on-disk layout.
- `warnSystemFallbackOnce` is gated on `process.platform === "linux"` and on the binary name (`basename` of the path being `chrome-headless-shell`/`.exe`). One-shot latch so a long-running `hyperframes studio` process isn't spammed. Exported test-reset helper `_resetSystemFallbackWarnForTests` for the unit tests.

No public-API changes. `findBrowser`, `ensureBrowser`, `clearBrowser`, `setBrowserPath` all keep the same signatures.

## Test plan

- [x] Unit tests added (`packages/cli/src/browser/manager.test.ts`, 7 tests):
  - cache hit on hyperframes dir
  - cache hit on puppeteer dir (the new path)
  - newest-version preference when multiple versions are cached
  - system fallback + Linux warning emitted
  - no warning when the resolved path is itself `chrome-headless-shell` (e.g. `HYPERFRAMES_BROWSER_PATH` override)
  - no warning on macOS (Linux-only perf path)
  - one-time warning idempotency across repeated `findBrowser()` calls
- [x] `bun run --filter @hyperframes/cli typecheck` — passes
- [x] `bun run --filter @hyperframes/cli test` — 305/305 passing
- [x] `bun run --filter @hyperframes/cli build` — passes
- [ ] Manual smoke on a Linux host with chrome-headless-shell in the puppeteer cache (skipped — sandbox already has both binaries and the unit tests cover the resolution logic deterministically; reviewers welcome to verify)

— Vai
This commit is contained in:
Vance Ingalls
2026-05-14 17:09:35 -07:00
committed by GitHub
parent 9abf65ae5e
commit c1ba528a1f
2 changed files with 317 additions and 8 deletions
+217
View File
@@ -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<string>;
/** map of dir path -> entries returned by readdirSync */
dirs?: Record<string, string[]>;
}
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);
});
});
+100 -8
View File
@@ -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<BrowserResult | undefined> {
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<BrowserResult | undefined> {
const fromCache = await findFromCache();
if (fromCache) return fromCache;
return findFromSystem();
const fromSystem = findFromSystem();
if (fromSystem) {
warnSystemFallbackOnce(fromSystem.executablePath);
}
return fromSystem;
}
/**