fix(cli): re-download the browser when the cached archive is corrupt

A partially-downloaded or interrupted chrome-headless-shell archive left in the
cache makes @puppeteer/browsers' install() throw "invalid
end-of-central-directory" during extraction. That error propagated out of the
browser check and hard-blocked the render, forcing users onto the fallback
renderer until they manually cleared the cache — a recurring Windows failure.

Detect the corrupt-archive extraction error (isCorruptArchiveError), clear the
cache to drop the bad archive, and retry the download exactly once; non-corrupt
errors and a second corruption still propagate (no infinite retry). The pure
predicate and the recovery wrapper are unit-tested.
This commit is contained in:
Miguel Ángel
2026-07-07 17:10:06 -04:00
committed by GitHub
parent 4834de37f4
commit 92f3116dee
2 changed files with 128 additions and 7 deletions
+66
View File
@@ -501,3 +501,69 @@ describe("findBrowser — cache resolution", () => {
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);
});
});