Skip to content
Open
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
196 changes: 193 additions & 3 deletions packages/cli/src/browser/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,7 +300,7 @@ describe("findBrowser — cache resolution", () => {
expect(result).toEqual({ executablePath: macArm64Binary, source: "cache" });
});

it("re-downloads when the hyperframes cache manifest points at a missing binary", async () => {
it("ensureBrowser re-downloads when the hyperframes cache manifest points at a missing binary", async () => {
const redownloadedBinary = join(
HF_CACHE,
"chrome-headless-shell",
Expand All @@ -326,8 +326,8 @@ describe("findBrowser — cache resolution", () => {
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});

const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
const { ensureBrowser } = await import("./manager.js");
const result = await ensureBrowser();

expect(result).toEqual({ executablePath: redownloadedBinary, source: "download" });
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Cached binary missing"));
Expand All @@ -337,6 +337,32 @@ describe("findBrowser — cache resolution", () => {
expect(paths.has(staleInstallDir)).toBe(false);
});

it("findBrowser reports a stale hyperframes-cache entry as not found instead of downloading", async () => {
// findBrowser is the find-only half of the API (doctor, `browser path`):
// a manifest whose executable is gone must not trigger a download from a
// diagnostic, and must not be reported as a hit either.
const staleInstallDir = join(HF_CACHE, "chrome-headless-shell", "linux-131.0.6778.85");
installFsMocks({ existing: new Set([HF_CACHE, staleInstallDir]) });
const install = vi.fn(async () => ({ executablePath: HF_BINARY }));
installPuppeteerBrowsersMock({
installedInHfCache: [
{
browser: "chrome-headless-shell",
executablePath: HF_BINARY,
path: staleInstallDir,
buildId: CHROME_VERSION,
},
],
installImpl: install,
});

const { findBrowser } = await import("./manager.js");

expect(await findBrowser()).toBeUndefined();
expect(await findBrowser({ preferManagedChrome: true })).toBeUndefined();
expect(install).not.toHaveBeenCalled();
});

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");
Expand Down Expand Up @@ -843,6 +869,170 @@ describe("findBrowser — cache resolution", () => {
});
});

describe("findBrowser — preferManagedChrome", () => {
const origPlatform = process.platform;
const origArch = process.arch;

beforeEach(() => {
vi.resetModules();
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(() => {
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("ignores a puppeteer-cache hit and resolves to the pinned hyperframes cache instead", async () => {
// Same "both populated" fixture as the unqualified test above, except the
// HF-cache entry is pinned to CHROME_VERSION so it's actually a valid
// match — proving preferManagedChrome skips the puppeteer cache entirely
// rather than merely losing a tiebreak to it.
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, buildId: CHROME_VERSION },
],
});

const { findBrowser } = await import("./manager.js");

expect(await findBrowser()).toEqual({ executablePath: PUPPETEER_BINARY, source: "cache" });
expect(await findBrowser({ preferManagedChrome: true })).toEqual({
executablePath: HF_BINARY,
source: "cache",
});
});

it("does not report a false cache hit against a puppeteer-cache build off the pinned version", async () => {
// The exact reported divergence: doctor's unqualified check accepted any
// puppeteer-cached version as "found", while a preferManagedChrome render
// only accepts the pinned build and would re-download here.
installFsMocks({
existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY]),
dirs: { [PUPPETEER_CACHE]: ["linux-148.0.7778.97"] },
});
installPuppeteerBrowsersMock();

const { findBrowser } = await import("./manager.js");

expect(await findBrowser()).toEqual({ executablePath: PUPPETEER_BINARY, source: "cache" });
expect(await findBrowser({ preferManagedChrome: true })).toBeUndefined();
});

it("does not fall back to system Chrome, unlike the unqualified resolution", async () => {
installFsMocks({ existing: new Set([SYSTEM_CHROME]) });
installPuppeteerBrowsersMock();
// The unqualified call below takes the system-Chrome path, which warns.
vi.spyOn(console, "warn").mockImplementation(() => {});

const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js");
_resetSystemFallbackWarnForTests();

expect(await findBrowser()).toEqual({ executablePath: SYSTEM_CHROME, source: "system" });
expect(await findBrowser({ preferManagedChrome: true })).toBeUndefined();
});

it("still resolves the env var override first", async () => {
// An explicit HYPERFRAMES_BROWSER_PATH must win even when the pinned
// managed cache is populated — same precedence as ensureBrowser.
const envBinary = join(FAKE_HOME, "custom", "chrome-headless-shell");
process.env["HYPERFRAMES_BROWSER_PATH"] = envBinary;
installFsMocks({ existing: new Set([HF_CACHE, HF_BINARY, envBinary]) });
installPuppeteerBrowsersMock({
installedInHfCache: [
{ browser: "chrome-headless-shell", executablePath: HF_BINARY, buildId: CHROME_VERSION },
],
});

const { findBrowser } = await import("./manager.js");

expect(await findBrowser({ preferManagedChrome: true })).toEqual({
executablePath: envBinary,
source: "env",
});
});

it("ensureBrowser ignores a puppeteer-cache hit and returns the pinned hyperframes cache without installing", async () => {
// ensureBrowser's own first cache lookup must make the same managed-only
// decision as findBrowser — otherwise render launches the puppeteer-cache
// build that doctor / `browser path` just said it would skip.
installFsMocks({
existing: new Set([HF_CACHE, HF_BINARY, PUPPETEER_CACHE, PUPPETEER_BINARY]),
dirs: { [PUPPETEER_CACHE]: ["linux-148.0.7778.97"] },
});
const install = vi.fn(async () => ({ executablePath: HF_BINARY }));
installPuppeteerBrowsersMock({
installedInHfCache: [
{ browser: "chrome-headless-shell", executablePath: HF_BINARY, buildId: CHROME_VERSION },
],
installImpl: install,
});

const { ensureBrowser } = await import("./manager.js");

expect(await ensureBrowser()).toEqual({ executablePath: PUPPETEER_BINARY, source: "cache" });
expect(await ensureBrowser({ preferManagedChrome: true })).toEqual({
executablePath: HF_BINARY,
source: "cache",
});
expect(install).not.toHaveBeenCalled();
});

it("still finds system Chromium on Linux ARM64, where no managed build exists", async () => {
// Chrome for Testing publishes no linux-arm64 chrome-headless-shell, so a
// preferManagedChrome render reroutes to system Chromium there
// (ensureLinuxArmBrowser). A managed-only lookup would report "not found"
// on a correctly set-up machine — the option has to be a no-op on ARM64.
Object.defineProperty(process, "arch", { value: "arm64", configurable: true });
installFsMocks({ existing: new Set([SYSTEM_CHROME]) });
installPuppeteerBrowsersMock();
vi.spyOn(console, "warn").mockImplementation(() => {});

const { findBrowser } = await import("./manager.js");

expect(await findBrowser({ preferManagedChrome: true })).toEqual({
executablePath: SYSTEM_CHROME,
source: "system",
});
});

it("ensureBrowser also resolves system Chromium directly on Linux ARM64, without taking the install lock", async () => {
// Same decision as findBrowser (resolvesManagedOnly): a render on ARM64
// must not detour through the download path — and serialize on the
// install lock — just to end up at the system Chromium it could have
// found up front.
Object.defineProperty(process, "arch", { value: "arm64", configurable: true });
const paths = installFsMocks({ existing: new Set([SYSTEM_CHROME]) });
const install = vi.fn(async () => ({ executablePath: HF_BINARY }));
installPuppeteerBrowsersMock({ installImpl: install });
vi.spyOn(console, "warn").mockImplementation(() => {});

const { ensureBrowser } = await import("./manager.js");

expect(await ensureBrowser({ preferManagedChrome: true })).toEqual({
executablePath: SYSTEM_CHROME,
source: "system",
});
expect(install).not.toHaveBeenCalled();
// withInstallLock creates the cache root before locking; the fixture
// starts without it, so its absence proves the download path never ran.
expect(paths.has(CACHE_ROOT)).toBe(false);
});
});

describe("isCorruptArchiveError", () => {
it("matches truncated / corrupt archive extraction failures", async () => {
const { isCorruptArchiveError } = await import("./manager.js");
Expand Down
76 changes: 49 additions & 27 deletions packages/cli/src/browser/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -521,35 +521,59 @@ export function findSystemBrowser(): BrowserResult | undefined {
return undefined;
}

/**
* Whether resolution is restricted to OUR pinned cache. `preferManagedChrome`
* is a no-op on Linux ARM64: Chrome for Testing publishes no linux-arm64
* chrome-headless-shell, so the only browser a render can use there is system
* Chromium (`ensureLinuxArmBrowser`), and a managed-only lookup would report
* "not found" on a correctly set-up machine. Shared by `findBrowser` and
* `ensureBrowser` so the two halves of the API cannot disagree.
*/
function resolvesManagedOnly(options?: Pick<EnsureBrowserOptions, "preferManagedChrome">): boolean {
return options?.preferManagedChrome === true && !isLinuxArm();
}

/**
* The cache leg of resolution, keyed on `resolvesManagedOnly`: OUR pinned cache
* alone when managed-only, otherwise puppeteer's cache first (see
* `findFromCache`). One function so every lookup in `findBrowser` and
* `ensureBrowser` picks the same source.
*/
function lookupCache(managedOnly: boolean): Promise<CacheLookupResult> {
return managedOnly ? findFromHyperframesCache() : findFromCache();
}

// --- Public API -------------------------------------------------------------

/**
* Find an existing browser without downloading.
* Resolution: env var -> cached download -> system Chrome.
*
* A stale hyperframes-cache entry (manifest present, executable missing) is
* "not found" here — the re-download belongs to `ensureBrowser`.
*
* With `preferManagedChrome`: env var -> OUR pinned cache only (puppeteer-cache
* preference and system Chrome are both skipped) — the same restriction
* `ensureBrowser` applies, minus its auto-download. Pass it when a "found"
* report has to predict what a `preferManagedChrome` render will actually use
* (`doctor`'s Chrome check, `browser path`): the unqualified resolution
* reports hits that such a render re-downloads over. No-op on Linux ARM64
* (see `resolvesManagedOnly`).
*/
export async function findBrowser(): Promise<BrowserResult | undefined> {
export async function findBrowser(
options?: Pick<EnsureBrowserOptions, "preferManagedChrome">,
): Promise<BrowserResult | undefined> {
const fromEnv = findFromEnv();
if (fromEnv) return fromEnv;

const fromCache = await findFromCache();
const managedOnly = resolvesManagedOnly(options);
const fromCache = await lookupCache(managedOnly);
if (fromCache.result) return fromCache.result;
if (fromCache.staleHyperframesCachePath) {
console.warn(
`[browser] Cached binary missing at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
);
try {
return await withInstallLock(async () => {
if (fromCache.staleInstallPath) purgeStaleInstall(fromCache.staleInstallPath);
return downloadBrowser();
});
} catch (err) {
const cause = normalizeErrorMessage(err);
throw new Error(
`Cached Chrome binary was missing at ${fromCache.staleHyperframesCachePath}, and re-download failed: ${cause}\n` +
`Run \`hyperframes browser ensure --force\` to re-download.`,
);
}
}

// A managed-only render never falls back to system Chrome (see
// `ensureBrowser`), so reporting it here would only relocate the false hit
// from the puppeteer cache to system Chrome, not remove it.
if (managedOnly) return undefined;

const fromSystem = findSystemBrowser();
if (fromSystem) {
Expand Down Expand Up @@ -611,16 +635,16 @@ async function ensureLinuxArmBrowser(options?: EnsureBrowserOptions): Promise<Br
* Find or download a browser.
* Resolution: env var -> cached download -> system Chrome -> auto-download.
* With `preferManagedChrome`: env var -> OUR pinned cache -> auto-download
* (puppeteer-cache preference and system Chrome are both skipped).
* (puppeteer-cache preference and system Chrome are both skipped). No-op on
* Linux ARM64 (see `resolvesManagedOnly`).
*/
export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<BrowserResult> {
const fromEnv = findFromEnv();
if (fromEnv) return fromEnv;

const managedOnly = resolvesManagedOnly(options);
if (!options?.force) {
const fromCache = await (options?.preferManagedChrome
? findFromHyperframesCache()
: findFromCache());
const fromCache = await lookupCache(managedOnly);
if (fromCache.result) return fromCache.result;
if (fromCache.staleHyperframesCachePath) {
console.warn(
Expand All @@ -632,7 +656,7 @@ export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<Bro
});
}

if (!options?.preferManagedChrome) {
if (!managedOnly) {
const fromSystem = findSystemBrowser();
if (fromSystem) {
warnSystemFallbackOnce(fromSystem.executablePath);
Expand All @@ -655,9 +679,7 @@ export async function ensureBrowser(options?: EnsureBrowserOptions): Promise<Bro
// result instead of downloading and extracting a second time. Skipped
// under --force, which already purged and always wants a fresh download.
if (!options?.force) {
const afterLock = await (options?.preferManagedChrome
? findFromHyperframesCache()
: findFromCache());
const afterLock = await lookupCache(managedOnly);
if (afterLock.result) return afterLock.result;
if (afterLock.staleInstallPath) purgeStaleInstall(afterLock.staleInstallPath);
}
Expand Down
17 changes: 16 additions & 1 deletion packages/cli/src/browser/preflight.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,22 @@ describe("runEnvironmentChecks", () => {
});
});

it("reports Chrome as not found (no throw) when browser discovery throws on a corrupt cache", async () => {
it("resolves Chrome via preferManagedChrome so a reported hit predicts what render will actually use", async () => {
const spy = vi.spyOn(manager, "findBrowser").mockResolvedValue({
executablePath: process.execPath,
source: "cache",
});

try {
await runEnvironmentChecks({ includeBrowser: true });

expect(spy).toHaveBeenCalledWith({ preferManagedChrome: true });
} finally {
spy.mockRestore();
}
});

it("reports Chrome as not found (no throw) when browser discovery throws", async () => {
const spy = vi.spyOn(manager, "findBrowser").mockRejectedValue(
Object.assign(new Error("ENOTDIR: not a directory, scandir 'chrome-headless-shell'"), {
code: "ENOTDIR",
Expand Down
17 changes: 11 additions & 6 deletions packages/cli/src/browser/preflight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,14 +247,19 @@ async function checkChrome(browserPath?: string): Promise<EnvironmentCheckOutcom
};
}

// A corrupt/partial browser cache (stub files where a version dir is
// expected, missing executable, malformed metadata) makes findBrowser throw.
// That is the exact condition this check exists to report, so treat any
// failure as "Chrome not found" rather than letting it crash the caller
// (notably `doctor`, which is documented to exit 0 even when checks fail).
// Both cache lookups inside findBrowser swallow their own read errors, but
// it can still throw — e.g. `@puppeteer/browsers` failing to load when the
// dependency is missing. An unusable browser setup is the exact condition
// this check exists to report, so treat any failure as "Chrome not found"
// rather than letting it crash the caller (notably `doctor`, which is
// documented to exit 0 even when checks fail).
let info: Awaited<ReturnType<typeof findBrowser>>;
try {
info = await findBrowser();
// `preferManagedChrome` so a hit here predicts what render's
// `ensureBrowser({ preferManagedChrome: true })` will use — the unqualified
// resolution also accepts any puppeteer-cached version or system Chrome,
// neither of which render's pinned-version path honors.
info = await findBrowser({ preferManagedChrome: true });
} catch {
info = undefined;
}
Expand Down
Loading
Loading