mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-12 23:29:50 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user