From 3647ef78b1a48174e2b9bda975bd989583c172e8 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 30 Jun 2026 15:30:13 -0700 Subject: [PATCH 1/2] fix(cli): keep doctor resilient to a corrupt browser cache A partial or corrupt browser cache (a stub file where a version directory is expected, a missing executable, or malformed metadata) makes getInstalledBrowsers throw ENOTDIR. That throw propagated up through findBrowser -> checkChrome -> runEnvironmentChecks, and since doctor.run calls runEnvironmentChecks before any try/catch or the --json output, the command crashed with exit 1. doctor --json is documented to exit 0 even when checks fail, so it must report a corrupt cache as "Chrome not found", not crash on it. - checkChrome now catches any error from findBrowser and converts it to the existing ok:false "Chrome not found" outcome with the browser ensure hint, so runEnvironmentChecks never throws for a missing or corrupt browser. - findFromCache treats a throwing getInstalledBrowsers as "no cached browser", letting resolution fall through to system/download instead of crashing every caller (render included), not just doctor. A healthy browser still reports ok:true. Adds a preflight test asserting an ok:false Chrome outcome when discovery throws, instead of propagating. --- packages/cli/src/browser/manager.ts | 11 +++++++++- packages/cli/src/browser/preflight.test.ts | 24 +++++++++++++++++++++- packages/cli/src/browser/preflight.ts | 12 ++++++++++- 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/browser/manager.ts b/packages/cli/src/browser/manager.ts index d2615b53bc..0be28d1387 100644 --- a/packages/cli/src/browser/manager.ts +++ b/packages/cli/src/browser/manager.ts @@ -105,7 +105,16 @@ async function findFromCache(): Promise { // no puppeteer-cache binary exists. if (existsSync(CACHE_DIR)) { const { Browser, getInstalledBrowsers } = await loadPuppeteerBrowsers(); - const installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR }); + // A corrupt cache (stub file where a browser dir is expected, malformed + // metadata) makes getInstalledBrowsers throw. Treat that as "no cached + // browser" so resolution falls through to system/download instead of + // crashing every caller. + let installed: Awaited>; + try { + installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR }); + } catch { + installed = []; + } const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL); if (match && existsSync(match.executablePath)) { return { result: { executablePath: match.executablePath, source: "cache" } }; diff --git a/packages/cli/src/browser/preflight.test.ts b/packages/cli/src/browser/preflight.test.ts index 9c635452e0..2ad91b29e4 100644 --- a/packages/cli/src/browser/preflight.test.ts +++ b/packages/cli/src/browser/preflight.test.ts @@ -1,6 +1,7 @@ // fallow-ignore-file code-duplication -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { parseToolVersion, runEnvironmentChecks } from "./preflight.js"; +import * as manager from "./manager.js"; describe("runEnvironmentChecks", () => { const originalFfmpegPath = process.env.HYPERFRAMES_FFMPEG_PATH; @@ -67,6 +68,27 @@ describe("runEnvironmentChecks", () => { }); }); + it("reports Chrome as not found (no throw) when browser discovery throws on a corrupt cache", async () => { + const spy = vi.spyOn(manager, "findBrowser").mockRejectedValue( + Object.assign(new Error("ENOTDIR: not a directory, scandir 'chrome-headless-shell'"), { + code: "ENOTDIR", + }), + ); + + try { + const result = await runEnvironmentChecks({ includeBrowser: true }); + + expect(result.outcomes.find((outcome) => outcome.name === "Chrome")).toMatchObject({ + ok: false, + title: "Chrome not found", + hint: "Run: npx hyperframes browser ensure", + }); + expect(result.browser).toBeUndefined(); + } finally { + spy.mockRestore(); + } + }); + it("reports an explicit missing browser path before render starts", async () => { const result = await runEnvironmentChecks({ includeBrowser: true, diff --git a/packages/cli/src/browser/preflight.ts b/packages/cli/src/browser/preflight.ts index ac3c755452..efe32a9cfe 100644 --- a/packages/cli/src/browser/preflight.ts +++ b/packages/cli/src/browser/preflight.ts @@ -139,7 +139,17 @@ async function checkChrome(browserPath?: string): Promise>; + try { + info = await findBrowser(); + } catch { + info = undefined; + } if (info) { return { name: "Chrome", From cf2d7d85e36a23490ec44dc3c3ca16e44c8023c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 1 Jul 2026 02:35:17 +0000 Subject: [PATCH 2/2] fix(cli): warn on corrupt browser cache fallback --- packages/cli/src/browser/manager.test.ts | 23 ++++++++++++++++++++++- packages/cli/src/browser/manager.ts | 7 ++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/browser/manager.test.ts b/packages/cli/src/browser/manager.test.ts index 1a55002e49..32953e66a3 100644 --- a/packages/cli/src/browser/manager.test.ts +++ b/packages/cli/src/browser/manager.test.ts @@ -73,13 +73,16 @@ function installFsMocks({ existing, dirs }: FsMockOptions) { function installPuppeteerBrowsersMock( opts: { installedInHfCache?: Array<{ browser: string; executablePath: string }>; + installedInHfCacheError?: Error; installResult?: { executablePath: string }; } = {}, ) { vi.doMock("@puppeteer/browsers", () => ({ Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" }, detectBrowserPlatform: () => "linux", - getInstalledBrowsers: vi.fn().mockResolvedValue(opts.installedInHfCache ?? []), + getInstalledBrowsers: opts.installedInHfCacheError + ? vi.fn().mockRejectedValue(opts.installedInHfCacheError) + : vi.fn().mockResolvedValue(opts.installedInHfCache ?? []), install: vi.fn().mockResolvedValue(opts.installResult ?? { executablePath: HF_BINARY }), })); } @@ -144,6 +147,24 @@ describe("findBrowser — cache resolution", () => { expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Cached binary missing")); }); + 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. diff --git a/packages/cli/src/browser/manager.ts b/packages/cli/src/browser/manager.ts index 0be28d1387..c7cd49543d 100644 --- a/packages/cli/src/browser/manager.ts +++ b/packages/cli/src/browser/manager.ts @@ -112,7 +112,12 @@ async function findFromCache(): Promise { let installed: Awaited>; try { installed = await getInstalledBrowsers({ cacheDir: CACHE_DIR }); - } catch { + } catch (err) { + const code = (err as NodeJS.ErrnoException | undefined)?.code; + const suffix = code ? ` (${code})` : ""; + console.warn( + `[hyperframes] Browser cache read failed${suffix}: ${normalizeErrorMessage(err)}. Falling back to system Chrome or a fresh download.`, + ); installed = []; } const match = installed.find((b) => b.browser === Browser.CHROMEHEADLESSSHELL);