Files
hyperframes/packages/cli/src/browser/manager.test.ts
T
Miguel Ángel 85f0c9d354 fix(cli): select host-compatible cached browser (#2861)
* fix(cli): select host-compatible cached browser

* test(engine): make browser cache fixture portable

* fix(browser): reject foreign ARM cache binaries
2026-07-29 20:51:01 +02:00

1011 lines
42 KiB
TypeScript

// fallow-ignore-file code-duplication
/**
* 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, sep } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CHROME_VERSION } from "./manager.js";
// 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 CACHE_ROOT = join(FAKE_HOME, ".cache", "hyperframes");
const HF_CACHE = join(FAKE_HOME, ".cache", "hyperframes", "chrome");
const HF_LOCK = join(CACHE_ROOT, ".chrome.install.lock");
const HF_RECLAIM_LOCK = join(CACHE_ROOT, ".chrome.install.reclaim.lock");
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";
const TEST_LOCK_TIMINGS = {
staleMs: 50,
pollMs: 5,
heartbeatMs: 10,
waitNoticeMs: 1_000,
};
interface FsMockOptions {
existing: ReadonlySet<string>;
/** map of dir path -> entries returned by readdirSync */
dirs?: Record<string, string[]>;
touchError?: Error;
initialMtimeMs?: number;
}
function installFsMocks({ existing, dirs, touchError, initialMtimeMs = 0 }: FsMockOptions) {
// Mutable, and returned, so tests can pre-seed a "lock already held" path or
// assert the lock dir doesn't leak after ensureBrowser resolves.
const paths = new Set(existing);
const mtimes = new Map([...existing].map((p) => [p, initialMtimeMs]));
vi.doMock("node:fs", () => ({
existsSync: (p: string) => paths.has(p),
readdirSync: (p: string) => {
const entries = dirs?.[p];
if (!entries) throw new Error(`ENOENT: readdirSync mock had no entry for ${p}`);
return entries;
},
mkdirSync: (p: string, opts?: { recursive?: boolean }) => {
if (!opts?.recursive && paths.has(p)) {
const err = new Error(`EEXIST: file already exists, mkdir '${p}'`);
(err as NodeJS.ErrnoException).code = "EEXIST";
throw err;
}
paths.add(p);
mtimes.set(p, Date.now());
},
rmSync: (p: string) => {
// Real rmSync({recursive:true}) removes the target AND everything under
// it; the mock's flat path Set has no real tree structure, so simulate
// that by also dropping any tracked path nested under `p`.
for (const existingPath of [...paths]) {
if (existingPath === p || existingPath.startsWith(p + sep)) {
paths.delete(existingPath);
mtimes.delete(existingPath);
}
}
},
statSync: (p: string) => {
if (!paths.has(p)) {
const err = new Error(`ENOENT: no such file or directory, stat '${p}'`);
(err as NodeJS.ErrnoException).code = "ENOENT";
throw err;
}
return { mtimeMs: mtimes.get(p) ?? 0 };
},
utimesSync: (p: string, _atime: Date, mtime: Date) => {
if (touchError) throw touchError;
if (!paths.has(p)) {
const err = new Error(`ENOENT: no such file or directory, utimes '${p}'`);
(err as NodeJS.ErrnoException).code = "ENOENT";
throw err;
}
mtimes.set(p, mtime.getTime());
},
}));
vi.doMock("node:os", () => ({
homedir: () => FAKE_HOME,
platform: () => "linux",
arch: () => "x64",
}));
return paths;
}
function installPuppeteerBrowsersMock(
opts: {
installedInHfCache?: Array<{
browser: string;
executablePath: string;
path?: string;
buildId?: string;
platform?: string;
}>;
browserPlatform?: string;
installedInHfCacheError?: Error;
installResult?: { executablePath: string };
installImpl?: () => Promise<{ executablePath: string }>;
} = {},
) {
vi.doMock("@puppeteer/browsers", () => ({
Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" },
detectBrowserPlatform: () => opts.browserPlatform ?? "linux",
getInstalledBrowsers: opts.installedInHfCacheError
? vi.fn().mockRejectedValue(opts.installedInHfCacheError)
: vi.fn().mockResolvedValue(
(opts.installedInHfCache ?? []).map((browser) => ({
platform: opts.browserPlatform ?? "linux",
...browser,
})),
),
install: vi
.fn()
.mockImplementation(
opts.installImpl ?? (async () => opts.installResult ?? { executablePath: HF_BINARY }),
),
}));
}
function installChildProcessMocks() {
vi.doMock("node:child_process", () => ({
execSync: vi.fn(() => {
throw new Error("not found");
}),
spawnSync: vi.fn(),
}));
}
describe("findBrowser — cache resolution", () => {
const origPlatform = process.platform;
const origArch = process.arch;
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 });
Object.defineProperty(process, "arch", { value: "x64", configurable: true });
delete process.env["HYPERFRAMES_BROWSER_PATH"];
delete process.env["PRODUCER_HEADLESS_SHELL_PATH"];
installChildProcessMocks();
});
afterEach(() => {
vi.useRealTimers();
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
Object.defineProperty(process, "arch", { value: origArch, configurable: true });
vi.restoreAllMocks();
vi.doUnmock("node:fs");
vi.doUnmock("node:os");
vi.doUnmock("node:child_process");
vi.doUnmock("@puppeteer/browsers");
});
it("resolves to the hyperframes-managed cache when puppeteer cache is empty", async () => {
// Only HF cache populated. Puppeteer cache is the higher-priority path
// (see "prefers puppeteer cache" test below), so this exercises the
// last-resort fallback.
installFsMocks({ existing: new Set([HF_CACHE, HF_BINARY]) });
installPuppeteerBrowsersMock({
installedInHfCache: [
{ browser: "chrome-headless-shell", executablePath: HF_BINARY, buildId: CHROME_VERSION },
],
});
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result).toEqual({ executablePath: HF_BINARY, source: "cache" });
});
it("does not resolve to a hyperframes-cache build from an older CHROME_VERSION pin", async () => {
// A build downloaded by a prior hyperframes version (this pin has moved
// 131 -> 151 -> 152 across releases) must not satisfy resolution, or an
// upgrade silently keeps running a stale build forever instead of ever
// fetching the version the new release actually needs (HF#2060 review).
installFsMocks({ existing: new Set([HF_CACHE, HF_BINARY, SYSTEM_CHROME]) });
installPuppeteerBrowsersMock({
installedInHfCache: [
{ browser: "chrome-headless-shell", executablePath: HF_BINARY, buildId: "131.0.6778.85" },
],
});
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result?.executablePath).not.toBe(HF_BINARY);
expect(result).toEqual({ executablePath: SYSTEM_CHROME, source: "system" });
});
it("ignores a current-version HyperFrames cache entry for another platform", async () => {
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
Object.defineProperty(process, "arch", { value: "arm64", configurable: true });
const macArm64Binary = join(
HF_CACHE,
"chrome-headless-shell",
"mac_arm-131.0.6778.85",
"chrome-headless-shell-mac-arm64",
"chrome-headless-shell",
);
installFsMocks({ existing: new Set([HF_CACHE, HF_BINARY, macArm64Binary]) });
installPuppeteerBrowsersMock({
browserPlatform: "mac_arm",
installedInHfCache: [
{
browser: "chrome-headless-shell",
executablePath: HF_BINARY,
buildId: CHROME_VERSION,
platform: "linux",
},
{
browser: "chrome-headless-shell",
executablePath: macArm64Binary,
buildId: CHROME_VERSION,
platform: "mac_arm",
},
],
});
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result).toEqual({ executablePath: macArm64Binary, source: "cache" });
});
it("re-downloads when the hyperframes cache manifest points at a missing binary", async () => {
const redownloadedBinary = join(
HF_CACHE,
"chrome-headless-shell",
"linux-131.0.6778.85",
"chrome-headless-shell-linux64",
"redownloaded-chrome-headless-shell",
);
const staleInstallDir = join(HF_CACHE, "chrome-headless-shell", "linux-131.0.6778.85");
// The stale install DIR is present (extraction got partway through, e.g. an
// ABOUT/LICENSE-only extract) even though the exe itself is missing —
// exercises the purge-before-redownload fix, not just the redownload path.
const paths = installFsMocks({ existing: new Set([HF_CACHE, staleInstallDir]) });
installPuppeteerBrowsersMock({
installedInHfCache: [
{
browser: "chrome-headless-shell",
executablePath: HF_BINARY,
path: staleInstallDir,
buildId: CHROME_VERSION,
},
],
installResult: { executablePath: redownloadedBinary },
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result).toEqual({ executablePath: redownloadedBinary, source: "download" });
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Cached binary missing"));
// The stale directory must be gone before @puppeteer/browsers' install()
// sees it — otherwise install() throws "folder exists but exe missing"
// instead of re-extracting (the exact bug both feedback reports hit).
expect(paths.has(staleInstallDir)).toBe(false);
});
it("ensureBrowser({force: true}) purges the whole cache before downloading, bypassing any cache/system shortcut", async () => {
const staleInstallDir = join(HF_CACHE, "chrome-headless-shell", "linux-131.0.6778.85");
const downloadedBinary = join(HF_CACHE, "chrome-headless-shell", "force-downloaded");
// A HEALTHY cached binary AND system Chrome are both present — force must
// ignore both shortcuts and always re-download, which is the whole point
// of the flag (the reported bug: --force did nothing, a stale dir kept
// winning over every retry).
const paths = installFsMocks({
existing: new Set([HF_CACHE, HF_BINARY, staleInstallDir, SYSTEM_CHROME]),
});
installPuppeteerBrowsersMock({
installedInHfCache: [
{ browser: "chrome-headless-shell", executablePath: HF_BINARY, path: staleInstallDir },
],
installResult: { executablePath: downloadedBinary },
});
const { ensureBrowser } = await import("./manager.js");
const result = await ensureBrowser({ force: true });
expect(result).toEqual({ executablePath: downloadedBinary, source: "download" });
// clearBrowser() wipes prior contents; withInstallLock uses a sibling lock
// outside CACHE_DIR, so assert the purge on what was actually INSIDE it,
// not the directory's own existence.
expect(paths.has(staleInstallDir)).toBe(false);
expect(paths.has(HF_BINARY)).toBe(false);
});
it("serializes concurrent force downloads so one purge cannot delete another installer's lock", async () => {
const downloadedBinary = join(HF_CACHE, "chrome-headless-shell", "force-downloaded");
const paths = installFsMocks({ existing: new Set([CACHE_ROOT, HF_CACHE, HF_BINARY]) });
let activeInstalls = 0;
let maxActiveInstalls = 0;
installPuppeteerBrowsersMock({
installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }],
installImpl: async () => {
activeInstalls += 1;
maxActiveInstalls = Math.max(maxActiveInstalls, activeInstalls);
expect(paths.has(HF_LOCK)).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 20));
activeInstalls -= 1;
return { executablePath: downloadedBinary };
},
});
const { ensureBrowser } = await import("./manager.js");
await expect(
Promise.all([ensureBrowser({ force: true }), ensureBrowser({ force: true })]),
).resolves.toEqual([
{ executablePath: downloadedBinary, source: "download" },
{ executablePath: downloadedBinary, source: "download" },
]);
expect(maxActiveInstalls).toBe(1);
expect(paths.has(HF_LOCK)).toBe(false);
});
it("ensureBrowser does not leak the install lock directory after a successful download", async () => {
// Regression: @puppeteer/browsers' install() has no concurrency guard —
// two CLI invocations that both miss the cache AND system Chrome (the
// reported scenario: two `hyperframes browser ensure` runs racing) hit
// ensureBrowser's final download-of-last-resort at once, racing on the
// same extract target. mkdirSync as an atomic mutex closes that race;
// this asserts the lock is actually released afterward (a leaked lock
// would permanently wedge every future render on this machine).
const downloadedBinary = join(HF_CACHE, "chrome-headless-shell", "downloaded");
// Cache dir exists but is empty (no manifest entries) — distinct from the
// ENOTDIR "cache unreadable" case, which falls back to system instead.
const paths = installFsMocks({ existing: new Set([HF_CACHE]) });
installPuppeteerBrowsersMock({
installedInHfCache: [],
installResult: { executablePath: downloadedBinary },
});
const { ensureBrowser } = await import("./manager.js");
const result = await ensureBrowser();
expect(result).toEqual({ executablePath: downloadedBinary, source: "download" });
expect(paths.has(HF_LOCK)).toBe(false);
});
it("withInstallLock reclaims a lock held past the timeout instead of hanging forever", async () => {
const paths = installFsMocks({ existing: new Set([CACHE_ROOT, HF_LOCK]) });
const { withInstallLock } = await import("./manager.js");
const result = await withInstallLock(async () => "done", TEST_LOCK_TIMINGS);
expect(result).toBe("done");
expect(paths.has(HF_LOCK)).toBe(false);
});
it("withInstallLock recovers when a crashed reclaimer leaves both lock directories", async () => {
vi.useFakeTimers();
const paths = installFsMocks({
existing: new Set([CACHE_ROOT, HF_LOCK, HF_RECLAIM_LOCK]),
});
const { withInstallLock } = await import("./manager.js");
const acquisition = withInstallLock(async () => "done", TEST_LOCK_TIMINGS);
await vi.advanceTimersByTimeAsync(TEST_LOCK_TIMINGS.pollMs * 2);
await expect(Promise.race([acquisition, Promise.resolve("still waiting")])).resolves.toBe(
"done",
);
expect(paths.has(HF_LOCK)).toBe(false);
expect(paths.has(HF_RECLAIM_LOCK)).toBe(false);
});
it("withInstallLock does not reclaim another waiter's fresh lock after this waiter timed out", async () => {
// Regression guard for the timeout-reclaim race: if multiple waiters cross
// the stale-lock deadline together, waiter A can reclaim the stale lock and
// acquire a fresh one. Waiter B's old deadline is still expired, but it must
// not delete A's fresh lock.
const paths = installFsMocks({ existing: new Set([CACHE_ROOT, HF_LOCK]) });
const { withInstallLock } = await import("./manager.js");
const reclaimOnlyTimings = { ...TEST_LOCK_TIMINGS, heartbeatMs: 1_000 };
const first = withInstallLock(async () => {
await new Promise((resolve) => setTimeout(resolve, 30));
return "first";
}, reclaimOnlyTimings);
const second = withInstallLock(async () => "second", reclaimOnlyTimings);
await expect(Promise.all([first, second])).resolves.toEqual(["first", "second"]);
expect(paths.has(HF_LOCK)).toBe(false);
expect(paths.has(HF_RECLAIM_LOCK)).toBe(false);
});
it("withInstallLock does not let a second caller run concurrently with a slow-but-alive holder", async () => {
const paths = installFsMocks({ existing: new Set([CACHE_ROOT]) });
const { withInstallLock } = await import("./manager.js");
let concurrent = 0;
let maxConcurrent = 0;
const trackConcurrency = async (label: string, durationMs: number) => {
concurrent += 1;
maxConcurrent = Math.max(maxConcurrent, concurrent);
await new Promise((resolve) => setTimeout(resolve, durationMs));
concurrent -= 1;
return label;
};
const first = withInstallLock(() => trackConcurrency("first", 120), TEST_LOCK_TIMINGS);
await new Promise((resolve) => setTimeout(resolve, 5)); // let `first` acquire the lock
const second = withInstallLock(() => trackConcurrency("second", 10), TEST_LOCK_TIMINGS);
await expect(Promise.all([first, second])).resolves.toEqual(["first", "second"]);
expect(maxConcurrent).toBe(1);
expect(paths.has(HF_LOCK)).toBe(false);
});
it("withInstallLock reports progress while waiting instead of staying silent", async () => {
// Fake timers freeze Date.now() so the lock mtime stays non-stale across
// the dynamic import that follows — without them, a slow import beat could
// push wall-clock past staleMs before `withInstallLock` even starts polling,
// firing the immediate-stale short-circuit and skipping the wait-notice
// branch this test exists to observe.
vi.useFakeTimers();
const paths = installFsMocks({
existing: new Set([CACHE_ROOT, HF_LOCK]),
initialMtimeMs: Date.now(),
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { withInstallLock } = await import("./manager.js");
const acquisition = withInstallLock(async () => "done", {
...TEST_LOCK_TIMINGS,
waitNoticeMs: 20,
});
// Advance past waitNoticeMs (fires the "Waiting for…" warn), then past
// staleMs (lets `reclaimStaleInstallLock` clear the held lock so the
// acquisition resolves).
await vi.advanceTimersByTimeAsync(TEST_LOCK_TIMINGS.staleMs + TEST_LOCK_TIMINGS.pollMs * 5);
await expect(acquisition).resolves.toBe("done");
expect(paths.has(HF_LOCK)).toBe(false);
expect(
warnSpy.mock.calls.some(([msg]) =>
String(msg).includes("Waiting for another hyperframes process"),
),
).toBe(true);
});
it("keeps the holder running when a heartbeat cannot touch the lock", async () => {
installFsMocks({
existing: new Set([CACHE_ROOT]),
touchError: Object.assign(new Error("EACCES"), { code: "EACCES" }),
});
const { withInstallLock } = await import("./manager.js");
await expect(
withInstallLock(async () => {
await new Promise((resolve) => setTimeout(resolve, 25));
return "done";
}, TEST_LOCK_TIMINGS),
).resolves.toBe("done");
});
it("warns and falls through when the hyperframes cache cannot be read", async () => {
installFsMocks({ existing: new Set([HF_CACHE, SYSTEM_CHROME]) });
installPuppeteerBrowsersMock({
installedInHfCacheError: Object.assign(new Error("ENOTDIR: not a directory"), {
code: "ENOTDIR",
}),
});
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.mock.calls[0]?.[0]).toContain("Browser cache read failed (ENOTDIR)");
expect(warnSpy.mock.calls[0]?.[0]).toContain("Falling back to system Chrome");
});
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.each([
{
hostPlatform: "darwin",
hostArch: "arm64",
expectedDirectory: "chrome-headless-shell-mac-arm64",
expectedExecutable: "chrome-headless-shell",
},
{
hostPlatform: "darwin",
hostArch: "x64",
expectedDirectory: "chrome-headless-shell-mac-x64",
expectedExecutable: "chrome-headless-shell",
},
{
hostPlatform: "linux",
hostArch: "x64",
expectedDirectory: "chrome-headless-shell-linux64",
expectedExecutable: "chrome-headless-shell",
},
{
hostPlatform: "win32",
hostArch: "ia32",
expectedDirectory: "chrome-headless-shell-win32",
expectedExecutable: "chrome-headless-shell.exe",
},
{
hostPlatform: "win32",
hostArch: "x64",
expectedDirectory: "chrome-headless-shell-win64",
expectedExecutable: "chrome-headless-shell.exe",
},
])(
"selects only the host-compatible cached shell on $hostPlatform/$hostArch when every platform is present",
async ({ hostPlatform, hostArch, expectedDirectory, expectedExecutable }) => {
Object.defineProperty(process, "platform", { value: hostPlatform, configurable: true });
Object.defineProperty(process, "arch", { value: hostArch, configurable: true });
const version = "host-148.0.7778.97";
const candidates = [
["chrome-headless-shell-linux64", "chrome-headless-shell"],
["chrome-headless-shell-mac-arm64", "chrome-headless-shell"],
["chrome-headless-shell-mac-x64", "chrome-headless-shell"],
["chrome-headless-shell-win32", "chrome-headless-shell.exe"],
["chrome-headless-shell-win64", "chrome-headless-shell.exe"],
] as const;
const binaries = candidates.map(([directory, executable]) =>
join(PUPPETEER_CACHE, version, directory, executable),
);
const expectedBinary = join(PUPPETEER_CACHE, version, expectedDirectory, expectedExecutable);
installFsMocks({
existing: new Set([PUPPETEER_CACHE, ...binaries]),
dirs: { [PUPPETEER_CACHE]: [version] },
});
installPuppeteerBrowsersMock();
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result).toEqual({ executablePath: expectedBinary, source: "cache" });
},
);
it.each([
{ hostPlatform: "linux", hostArch: "arm64" },
{ hostPlatform: "win32", hostArch: "arm64" },
])(
"does not select a foreign cached shell on unsupported $hostPlatform/$hostArch",
async ({ hostPlatform, hostArch }) => {
Object.defineProperty(process, "platform", { value: hostPlatform, configurable: true });
Object.defineProperty(process, "arch", { value: hostArch, configurable: true });
const version = "host-148.0.7778.97";
const binaries = [
join(PUPPETEER_CACHE, version, "chrome-headless-shell-linux64", "chrome-headless-shell"),
join(PUPPETEER_CACHE, version, "chrome-headless-shell-win64", "chrome-headless-shell.exe"),
];
installFsMocks({
existing: new Set([PUPPETEER_CACHE, ...binaries]),
dirs: { [PUPPETEER_CACHE]: [version] },
});
installPuppeteerBrowsersMock();
const { findBrowser } = await import("./manager.js");
await expect(findBrowser()).resolves.toBeUndefined();
},
);
it("prefers the puppeteer cache over the hyperframes cache when BOTH are populated", async () => {
// The HF cache is pinned to `CHROME_VERSION` (131-era) which lags upstream
// by many releases. The engine's `resolveHeadlessShellPath` scans the
// puppeteer cache and selects newest-version-first; if the CLI handed
// engine the older HF-cache binary while a newer puppeteer-cache binary
// exists, the two would silently disagree on which binary to use.
// This test pins the priority: puppeteer cache wins when both are populated.
installFsMocks({
existing: new Set([HF_CACHE, HF_BINARY, PUPPETEER_CACHE, PUPPETEER_BINARY]),
dirs: { [PUPPETEER_CACHE]: ["linux-148.0.7778.97"] },
});
installPuppeteerBrowsersMock({
installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }],
});
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result?.executablePath).toBe(PUPPETEER_BINARY);
expect(result?.source).toBe("cache");
});
it("picks the newest version when multiple chrome-headless-shell builds are cached", async () => {
const olderBinary = join(
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("uses numeric (not lexicographic) version ordering — linux-148 beats linux-99", async () => {
// Regression guard for the lexicographic-sort bug: `"linux-99..."` sorts
// after `"linux-148..."` character-by-character (because `'9' > '1'`),
// which would have caused the CLI to hand engine an ancient 99-era binary
// when a fresh 148 was sitting right next to it. Numeric semver-style
// ordering is the only correct semantic.
const linux99Binary = join(
PUPPETEER_CACHE,
"linux-99.0.6533.123",
"chrome-headless-shell-linux64",
"chrome-headless-shell",
);
installFsMocks({
existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY, linux99Binary]),
// Intentionally list the entries in an order that would expose the bug
// under naive `.sort().reverse()` (which puts `linux-99...` first).
dirs: { [PUPPETEER_CACHE]: ["linux-99.0.6533.123", "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();
});
// Sibling env-var alias for the CLI resolver. The engine layer already
// honors `PRODUCER_HEADLESS_SHELL_PATH` (see
// `packages/engine/src/services/browserManager.ts`), and docs
// (skills/hyperframes-animation/adapters/typegpu.md,
// packages/gcp-cloud-run/Dockerfile, examples/k8s-jobs/Dockerfile.example)
// all instruct users to set that name. Before this alias, `hyperframes
// check`/`snapshot`/`compare` — which all route through `openSettledCompositionPage`
// → `ensureBrowser` → `findFromEnv` — silently ignored a documented escape
// hatch that `render` had honored, so a user with a broken pinned build
// (win32/x64 STATUS_STACK_BUFFER_OVERRUN 3221225595, #hyperframes-cli-feedback
// ts=1784095034) could render successfully but check would still crash on
// the cached headless-shell. The alias closes that direction of the
// symmetry (the engine side is being closed by #2459).
it("resolves via PRODUCER_HEADLESS_SHELL_PATH when HYPERFRAMES_BROWSER_PATH is unset", async () => {
const directShell = "/opt/chrome-headless-shell/chrome-headless-shell";
installFsMocks({ existing: new Set([directShell]) });
installPuppeteerBrowsersMock();
process.env["PRODUCER_HEADLESS_SHELL_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(result?.source).toBe("env");
expect(warnSpy).not.toHaveBeenCalled();
});
it("prefers HYPERFRAMES_BROWSER_PATH over PRODUCER_HEADLESS_SHELL_PATH when both are set", async () => {
// Tiebreak matches `render.ts` — the CLI-native name canonicalizes; the
// engine name is a compatibility alias. If both are set the caller almost
// certainly meant the CLI-native one.
const hfPath = "/opt/hf/chrome-headless-shell";
const producerPath = "/opt/producer/chrome-headless-shell";
installFsMocks({ existing: new Set([hfPath, producerPath]) });
installPuppeteerBrowsersMock();
process.env["HYPERFRAMES_BROWSER_PATH"] = hfPath;
process.env["PRODUCER_HEADLESS_SHELL_PATH"] = producerPath;
const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js");
_resetSystemFallbackWarnForTests();
const result = await findBrowser();
expect(result?.executablePath).toBe(hfPath);
expect(result?.source).toBe("env");
});
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);
});
});
describe("isCorruptArchiveError", () => {
it("matches truncated / corrupt archive extraction failures", async () => {
const { isCorruptArchiveError } = await import("./manager.js");
for (const msg of [
"invalid end-of-central-directory record",
"end of central directory record signature not found",
"invalid or corrupt zip file",
"File is not a zip file",
"unexpected end of file",
"the archive is corrupted",
]) {
expect(isCorruptArchiveError(new Error(msg))).toBe(true);
}
});
it("does not match network or unrelated errors", async () => {
const { isCorruptArchiveError } = await import("./manager.js");
for (const msg of ["ECONNRESET", "socket hang up", "ENOENT: no such file", "boom"]) {
expect(isCorruptArchiveError(new Error(msg))).toBe(false);
}
});
});
describe("installWithCorruptArchiveRecovery", () => {
it("clears the cache and re-downloads once on a corrupt archive, then succeeds", async () => {
const { installWithCorruptArchiveRecovery } = await import("./manager.js");
const runInstall = vi
.fn()
.mockRejectedValueOnce(new Error("invalid end-of-central-directory record"))
.mockResolvedValueOnce({ executablePath: "/ok" });
const clearCache = vi.fn();
const onRecover = vi.fn();
const result = await installWithCorruptArchiveRecovery(runInstall, clearCache, onRecover);
expect(result).toEqual({ executablePath: "/ok" });
expect(runInstall).toHaveBeenCalledTimes(2);
expect(clearCache).toHaveBeenCalledTimes(1);
expect(onRecover).toHaveBeenCalledTimes(1);
});
it("propagates a non-corruption error without clearing the cache", async () => {
const { installWithCorruptArchiveRecovery } = await import("./manager.js");
const runInstall = vi.fn().mockRejectedValue(new Error("ECONNRESET"));
const clearCache = vi.fn();
await expect(installWithCorruptArchiveRecovery(runInstall, clearCache)).rejects.toThrow(
"ECONNRESET",
);
expect(runInstall).toHaveBeenCalledTimes(1);
expect(clearCache).not.toHaveBeenCalled();
});
it("does not retry forever: a second corruption propagates", async () => {
const { installWithCorruptArchiveRecovery } = await import("./manager.js");
const runInstall = vi.fn().mockRejectedValue(new Error("end of central directory not found"));
const clearCache = vi.fn();
await expect(installWithCorruptArchiveRecovery(runInstall, clearCache)).rejects.toThrow(
"end of central directory",
);
expect(runInstall).toHaveBeenCalledTimes(2);
expect(clearCache).toHaveBeenCalledTimes(1);
});
});
// Sibling failure mode to #2078 (SIGTRAP at launch): the field feedback in
// #hyperframes-cli-feedback ts 1784055194.202169 (darwin/arm64, HF CLI 0.7.57)
// hit `All providers failed for chrome-headless-shell 152.0.7928.2` at download
// time and had to discover `HYPERFRAMES_BROWSER_PATH` on their own. The raw
// error propagated straight through `downloadBrowser` without naming the
// escape hatch. This guards the rewrap so the next reporter sees the hint.
//
// Parameterized across all three OS families because `browserPathHintForPlatform`
// branches on `process.platform` and each branch has to survive on its own —
// the field reporter was macOS but the same rewrap is what a Windows or Linux
// (non-ARM) user would see next time providers fail, and each branch names a
// different Chrome install path that has to be spelled correctly.
describe("downloadBrowser — install failure surfaces HYPERFRAMES_BROWSER_PATH hint", () => {
const origPlatform = process.platform;
const origArch = process.arch;
beforeEach(() => {
vi.resetModules();
delete process.env["HYPERFRAMES_BROWSER_PATH"];
delete process.env["PRODUCER_HEADLESS_SHELL_PATH"];
installChildProcessMocks();
});
afterEach(() => {
Object.defineProperty(process, "platform", { value: origPlatform, configurable: true });
Object.defineProperty(process, "arch", { value: origArch, configurable: true });
vi.restoreAllMocks();
vi.doUnmock("node:fs");
vi.doUnmock("node:os");
vi.doUnmock("node:child_process");
vi.doUnmock("@puppeteer/browsers");
});
// Note: linux/arm64 is deliberately excluded — `downloadBrowser` short-circuits
// into `ensureLinuxArmBrowser` before it ever reaches the install() call this
// suite guards (chrome-headless-shell has no linux-arm64 build; see `isLinuxArm`
// at the top of `downloadBrowser`). Use linux/x64 to exercise the linux branch
// of `browserPathHintForPlatform`.
it.each([
{
label: "darwin/arm64",
platform: "darwin",
arch: "arm64",
expectedPathHint: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
},
{
label: "win32/x64",
platform: "win32",
arch: "x64",
expectedPathHint: "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
},
{
label: "linux/x64",
platform: "linux",
arch: "x64",
expectedPathHint: "/usr/bin/google-chrome",
},
])(
"rethrows a non-corrupt install failure with an HYPERFRAMES_BROWSER_PATH hint and preserves the original via cause ($label)",
async ({ platform, arch, expectedPathHint }) => {
Object.defineProperty(process, "platform", { value: platform, configurable: true });
Object.defineProperty(process, "arch", { value: arch, configurable: true });
// No cache, no system Chrome — forces the download-of-last-resort path
// that ends in @puppeteer/browsers install().
installFsMocks({ existing: new Set([CACHE_ROOT]) });
const rawMsg = "All providers failed for chrome-headless-shell 152.0.7928.2";
const originalError = new Error(rawMsg);
installPuppeteerBrowsersMock({
installedInHfCache: [],
installImpl: async () => {
throw originalError;
},
});
const { ensureBrowser } = await import("./manager.js");
let caught: unknown;
try {
await ensureBrowser();
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(Error);
const msg = (caught as Error).message;
// Names the escape-hatch env var by name (that's the entire point).
expect(msg).toContain("HYPERFRAMES_BROWSER_PATH");
// Includes the platform-specific example path from
// `browserPathHintForPlatform`.
expect(msg).toContain(expectedPathHint);
// Keeps the original provider-failure text so the user can still
// diagnose the underlying cause from the surfaced message.
expect(msg).toContain(rawMsg);
// Structured `cause` chain intact for tooling that walks it.
expect((caught as Error).cause).toBe(originalError);
},
);
});
// Regression guard for HF#2103: `hyperframes render` hung forever on macOS
// (Apple Silicon) under Node >= 24.16. Root cause was NOT in this file — it was
// the extractor `@puppeteer/browsers` <3.0.2 shells out to. That chain
// (`@puppeteer/browsers` -> `extract-zip@2.0.1` -> `yauzl@2.10.0`) hits a
// classic-stream backpressure regression (nodejs/node#63487) that surfaces a
// latent fd-slicer `destroy()` bug in yauzl 2.x (yauzl#169): the inflate read
// stream stalls partway through the first entry large enough to cross the write
// highWaterMark, never emits `end`, and `stream.pipeline` never settles — so
// extraction busy-spins forever, leaving a half-extracted cache with no
// executable (puppeteer/puppeteer#14957).
//
// `@puppeteer/browsers` 3.0.2 dropped `extract-zip` as a dependency and now
// extracts with `modern-tar` by default (`yauzl` lingers only as an optional
// peer fallback — no longer a runtime dependency), which is the fix. This test
// fails if a dependency change ever drags the pin back below 3.x — i.e.
// reintroduces the broken extractor as a hard dependency.
describe("@puppeteer/browsers pin (HF#2103 extractor-hang regression guard)", () => {
it("stays on the major (>= 3) that dropped extract-zip and no longer depends on yauzl", async () => {
const { createRequire } = await import("node:module");
const require = createRequire(import.meta.url);
const pkg = require("@puppeteer/browsers/package.json") as {
version: string;
dependencies?: Record<string, string>;
};
const major = Number.parseInt(pkg.version.split(".")[0] ?? "0", 10);
expect(major).toBeGreaterThanOrEqual(3);
// Belt and suspenders: the durable fix is the *absence* of the broken
// extractor, not just a version number, so assert it directly.
const deps = pkg.dependencies ?? {};
expect(deps["extract-zip"]).toBeUndefined();
expect(deps["yauzl"]).toBeUndefined();
});
});