fix(cli): name the unzip errors when every Windows unzip tool fails (#5355)

* fix(cli): name the unzip errors when every Windows unzip tool fails

* fix(cli): a cached empty or folder Chrome counts as missing, not found

* fix(engine): skip an empty cached Chrome the same way the CLI does

* fix(engine): keep scanning the Chrome cache past a build it cannot read

* test(engine): a folder at the cached Chrome path is skipped
This commit is contained in:
Miguel Ángel
2026-10-09 19:18:27 +00:00
committed by GitHub
parent 5eb2a96fc2
commit 0241078560
8 changed files with 236 additions and 44 deletions
+85 -7
View File
@@ -94,6 +94,8 @@ interface FsMockOptions {
initialMtimeMs?: number;
/** what node:os reports; defaults to a Linux host */
osHost?: { platform: string; release: string };
/** paths whose stat reports 0 bytes */
emptyFiles?: ReadonlySet<string>;
}
function installFsMocks({
@@ -102,6 +104,7 @@ function installFsMocks({
touchError,
initialMtimeMs = 0,
osHost = { platform: "linux", release: "24.0.0" },
emptyFiles,
}: 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.
@@ -160,7 +163,7 @@ function installFsMocks({
(err as NodeJS.ErrnoException).code = "ENOENT";
throw err;
}
return { mtimeMs: mtimes.get(p) ?? 0, size: 1 };
return { mtimeMs: mtimes.get(p) ?? 0, size: emptyFiles?.has(p) ? 0 : 1, isFile: () => true };
},
utimesSync: (p: string, _atime: Date, mtime: Date) => {
if (touchError) throw touchError;
@@ -463,7 +466,7 @@ describe("findBrowser — cache resolution", () => {
executablePath: redownloadedBinary,
source: "download",
});
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Cached binary missing"));
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Cached binary unusable"));
// The partial extract is replaced wholesale, not merged into (install() would
// otherwise throw "folder exists but exe missing", the bug both feedback reports hit).
expect(paths.has(staleLeftover)).toBe(false);
@@ -533,12 +536,44 @@ describe("findBrowser — cache resolution", () => {
}
});
it("installs again instead of reusing an empty cached executable", async () => {
const home = useRealCacheHome();
installPuppeteerBrowsersMock({
installImpl: async ({ cacheDir, buildId }) => writeStagedInstall(cacheDir, buildId, "fresh"),
});
try {
const { ensureBrowser } = await import("./manager.js");
const cached = await ensureBrowser({ force: true });
writeFileSync(cached.executablePath, "");
const { getInstalledBrowsers } = await import("@puppeteer/browsers");
vi.mocked(getInstalledBrowsers).mockResolvedValue([
{
browser: "chrome-headless-shell",
buildId: CHROME_VERSION,
platform: "linux",
executablePath: cached.executablePath,
} as never,
]);
vi.spyOn(console, "warn").mockImplementation(() => {});
const again = await ensureBrowser({ preferManagedChrome: true });
expect(again.source).toBe("download");
expect(readFileSync(again.executablePath, "utf8")).toBe("fresh");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
it("restores the previous version when the staged install cannot be moved in", async () => {
const home = useRealCacheHome();
let stageNothing = false;
installPuppeteerBrowsersMock({
installImpl: async ({ cacheDir, buildId }) => {
installImpl: async ({ cacheDir, buildId, logger }) => {
if (!stageNothing) return writeStagedInstall(cacheDir, buildId, "old");
logger?.("puppeteer:browsers:fileUtil")?.(
"tar.exe extraction failed: before PowerShell worked",
);
// A real executable passes the unzip check, but its version dir is gone, so only the move fails.
const elsewhere = writeStagedInstall(join(cacheDir, "elsewhere"), buildId, "new");
return {
@@ -552,7 +587,9 @@ describe("findBrowser — cache resolution", () => {
const liveBinary = (await ensureBrowser({ force: true })).executablePath;
stageNothing = true;
await expect(ensureBrowser({ force: true })).rejects.toThrow("HYPERFRAMES_BROWSER_PATH");
const failure = await ensureBrowser({ force: true }).catch((err: unknown) => String(err));
expect(failure).toContain("HYPERFRAMES_BROWSER_PATH");
expect(failure).not.toContain("Unzip errors");
expect(readFileSync(liveBinary, "utf8")).toBe("old");
} finally {
rmSync(home, { recursive: true, force: true });
@@ -560,15 +597,18 @@ describe("findBrowser — cache resolution", () => {
});
it.each([
{ label: "missing", unpacked: undefined, state: "is missing" },
{ label: "empty", unpacked: "", state: "is empty" },
{ label: "missing", unpacked: "none", state: "is missing" },
{ label: "empty", unpacked: "file", state: "is empty" },
{ label: "a folder", unpacked: "folder", state: "is not a file" },
])("fails instead of reporting a browser the unzip left $label", async ({ unpacked, state }) => {
const home = useRealCacheHome();
installPuppeteerBrowsersMock({
installImpl: async ({ cacheDir, buildId, logger }) => {
const staged = writeStagedInstall(cacheDir, buildId, "");
rmSync(staged.executablePath);
if (unpacked !== undefined) writeFileSync(staged.executablePath, unpacked);
if (unpacked === "file") writeFileSync(staged.executablePath, "");
if (unpacked === "folder")
mkdirSync(join(staged.executablePath, "inside"), { recursive: true });
logger?.("puppeteer:browsers:fileUtil")?.("tar.exe extraction failed: Error: boom");
return staged;
},
@@ -586,6 +626,30 @@ describe("findBrowser — cache resolution", () => {
}
});
it("names the unzip errors when every unzip tool failed", async () => {
const home = useRealCacheHome();
installPuppeteerBrowsersMock({
installImpl: async ({ logger }) => {
logger?.("puppeteer:browsers:fileUtil")?.("tar.exe extraction failed: Error: tar boom");
logger?.("puppeteer:browsers:fileUtil")?.(
"powershell.exe extraction failed: Error: ps boom",
);
throw new Error("Extraction failed: no zip archiver is available.");
},
});
try {
const { ensureBrowser } = await import("./manager.js");
const ensured = ensureBrowser({ force: true });
await expect(ensured).rejects.toThrow(
"no zip archiver is available. Unzip errors: tar.exe extraction failed: Error: tar boom; powershell.exe extraction failed: Error: ps boom",
);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
it("reports only the last attempt's unzip errors after a corrupt-archive retry", async () => {
const home = useRealCacheHome();
let attempt = 0;
@@ -949,6 +1013,20 @@ describe("findBrowser — cache resolution", () => {
},
);
it("skips an empty executable in the puppeteer cache", async () => {
installFsMocks({
existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY]),
dirs: { [PUPPETEER_CACHE]: ["linux-148.0.7778.97"] },
emptyFiles: new Set([PUPPETEER_BINARY]),
});
installPuppeteerBrowsersMock();
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result?.executablePath).not.toBe(PUPPETEER_BINARY);
});
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
+33 -19
View File
@@ -378,7 +378,7 @@ async function findFromHyperframesCache(): Promise<CacheLookupResult> {
b.buildId === managedChromeVersion() &&
b.platform === hostPlatform,
);
if (match && existsSync(match.executablePath)) {
if (match && isUsableExecutable(match.executablePath)) {
return { result: { executablePath: match.executablePath, source: "cache" } };
}
if (match) {
@@ -499,7 +499,7 @@ function findFromPuppeteerCache(): BrowserResult | undefined {
// keep them aligned. If puppeteer ever changes the on-disk layout the two
// need to move together.
const binary = join(PUPPETEER_CACHE_DIR, version, ...executable);
if (existsSync(binary)) {
if (isUsableExecutable(binary)) {
return { executablePath: binary, source: "cache" };
}
}
@@ -568,14 +568,14 @@ export async function findBrowser(): Promise<BrowserResult | undefined> {
if (fromCache.result) return fromCache.result;
if (fromCache.staleHyperframesCachePath) {
console.warn(
`[browser] Cached binary missing at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
`[browser] Cached binary unusable (missing, empty or not a file) at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
);
try {
return await withInstallLock(() => downloadBrowser());
} catch (err) {
const cause = normalizeErrorMessage(err);
throw new Error(
`Cached Chrome binary was missing at ${fromCache.staleHyperframesCachePath}, and re-download failed: ${cause}\n` +
`Cached Chrome binary was unusable at ${fromCache.staleHyperframesCachePath}, and re-download failed: ${cause}\n` +
`Run \`hyperframes browser ensure --force\` to re-download.`,
);
}
@@ -662,7 +662,7 @@ async function ensureBrowserInCurrentProcess(
if (fromCache.result) return fromCache.result;
if (fromCache.staleHyperframesCachePath) {
console.warn(
`[browser] Cached binary missing at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
`[browser] Cached binary unusable (missing, empty or not a file) at ${fromCache.staleHyperframesCachePath} — re-downloading...`,
);
return withInstallLock(() => downloadBrowser(options));
}
@@ -779,11 +779,15 @@ function browserPathHintForPlatform(): string {
return "/usr/bin/google-chrome";
}
function wrapDownloadFailureWithBrowserPathHint(cause: unknown): Error {
function wrapDownloadFailureWithBrowserPathHint(
cause: unknown,
unzipLog: readonly string[],
): Error {
const original = normalizeErrorMessage(cause);
const unzipErrors = unzipLog.length > 0 ? ` Unzip errors: ${unzipLog.join("; ")}` : "";
const example = browserPathHintForPlatform();
const message =
`Failed to install chrome-headless-shell ${managedChromeVersion()}: ${original}\n\n` +
`Failed to install chrome-headless-shell ${managedChromeVersion()}: ${original}${unzipErrors}\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 ` +
@@ -839,27 +843,37 @@ async function downloadBrowser(options?: EnsureBrowserOptions): Promise<BrowserR
`[hyperframes] Downloaded browser archive was corrupt (${normalizeErrorMessage(err)}); re-downloading.`,
),
);
assertExecutableUnpacked(stagingDir, staged.executablePath, unzipLog);
assertExecutableUnpacked(stagingDir, staged.executablePath);
unzipLog = [];
return { executablePath: moveStagedInstallIntoCache(stagingDir, staged), source: "download" };
} catch (err) {
throw wrapDownloadFailureWithBrowserPathHint(err);
throw wrapDownloadFailureWithBrowserPathHint(err, unzipLog);
} finally {
clearStaging();
}
}
function executableState(path: string): "usable" | "is missing" | "is empty" | "is not a file" {
const stat = statSync(path, { throwIfNoEntry: false });
if (!stat) return "is missing";
if (!stat.isFile()) return "is not a file";
return stat.size > 0 ? "usable" : "is empty";
}
function isUsableExecutable(path: string): boolean {
try {
return executableState(path) === "usable";
} catch {
return false;
}
}
// install() returns without checking that the unzip produced the executable.
function assertExecutableUnpacked(
stagingDir: string,
executablePath: string,
unzipLog: readonly string[],
): void {
const size = statSync(executablePath, { throwIfNoEntry: false })?.size;
if (size) return;
const state = size === 0 ? "is empty" : "is missing";
const log = unzipLog.length > 0 ? ` Unzip errors: ${unzipLog.join("; ")}` : "";
function assertExecutableUnpacked(stagingDir: string, executablePath: string): void {
const state = executableState(executablePath);
if (state === "usable") return;
throw new Error(
`the archive downloaded, but ${relative(stagingDir, executablePath)} ${state} after unzipping.${log}`,
`the archive downloaded, but ${relative(stagingDir, executablePath)} ${state} after unzipping.`,
);
}
+16 -2
View File
@@ -18,13 +18,15 @@ vi.mock("@clack/prompts", () => ({
}));
const ensureBrowser = vi.fn();
const findBrowser = vi.fn();
let linuxArm = false;
vi.mock("../browser/manager.js", () => ({
ensureBrowser: (...args: unknown[]) => ensureBrowser(...args),
findBrowser: vi.fn(),
findBrowser: (...args: unknown[]) => findBrowser(...args),
clearBrowser: vi.fn(),
managedChromeVersion: () => "152.0.7977.30",
CACHE_DIR: "/cache",
isLinuxArm: () => false,
isLinuxArm: () => linuxArm,
}));
vi.mock("../telemetry/events.js", () => ({ trackBrowserInstall: vi.fn() }));
@@ -44,6 +46,8 @@ describe("browser ensure", () => {
spinners.length = 0;
logError.mockReset();
ensureBrowser.mockReset();
findBrowser.mockReset();
linuxArm = false;
vi.spyOn(console, "log").mockImplementation(() => {});
});
@@ -70,4 +74,14 @@ describe("browser ensure", () => {
expect(spinners.length).toBeGreaterThan(0);
expect(spinners.filter((s) => s.running)).toEqual([]);
});
it("stops its spinner and fails when the Linux ARM64 lookup throws", async () => {
linuxArm = true;
findBrowser.mockRejectedValue(new Error("EACCES: permission denied"));
await expect(runEnsure(false)).rejects.toThrow("Command failed");
expect(logError).toHaveBeenCalledWith("EACCES: permission denied");
expect(spinners.filter((s) => s.running)).toEqual([]);
});
});
+10 -4
View File
@@ -23,6 +23,12 @@ import {
import { trackBrowserInstall } from "../telemetry/events.js";
import { normalizeErrorMessage } from "../utils/errorMessage.js";
function failSpinner(s: ReturnType<typeof clack.spinner>, label: string, err: unknown): never {
s.stop(c.error(label));
clack.log.error(normalizeErrorMessage(err));
failCommand(1, err);
}
function printBrowser(browser: BrowserResult): void {
console.log();
console.log(` ${c.dim("Source:")} ${c.bold(browser.source)}`);
@@ -38,7 +44,9 @@ async function runEnsure(options?: { force?: boolean }): Promise<void> {
if (isLinuxArm()) {
const s = clack.spinner();
s.start("Linux ARM64 detected — looking for system Chromium...");
const existing = await findBrowser();
const existing = await findBrowser().catch((err: unknown) =>
failSpinner(s, "Browser lookup failed", err),
);
if (existing) {
s.stop(c.success("System Chromium found"));
printBrowser(existing);
@@ -88,9 +96,7 @@ async function runEnsure(options?: { force?: boolean }): Promise<void> {
},
});
} catch (err) {
s.stop(c.error("Browser not available"));
clack.log.error(normalizeErrorMessage(err));
failCommand(1, err);
failSpinner(s, "Browser not available", err);
}
if (result.source === "download") trackBrowserInstall();
+5 -3
View File
@@ -66,15 +66,17 @@ describe("render setup worker failure line", () => {
"Failed to install chrome-headless-shell: missing.\n\n export HYPERFRAMES_BROWSER_PATH=x";
const failure = new Error(reason, { cause: new Error("tar.exe extraction failed") });
const stderr = [
"warning before",
renderSetupErrorLine(failure).trimEnd(),
`warning without a newline${renderSetupErrorLine(failure).trimEnd()}`,
"/worker.ts:20",
" throw error;",
"Error: Failed to install chrome-headless-shell: missing.",
" at downloadBrowser (manager.ts:845:11)",
].join("\n");
expect(renderSetupFailureFrom(stderr)).toEqual({ reason, earlierOutput: "warning before" });
expect(renderSetupFailureFrom(stderr)).toEqual({
reason,
earlierOutput: "warning without a newline",
});
});
it.each([
@@ -61,7 +61,7 @@ export function renderSetupResultFrom(stdout: string): unknown {
}
export function renderSetupErrorLine(error: unknown): string {
return prefixedLine(RENDER_SETUP_ERROR_PREFIX, normalizeErrorMessage(error));
return "\n" + prefixedLine(RENDER_SETUP_ERROR_PREFIX, normalizeErrorMessage(error));
}
export function renderSetupFailureFrom(
@@ -1,7 +1,7 @@
// fallow-ignore-file code-duplication
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { execFileSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -696,7 +696,7 @@ describe("resolveHeadlessShellPath", () => {
for (const [directory, executable] of candidates) {
const binary = join(cacheVersion, directory, executable);
mkdirSync(join(binary, ".."), { recursive: true });
writeFileSync(binary, "");
writeFileSync(binary, "shell");
}
const expectedBinary = join(cacheVersion, expectedDirectory, expectedExecutable);
@@ -737,7 +737,7 @@ describe("resolveHeadlessShellPath", () => {
] as const) {
const binary = join(cacheVersion, directory, executable);
mkdirSync(join(binary, ".."), { recursive: true });
writeFileSync(binary, "");
writeFileSync(binary, "shell");
}
const env = { ...process.env, HOME: home, USERPROFILE: home };
@@ -770,10 +770,10 @@ describe("resolveHeadlessShellPath", () => {
"chrome-headless-shell",
);
mkdirSync(join(binary, ".."), { recursive: true });
writeFileSync(binary, "");
writeFileSync(binary, "shell");
const olderBinary = binary.replace("linux-152.0.7928.2", "linux-99.0.1.1");
mkdirSync(join(olderBinary, ".."), { recursive: true });
writeFileSync(olderBinary, "");
writeFileSync(olderBinary, "shell");
// os.homedir() reads HOME on POSIX and USERPROFILE on Windows.
const env = { ...process.env, HOME: home, USERPROFILE: home };
@@ -787,6 +787,75 @@ describe("resolveHeadlessShellPath", () => {
}
});
it("skips a cached shell that is a folder or empty and uses the next older build", () => {
const home = mkdtempSync(join(tmpdir(), "hyperframes-engine-browser-empty-"));
try {
const shell = (version: string) =>
join(
home,
".cache",
"hyperframes",
"chrome",
"chrome-headless-shell",
`linux-${version}`,
"chrome-headless-shell-linux64",
"chrome-headless-shell",
);
mkdirSync(shell("153.0.7990.1"), { recursive: true });
for (const [version, content] of [
["152.0.7977.30", ""],
["150.0.7871.124", "shell"],
]) {
mkdirSync(join(shell(version), ".."), { recursive: true });
writeFileSync(shell(version), content);
}
const env = { ...process.env, HOME: home, USERPROFILE: home };
delete env.PRODUCER_HEADLESS_SHELL_PATH;
delete env.HYPERFRAMES_BROWSER_PATH;
const stdout = resolveHeadlessShellInSubprocess(env, { platform: "linux", arch: "x64" });
expect(stdout).toBe(shell("150.0.7871.124"));
} finally {
rmSync(home, { recursive: true, force: true });
}
});
// Permission bits do not stop root, and Windows ignores them.
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"keeps scanning past a cached build it cannot read",
() => {
const home = mkdtempSync(join(tmpdir(), "hyperframes-engine-browser-unreadable-"));
const shell = (version: string) =>
join(
home,
".cache",
"hyperframes",
"chrome",
"chrome-headless-shell",
`linux-${version}`,
"chrome-headless-shell-linux64",
"chrome-headless-shell",
);
const lockedDir = join(shell("152.0.7977.30"), "..");
try {
for (const version of ["152.0.7977.30", "150.0.7871.124"]) {
mkdirSync(join(shell(version), ".."), { recursive: true });
writeFileSync(shell(version), "shell");
}
chmodSync(lockedDir, 0o000);
const env = { ...process.env, HOME: home, USERPROFILE: home };
delete env.PRODUCER_HEADLESS_SHELL_PATH;
delete env.HYPERFRAMES_BROWSER_PATH;
const stdout = resolveHeadlessShellInSubprocess(env, { platform: "linux", arch: "x64" });
expect(stdout).toBe(shell("150.0.7871.124"));
} finally {
chmodSync(lockedDir, 0o755);
rmSync(home, { recursive: true, force: true });
}
},
);
it("skips managed-cache builds newer than Chrome 150 on macOS 12", () => {
const home = mkdtempSync(join(tmpdir(), "hyperframes-engine-browser-macos12-"));
try {
@@ -803,7 +872,7 @@ describe("resolveHeadlessShellPath", () => {
);
for (const version of ["152.0.7977.30", "150.0.7871.124"]) {
mkdirSync(join(shell(version), ".."), { recursive: true });
writeFileSync(shell(version), "");
writeFileSync(shell(version), "shell");
}
const env = { ...process.env, HOME: home, USERPROFILE: home };
delete env.PRODUCER_HEADLESS_SHELL_PATH;
+11 -2
View File
@@ -8,7 +8,7 @@
import type { Browser, Page, PuppeteerNode } from "puppeteer-core";
import { execSync } from "child_process";
import { existsSync, readdirSync } from "fs";
import { existsSync, readdirSync, statSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import { chromeMajorCeiling, exceedsChromeCeiling } from "./chromeHostCeiling.js";
@@ -168,6 +168,15 @@ function cachedHeadlessShellExecutable(
return CACHED_HEADLESS_SHELL_EXECUTABLES[`${hostPlatform}/${hostArch}`];
}
function isNonEmptyFile(path: string): boolean {
try {
const stat = statSync(path, { throwIfNoEntry: false });
return stat?.isFile() === true && stat.size > 0;
} catch {
return false;
}
}
function findCachedHeadlessShell(baseDir: string): string | undefined {
if (!existsSync(baseDir)) return undefined;
const executable = cachedHeadlessShellExecutable();
@@ -178,7 +187,7 @@ function findCachedHeadlessShell(baseDir: string): string | undefined {
for (const version of versions) {
if (exceedsChromeCeiling(version, ceiling)) continue;
const binary = join(baseDir, version, ...executable);
if (existsSync(binary)) return binary;
if (isNonEmptyFile(binary)) return binary;
}
} catch {
// Ignore unreadable cache directories and continue browser discovery.