mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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.
602 lines
25 KiB
TypeScript
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);
|
|
});
|
|
});
|