Files
hyperframes/packages/cli/src/browser/manager.test.ts
T
Vance Ingalls df1d20b765 fix(cli): check buildId when resolving the managed Chrome cache
Address the highest-severity max-effort code-review finding on the now-
merged #2082 (the drawElement Chrome-version-pin fix): findFromHyperframesCache
matched a cached Chrome by browser type only, never comparing its
buildId against CHROME_VERSION. Any machine that already rendered with
an older hyperframes version has an old build (this pin has moved
131 -> 151 -> 152 across releases) sitting in ~/.cache/hyperframes/chrome,
which satisfied the lookup and silently defeated the whole point of
#2082's version bump for exactly the population it was meant to fix —
drawElement's new capability probe would then permanently and silently
fall back to screenshot capture instead of ever fetching a build that
implements canvas.drawElementImage.

Verified directly (not just via review): seeded ~/.cache/hyperframes/chrome
with the old 131 build, confirmed a real render previously kept using it
forever; with this fix it's correctly ignored and 152 is downloaded.
New regression test locks in the buildId mismatch case.
2026-07-08 16:10:43 -07:00

602 lines
25 KiB
TypeScript

// fallow-ignore-file code-duplication
/**
* Browser-binary resolution tests for `findBrowser()`.
*
* The CLI's `ensureBrowser` is responsible for picking the Chrome binary the
* engine will be launched with. There are two real-world failure modes this
* suite guards against:
*
* 1. `chrome-headless-shell` is installed in the puppeteer cache (the
* directory the engine itself reads), but the CLI used to only scan its
* own `~/.cache/hyperframes/chrome` cache — leaving the engine without a
* headless-shell binary and silently disabling the BeginFrame capture
* path.
* 2. The CLI falls back to system Chrome (`/usr/bin/google-chrome`) on
* Linux, which still launches successfully but has dropped
* `HeadlessExperimental.enable` — again disabling the BeginFrame path
* with no user-visible signal.
*
* Each test stubs filesystem + `@puppeteer/browsers` access using `vi.doMock`
* + dynamic import (the same pattern other modules in this package use, e.g.
* `background-removal/manager.test.ts`) so we don't touch the real
* `HOME` cache.
*/
import { join, sep } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CHROME_VERSION } from "./manager.js";
// Use `path.join` so the fake paths line up with whatever separator Node's
// real `path.join` produces in `manager.ts` on the host running the test
// (forward slashes on Linux/macOS, backslashes on Windows CI). Hardcoded
// `/fake/home/...` literals would fail on Windows because the set lookup
// would never match the `\\`-joined real paths.
const FAKE_HOME = join("/", "fake", "home");
const CACHE_ROOT = join(FAKE_HOME, ".cache", "hyperframes");
const HF_CACHE = join(FAKE_HOME, ".cache", "hyperframes", "chrome");
const HF_LOCK = join(CACHE_ROOT, ".chrome.install.lock");
const HF_RECLAIM_LOCK = join(CACHE_ROOT, ".chrome.install.reclaim.lock");
const PUPPETEER_CACHE = join(FAKE_HOME, ".cache", "puppeteer", "chrome-headless-shell");
const PUPPETEER_BINARY = join(
PUPPETEER_CACHE,
"linux-148.0.7778.97",
"chrome-headless-shell-linux64",
"chrome-headless-shell",
);
const HF_BINARY = join(
HF_CACHE,
"chrome-headless-shell",
"linux-131.0.6778.85",
"chrome-headless-shell-linux64",
"chrome-headless-shell",
);
const SYSTEM_CHROME = "/usr/bin/google-chrome";
interface FsMockOptions {
existing: ReadonlySet<string>;
/** map of dir path -> entries returned by readdirSync */
dirs?: Record<string, string[]>;
}
function installFsMocks({ existing, dirs }: 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.
const paths = new Set(existing);
const mtimes = new Map([...existing].map((p) => [p, 0]));
vi.doMock("node:fs", () => ({
existsSync: (p: string) => paths.has(p),
readdirSync: (p: string) => {
const entries = dirs?.[p];
if (!entries) throw new Error(`ENOENT: readdirSync mock had no entry for ${p}`);
return entries;
},
mkdirSync: (p: string, opts?: { recursive?: boolean }) => {
if (!opts?.recursive && paths.has(p)) {
const err = new Error(`EEXIST: file already exists, mkdir '${p}'`);
(err as NodeJS.ErrnoException).code = "EEXIST";
throw err;
}
paths.add(p);
mtimes.set(p, Date.now());
},
rmSync: (p: string) => {
// Real rmSync({recursive:true}) removes the target AND everything under
// it; the mock's flat path Set has no real tree structure, so simulate
// that by also dropping any tracked path nested under `p`.
for (const existingPath of [...paths]) {
if (existingPath === p || existingPath.startsWith(p + sep)) {
paths.delete(existingPath);
mtimes.delete(existingPath);
}
}
},
statSync: (p: string) => {
if (!paths.has(p)) {
const err = new Error(`ENOENT: no such file or directory, stat '${p}'`);
(err as NodeJS.ErrnoException).code = "ENOENT";
throw err;
}
return { mtimeMs: mtimes.get(p) ?? 0 };
},
}));
vi.doMock("node:os", () => ({
homedir: () => FAKE_HOME,
platform: () => "linux",
arch: () => "x64",
}));
return paths;
}
function installPuppeteerBrowsersMock(
opts: {
installedInHfCache?: Array<{
browser: string;
executablePath: string;
path?: string;
buildId?: string;
}>;
installedInHfCacheError?: Error;
installResult?: { executablePath: string };
installImpl?: () => Promise<{ executablePath: string }>;
} = {},
) {
vi.doMock("@puppeteer/browsers", () => ({
Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" },
detectBrowserPlatform: () => "linux",
getInstalledBrowsers: opts.installedInHfCacheError
? vi.fn().mockRejectedValue(opts.installedInHfCacheError)
: vi.fn().mockResolvedValue(opts.installedInHfCache ?? []),
install: vi
.fn()
.mockImplementation(
opts.installImpl ?? (async () => opts.installResult ?? { executablePath: HF_BINARY }),
),
}));
}
function installChildProcessMocks() {
vi.doMock("node:child_process", () => ({
execSync: vi.fn(() => {
throw new Error("not found");
}),
spawnSync: vi.fn(),
}));
}
describe("findBrowser — cache resolution", () => {
const origPlatform = process.platform;
const origArch = process.arch;
beforeEach(() => {
vi.resetModules();
// Force Linux for the system-fallback warning assertions. The
// `Object.defineProperty` dance is needed because `process.platform` is a
// getter on Node — direct assignment is silently a no-op.
Object.defineProperty(process, "platform", { value: "linux", configurable: true });
Object.defineProperty(process, "arch", { value: "x64", configurable: true });
delete process.env["HYPERFRAMES_BROWSER_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("resolves to the hyperframes-managed cache when puppeteer cache is empty", async () => {
// Only HF cache populated. Puppeteer cache is the higher-priority path
// (see "prefers puppeteer cache" test below), so this exercises the
// last-resort fallback.
installFsMocks({ existing: new Set([HF_CACHE, HF_BINARY]) });
installPuppeteerBrowsersMock({
installedInHfCache: [
{ browser: "chrome-headless-shell", executablePath: HF_BINARY, buildId: CHROME_VERSION },
],
});
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result).toEqual({ executablePath: HF_BINARY, source: "cache" });
});
it("does not resolve to a hyperframes-cache build from an older CHROME_VERSION pin", async () => {
// A build downloaded by a prior hyperframes version (this pin has moved
// 131 -> 151 -> 152 across releases) must not satisfy resolution, or an
// upgrade silently keeps running a stale build forever instead of ever
// fetching the version the new release actually needs (HF#2060 review).
installFsMocks({ existing: new Set([HF_CACHE, HF_BINARY, SYSTEM_CHROME]) });
installPuppeteerBrowsersMock({
installedInHfCache: [
{ browser: "chrome-headless-shell", executablePath: HF_BINARY, buildId: "131.0.6778.85" },
],
});
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result?.executablePath).not.toBe(HF_BINARY);
expect(result).toEqual({ executablePath: SYSTEM_CHROME, source: "system" });
});
it("re-downloads when the hyperframes cache manifest points at a missing binary", async () => {
const redownloadedBinary = join(
HF_CACHE,
"chrome-headless-shell",
"linux-131.0.6778.85",
"chrome-headless-shell-linux64",
"redownloaded-chrome-headless-shell",
);
const staleInstallDir = join(HF_CACHE, "chrome-headless-shell", "linux-131.0.6778.85");
// The stale install DIR is present (extraction got partway through, e.g. an
// ABOUT/LICENSE-only extract) even though the exe itself is missing —
// exercises the purge-before-redownload fix, not just the redownload path.
const paths = installFsMocks({ existing: new Set([HF_CACHE, staleInstallDir]) });
installPuppeteerBrowsersMock({
installedInHfCache: [
{
browser: "chrome-headless-shell",
executablePath: HF_BINARY,
path: staleInstallDir,
buildId: CHROME_VERSION,
},
],
installResult: { executablePath: redownloadedBinary },
});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result).toEqual({ executablePath: redownloadedBinary, source: "download" });
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("Cached binary missing"));
// The stale directory must be gone before @puppeteer/browsers' install()
// sees it — otherwise install() throws "folder exists but exe missing"
// instead of re-extracting (the exact bug both feedback reports hit).
expect(paths.has(staleInstallDir)).toBe(false);
});
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");
// A HEALTHY cached binary AND system Chrome are both present — force must
// ignore both shortcuts and always re-download, which is the whole point
// of the flag (the reported bug: --force did nothing, a stale dir kept
// winning over every retry).
const paths = installFsMocks({
existing: new Set([HF_CACHE, HF_BINARY, staleInstallDir, SYSTEM_CHROME]),
});
installPuppeteerBrowsersMock({
installedInHfCache: [
{ browser: "chrome-headless-shell", executablePath: HF_BINARY, path: staleInstallDir },
],
installResult: { executablePath: downloadedBinary },
});
const { ensureBrowser } = await import("./manager.js");
const result = await ensureBrowser({ force: true });
expect(result).toEqual({ executablePath: downloadedBinary, source: "download" });
// clearBrowser() wipes prior contents; withInstallLock uses a sibling lock
// outside CACHE_DIR, so assert the purge on what was actually INSIDE it,
// not the directory's own existence.
expect(paths.has(staleInstallDir)).toBe(false);
expect(paths.has(HF_BINARY)).toBe(false);
});
it("serializes concurrent force downloads so one purge cannot delete another installer's lock", async () => {
const downloadedBinary = join(HF_CACHE, "chrome-headless-shell", "force-downloaded");
const paths = installFsMocks({ existing: new Set([CACHE_ROOT, HF_CACHE, HF_BINARY]) });
let activeInstalls = 0;
let maxActiveInstalls = 0;
installPuppeteerBrowsersMock({
installedInHfCache: [{ browser: "chrome-headless-shell", executablePath: HF_BINARY }],
installImpl: async () => {
activeInstalls += 1;
maxActiveInstalls = Math.max(maxActiveInstalls, activeInstalls);
expect(paths.has(HF_LOCK)).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 20));
activeInstalls -= 1;
return { executablePath: downloadedBinary };
},
});
const { ensureBrowser } = await import("./manager.js");
await expect(
Promise.all([ensureBrowser({ force: true }), ensureBrowser({ force: true })]),
).resolves.toEqual([
{ executablePath: downloadedBinary, source: "download" },
{ executablePath: downloadedBinary, source: "download" },
]);
expect(maxActiveInstalls).toBe(1);
expect(paths.has(HF_LOCK)).toBe(false);
});
it("ensureBrowser does not leak the install lock directory after a successful download", async () => {
// Regression: @puppeteer/browsers' install() has no concurrency guard —
// two CLI invocations that both miss the cache AND system Chrome (the
// reported scenario: two `hyperframes browser ensure` runs racing) hit
// ensureBrowser's final download-of-last-resort at once, racing on the
// same extract target. mkdirSync as an atomic mutex closes that race;
// this asserts the lock is actually released afterward (a leaked lock
// would permanently wedge every future render on this machine).
const downloadedBinary = join(HF_CACHE, "chrome-headless-shell", "downloaded");
// Cache dir exists but is empty (no manifest entries) — distinct from the
// ENOTDIR "cache unreadable" case, which falls back to system instead.
const paths = installFsMocks({ existing: new Set([HF_CACHE]) });
installPuppeteerBrowsersMock({
installedInHfCache: [],
installResult: { executablePath: downloadedBinary },
});
const { ensureBrowser } = await import("./manager.js");
const result = await ensureBrowser();
expect(result).toEqual({ executablePath: downloadedBinary, source: "download" });
expect(paths.has(HF_LOCK)).toBe(false);
});
it("withInstallLock reclaims a lock held past the timeout instead of hanging forever", async () => {
// A crashed/killed process could leave the lock directory behind
// permanently. Reclaiming after a timeout (rather than hanging or
// refusing forever) is the behavior that makes the lock safe to add at
// all — otherwise one bad exit wedges every future render. Exercises
// withInstallLock directly with tiny real timeouts (it takes an
// injectable timeoutMs/pollMs for exactly this) rather than mocking
// Date.now()/setTimeout through the full ensureBrowser call graph.
const paths = installFsMocks({ existing: new Set([CACHE_ROOT, HF_LOCK]) });
const { withInstallLock } = await import("./manager.js");
const result = await withInstallLock(async () => "done", 10, 5);
expect(result).toBe("done");
expect(paths.has(HF_LOCK)).toBe(false);
});
it("withInstallLock does not reclaim another waiter's fresh lock after this waiter timed out", async () => {
// Regression guard for the timeout-reclaim race: if multiple waiters cross
// the stale-lock deadline together, waiter A can reclaim the stale lock and
// acquire a fresh one. Waiter B's old deadline is still expired, but it must
// not delete A's fresh lock.
const paths = installFsMocks({ existing: new Set([CACHE_ROOT, HF_LOCK]) });
const { withInstallLock } = await import("./manager.js");
const first = withInstallLock(
async () => {
await new Promise((resolve) => setTimeout(resolve, 30));
return "first";
},
10,
5,
);
const second = withInstallLock(async () => "second", 10, 5);
await expect(Promise.all([first, second])).resolves.toEqual(["first", "second"]);
expect(paths.has(HF_LOCK)).toBe(false);
expect(paths.has(HF_RECLAIM_LOCK)).toBe(false);
});
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.
installFsMocks({
existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY]),
dirs: { [PUPPETEER_CACHE]: ["linux-148.0.7778.97"] },
});
installPuppeteerBrowsersMock();
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result).toEqual({ executablePath: PUPPETEER_BINARY, source: "cache" });
});
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
// puppeteer cache and selects newest-version-first; if the CLI handed
// engine the older HF-cache binary while a newer puppeteer-cache binary
// exists, the two would silently disagree on which binary to use.
// This test pins the priority: puppeteer cache wins when both are populated.
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 }],
});
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result?.executablePath).toBe(PUPPETEER_BINARY);
expect(result?.source).toBe("cache");
});
it("picks the newest version when multiple chrome-headless-shell builds are cached", async () => {
const olderBinary = join(
PUPPETEER_CACHE,
"linux-131.0.6778.85",
"chrome-headless-shell-linux64",
"chrome-headless-shell",
);
installFsMocks({
existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY, olderBinary]),
dirs: { [PUPPETEER_CACHE]: ["linux-131.0.6778.85", "linux-148.0.7778.97"] },
});
installPuppeteerBrowsersMock();
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result?.executablePath).toBe(PUPPETEER_BINARY);
});
it("uses numeric (not lexicographic) version ordering — linux-148 beats linux-99", async () => {
// Regression guard for the lexicographic-sort bug: `"linux-99..."` sorts
// after `"linux-148..."` character-by-character (because `'9' > '1'`),
// which would have caused the CLI to hand engine an ancient 99-era binary
// when a fresh 148 was sitting right next to it. Numeric semver-style
// ordering is the only correct semantic.
const linux99Binary = join(
PUPPETEER_CACHE,
"linux-99.0.6533.123",
"chrome-headless-shell-linux64",
"chrome-headless-shell",
);
installFsMocks({
existing: new Set([PUPPETEER_CACHE, PUPPETEER_BINARY, linux99Binary]),
// Intentionally list the entries in an order that would expose the bug
// under naive `.sort().reverse()` (which puts `linux-99...` first).
dirs: { [PUPPETEER_CACHE]: ["linux-99.0.6533.123", "linux-148.0.7778.97"] },
});
installPuppeteerBrowsersMock();
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result?.executablePath).toBe(PUPPETEER_BINARY);
});
it("falls back to system Chrome and warns on Linux when no cache has headless-shell", async () => {
installFsMocks({ existing: new Set([SYSTEM_CHROME]) });
installPuppeteerBrowsersMock();
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).toHaveBeenCalledTimes(1);
const message = warnSpy.mock.calls[0]?.[0];
expect(message).toContain(SYSTEM_CHROME);
expect(message).toContain("HeadlessExperimental");
expect(message).toContain("chrome-headless-shell");
});
it("does NOT warn when the system path happens to be chrome-headless-shell", async () => {
// HYPERFRAMES_BROWSER_PATH-style override pointing directly at a
// headless-shell binary should NOT trigger the system-Chrome warning. The
// warning is gated on the binary name, not the path source.
const directShell = "/opt/chrome-headless-shell/chrome-headless-shell";
installFsMocks({ existing: new Set([directShell]) });
installPuppeteerBrowsersMock();
process.env["HYPERFRAMES_BROWSER_PATH"] = directShell;
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js");
_resetSystemFallbackWarnForTests();
const result = await findBrowser();
expect(result?.executablePath).toBe(directShell);
expect(warnSpy).not.toHaveBeenCalled();
});
it("does NOT warn on macOS when falling back to system Chrome", async () => {
// macOS Chrome still works fine for the screenshot path and the perf
// claims around BeginFrame are Linux-only — keep the warning Linux-scoped
// so darwin users don't get spammed about a "fix" that doesn't apply.
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
const darwinChrome = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
installFsMocks({ existing: new Set([darwinChrome]) });
vi.doMock("@puppeteer/browsers", () => ({
Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" },
detectBrowserPlatform: () => "mac_arm",
getInstalledBrowsers: vi.fn().mockResolvedValue([]),
install: vi.fn(),
}));
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js");
_resetSystemFallbackWarnForTests();
const result = await findBrowser();
expect(result?.executablePath).toBe(darwinChrome);
expect(warnSpy).not.toHaveBeenCalled();
});
it("only warns once across repeated findBrowser() calls", async () => {
installFsMocks({ existing: new Set([SYSTEM_CHROME]) });
installPuppeteerBrowsersMock();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { findBrowser, _resetSystemFallbackWarnForTests } = await import("./manager.js");
_resetSystemFallbackWarnForTests();
await findBrowser();
await findBrowser();
await findBrowser();
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);
});
});