diff --git a/packages/cli/src/browser/manager.test.ts b/packages/cli/src/browser/manager.test.ts index 7f166efb9..3e60d6bb7 100644 --- a/packages/cli/src/browser/manager.test.ts +++ b/packages/cli/src/browser/manager.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/browser/manager.ts b/packages/cli/src/browser/manager.ts index a2a345eb4..02a5437f9 100644 --- a/packages/cli/src/browser/manager.ts +++ b/packages/cli/src/browser/manager.ts @@ -513,6 +513,48 @@ export async function ensureBrowser(options?: EnsureBrowserOptions): Promise( + runInstall: () => Promise, + clearCache: () => void, + onRecover?: (err: unknown) => void, +): Promise { + try { + return await runInstall(); + } catch (err) { + if (!isCorruptArchiveError(err)) throw err; + onRecover?.(err); + clearCache(); + return await runInstall(); + } +} + async function downloadBrowser(options?: EnsureBrowserOptions): Promise { if (isLinuxArm()) { return ensureLinuxArmBrowser(options); @@ -525,13 +567,26 @@ async function downloadBrowser(options?: EnsureBrowserOptions): Promise + install({ + cacheDir: CACHE_DIR, + browser: Browser.CHROMEHEADLESSSHELL, + buildId: CHROME_VERSION, + platform, + 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.`, + ), + ); return { executablePath: installed.executablePath, source: "download" }; }