Skip to content

Commit c1ba528

Browse files
authored
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
1 parent 9abf65a commit c1ba528

2 files changed

Lines changed: 317 additions & 8 deletions

File tree

Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/**
2+
* Browser-binary resolution tests for `findBrowser()`.
3+
*
4+
* The CLI's `ensureBrowser` is responsible for picking the Chrome binary the
5+
* engine will be launched with. There are two real-world failure modes this
6+
* suite guards against:
7+
*
8+
* 1. `chrome-headless-shell` is installed in the puppeteer cache (the
9+
* directory the engine itself reads), but the CLI used to only scan its
10+
* own `~/.cache/hyperframes/chrome` cache — leaving the engine without a
11+
* headless-shell binary and silently disabling the BeginFrame capture
12+
* path.
13+
* 2. The CLI falls back to system Chrome (`/usr/bin/google-chrome`) on
14+
* Linux, which still launches successfully but has dropped
15+
* `HeadlessExperimental.enable` — again disabling the BeginFrame path
16+
* with no user-visible signal.
17+
*
18+
* Each test stubs filesystem + `@puppeteer/browsers` access using `vi.doMock`
19+
* + dynamic import (the same pattern other modules in this package use, e.g.
20+
* `background-removal/manager.test.ts`) so we don't touch the real
21+
* `HOME` cache.
22+
*/
23+
import { join } from "node:path";
24+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
25+
26+
// Use `path.join` so the fake paths line up with whatever separator Node's
27+
// real `path.join` produces in `manager.ts` on the host running the test
28+
// (forward slashes on Linux/macOS, backslashes on Windows CI). Hardcoded
29+
// `/fake/home/...` literals would fail on Windows because the set lookup
30+
// would never match the `\\`-joined real paths.
31+
const FAKE_HOME = join("/", "fake", "home");
32+
const HF_CACHE = join(FAKE_HOME, ".cache", "hyperframes", "chrome");
33+
const PUPPETEER_CACHE = join(FAKE_HOME, ".cache", "puppeteer", "chrome-headless-shell");
34+
const PUPPETEER_BINARY = join(
35+
PUPPETEER_CACHE,
36+
"linux-148.0.7778.97",
37+
"chrome-headless-shell-linux64",
38+
"chrome-headless-shell",
39+
);
40+
const HF_BINARY = join(
41+
HF_CACHE,
42+
"chrome-headless-shell",
43+
"linux-131.0.6778.85",
44+
"chrome-headless-shell-linux64",
45+
"chrome-headless-shell",
46+
);
47+
const SYSTEM_CHROME = "/usr/bin/google-chrome";
48+
49+
interface FsMockOptions {
50+
existing: ReadonlySet<string>;
51+
/** map of dir path -> entries returned by readdirSync */
52+
dirs?: Record<string, string[]>;
53+
}
54+
55+
function installFsMocks({ existing, dirs }: FsMockOptions) {
56+
vi.doMock("node:fs", () => ({
57+
existsSync: (p: string) => existing.has(p),
58+
readdirSync: (p: string) => {
59+
const entries = dirs?.[p];
60+
if (!entries) throw new Error(`ENOENT: readdirSync mock had no entry for ${p}`);
61+
return entries;
62+
},
63+
rmSync: () => {},
64+
}));
65+
vi.doMock("node:os", () => ({
66+
homedir: () => FAKE_HOME,
67+
platform: () => "linux",
68+
arch: () => "x64",
69+
}));
70+
}
71+
72+
function installPuppeteerBrowsersMock(
73+
opts: {
74+
installedInHfCache?: Array<{ browser: string; executablePath: string }>;
75+
} = {},
76+
) {
77+
vi.doMock("@puppeteer/browsers", () => ({
78+
Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" },
79+
detectBrowserPlatform: () => "linux",
80+
getInstalledBrowsers: vi.fn().mockResolvedValue(opts.installedInHfCache ?? []),
81+
install: vi.fn(),
82+
}));
83+
}
84+
85+
describe("findBrowser — cache resolution", () => {
86+
const origPlatform = process.platform;
87+
88+
beforeEach(() => {
89+
vi.resetModules();
90+
// Force Linux for the system-fallback warning assertions. The
91+
// `Object.defineProperty` dance is needed because `process.platform` is a
92+
// getter on Node — direct assignment is silently a no-op.
93+
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
94+
delete process.env["HYPERFRAMES_BROWSER_PATH"];
95+
});
96+
97+
afterEach(() => {
98+
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
99+
vi.restoreAllMocks();
100+
vi.doUnmock("node:fs");
101+
vi.doUnmock("node:os");
102+
vi.doUnmock("@puppeteer/browsers");
103+
});
104+
105+
it("resolves to the hyperframes-managed cache when present", async () => {
106+
installFsMocks({ existing: new Set([HF_CACHE, HF_BINARY]) });
107+
installPuppeteerBrowsersMock({
108+
installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }],
109+
});
110+
111+
const { findBrowser } = await import("./manager.js");
112+
const result = await findBrowser();
113+
114+
expect(result).toEqual({ executablePath: HF_BINARY, source: "cache" });
115+
});
116+
117+
it("falls back to the puppeteer-managed cache when hyperframes cache is empty", async () => {
118+
// Empty hyperframes cache, populated puppeteer cache — the regression
119+
// scenario from the hf#677 spike.
120+
installFsMocks({
121+
existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY]),
122+
dirs: { [PUPPETEER_CACHE]: ["linux-148.0.7778.97"] },
123+
});
124+
installPuppeteerBrowsersMock();
125+
126+
const { findBrowser } = await import("./manager.js");
127+
const result = await findBrowser();
128+
129+
expect(result).toEqual({ executablePath: PUPPETEER_BINARY, source: "cache" });
130+
});
131+
132+
it("picks the newest version when multiple chrome-headless-shell builds are cached", async () => {
133+
const olderBinary = `${PUPPETEER_CACHE}/linux-131.0.6778.85/chrome-headless-shell-linux64/chrome-headless-shell`;
134+
installFsMocks({
135+
existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY, olderBinary]),
136+
dirs: { [PUPPETEER_CACHE]: ["linux-131.0.6778.85", "linux-148.0.7778.97"] },
137+
});
138+
installPuppeteerBrowsersMock();
139+
140+
const { findBrowser } = await import("./manager.js");
141+
const result = await findBrowser();
142+
143+
expect(result?.executablePath).toBe(PUPPETEER_BINARY);
144+
});
145+
146+
it("falls back to system Chrome and warns on Linux when no cache has headless-shell", async () => {
147+
installFsMocks({ existing: new Set([SYSTEM_CHROME]) });
148+
installPuppeteerBrowsersMock();
149+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
150+
151+
const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js");
152+
_resetSystemFallbackWarnForTests();
153+
const result = await findBrowser();
154+
155+
expect(result).toEqual({ executablePath: SYSTEM_CHROME, source: "system" });
156+
expect(warnSpy).toHaveBeenCalledTimes(1);
157+
const message = warnSpy.mock.calls[0]?.[0];
158+
expect(message).toContain(SYSTEM_CHROME);
159+
expect(message).toContain("HeadlessExperimental");
160+
expect(message).toContain("chrome-headless-shell");
161+
});
162+
163+
it("does NOT warn when the system path happens to be chrome-headless-shell", async () => {
164+
// HYPERFRAMES_BROWSER_PATH-style override pointing directly at a
165+
// headless-shell binary should NOT trigger the system-Chrome warning. The
166+
// warning is gated on the binary name, not the path source.
167+
const directShell = "/opt/chrome-headless-shell/chrome-headless-shell";
168+
installFsMocks({ existing: new Set([directShell]) });
169+
installPuppeteerBrowsersMock();
170+
process.env["HYPERFRAMES_BROWSER_PATH"] = directShell;
171+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
172+
173+
const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js");
174+
_resetSystemFallbackWarnForTests();
175+
const result = await findBrowser();
176+
177+
expect(result?.executablePath).toBe(directShell);
178+
expect(warnSpy).not.toHaveBeenCalled();
179+
});
180+
181+
it("does NOT warn on macOS when falling back to system Chrome", async () => {
182+
// macOS Chrome still works fine for the screenshot path and the perf
183+
// claims around BeginFrame are Linux-only — keep the warning Linux-scoped
184+
// so darwin users don't get spammed about a "fix" that doesn't apply.
185+
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
186+
const darwinChrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
187+
installFsMocks({ existing: new Set([darwinChrome]) });
188+
vi.doMock("@puppeteer/browsers", () => ({
189+
Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" },
190+
detectBrowserPlatform: () => "mac_arm",
191+
getInstalledBrowsers: vi.fn().mockResolvedValue([]),
192+
install: vi.fn(),
193+
}));
194+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
195+
196+
const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js");
197+
_resetSystemFallbackWarnForTests();
198+
const result = await findBrowser();
199+
200+
expect(result?.executablePath).toBe(darwinChrome);
201+
expect(warnSpy).not.toHaveBeenCalled();
202+
});
203+
204+
it("only warns once across repeated findBrowser() calls", async () => {
205+
installFsMocks({ existing: new Set([SYSTEM_CHROME]) });
206+
installPuppeteerBrowsersMock();
207+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
208+
209+
const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js");
210+
_resetSystemFallbackWarnForTests();
211+
await findBrowser();
212+
await findBrowser();
213+
await findBrowser();
214+
215+
expect(warnSpy).toHaveBeenCalledTimes(1);
216+
});
217+
});

packages/cli/src/browser/manager.ts

Lines changed: 100 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { execSync, spawnSync } from "node:child_process";
2-
import { existsSync, rmSync } from "node:fs";
2+
import { existsSync, readdirSync, rmSync } from "node:fs";
3+
import { basename } from "node:path";
34
import { homedir } from "node:os";
45
import { join } from "node:path";
56
import { Browser, detectBrowserPlatform, getInstalledBrowsers, install } from "@puppeteer/browsers";
67

78
const CHROME_VERSION = "131.0.6778.85";
89
const CACHE_DIR = join(homedir(), ".cache", "hyperframes", "chrome");
10+
// Puppeteer's managed cache — where `@puppeteer/browsers install
11+
// chrome-headless-shell` (and `puppeteer install`) drop binaries. The engine's
12+
// `resolveHeadlessShellPath` scans the same directory; the CLI must look here
13+
// too or it silently picks system Chrome over a perfectly good headless-shell.
14+
const PUPPETEER_CACHE_DIR = join(homedir(), ".cache", "puppeteer", "chrome-headless-shell");
915

1016
/** Override browser path via --browser-path flag. Takes priority over env var. */
1117
let _browserPathOverride: string | undefined;
@@ -67,19 +73,101 @@ function findFromEnv(): BrowserResult | undefined {
6773
}
6874

6975
async function findFromCache(): Promise<BrowserResult | undefined> {
70-
if (!existsSync(CACHE_DIR)) {
71-
return undefined;
76+
// 1) Hyperframes-managed cache (populated by `clearBrowser` + `install` below).
77+
if (existsSync(CACHE_DIR)) {
78+
const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
79+
const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
80+
if (match) {
81+
return { executablePath: match.executablePath, source: "cache" };
82+
}
7283
}
7384

74-
const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR });
75-
const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);
76-
if (match) {
77-
return { executablePath: match.executablePath, source: "cache" };
85+
// 2) Puppeteer's managed cache — where `npx @puppeteer/browsers install
86+
// chrome-headless-shell` lands, and where `puppeteer install` from a project
87+
// that depends on full `puppeteer` (not `puppeteer-core`) lands. The engine
88+
// already reads from here (`resolveHeadlessShellPath`); without this branch
89+
// the CLI would skip past a perfectly good chrome-headless-shell and fall
90+
// through to `findFromSystem()`, picking regular Chrome which has dropped
91+
// `HeadlessExperimental.enable` and disables the perf-optimized capture
92+
// path.
93+
const fromPuppeteer = findFromPuppeteerCache();
94+
if (fromPuppeteer) {
95+
return fromPuppeteer;
7896
}
7997

8098
return undefined;
8199
}
82100

101+
function findFromPuppeteerCache(): BrowserResult | undefined {
102+
if (!existsSync(PUPPETEER_CACHE_DIR)) return undefined;
103+
let versions: string[];
104+
try {
105+
versions = readdirSync(PUPPETEER_CACHE_DIR).sort().reverse(); // newest first
106+
} catch {
107+
return undefined;
108+
}
109+
for (const version of versions) {
110+
// Same shape as `resolveHeadlessShellPath` in engine/browserManager.ts —
111+
// keep them aligned. If puppeteer ever changes the on-disk layout the two
112+
// need to move together.
113+
const candidates = [
114+
join(PUPPETEER_CACHE_DIR, version, "chrome-headless-shell-linux64", "chrome-headless-shell"),
115+
join(
116+
PUPPETEER_CACHE_DIR,
117+
version,
118+
"chrome-headless-shell-mac-arm64",
119+
"chrome-headless-shell",
120+
),
121+
join(PUPPETEER_CACHE_DIR, version, "chrome-headless-shell-mac-x64", "chrome-headless-shell"),
122+
join(
123+
PUPPETEER_CACHE_DIR,
124+
version,
125+
"chrome-headless-shell-win64",
126+
"chrome-headless-shell.exe",
127+
),
128+
];
129+
for (const binary of candidates) {
130+
if (existsSync(binary)) {
131+
return { executablePath: binary, source: "cache" };
132+
}
133+
}
134+
}
135+
return undefined;
136+
}
137+
138+
/**
139+
* True iff the binary at `executablePath` is `chrome-headless-shell` (i.e. the
140+
* Chromium build that still exposes `HeadlessExperimental.enable` /
141+
* `beginFrame`). Regular Chrome and `chromium` have dropped those domains, so
142+
* the engine's perf-optimized BeginFrame capture path silently degrades to
143+
* screenshot mode when those are used.
144+
*/
145+
function isHeadlessShellBinary(executablePath: string): boolean {
146+
const name = basename(executablePath).toLowerCase();
147+
return name === "chrome-headless-shell" || name === "chrome-headless-shell.exe";
148+
}
149+
150+
/**
151+
* Emit a one-time warning when the CLI selects a non-headless-shell binary on
152+
* Linux. Idempotent across repeated `findBrowser()` calls so a long-running
153+
* `hyperframes studio` process doesn't get spammed.
154+
*/
155+
let _warnedSystemFallback = false;
156+
function warnSystemFallbackOnce(executablePath: string): void {
157+
if (_warnedSystemFallback) return;
158+
if (process.platform !== "linux") return;
159+
if (isHeadlessShellBinary(executablePath)) return;
160+
_warnedSystemFallback = true;
161+
console.warn(
162+
`[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`,
163+
);
164+
}
165+
166+
/** Test-only: reset the one-shot warn latch. */
167+
export function _resetSystemFallbackWarnForTests(): void {
168+
_warnedSystemFallback = false;
169+
}
170+
83171
function findFromSystem(): BrowserResult | undefined {
84172
for (const p of SYSTEM_CHROME_PATHS) {
85173
if (existsSync(p)) {
@@ -108,7 +196,11 @@ export async function findBrowser(): Promise<BrowserResult | undefined> {
108196
const fromCache = await findFromCache();
109197
if (fromCache) return fromCache;
110198

111-
return findFromSystem();
199+
const fromSystem = findFromSystem();
200+
if (fromSystem) {
201+
warnSystemFallbackOnce(fromSystem.executablePath);
202+
}
203+
return fromSystem;
112204
}
113205

114206
/**

0 commit comments

Comments
 (0)