Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
217 changes: 217 additions & 0 deletions packages/cli/src/browser/manager.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
108 changes: 100 additions & 8 deletions packages/cli/src/browser/manager.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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;
}

/**
Expand Down
Loading