Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 129 additions & 2 deletions packages/cli/src/browser/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,18 +130,25 @@ function installPuppeteerBrowsersMock(
executablePath: string;
path?: string;
buildId?: string;
platform?: string;
}>;
browserPlatform?: string;
installedInHfCacheError?: Error;
installResult?: { executablePath: string };
installImpl?: () => Promise<{ executablePath: string }>;
} = {},
) {
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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
59 changes: 36 additions & 23 deletions packages/cli/src/browser/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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" } };
Expand Down Expand Up @@ -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()`
Expand All @@ -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;
Expand Down
123 changes: 122 additions & 1 deletion packages/engine/src/services/browserManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 },
);
Expand Down
Loading
Loading