fix(cli): select host-compatible cached browser (#2861)

* fix(cli): select host-compatible cached browser

* test(engine): make browser cache fixture portable

* fix(browser): reject foreign ARM cache binaries
This commit is contained in:
Miguel Ángel
2026-07-29 20:51:01 +02:00
committed by GitHub
parent fdc5932897
commit 85f0c9d354
4 changed files with 314 additions and 35 deletions
+129 -2
View File
@@ -130,7 +130,9 @@ function installPuppeteerBrowsersMock(
executablePath: string;
path?: string;
buildId?: string;
platform?: string;
}>;
browserPlatform?: string;
installedInHfCacheError?: Error;
installResult?: { executablePath: string };
installImpl?: () => Promise<{ executablePath: string }>;
@@ -138,10 +140,15 @@ function installPuppeteerBrowsersMock(
) {
vi.doMock("@puppeteer/browsers", () => ({
Browser: { CHROMEHEADLESSSHELL: "chrome-headless-shell" },
detectBrowserPlatform: () => "linux",
detectBrowserPlatform: () => opts.browserPlatform ?? "linux",
getInstalledBrowsers: opts.installedInHfCacheError
? vi.fn().mockRejectedValue(opts.installedInHfCacheError)
: vi.fn().mockResolvedValue(opts.installedInHfCache ?? []),
: vi.fn().mockResolvedValue(
(opts.installedInHfCache ?? []).map((browser) => ({
platform: opts.browserPlatform ?? "linux",
...browser,
})),
),
install: vi
.fn()
.mockImplementation(
@@ -222,6 +229,41 @@ describe("findBrowser — cache resolution", () => {
expect(result).toEqual({ executablePath: SYSTEM_CHROME, source: "system" });
});
it("ignores a current-version HyperFrames cache entry for another platform", async () => {
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
Object.defineProperty(process, "arch", { value: "arm64", configurable: true });
const macArm64Binary = join(
HF_CACHE,
"chrome-headless-shell",
"mac_arm-131.0.6778.85",
"chrome-headless-shell-mac-arm64",
"chrome-headless-shell",
);
installFsMocks({ existing: new Set([HF_CACHE, HF_BINARY, macArm64Binary]) });
installPuppeteerBrowsersMock({
browserPlatform: "mac_arm",
installedInHfCache: [
{
browser: "chrome-headless-shell",
executablePath: HF_BINARY,
buildId: CHROME_VERSION,
platform: "linux",
},
{
browser: "chrome-headless-shell",
executablePath: macArm64Binary,
buildId: CHROME_VERSION,
platform: "mac_arm",
},
],
});
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result).toEqual({ executablePath: macArm64Binary, source: "cache" });
});
it("re-downloads when the hyperframes cache manifest points at a missing binary", async () => {
const redownloadedBinary = join(
HF_CACHE,
@@ -492,6 +534,91 @@ describe("findBrowser — cache resolution", () => {
expect(result).toEqual({ executablePath: PUPPETEER_BINARY, source: "cache" });
});
it.each([
{
hostPlatform: "darwin",
hostArch: "arm64",
expectedDirectory: "chrome-headless-shell-mac-arm64",
expectedExecutable: "chrome-headless-shell",
},
{
hostPlatform: "darwin",
hostArch: "x64",
expectedDirectory: "chrome-headless-shell-mac-x64",
expectedExecutable: "chrome-headless-shell",
},
{
hostPlatform: "linux",
hostArch: "x64",
expectedDirectory: "chrome-headless-shell-linux64",
expectedExecutable: "chrome-headless-shell",
},
{
hostPlatform: "win32",
hostArch: "ia32",
expectedDirectory: "chrome-headless-shell-win32",
expectedExecutable: "chrome-headless-shell.exe",
},
{
hostPlatform: "win32",
hostArch: "x64",
expectedDirectory: "chrome-headless-shell-win64",
expectedExecutable: "chrome-headless-shell.exe",
},
])(
"selects only the host-compatible cached shell on $hostPlatform/$hostArch when every platform is present",
async ({ hostPlatform, hostArch, expectedDirectory, expectedExecutable }) => {
Object.defineProperty(process, "platform", { value: hostPlatform, configurable: true });
Object.defineProperty(process, "arch", { value: hostArch, configurable: true });
const version = "host-148.0.7778.97";
const candidates = [
["chrome-headless-shell-linux64", "chrome-headless-shell"],
["chrome-headless-shell-mac-arm64", "chrome-headless-shell"],
["chrome-headless-shell-mac-x64", "chrome-headless-shell"],
["chrome-headless-shell-win32", "chrome-headless-shell.exe"],
["chrome-headless-shell-win64", "chrome-headless-shell.exe"],
] as const;
const binaries = candidates.map(([directory, executable]) =>
join(PUPPETEER_CACHE, version, directory, executable),
);
const expectedBinary = join(PUPPETEER_CACHE, version, expectedDirectory, expectedExecutable);
installFsMocks({
existing: new Set([PUPPETEER_CACHE, ...binaries]),
dirs: { [PUPPETEER_CACHE]: [version] },
});
installPuppeteerBrowsersMock();
const { findBrowser } = await import("./manager.js");
const result = await findBrowser();
expect(result).toEqual({ executablePath: expectedBinary, source: "cache" });
},
);
it.each([
{ hostPlatform: "linux", hostArch: "arm64" },
{ hostPlatform: "win32", hostArch: "arm64" },
])(
"does not select a foreign cached shell on unsupported $hostPlatform/$hostArch",
async ({ hostPlatform, hostArch }) => {
Object.defineProperty(process, "platform", { value: hostPlatform, configurable: true });
Object.defineProperty(process, "arch", { value: hostArch, configurable: true });
const version = "host-148.0.7778.97";
const binaries = [
join(PUPPETEER_CACHE, version, "chrome-headless-shell-linux64", "chrome-headless-shell"),
join(PUPPETEER_CACHE, version, "chrome-headless-shell-win64", "chrome-headless-shell.exe"),
];
installFsMocks({
existing: new Set([PUPPETEER_CACHE, ...binaries]),
dirs: { [PUPPETEER_CACHE]: [version] },
});
installPuppeteerBrowsersMock();
const { findBrowser } = await import("./manager.js");
await expect(findBrowser()).resolves.toBeUndefined();
},
);
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
+36 -23
View File
@@ -282,7 +282,7 @@ function findFromEnv(): BrowserResult | undefined {
*/
async function findFromHyperframesCache(): Promise<CacheLookupResult> {
if (!existsSync(CACHE_DIR)) return {};
const { Browser, getInstalledBrowsers } = await loadPuppeteerBrowsers();
const { Browser, detectBrowserPlatform, getInstalledBrowsers } = await loadPuppeteerBrowsers();
// A corrupt cache (stub file where a browser dir is expected, malformed
// metadata) makes getInstalledBrowsers throw. Treat that as "no cached
// browser" so resolution falls through to system/download instead of
@@ -302,9 +302,14 @@ async function findFromHyperframesCache(): Promise<CacheLookupResult> {
// an older hyperframes version (this pin has moved 131 → 151 → 152 across
// releases) must NOT satisfy resolution, or an upgrade silently keeps
// running whatever build happened to be cached instead of ever fetching
// the version this release actually needs (HF#2060 review).
// the version this release actually needs (HF#2060 review). Match platform
// as well so a shared/migrated cache cannot return a foreign executable.
const hostPlatform = detectBrowserPlatform();
const match = installed.find(
(b) => b.browser === Browser.CHROMEHEADLESSSHELL && b.buildId === CHROME_VERSION,
(b) =>
b.browser === Browser.CHROMEHEADLESSSHELL &&
b.buildId === CHROME_VERSION &&
b.platform === hostPlatform,
);
if (match && existsSync(match.executablePath)) {
return { result: { executablePath: match.executablePath, source: "cache" } };
@@ -383,8 +388,33 @@ function compareVersionDirsDescending(a: string, b: string): number {
return 0;
}
const CACHED_HEADLESS_SHELL_EXECUTABLES: Readonly<
Record<string, readonly [directory: string, executable: string]>
> = {
"darwin/arm64": ["chrome-headless-shell-mac-arm64", "chrome-headless-shell"],
"darwin/x64": ["chrome-headless-shell-mac-x64", "chrome-headless-shell"],
"linux/x64": ["chrome-headless-shell-linux64", "chrome-headless-shell"],
"win32/ia32": ["chrome-headless-shell-win32", "chrome-headless-shell.exe"],
"win32/x64": ["chrome-headless-shell-win64", "chrome-headless-shell.exe"],
};
function cachedHeadlessShellExecutable(
hostPlatform = process.platform,
hostArch = process.arch,
): readonly [directory: string, executable: string] | undefined {
// Chrome for Testing cache entries are host-specific. Resolve exactly one
// platform/architecture directory so a foreign binary cannot win by probe order.
// Chrome for Testing does not publish Linux ARM64 binaries. Windows ARM64 can
// emulate x64 only on supported Windows 11 builds, which this platform/arch-only
// resolver cannot prove, so both hosts deliberately fall through to system
// browser discovery instead of attempting a potentially foreign cached binary.
return CACHED_HEADLESS_SHELL_EXECUTABLES[`${hostPlatform}/${hostArch}`];
}
function findFromPuppeteerCache(): BrowserResult | undefined {
if (!existsSync(PUPPETEER_CACHE_DIR)) return undefined;
const executable = cachedHeadlessShellExecutable();
if (!executable) return undefined;
let versions: string[];
try {
// Numeric semver-style sort, newest first. Lexicographic `.sort().reverse()`
@@ -399,26 +429,9 @@ function findFromPuppeteerCache(): BrowserResult | undefined {
// Same shape as `resolveHeadlessShellPath` in engine/browserManager.ts —
// keep them aligned. If puppeteer ever changes the on-disk layout the two
// need to move together.
const candidates = [
join(PUPPETEER_CACHE_DIR, version, "chrome-headless-shell-linux64", "chrome-headless-shell"),
join(
PUPPETEER_CACHE_DIR,
version,
"chrome-headless-shell-mac-arm64",
"chrome-headless-shell",
),
join(PUPPETEER_CACHE_DIR, version, "chrome-headless-shell-mac-x64", "chrome-headless-shell"),
join(
PUPPETEER_CACHE_DIR,
version,
"chrome-headless-shell-win64",
"chrome-headless-shell.exe",
),
];
for (const binary of candidates) {
if (existsSync(binary)) {
return { executablePath: binary, source: "cache" };
}
const binary = join(PUPPETEER_CACHE_DIR, version, ...executable);
if (existsSync(binary)) {
return { executablePath: binary, source: "cache" };
}
}
return undefined;
@@ -401,6 +401,127 @@ describe("resolveHeadlessShellPath", () => {
}
});
it.each([
{
hostPlatform: "darwin",
hostArch: "arm64",
expectedDirectory: "chrome-headless-shell-mac-arm64",
expectedExecutable: "chrome-headless-shell",
},
{
hostPlatform: "darwin",
hostArch: "x64",
expectedDirectory: "chrome-headless-shell-mac-x64",
expectedExecutable: "chrome-headless-shell",
},
{
hostPlatform: "linux",
hostArch: "x64",
expectedDirectory: "chrome-headless-shell-linux64",
expectedExecutable: "chrome-headless-shell",
},
{
hostPlatform: "win32",
hostArch: "ia32",
expectedDirectory: "chrome-headless-shell-win32",
expectedExecutable: "chrome-headless-shell.exe",
},
{
hostPlatform: "win32",
hostArch: "x64",
expectedDirectory: "chrome-headless-shell-win64",
expectedExecutable: "chrome-headless-shell.exe",
},
])(
"selects only the host-compatible cached shell on $hostPlatform/$hostArch when every platform is present",
({ hostPlatform, hostArch, expectedDirectory, expectedExecutable }) => {
const home = mkdtempSync(join(tmpdir(), "hyperframes-engine-browser-platform-"));
try {
const cacheVersion = join(
home,
".cache",
"puppeteer",
"chrome-headless-shell",
"host-152.0.7928.2",
);
const candidates = [
["chrome-headless-shell-linux64", "chrome-headless-shell"],
["chrome-headless-shell-mac-arm64", "chrome-headless-shell"],
["chrome-headless-shell-mac-x64", "chrome-headless-shell"],
["chrome-headless-shell-win32", "chrome-headless-shell.exe"],
["chrome-headless-shell-win64", "chrome-headless-shell.exe"],
] as const;
for (const [directory, executable] of candidates) {
const binary = join(cacheVersion, directory, executable);
mkdirSync(join(binary, ".."), { recursive: true });
writeFileSync(binary, "");
}
const expectedBinary = join(cacheVersion, expectedDirectory, expectedExecutable);
const env = { ...process.env, HOME: home, USERPROFILE: home };
delete env.PRODUCER_HEADLESS_SHELL_PATH;
delete env.HYPERFRAMES_BROWSER_PATH;
const moduleUrl = new URL("./browserManager.ts", import.meta.url).href;
const stdout = execFileSync(
"bun",
[
"--eval",
`Object.defineProperty(process, "platform", { value: ${JSON.stringify(hostPlatform)} }); Object.defineProperty(process, "arch", { value: ${JSON.stringify(hostArch)} }); import(${JSON.stringify(moduleUrl)}).then(({ resolveHeadlessShellPath }) => process.stdout.write(resolveHeadlessShellPath({}) ?? ""))`,
],
{ encoding: "utf8", env },
);
expect(stdout).toBe(expectedBinary);
} finally {
rmSync(home, { recursive: true, force: true });
}
},
);
it.each([
{ hostPlatform: "linux", hostArch: "arm64" },
{ hostPlatform: "win32", hostArch: "arm64" },
])(
"does not select a foreign cached shell on unsupported $hostPlatform/$hostArch",
({ hostPlatform, hostArch }) => {
const home = mkdtempSync(join(tmpdir(), "hyperframes-engine-browser-unsupported-"));
try {
const cacheVersion = join(
home,
".cache",
"puppeteer",
"chrome-headless-shell",
"host-152.0.7928.2",
);
for (const [directory, executable] of [
["chrome-headless-shell-linux64", "chrome-headless-shell"],
["chrome-headless-shell-win64", "chrome-headless-shell.exe"],
] as const) {
const binary = join(cacheVersion, directory, executable);
mkdirSync(join(binary, ".."), { recursive: true });
writeFileSync(binary, "");
}
const env = { ...process.env, HOME: home, USERPROFILE: home };
delete env.PRODUCER_HEADLESS_SHELL_PATH;
delete env.HYPERFRAMES_BROWSER_PATH;
const moduleUrl = new URL("./browserManager.ts", import.meta.url).href;
const stdout = execFileSync(
"bun",
[
"--eval",
`Object.defineProperty(process, "platform", { value: ${JSON.stringify(hostPlatform)} }); Object.defineProperty(process, "arch", { value: ${JSON.stringify(hostArch)} }); import(${JSON.stringify(moduleUrl)}).then(({ resolveHeadlessShellPath }) => process.stdout.write(resolveHeadlessShellPath({}) ?? ""))`,
],
{ encoding: "utf8", env },
);
expect(stdout).toBe("");
} finally {
rmSync(home, { recursive: true, force: true });
}
},
);
it("reuses chrome-headless-shell from the HyperFrames-managed cache", () => {
const home = mkdtempSync(join(tmpdir(), "hyperframes-engine-browser-cache-"));
try {
@@ -429,7 +550,7 @@ describe("resolveHeadlessShellPath", () => {
"bun",
[
"--eval",
`import(${JSON.stringify(moduleUrl)}).then(({ resolveHeadlessShellPath }) => process.stdout.write(resolveHeadlessShellPath({}) ?? ""))`,
`Object.defineProperty(process, "platform", { value: "linux" }); Object.defineProperty(process, "arch", { value: "x64" }); import(${JSON.stringify(moduleUrl)}).then(({ resolveHeadlessShellPath }) => process.stdout.write(resolveHeadlessShellPath({}) ?? ""))`,
],
{ encoding: "utf8", env },
);
+27 -9
View File
@@ -133,20 +133,38 @@ function compareBrowserVersionsDescending(left: string, right: string): number {
return 0;
}
const CACHED_HEADLESS_SHELL_EXECUTABLES: Readonly<
Record<string, readonly [directory: string, executable: string]>
> = {
"darwin/arm64": ["chrome-headless-shell-mac-arm64", "chrome-headless-shell"],
"darwin/x64": ["chrome-headless-shell-mac-x64", "chrome-headless-shell"],
"linux/x64": ["chrome-headless-shell-linux64", "chrome-headless-shell"],
"win32/ia32": ["chrome-headless-shell-win32", "chrome-headless-shell.exe"],
"win32/x64": ["chrome-headless-shell-win64", "chrome-headless-shell.exe"],
};
function cachedHeadlessShellExecutable(
hostPlatform = process.platform,
hostArch = process.arch,
): readonly [directory: string, executable: string] | undefined {
// Chrome for Testing cache entries are host-specific. Resolve exactly one
// platform/architecture directory so a foreign binary cannot win by probe order.
// Chrome for Testing does not publish Linux ARM64 binaries. Windows ARM64 can
// emulate x64 only on supported Windows 11 builds, which this platform/arch-only
// resolver cannot prove, so both hosts deliberately fall through to system
// browser discovery instead of attempting a potentially foreign cached binary.
return CACHED_HEADLESS_SHELL_EXECUTABLES[`${hostPlatform}/${hostArch}`];
}
function findCachedHeadlessShell(baseDir: string): string | undefined {
if (!existsSync(baseDir)) return undefined;
const executable = cachedHeadlessShellExecutable();
if (!executable) return undefined;
try {
const versions = readdirSync(baseDir).sort(compareBrowserVersionsDescending);
for (const version of versions) {
const candidates = [
join(baseDir, version, "chrome-headless-shell-linux64", "chrome-headless-shell"),
join(baseDir, version, "chrome-headless-shell-mac-arm64", "chrome-headless-shell"),
join(baseDir, version, "chrome-headless-shell-mac-x64", "chrome-headless-shell"),
join(baseDir, version, "chrome-headless-shell-win64", "chrome-headless-shell.exe"),
];
for (const binary of candidates) {
if (existsSync(binary)) return binary;
}
const binary = join(baseDir, version, ...executable);
if (existsSync(binary)) return binary;
}
} catch {
// Ignore unreadable cache directories and continue browser discovery.