mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 12:54:29 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user