Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
100 changes: 98 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,60 @@ 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: "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-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("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
57 changes: 34 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,31 @@ 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/arm64": ["chrome-headless-shell-linux64", "chrome-headless-shell"],
"linux/x64": ["chrome-headless-shell-linux64", "chrome-headless-shell"],
"win32/arm64": ["chrome-headless-shell-win64", "chrome-headless-shell.exe"],
"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.
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 +427,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
72 changes: 71 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,76 @@ 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: "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-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("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 +499,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
34 changes: 25 additions & 9 deletions packages/engine/src/services/browserManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,20 +133,36 @@ 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/arm64": ["chrome-headless-shell-linux64", "chrome-headless-shell"],
"linux/x64": ["chrome-headless-shell-linux64", "chrome-headless-shell"],
"win32/arm64": ["chrome-headless-shell-win64", "chrome-headless-shell.exe"],
"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.
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.
Expand Down
Loading