Merge pull request #2443 from heygen-com/via/browser-install-error-hint

fix(cli): surface HYPERFRAMES_BROWSER_PATH hint on pinned browser download failures
This commit is contained in:
Vance Ingalls
2026-07-14 20:13:16 -07:00
committed by GitHub
2 changed files with 153 additions and 11 deletions
+99
View File
@@ -697,6 +697,105 @@ describe("installWithCorruptArchiveRecovery", () => {
});
});
// 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"];
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
+54 -11
View File
@@ -640,6 +640,44 @@ export async function installWithCorruptArchiveRecovery<T>(
}
}
/**
* When `@puppeteer/browsers`' install() rejects for any reason the corrupt-
* archive recovery path can't handle (all CDN providers rejected — the
* `All providers failed for chrome-headless-shell <ver>` case reported from the
* field on darwin/arm64 with CLI 0.7.57; DNS/network failure; a macOS Gatekeeper
* quarantine that blocks the pinned Dev-channel binary from launching a probe;
* a second corruption that trips the retry gate), the raw error names none of
* the escape hatches that would unblock the user. Rewrap it in one that does:
* `HYPERFRAMES_BROWSER_PATH` wins over both the managed download and system
* lookup (see `findFromEnv` above), so pointing it at an already-installed
* Chrome renders successfully via the screenshot fallback while the pinned
* chrome-headless-shell download is broken. Sibling failure mode: #2078
* (SIGTRAP at launch), same remediation, different trigger.
*/
function browserPathHintForPlatform(): string {
if (process.platform === "darwin") {
return "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
}
if (process.platform === "win32") {
return "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
}
return "/usr/bin/google-chrome";
}
function wrapDownloadFailureWithBrowserPathHint(cause: unknown): Error {
const original = normalizeErrorMessage(cause);
const example = browserPathHintForPlatform();
const message =
`Failed to download chrome-headless-shell ${CHROME_VERSION}: ${original}\n\n` +
`Point hyperframes at an already-installed Chrome/Chromium instead:\n\n` +
` export HYPERFRAMES_BROWSER_PATH="${example}"\n\n` +
`Then re-run your command. Any Chrome build works for the screenshot ` +
`capture path; install a real chrome-headless-shell later if you need the ` +
`perf-optimized BeginFrame path. Alternatively, run inside the hyperframes ` +
`Docker image which ships a compatible headless-shell.`;
return new Error(message, { cause: cause instanceof Error ? cause : undefined });
}
async function downloadBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
if (isLinuxArm()) {
return ensureLinuxArmBrowser(options);
@@ -661,17 +699,22 @@ async function downloadBrowser(options?: EnsureBrowserOptions): Promise<BrowserR
downloadProgressCallback: options?.onProgress,
});
const installed = await installWithCorruptArchiveRecovery(
runInstall,
() => {
rmSync(CACHE_DIR, { recursive: true, force: true });
mkdirSync(CACHE_DIR, { recursive: true });
},
(err) =>
console.warn(
`[hyperframes] Cached browser archive was corrupt (${normalizeErrorMessage(err)}); clearing the cache and re-downloading.`,
),
);
let installed;
try {
installed = await installWithCorruptArchiveRecovery(
runInstall,
() => {
rmSync(CACHE_DIR, { recursive: true, force: true });
mkdirSync(CACHE_DIR, { recursive: true });
},
(err) =>
console.warn(
`[hyperframes] Cached browser archive was corrupt (${normalizeErrorMessage(err)}); clearing the cache and re-downloading.`,
),
);
} catch (err) {
throw wrapDownloadFailureWithBrowserPathHint(err);
}
return { executablePath: installed.executablePath, source: "download" };
}