Files
hyperframes/packages/engine/src/services/browserManager.test.ts
T
Miguel Ángel f16c23cd99 fix(engine): a slow first Chrome start no longer drops a render to screenshot capture (#6420)
The beginFrame probe now tells a timeout from a real rejection.

A timeout relaunches Chrome and probes once more with a longer budget.

An unsupported answer still falls back at once.
2026-10-26 13:49:57 +00:00

1810 lines
69 KiB
TypeScript

// fallow-ignore-file code-duplication
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { execFileSync } from "node:child_process";
import { chmodSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Browser, Page, PuppeteerNode } from "puppeteer-core";
import type { CaptureMode } from "./browserLeasePool.js";
import { managedChromeVersion } from "./chromeHostCeiling.js";
import { resolveConfig } from "../config.js";
import {
_resetAutoBrowserGpuModeCacheForTests,
_resetBrowserPoolForTests,
_closeBrowserAfterFailedProbeForTests,
_createBrowserLaunchFingerprintForTests,
_probeBeginFrameSupportForTests,
_setPuppeteerForTests,
acquireBrowser,
buildChromeArgs,
compositionRequiresWebGpu,
assertWebGpuAdapterAvailable,
usesSoftwareWebGpu,
WebGpuUnavailableError,
drainBrowserPool,
forceReleaseBrowser,
releaseBrowser,
resolveHeadlessShellPath,
resolveBrowserGpuMode,
resolveFullChromePath,
} from "./browserManager.js";
describe("BeginFrame capability probe", () => {
it("waits for a document and validates a PNG-returning frame", async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const send = vi
.fn()
.mockResolvedValueOnce({})
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ hasDamage: true, screenshotData: png.toString("base64") });
const detach = vi.fn().mockResolvedValue(undefined);
const goto = vi.fn().mockResolvedValue(null);
const close = vi.fn().mockResolvedValue(undefined);
const browser = {
newPage: vi.fn().mockResolvedValue({
goto,
createCDPSession: vi.fn().mockResolvedValue({ send, detach }),
close,
}),
} as unknown as Browser;
const result = await _probeBeginFrameSupportForTests(browser);
expect(result.supported).toBe(true);
expect(goto).toHaveBeenCalledWith(expect.stringContaining("hf-beginframe-probe"), {
waitUntil: "domcontentloaded",
timeout: 2000,
});
expect(send.mock.calls.map(([method]) => method)).toEqual([
"HeadlessExperimental.enable",
"HeadlessExperimental.beginFrame",
"HeadlessExperimental.beginFrame",
]);
expect(detach).toHaveBeenCalledOnce();
expect(close).toHaveBeenCalledOnce();
});
it("reports an empty screenshot as unsupported", async () => {
const send = vi.fn().mockResolvedValue({ hasDamage: false });
const browser = {
newPage: vi.fn().mockResolvedValue({
goto: vi.fn().mockResolvedValue(null),
createCDPSession: vi.fn().mockResolvedValue({
send,
detach: vi.fn().mockResolvedValue(undefined),
}),
close: vi.fn().mockResolvedValue(undefined),
}),
} as unknown as Browser;
const result = await _probeBeginFrameSupportForTests(browser, 120);
expect(result.supported).toBe(false);
expect(result.detail).toMatch(/returned 0 bytes after \d+ attempts in \d+ms/);
expect(result.timedOut).toBe(true);
});
it("reports frames that are not a PNG as unsupported, not as a slow start", async () => {
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0, 0]);
const send = vi.fn().mockResolvedValue({ screenshotData: jpeg.toString("base64") });
const browser = {
newPage: vi.fn().mockResolvedValue({
goto: vi.fn().mockResolvedValue(null),
createCDPSession: vi.fn().mockResolvedValue({
send,
detach: vi.fn().mockResolvedValue(undefined),
}),
close: vi.fn().mockResolvedValue(undefined),
}),
} as unknown as Browser;
const result = await _probeBeginFrameSupportForTests(browser, 120);
expect(result.supported).toBe(false);
expect(result.timedOut).toBe(false);
});
it("keeps trying a renderer that has not drawn its first surface yet, up to the probe deadline", async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
let screenshotCalls = 0;
const send = vi
.fn()
.mockImplementation(async (_method: string, params?: { screenshot?: unknown }) => {
if (!params?.screenshot) return {};
screenshotCalls += 1;
return screenshotCalls <= 25
? { hasDamage: false }
: { screenshotData: png.toString("base64") };
});
const browser = {
newPage: vi.fn().mockResolvedValue({
goto: vi.fn().mockResolvedValue(null),
createCDPSession: vi.fn().mockResolvedValue({
send,
detach: vi.fn().mockResolvedValue(undefined),
}),
close: vi.fn().mockResolvedValue(undefined),
}),
} as unknown as Browser;
const result = await _probeBeginFrameSupportForTests(browser);
expect(result.supported).toBe(true);
expect(screenshotCalls).toBe(26);
});
it("bounds a screenshot-bearing CDP call that never resolves", async () => {
const never = new Promise<never>(() => {});
const send = vi
.fn()
.mockResolvedValueOnce({})
.mockResolvedValueOnce({})
.mockReturnValueOnce(never);
const close = vi.fn().mockResolvedValue(undefined);
const browser = {
newPage: vi.fn().mockResolvedValue({
goto: vi.fn().mockResolvedValue(null),
createCDPSession: vi.fn().mockResolvedValue({
send,
detach: vi.fn().mockResolvedValue(undefined),
}),
close,
}),
} as unknown as Browser;
const result = await _probeBeginFrameSupportForTests(browser, 25);
expect(result.supported).toBe(false);
expect(result.detail).toContain("timeout during screenshot beginFrame attempt 1");
expect(close).toHaveBeenCalledOnce();
});
it("keeps a passed probe supported when its page close is slow", async () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const send = vi
.fn()
.mockResolvedValueOnce({})
.mockResolvedValueOnce({})
.mockResolvedValueOnce({ hasDamage: true, screenshotData: png.toString("base64") });
const browser = {
newPage: vi.fn().mockResolvedValue({
goto: vi.fn().mockResolvedValue(null),
createCDPSession: vi.fn().mockResolvedValue({
send,
detach: vi.fn().mockResolvedValue(undefined),
}),
close: vi.fn().mockReturnValue(new Promise<never>(() => {})),
}),
} as unknown as Browser;
const result = await _probeBeginFrameSupportForTests(browser);
expect(result.supported).toBe(true);
expect(result.detail).toContain("probe page cleanup timed out");
});
it("marks a probe that ran out of time as timed out, and a rejection as not", async () => {
const stuck = {
newPage: vi.fn().mockResolvedValue({
goto: vi.fn().mockReturnValue(new Promise<never>(() => {})),
close: vi.fn().mockResolvedValue(undefined),
}),
} as unknown as Browser;
const rejecting = {
newPage: vi.fn().mockResolvedValue({
goto: vi.fn().mockResolvedValue(null),
createCDPSession: vi.fn().mockResolvedValue({
send: vi.fn().mockRejectedValue(new Error("'HeadlessExperimental.enable' wasn't found")),
detach: vi.fn().mockResolvedValue(undefined),
}),
close: vi.fn().mockResolvedValue(undefined),
}),
} as unknown as Browser;
expect((await _probeBeginFrameSupportForTests(stuck, 25)).timedOut).toBe(true);
const rejected = await _probeBeginFrameSupportForTests(rejecting);
expect(rejected.supported).toBe(false);
expect(rejected.timedOut).toBe(false);
});
it("force-kills and disconnects when graceful browser cleanup never resolves", async () => {
const kill = vi.fn();
const disconnect = vi.fn().mockResolvedValue(undefined);
const browser = {
close: vi.fn().mockReturnValue(new Promise<never>(() => {})),
process: vi.fn().mockReturnValue({ kill }),
disconnect,
} as unknown as Browser;
await _closeBrowserAfterFailedProbeForTests(browser, 25);
expect(kill).toHaveBeenCalledWith("SIGKILL");
expect(disconnect).toHaveBeenCalledOnce();
});
});
describe("buildChromeArgs browser GPU mode", () => {
const base = { width: 1920, height: 1080 };
it("uses SwiftShader software GL by default for reproducible local renders", () => {
const args = buildChromeArgs(base);
expect(args).toContain("--enable-features=CanvasDrawElement");
expect(args).not.toContain("--enable-unsafe-webgpu");
expect(args).toContain("--use-gl=angle");
expect(args).toContain("--use-angle=swiftshader");
expect(args).toContain("--enable-unsafe-swiftshader");
expect(args).not.toContain("--enable-gpu-rasterization");
});
// HF#3049: the stale-raster accumulation lives in SwiftShader's compositor,
// which every capture mode reads from — so the gate is the GPU mode alone.
// Swept over the whole CaptureMode union so a future mode can't quietly opt
// out of the workaround the way `screenshot` did.
const captureModes: CaptureMode[] = ["beginframe", "screenshot", "drawelement"];
it.each(captureModes)("disables GPU compositing for software %s capture", (captureMode) => {
expect(
buildChromeArgs({ ...base, captureMode, platform: "linux" }, { browserGpuMode: "software" }),
).toContain("--disable-gpu-compositing");
});
it.each(captureModes)("leaves hardware %s capture on the GPU compositor", (captureMode) => {
expect(
buildChromeArgs({ ...base, captureMode, platform: "linux" }, { browserGpuMode: "hardware" }),
).not.toContain("--disable-gpu-compositing");
});
it("uses Metal-backed ANGLE for hardware browser GPU mode on macOS", () => {
const args = buildChromeArgs({ ...base, platform: "darwin" }, { browserGpuMode: "hardware" });
expect(args).toContain("--enable-unsafe-webgpu");
expect(args).toContain("--use-gl=angle");
expect(args).toContain("--use-angle=metal");
expect(args).toContain("--enable-gpu-rasterization");
expect(args).not.toContain("--use-angle=swiftshader");
});
it("uses D3D11-backed ANGLE for hardware browser GPU mode on Windows", () => {
const args = buildChromeArgs({ ...base, platform: "win32" }, { browserGpuMode: "hardware" });
expect(args).toContain("--use-gl=angle");
expect(args).toContain("--use-angle=d3d11");
expect(args).toContain("--enable-gpu-rasterization");
expect(args).not.toContain("--use-angle=swiftshader");
});
it("disables DXC for hardware WebGPU on Windows", () => {
const args = buildChromeArgs({ ...base, platform: "win32" }, { browserGpuMode: "hardware" });
expect(args).toContain("--enable-unsafe-webgpu");
expect(args).toContain("--disable-dawn-features=use_dxc");
});
it("disables DXC for a WebGPU composition in software mode on Windows", () => {
const args = buildChromeArgs(
{ ...base, platform: "win32", requiresWebGpu: true },
{ browserGpuMode: "software" },
);
expect(args).toContain("--enable-unsafe-webgpu");
expect(args).toContain("--disable-dawn-features=use_dxc");
});
it("leaves DXC unchanged without WebGPU in software mode on Windows", () => {
const args = buildChromeArgs({ ...base, platform: "win32" }, { browserGpuMode: "software" });
expect(args).not.toContain("--enable-unsafe-webgpu");
expect(args).not.toContain("--disable-dawn-features=use_dxc");
});
it("leaves DXC unchanged for hardware WebGPU on macOS and Linux", () => {
const platforms: NodeJS.Platform[] = ["darwin", "linux"];
for (const platform of platforms) {
const args = buildChromeArgs({ ...base, platform }, { browserGpuMode: "hardware" });
expect(args).toContain("--enable-unsafe-webgpu");
expect(args).not.toContain("--disable-dawn-features=use_dxc");
}
});
it("uses ANGLE-EGL for hardware browser GPU mode on Linux", () => {
const args = buildChromeArgs({ ...base, platform: "linux" }, { browserGpuMode: "hardware" });
expect(args).toContain("--use-gl=angle");
expect(args).toContain("--use-angle=gl-egl");
expect(args).toContain("--enable-gpu-rasterization");
expect(args).toContain("--ignore-gpu-blocklist");
expect(args).toContain("--disable-software-rasterizer");
expect(args).not.toContain("--use-angle=swiftshader");
});
it.each(["darwin", "win32", "linux"] as const)(
"pins the capture surface to 1 device pixel per CSS pixel on %s",
(platform) => {
expect(buildChromeArgs({ ...base, platform })).toContain("--force-device-scale-factor=1");
},
);
it("keeps --disable-gpu authoritative when requested", () => {
const args = buildChromeArgs(
{ ...base, platform: "darwin" },
{ browserGpuMode: "hardware", disableGpu: true },
);
expect(args).toContain("--disable-gpu");
expect(args).toContain("--use-angle=swiftshader");
expect(args).not.toContain("--use-angle=metal");
});
it("adds the WebGPU flag for a declaring composition even in software mode", () => {
const args = buildChromeArgs({ ...base, requiresWebGpu: true }, { browserGpuMode: "software" });
expect(args).toContain("--enable-unsafe-webgpu");
expect(args).toContain("--use-angle=swiftshader");
});
it("is byte-identical to the requiresWebGpu-absent case for a non-declaring composition", () => {
expect(buildChromeArgs({ ...base, requiresWebGpu: false })).toEqual(buildChromeArgs(base));
});
it("runs an opted-in WebGPU composition on SwiftShader's Vulkan with GPU compositing on", () => {
const args = buildChromeArgs(
{ ...base, platform: "linux", requiresWebGpu: true },
{ browserGpuMode: "software", allowSoftwareWebGpu: true },
);
expect(args).toContain("--enable-features=CanvasDrawElement,Vulkan");
expect(args).not.toContain("--enable-features=CanvasDrawElement");
expect(args).toContain("--use-vulkan=swiftshader");
expect(args).toContain("--enable-unsafe-webgpu");
expect(args).not.toContain("--disable-gpu-compositing");
});
it.each([
["without the opt-in", true, { browserGpuMode: "software" }],
[
"for a composition without WebGPU",
false,
{ browserGpuMode: "software", allowSoftwareWebGpu: true },
],
["on a GPU", true, { browserGpuMode: "hardware", allowSoftwareWebGpu: true }],
["under --disable-gpu", true, { disableGpu: true, allowSoftwareWebGpu: true }],
] as const)("leaves the launch as it was %s", (_, requiresWebGpu, config) => {
expect(usesSoftwareWebGpu(requiresWebGpu, config)).toBe(false);
expect(buildChromeArgs({ ...base, platform: "linux", requiresWebGpu }, config)).toEqual(
buildChromeArgs(
{ ...base, platform: "linux", requiresWebGpu },
{ ...config, allowSoftwareWebGpu: false },
),
);
});
});
describe("compositionRequiresWebGpu", () => {
it("detects the explicit WebGPU capability marker on the composition root", () => {
expect(
compositionRequiresWebGpu(
'<div data-requires-webgpu data-composition-id="gpu" data-duration="2"></div>',
),
).toBe(true);
expect(compositionRequiresWebGpu('<div data-composition-id="dom"></div>')).toBe(false);
});
it("finds the root after an inlined script whose code holds '<' and the marker name", () => {
const runtime = `<script>if(n<32)q="[data-composition-id]";if(a>b)go()</script>`;
expect(
compositionRequiresWebGpu(`${runtime}<div data-composition-id="main" data-requires-webgpu>`),
).toBe(true);
expect(
compositionRequiresWebGpu(`<div title="data-composition-id" data-requires-webgpu>`),
).toBe(false);
});
it("detects the marker on a mounted sub-composition's root in bundled HTML", () => {
expect(
compositionRequiresWebGpu(
'<div id="root" data-composition-id="main"><div id="aurora-host" data-composition-id="aurora">' +
'<div data-hf-inner-root="true" data-requires-webgpu=""><canvas data-shader="Aurora"></canvas></div></div></div>',
),
).toBe(true);
expect(
compositionRequiresWebGpu(
'<div data-composition-id="main"><div data-composition-id="scene"><div data-hf-inner-root="true"></div></div></div>',
),
).toBe(false);
});
it("reads only composition root tags and stays linear on repeated '<'", () => {
expect(
compositionRequiresWebGpu('<p data-requires-webgpu></p><div data-composition-id="a"></div>'),
).toBe(false);
expect(
compositionRequiresWebGpu('<div data-composition-id="a" title="x<y" data-requires-webgpu>'),
).toBe(true);
const started = performance.now();
expect(compositionRequiresWebGpu("<".repeat(200_000))).toBe(false);
expect(compositionRequiresWebGpu("<a'".repeat(100_000))).toBe(false);
expect(performance.now() - started).toBeLessThan(500);
});
});
describe("assertWebGpuAdapterAvailable", () => {
const pageWithAdapter = (hasAdapter: boolean) =>
({ evaluate: vi.fn().mockResolvedValue(hasAdapter) }) as unknown as Page;
it("no-ops for a composition that does not require WebGPU, regardless of adapter", async () => {
const page = pageWithAdapter(false);
await expect(assertWebGpuAdapterAvailable(page, false)).resolves.toBeUndefined();
expect(page.evaluate).not.toHaveBeenCalled();
});
it("resolves when the page confirms a usable WebGPU device", () => {
const page = pageWithAdapter(true);
return expect(assertWebGpuAdapterAvailable(page, true)).resolves.toBeUndefined();
});
it("throws naming the requirement when no adapter is obtainable", async () => {
const page = pageWithAdapter(false);
await expect(assertWebGpuAdapterAvailable(page, true)).rejects.toThrow("data-requires-webgpu");
});
it("preserves page evaluation failures separately from device unavailability", async () => {
const page = pageWithAdapter(false);
const failure = new Error("Execution context was destroyed");
vi.mocked(page.evaluate).mockRejectedValue(failure);
await expect(assertWebGpuAdapterAvailable(page, true)).rejects.toBe(failure);
});
describe("in-page adapter check (real callback, stubbed navigator.gpu)", () => {
const runInPage = {
evaluate: (fn: (...args: unknown[]) => unknown, ...args: unknown[]) => fn(...args),
} as unknown as Page;
const stubAdapter = (adapter: unknown) =>
vi.stubGlobal("navigator", { gpu: { requestAdapter: async () => adapter } });
const workingAdapter = (fallback = false) => ({
info: { isFallbackAdapter: fallback },
requestDevice: vi.fn().mockResolvedValue({ destroy: vi.fn() }),
});
beforeEach(() => vi.useFakeTimers());
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
it("accepts a hardware adapter", async () => {
stubAdapter(workingAdapter());
await expect(assertWebGpuAdapterAvailable(runInPage, true)).resolves.toBeUndefined();
});
it("refuses a software fallback adapter such as swiftshader", async () => {
const adapter = workingAdapter(true);
stubAdapter(adapter);
await expect(assertWebGpuAdapterAvailable(runInPage, true)).rejects.toBeInstanceOf(
WebGpuUnavailableError,
);
expect(adapter.requestDevice).not.toHaveBeenCalled();
});
it("accepts a software fallback adapter only on a launch that runs software WebGPU", async () => {
const adapter = workingAdapter(true);
const destroy = vi.fn();
adapter.requestDevice.mockResolvedValue({ destroy });
stubAdapter(adapter);
await expect(assertWebGpuAdapterAvailable(runInPage, true, true)).resolves.toBeUndefined();
await expect(assertWebGpuAdapterAvailable(runInPage, true, false)).rejects.toBeInstanceOf(
WebGpuUnavailableError,
);
expect(adapter.requestDevice).toHaveBeenCalledExactlyOnceWith();
expect(destroy).toHaveBeenCalledOnce();
});
it("refuses a host with no navigator.gpu", async () => {
vi.stubGlobal("navigator", {});
await expect(assertWebGpuAdapterAvailable(runInPage, true)).rejects.toBeInstanceOf(
WebGpuUnavailableError,
);
});
it("refuses a missing adapter and clears its timeout", async () => {
stubAdapter(null);
await expect(assertWebGpuAdapterAvailable(runInPage, true)).rejects.toBeInstanceOf(
WebGpuUnavailableError,
);
expect(vi.getTimerCount()).toBe(0);
});
it("creates a descriptor-free device, destroys it, and clears its timeout", async () => {
const destroy = vi.fn();
const adapter = workingAdapter();
adapter.requestDevice.mockResolvedValue({ destroy });
stubAdapter(adapter);
await expect(assertWebGpuAdapterAvailable(runInPage, true)).resolves.toBeUndefined();
expect(adapter.requestDevice).toHaveBeenCalledExactlyOnceWith();
expect(destroy).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it.each([false, true])("rejects a device-creation failure (software: %s)", async (software) => {
const adapter = workingAdapter(software);
adapter.requestDevice.mockRejectedValue(new Error("OperationError: device unavailable"));
stubAdapter(adapter);
await expect(assertWebGpuAdapterAvailable(runInPage, true, software)).rejects.toBeInstanceOf(
WebGpuUnavailableError,
);
expect(adapter.requestDevice).toHaveBeenCalledExactlyOnceWith();
expect(vi.getTimerCount()).toBe(0);
});
it("rejects a synchronous device-creation failure and clears its timeout", async () => {
const adapter = workingAdapter();
adapter.requestDevice.mockImplementation(() => {
throw new Error("device creation failed");
});
stubAdapter(adapter);
await expect(assertWebGpuAdapterAvailable(runInPage, true)).rejects.toBeInstanceOf(
WebGpuUnavailableError,
);
expect(vi.getTimerCount()).toBe(0);
});
it("shares the ten-second budget across adapter and device creation", async () => {
const adapter = workingAdapter();
adapter.requestDevice.mockImplementation(() => new Promise(() => {}));
vi.stubGlobal("navigator", {
gpu: {
requestAdapter: () => new Promise((resolve) => setTimeout(() => resolve(adapter), 9_000)),
},
});
const result = assertWebGpuAdapterAvailable(runInPage, true).catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(9_000);
expect(adapter.requestDevice).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(1_000);
expect(await result).toBeInstanceOf(WebGpuUnavailableError);
expect(vi.getTimerCount()).toBe(0);
});
it("bounds an adapter request that never completes", async () => {
vi.stubGlobal("navigator", { gpu: { requestAdapter: () => new Promise(() => {}) } });
const result = assertWebGpuAdapterAvailable(runInPage, true).catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(10_000);
expect(await result).toBeInstanceOf(WebGpuUnavailableError);
expect(vi.getTimerCount()).toBe(0);
});
it("does not create a device when the adapter arrives after the deadline", async () => {
const adapter = workingAdapter();
vi.stubGlobal("navigator", {
gpu: {
requestAdapter: () =>
new Promise((resolve) => setTimeout(() => resolve(adapter), 11_000)),
},
});
const result = assertWebGpuAdapterAvailable(runInPage, true).catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(10_000);
expect(await result).toBeInstanceOf(WebGpuUnavailableError);
await vi.advanceTimersByTimeAsync(1_000);
expect(adapter.requestDevice).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
it("destroys a device that resolves after the deadline", async () => {
const destroy = vi.fn();
const adapter = workingAdapter();
adapter.requestDevice.mockImplementation(
() => new Promise((resolve) => setTimeout(() => resolve({ destroy }), 11_000)),
);
stubAdapter(adapter);
const result = assertWebGpuAdapterAvailable(runInPage, true).catch((error: unknown) => error);
await vi.advanceTimersByTimeAsync(10_000);
expect(await result).toBeInstanceOf(WebGpuUnavailableError);
expect(destroy).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1_000);
expect(destroy).toHaveBeenCalledOnce();
expect(vi.getTimerCount()).toBe(0);
});
it("clears the timeout when the adapter request rejects", async () => {
vi.stubGlobal("navigator", {
gpu: {
requestAdapter: async () => {
throw new Error("adapter unavailable");
},
},
});
await expect(assertWebGpuAdapterAvailable(runInPage, true)).rejects.toBeInstanceOf(
WebGpuUnavailableError,
);
expect(vi.getTimerCount()).toBe(0);
});
});
});
describe("browser launch capture-mode contract", () => {
it("derives BeginFrame from the actual launch flags, not forceScreenshot alone", () => {
const dir = mkdtempSync(join(tmpdir(), "hf-browser-fingerprint-"));
const chromePath = join(dir, "chrome-headless-shell");
writeFileSync(chromePath, "");
try {
const withoutControl = _createBrowserLaunchFingerprintForTests([], {
chromePath,
forceScreenshot: false,
});
const withControl = _createBrowserLaunchFingerprintForTests(
["--enable-begin-frame-control"],
{ chromePath, forceScreenshot: false },
);
expect(withoutControl.requestedCaptureMode).toBe("screenshot");
expect(withControl.requestedCaptureMode).toBe(
process.platform === "linux" ? "beginframe" : "screenshot",
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
describe("resolveBrowserGpuMode", () => {
const setMockWebGlProbe = (info: { hasWebGL: boolean; vendor: string; renderer: string }) => {
const close = vi.fn().mockResolvedValue(undefined);
const evaluate = vi.fn().mockResolvedValue(info);
const launch = vi.fn().mockResolvedValue({
newPage: vi.fn().mockResolvedValue({ evaluate }),
close,
});
_setPuppeteerForTests({ launch } as unknown as PuppeteerNode);
return { close, launch };
};
const notWsl = {
platform: "linux" as const,
release: "6.8.0-45-generic",
readProcVersion: () => "Linux version 6.8.0-45-generic",
renderNodeAccess: () => ({ state: "read-write" as const }),
};
beforeEach(() => {
_resetAutoBrowserGpuModeCacheForTests();
// A named shell skips discovery, which starts any older Chrome in the user's cache to test it.
vi.stubEnv("PRODUCER_HEADLESS_SHELL_PATH", process.execPath);
});
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
_setPuppeteerForTests(undefined);
_resetAutoBrowserGpuModeCacheForTests();
});
it.runIf(process.platform === "linux" && process.arch === "x64")(
"does not launch a Chrome found in the user's cache while probing",
async () => {
const home = mkdtempSync(join(tmpdir(), "hf-gpu-probe-home-"));
const marker = join(home, "launched");
const shellDir = join(
home,
".cache/puppeteer/chrome-headless-shell",
"linux-100.0.0.0",
"chrome-headless-shell-linux64",
);
mkdirSync(shellDir, { recursive: true });
writeFileSync(join(shellDir, "chrome-headless-shell"), `#!/bin/sh\ntouch "${marker}"\n`);
chmodSync(join(shellDir, "chrome-headless-shell"), 0o755);
vi.stubEnv("HOME", home);
vi.stubEnv("HYPERFRAMES_BROWSER_PATH", "");
setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA" });
try {
await resolveBrowserGpuMode("auto");
expect(existsSync(marker)).toBe(false);
} finally {
rmSync(home, { recursive: true, force: true });
}
},
);
it("lets a host own GPU probe launch and cleanup", async () => {
const { launch } = setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA" });
const close = vi.fn(async (browser: Browser) => {
await browser.close();
});
const managedLaunch = vi.fn(
async (ppt: PuppeteerNode, options: Parameters<PuppeteerNode["launch"]>[0]) =>
ppt.launch(options),
);
await resolveBrowserGpuMode("auto", { probeLifecycle: { launch: managedLaunch, close } });
expect(managedLaunch).toHaveBeenCalledOnce();
expect(launch).toHaveBeenCalledOnce();
expect(close).toHaveBeenCalledOnce();
});
it("passes 'software' through unchanged without probing", async () => {
const mode = await resolveBrowserGpuMode("software");
expect(mode).toBe("software");
});
it("passes 'hardware' through unchanged", async () => {
setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA GeForce RTX 3070" });
const mode = await resolveBrowserGpuMode("hardware");
expect(mode).toBe("hardware");
});
it("warns when explicit 'hardware' probes to software, but still honours it", async () => {
// heygen-com/hyperframes#2967: `--browser-gpu` inside a container with no
// GPU passthrough rendered 19186 frames on CPU with no diagnostic.
setMockWebGlProbe({
hasWebGL: true,
vendor: "Google Inc. (Google)",
renderer: "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device))",
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const mode = await resolveBrowserGpuMode("hardware", { platform: "linux", wslHost: notWsl });
expect(mode).toBe("hardware");
const warning = warn.mock.calls.map((call) => String(call[0])).join("\n");
expect(warning).toContain("browserGpuMode=hardware was requested");
expect(warning).toContain("--gpus all");
// Once per process, not once per worker — the render path resolves the
// mode for the probe browser plus every parallel worker.
await resolveBrowserGpuMode("hardware", { platform: "linux", wslHost: notWsl });
await resolveBrowserGpuMode("hardware", { platform: "linux", wslHost: notWsl });
expect(warn).toHaveBeenCalledTimes(1);
});
it("stays quiet when explicit 'hardware' probes to hardware", async () => {
setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA GeForce RTX 3070" });
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(await resolveBrowserGpuMode("hardware")).toBe("hardware");
expect(warn).not.toHaveBeenCalled();
});
it("gives non-linux hosts the generic remediation, not the Docker one", async () => {
setMockWebGlProbe({
hasWebGL: true,
vendor: "Google Inc. (Google)",
renderer: "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device))",
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(await resolveBrowserGpuMode("hardware", { platform: "darwin", wslHost: notWsl })).toBe(
"hardware",
);
const warning = String(warn.mock.calls[0]?.[0]);
expect(warning).toContain("host exposes a GPU");
expect(warning).not.toContain("--gpus all");
});
it("does not blame the GPU when the probe itself failed to launch", async () => {
// A probe that could not run is NO evidence about the GPU. Sending this
// operator to `--gpus all` would hide a broken Chrome install behind a
// phantom passthrough problem.
_setPuppeteerForTests({
launch: vi.fn().mockRejectedValue(new Error("spawn ENOENT /bad/chrome")),
} as unknown as PuppeteerNode);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(await resolveBrowserGpuMode("hardware", { platform: "linux", wslHost: notWsl })).toBe(
"hardware",
);
const warning = String(warn.mock.calls[0]?.[0]);
expect(warning).toContain("GPU probe could not run");
expect(warning).toContain("hyperframes doctor");
expect(warning).not.toContain("--gpus all");
});
describe("under WSL (heygen-com/hyperframes#5548)", () => {
const wslHost = {
platform: "linux" as const,
release: "5.15.167.4-microsoft-standard-WSL2",
readProcVersion: () => "",
renderNodeAccess: () => ({ state: "denied" as const, group: "render", userInGroup: false }),
};
const output = (spy: { mock: { calls: unknown[][] } }) =>
spy.mock.calls.map((call) => String(call[0])).join("\n");
const neverAnsweringProbe = () => {
const launch = vi.fn().mockResolvedValue({
newPage: vi.fn().mockResolvedValue({ evaluate: () => new Promise(() => {}) }),
close: () => new Promise(() => {}),
process: () => ({ kill: vi.fn() }),
disconnect: vi.fn().mockResolvedValue(undefined),
});
_setPuppeteerForTests({ launch } as unknown as PuppeteerNode);
};
it("auto mode says it fell back to software, why, and the fix, once", async () => {
setMockWebGlProbe({ hasWebGL: false, vendor: "", renderer: "" });
const error = vi.spyOn(console, "error").mockImplementation(() => {});
expect(await resolveBrowserGpuMode("auto", { platform: "linux", wslHost })).toBe("software");
await resolveBrowserGpuMode("auto", { platform: "linux", wslHost });
const text = output(error);
expect(text).toContain("browserGpuMode probe → software (WebGL unavailable)");
expect(text).toContain(
"WSL: the GPU check found no hardware GPU, so this render uses software",
);
expect(text).toContain('is not in its group ("render")');
expect(text).toContain("sudo usermod -aG render $USER");
expect(text.match(/usermod/g)).toHaveLength(1);
});
it("auto mode says the check did not finish when the probe page times out", async () => {
vi.useFakeTimers();
try {
neverAnsweringProbe();
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const mode = resolveBrowserGpuMode("auto", { platform: "linux", wslHost });
await vi.advanceTimersByTimeAsync(15_000 + 250);
await expect(mode).resolves.toBe("software");
const text = output(error);
expect(text).toContain("did not answer within 15000ms");
expect(text).toContain("WSL: the GPU check could not finish");
expect(text).not.toContain("found no hardware GPU");
} finally {
vi.useRealTimers();
}
});
it("explicit 'hardware' after a timeout stays unverified and never claims software", async () => {
vi.useFakeTimers();
try {
neverAnsweringProbe();
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const mode = resolveBrowserGpuMode("hardware", { platform: "linux", wslHost });
await vi.advanceTimersByTimeAsync(15_000 + 250);
await expect(mode).resolves.toBe("hardware");
const warning = output(warn);
expect(warning).toContain("hardware acceleration is UNVERIFIED");
expect(warning).toContain("hyperframes doctor");
expect(warning).toContain("sudo usermod -aG render $USER");
expect(warning).not.toContain("found no hardware GPU");
expect(output(error)).not.toContain("this render uses software");
} finally {
vi.useRealTimers();
}
});
it("explicit 'hardware' with a broken Chrome keeps the Chrome-install advice", async () => {
_setPuppeteerForTests({
launch: vi.fn().mockRejectedValue(new Error("spawn ENOENT /bad/chrome")),
} as unknown as PuppeteerNode);
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(await resolveBrowserGpuMode("hardware", { platform: "linux", wslHost })).toBe(
"hardware",
);
const warning = output(warn);
expect(warning).toContain("GPU probe could not run");
expect(warning).toContain("hyperframes doctor");
expect(warning).not.toContain("--gpus all");
expect(output(error)).not.toContain("this render uses software");
});
it("explicit 'hardware' with no hardware GPU gives the WSL fix, not Docker passthrough", async () => {
setMockWebGlProbe({ hasWebGL: false, vendor: "", renderer: "" });
const error = vi.spyOn(console, "error").mockImplementation(() => {});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(await resolveBrowserGpuMode("hardware", { platform: "linux", wslHost })).toBe(
"hardware",
);
const warning = output(warn);
expect(warning).toContain("sudo usermod -aG render $USER");
expect(warning).not.toContain("--gpus all");
expect(output(error)).not.toContain("this render uses software");
});
it("stays quiet when WSL hands Chrome a hardware GPU", async () => {
setMockWebGlProbe({
hasWebGL: true,
vendor: "Google Inc. (Microsoft Corporation)",
renderer: "ANGLE (Microsoft Corporation, D3D12 (Intel(R) Graphics), OpenGL ES 3.0)",
});
const error = vi.spyOn(console, "error").mockImplementation(() => {});
expect(await resolveBrowserGpuMode("auto", { platform: "linux", wslHost })).toBe("hardware");
expect(output(error)).not.toContain("WSL");
});
});
it("finds no full Chrome, without throwing, when its cache cannot be read", () => {
const home = mkdtempSync(join(tmpdir(), "hf-full-chrome-cache-"));
const saved = { HOME: process.env.HOME, USERPROFILE: process.env.USERPROFILE };
try {
mkdirSync(join(home, ".cache", "puppeteer"), { recursive: true });
writeFileSync(join(home, ".cache", "puppeteer", "chrome"), "not a folder");
Object.assign(process.env, { HOME: home, USERPROFILE: home });
expect(resolveFullChromePath()).toBeUndefined();
} finally {
Object.assign(process.env, saved);
rmSync(home, { recursive: true, force: true });
}
});
it("falls back to 'software' within the probe budget when the probe page never answers", async () => {
vi.useFakeTimers();
try {
const kill = vi.fn();
const launch = vi.fn().mockResolvedValue({
newPage: vi.fn().mockResolvedValue({ evaluate: () => new Promise(() => {}) }),
close: () => new Promise(() => {}),
process: () => ({ kill }),
disconnect: vi.fn().mockResolvedValue(undefined),
});
_setPuppeteerForTests({ launch } as unknown as PuppeteerNode);
vi.spyOn(console, "log").mockImplementation(() => {});
const mode = resolveBrowserGpuMode("auto", { browserTimeout: 120_000 });
await vi.advanceTimersByTimeAsync(15_000 + 250);
await expect(mode).resolves.toBe("software");
expect(launch).toHaveBeenCalledWith(
expect.objectContaining({ timeout: 120_000, waitForInitialPage: false }),
);
expect(kill).toHaveBeenCalledWith("SIGKILL");
} finally {
vi.useRealTimers();
}
});
it("falls back to 'software' when the probe browser cannot launch", async () => {
// No chromePath, env unset, and (in the test env) no system Chrome to find
// → puppeteer.launch will throw → caller catches → software fallback.
// Force a definitely-missing chrome binary so the launch path errors fast.
const mode = await resolveBrowserGpuMode("auto", {
chromePath: "/definitely/not/a/real/chrome/binary",
browserTimeout: 2000,
});
expect(mode).toBe("software");
});
it("caches the probe result across calls", async () => {
const first = await resolveBrowserGpuMode("auto", {
chromePath: "/definitely/not/a/real/chrome/binary",
browserTimeout: 2000,
});
// Second call uses cache — no new launch. Assert the same answer comes back
// even with a different chromePath that would have a different probe outcome.
const second = await resolveBrowserGpuMode("auto", {
chromePath: "/another/definitely/missing/path",
browserTimeout: 2000,
});
expect(first).toBe("software");
expect(second).toBe("software");
// Reset and re-probe to confirm the test-only reset works.
_resetAutoBrowserGpuModeCacheForTests();
setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA GeForce RTX 3070" });
const third = await resolveBrowserGpuMode("auto");
expect(third).toBe("hardware");
});
it("deduplicates concurrent probes so only one Chrome launches", async () => {
// Parallel coordinator fires N workers via Promise.all — without Promise-
// level caching, a `--workers 4` render against a no-GPU host would launch
// 4 simultaneous probe Chromes. Assert the launch count directly rather
// than Promise identity: `"auto"` and `"hardware"` now each adapt the
// shared cached Promise via `.then`, so identity is no longer the
// invariant — "the probe browser starts exactly once" is.
const { launch } = setMockWebGlProbe({
hasWebGL: true,
vendor: "Google Inc. (Google)",
renderer: "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device))",
});
const results = await Promise.all([
resolveBrowserGpuMode("auto", { browserTimeout: 2000 }),
resolveBrowserGpuMode("auto", { browserTimeout: 2000 }),
resolveBrowserGpuMode("auto", { browserTimeout: 2000 }),
]);
expect(results).toEqual(["software", "software", "software"]);
expect(launch).toHaveBeenCalledTimes(1);
});
it("shares the one probe across mixed 'auto' and 'hardware' callers", async () => {
const { launch } = setMockWebGlProbe({
hasWebGL: true,
vendor: "NVIDIA",
renderer: "NVIDIA GeForce RTX 3070",
});
const results = await Promise.all([
resolveBrowserGpuMode("auto"),
resolveBrowserGpuMode("hardware"),
resolveBrowserGpuMode("auto"),
]);
expect(results).toEqual(["hardware", "hardware", "hardware"]);
expect(launch).toHaveBeenCalledTimes(1);
});
it.each([
[
"llvmpipe",
"Google Inc. (Mesa/X.org)",
"ANGLE (Mesa/X.org, llvmpipe (LLVM 12.0.0 256 bits), OpenGL ES 3.2)",
],
[
"Microsoft Basic Render Driver",
"Google Inc. (Microsoft)",
"ANGLE (Microsoft, Microsoft Basic Render Driver Direct3D11 vs_5_0 ps_5_0)",
],
["Mesa offscreen", "Google Inc. (Mesa)", "ANGLE (Mesa, Mesa offscreen, OpenGL ES 3.2)"],
[
"lavapipe",
"Google Inc. (Mesa)",
"ANGLE (Mesa, llvmpipe/lavapipe Vulkan software rasterizer)",
],
])("treats %s WebGL as software in auto mode", async (_label, vendor, renderer) => {
const { close, launch } = setMockWebGlProbe({
hasWebGL: true,
vendor,
renderer,
});
const mode = await resolveBrowserGpuMode("auto", {
chromePath: "/mock/chrome-headless-shell",
browserTimeout: 2000,
});
expect(mode).toBe("software");
expect(launch).toHaveBeenCalledTimes(1);
expect(close).toHaveBeenCalledTimes(1);
});
it("treats empty WebGL renderer metadata as software in auto mode", async () => {
const { close, launch } = setMockWebGlProbe({
hasWebGL: true,
vendor: "",
renderer: "",
});
const mode = await resolveBrowserGpuMode("auto", {
chromePath: "/mock/chrome-headless-shell",
browserTimeout: 2000,
});
expect(mode).toBe("software");
expect(launch).toHaveBeenCalledTimes(1);
expect(close).toHaveBeenCalledTimes(1);
});
it("keeps real hardware WebGL as hardware in auto mode", async () => {
const { close, launch } = setMockWebGlProbe({
hasWebGL: true,
vendor: "Google Inc. (NVIDIA Corporation)",
renderer: "ANGLE (NVIDIA, NVIDIA A10G, OpenGL 4.6)",
});
const mode = await resolveBrowserGpuMode("auto", {
chromePath: "/mock/chrome-headless-shell",
browserTimeout: 2000,
});
expect(mode).toBe("hardware");
expect(launch).toHaveBeenCalledTimes(1);
expect(close).toHaveBeenCalledTimes(1);
});
});
// CodeQL js/bad-code-sanitization: values must never be woven into the eval
// string itself. This script is a fixed constant; every variable (module
// URL, faked platform/arch, faked os.release) crosses as an env var instead.
const RESOLVE_HEADLESS_SHELL_SUBPROCESS_SCRIPT = `
if (process.env.HF_TEST_PLATFORM) {
Object.defineProperty(process, "platform", { value: process.env.HF_TEST_PLATFORM });
}
if (process.env.HF_TEST_ARCH) {
Object.defineProperty(process, "arch", { value: process.env.HF_TEST_ARCH });
}
if (process.env.HF_TEST_OS_RELEASE) {
const os = require("node:os");
os.release = () => process.env.HF_TEST_OS_RELEASE;
require("node:module").syncBuiltinESMExports();
}
import(process.env.HF_TEST_MODULE_URL).then(({ resolveHeadlessShellPath }) => {
process.stdout.write(resolveHeadlessShellPath({}) ?? "");
});
`;
/** Runs resolveHeadlessShellPath in a subprocess with a faked platform/arch/os.release. */
function resolveHeadlessShellInSubprocess(
env: NodeJS.ProcessEnv,
fakes: { platform?: string; arch?: string; osRelease?: string } = {},
): string {
const moduleUrl = new URL("./browserManager.ts", import.meta.url).href;
return execFileSync("bun", ["--eval", RESOLVE_HEADLESS_SHELL_SUBPROCESS_SCRIPT], {
encoding: "utf8",
env: {
...env,
HF_TEST_MODULE_URL: moduleUrl,
...(fakes.platform ? { HF_TEST_PLATFORM: fakes.platform } : {}),
...(fakes.arch ? { HF_TEST_ARCH: fakes.arch } : {}),
...(fakes.osRelease ? { HF_TEST_OS_RELEASE: fakes.osRelease } : {}),
},
});
}
describe("resolveHeadlessShellPath", () => {
const originalHeadlessShellPath = process.env.PRODUCER_HEADLESS_SHELL_PATH;
const originalHyperframesBrowserPath = process.env.HYPERFRAMES_BROWSER_PATH;
// A runner may export either var (the Windows job sets HYPERFRAMES_BROWSER_PATH); each case sets its own.
beforeEach(() => {
delete process.env.PRODUCER_HEADLESS_SHELL_PATH;
delete process.env.HYPERFRAMES_BROWSER_PATH;
});
afterEach(() => {
if (originalHeadlessShellPath === undefined) delete process.env.PRODUCER_HEADLESS_SHELL_PATH;
else process.env.PRODUCER_HEADLESS_SHELL_PATH = originalHeadlessShellPath;
if (originalHyperframesBrowserPath === undefined) delete process.env.HYPERFRAMES_BROWSER_PATH;
else process.env.HYPERFRAMES_BROWSER_PATH = originalHyperframesBrowserPath;
});
it("throws a clear error when PRODUCER_HEADLESS_SHELL_PATH points at a missing binary", () => {
process.env.PRODUCER_HEADLESS_SHELL_PATH = "/missing/chrome-headless-shell.exe";
expect(() => resolveHeadlessShellPath({})).toThrow(
/Chrome binary not found at PRODUCER_HEADLESS_SHELL_PATH/,
);
});
it("prefers HYPERFRAMES_BROWSER_PATH over PRODUCER_HEADLESS_SHELL_PATH, as the CLI does", () => {
const dir = mkdtempSync(join(tmpdir(), "hyperframes-engine-browser-env-"));
try {
const cliChoice = join(dir, "chosen-browser");
const other = join(dir, "other-browser");
writeFileSync(cliChoice, "");
writeFileSync(other, "");
process.env.HYPERFRAMES_BROWSER_PATH = cliChoice;
process.env.PRODUCER_HEADLESS_SHELL_PATH = other;
expect(resolveHeadlessShellPath({})).toBe(cliChoice);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("keeps that precedence through a render's resolved engine config", () => {
const dir = mkdtempSync(join(tmpdir(), "hyperframes-engine-browser-env-"));
try {
const cliChoice = join(dir, "chosen-browser");
const other = join(dir, "other-browser");
writeFileSync(cliChoice, "");
writeFileSync(other, "");
process.env.HYPERFRAMES_BROWSER_PATH = cliChoice;
process.env.PRODUCER_HEADLESS_SHELL_PATH = other;
expect(resolveHeadlessShellPath(resolveConfig())).toBe(cliChoice);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it("uses HYPERFRAMES_BROWSER_PATH when the CLI resolved a browser explicitly", () => {
const dir = mkdtempSync(join(tmpdir(), "hyperframes-engine-browser-env-"));
try {
const binary = join(dir, "chrome-headless-shell");
writeFileSync(binary, "");
delete process.env.PRODUCER_HEADLESS_SHELL_PATH;
process.env.HYPERFRAMES_BROWSER_PATH = binary;
expect(resolveHeadlessShellPath({})).toBe(binary);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
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-${managedChromeVersion("linux", "24.0.0")}`,
);
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, "shell");
}
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 stdout = resolveHeadlessShellInSubprocess(env, {
platform: hostPlatform,
arch: hostArch,
osRelease: "24.0.0",
});
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-${managedChromeVersion("linux", "24.0.0")}`,
);
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, "shell");
}
const env = { ...process.env, HOME: home, USERPROFILE: home };
delete env.PRODUCER_HEADLESS_SHELL_PATH;
delete env.HYPERFRAMES_BROWSER_PATH;
const stdout = resolveHeadlessShellInSubprocess(env, {
platform: hostPlatform,
arch: hostArch,
osRelease: "24.0.0",
});
expect(stdout).toBe("");
} finally {
rmSync(home, { recursive: true, force: true });
}
},
);
it("never uses another tool's shell that does not start", () => {
// Field report: only mac_arm-121 in the shared cache, and that shell died at launch.
const home = mkdtempSync(join(tmpdir(), "hyperframes-engine-browser-shared-old-"));
try {
const old = join(home, ".cache", "puppeteer", "chrome-headless-shell", "linux-121.0.6167.85");
const binary = join(old, "chrome-headless-shell-linux64", "chrome-headless-shell");
mkdirSync(join(binary, ".."), { recursive: true });
writeFileSync(binary, "shell");
const env = { ...process.env, HOME: home, USERPROFILE: home };
delete env.PRODUCER_HEADLESS_SHELL_PATH;
delete env.HYPERFRAMES_BROWSER_PATH;
expect(resolveHeadlessShellInSubprocess(env, { platform: "linux", arch: "x64" })).toBe("");
} finally {
rmSync(home, { recursive: true, force: true });
}
});
it("prefers the managed build in the shared cache over an older build of ours", () => {
const home = mkdtempSync(join(tmpdir(), "hyperframes-engine-browser-shared-managed-"));
try {
const shell = (root: string, version: string) =>
join(
home,
".cache",
...root.split("/"),
`linux-${version}`,
"chrome-headless-shell-linux64",
"chrome-headless-shell",
);
const managed = shell(
"puppeteer/chrome-headless-shell",
managedChromeVersion("linux", "24.0.0"),
);
for (const binary of [
managed,
shell("puppeteer/chrome-headless-shell", "121.0.6167.85"),
shell("hyperframes/chrome/chrome-headless-shell", "150.0.7871.124"),
]) {
mkdirSync(join(binary, ".."), { recursive: true });
writeFileSync(binary, "shell");
}
const env = { ...process.env, HOME: home, USERPROFILE: home };
delete env.PRODUCER_HEADLESS_SHELL_PATH;
delete env.HYPERFRAMES_BROWSER_PATH;
expect(resolveHeadlessShellInSubprocess(env, { platform: "linux", arch: "x64" })).toBe(
managed,
);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
// Stand-in shells are shell scripts.
it.skipIf(process.platform === "win32")(
"keeps the newest shared shell that starts and skips one that dies",
() => {
const home = mkdtempSync(join(tmpdir(), "hyperframes-engine-browser-shared-starts-"));
try {
const shell = (version: string, script: string) => {
const binary = join(
home,
".cache",
"puppeteer",
"chrome-headless-shell",
`linux-${version}`,
"chrome-headless-shell-linux64",
"chrome-headless-shell",
);
mkdirSync(join(binary, ".."), { recursive: true });
writeFileSync(binary, script, { mode: 0o755 });
return binary;
};
shell("149.0.7800.1", "#!/bin/sh\nkill -TRAP $$\n");
const working = shell("148.0.7778.97", "#!/bin/sh\nexit 0\n");
shell("99.0.6533.123", "#!/bin/sh\nexit 0\n");
const env = { ...process.env, HOME: home, USERPROFILE: home };
delete env.PRODUCER_HEADLESS_SHELL_PATH;
delete env.HYPERFRAMES_BROWSER_PATH;
expect(resolveHeadlessShellInSubprocess(env, { platform: "linux", arch: "x64" })).toBe(
working,
);
} 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 {
const binary = join(
home,
".cache",
"hyperframes",
"chrome",
"chrome-headless-shell",
"linux-152.0.7928.2",
"chrome-headless-shell-linux64",
"chrome-headless-shell",
);
mkdirSync(join(binary, ".."), { recursive: true });
writeFileSync(binary, "shell");
const olderBinary = binary.replace("linux-152.0.7928.2", "linux-99.0.1.1");
mkdirSync(join(olderBinary, ".."), { recursive: true });
writeFileSync(olderBinary, "shell");
// os.homedir() reads HOME on POSIX and USERPROFILE on Windows.
const env = { ...process.env, HOME: home, USERPROFILE: home };
delete env.PRODUCER_HEADLESS_SHELL_PATH;
delete env.HYPERFRAMES_BROWSER_PATH;
const stdout = resolveHeadlessShellInSubprocess(env, { platform: "linux", arch: "x64" });
expect(stdout).toBe(binary);
} finally {
rmSync(home, { recursive: true, force: true });
}
});
it("skips a cached shell that is a folder or empty and uses the next older build", () => {
const home = mkdtempSync(join(tmpdir(), "hyperframes-engine-browser-empty-"));
try {
const shell = (version: string) =>
join(
home,
".cache",
"hyperframes",
"chrome",
"chrome-headless-shell",
`linux-${version}`,
"chrome-headless-shell-linux64",
"chrome-headless-shell",
);
mkdirSync(shell("153.0.7990.1"), { recursive: true });
for (const [version, content] of [
["152.0.7977.30", ""],
["150.0.7871.124", "shell"],
]) {
mkdirSync(join(shell(version), ".."), { recursive: true });
writeFileSync(shell(version), content);
}
const env = { ...process.env, HOME: home, USERPROFILE: home };
delete env.PRODUCER_HEADLESS_SHELL_PATH;
delete env.HYPERFRAMES_BROWSER_PATH;
const stdout = resolveHeadlessShellInSubprocess(env, { platform: "linux", arch: "x64" });
expect(stdout).toBe(shell("150.0.7871.124"));
} finally {
rmSync(home, { recursive: true, force: true });
}
});
// Permission bits do not stop root, and Windows ignores them.
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"keeps scanning past a cached build it cannot read",
() => {
const home = mkdtempSync(join(tmpdir(), "hyperframes-engine-browser-unreadable-"));
const shell = (version: string) =>
join(
home,
".cache",
"hyperframes",
"chrome",
"chrome-headless-shell",
`linux-${version}`,
"chrome-headless-shell-linux64",
"chrome-headless-shell",
);
const lockedDir = join(shell("152.0.7977.30"), "..");
try {
for (const version of ["152.0.7977.30", "150.0.7871.124"]) {
mkdirSync(join(shell(version), ".."), { recursive: true });
writeFileSync(shell(version), "shell");
}
chmodSync(lockedDir, 0o000);
const env = { ...process.env, HOME: home, USERPROFILE: home };
delete env.PRODUCER_HEADLESS_SHELL_PATH;
delete env.HYPERFRAMES_BROWSER_PATH;
const stdout = resolveHeadlessShellInSubprocess(env, { platform: "linux", arch: "x64" });
expect(stdout).toBe(shell("150.0.7871.124"));
} finally {
chmodSync(lockedDir, 0o755);
rmSync(home, { recursive: true, force: true });
}
},
);
it("skips managed-cache builds newer than Chrome 150 on macOS 12", () => {
const home = mkdtempSync(join(tmpdir(), "hyperframes-engine-browser-macos12-"));
try {
const shell = (version: string) =>
join(
home,
".cache",
"hyperframes",
"chrome",
"chrome-headless-shell",
`mac-${version}`,
"chrome-headless-shell-mac-x64",
"chrome-headless-shell",
);
for (const version of ["152.0.7977.30", "150.0.7871.124"]) {
mkdirSync(join(shell(version), ".."), { recursive: true });
writeFileSync(shell(version), "shell");
}
const env = { ...process.env, HOME: home, USERPROFILE: home };
delete env.PRODUCER_HEADLESS_SHELL_PATH;
delete env.HYPERFRAMES_BROWSER_PATH;
const stdout = resolveHeadlessShellInSubprocess(env, {
platform: "darwin",
arch: "x64",
osRelease: "21.6.0",
});
expect(stdout).toBe(shell("150.0.7871.124"));
} finally {
rmSync(home, { recursive: true, force: true });
}
});
});
describe("forceReleaseBrowser", () => {
it("kills the browser process and disconnects", () => {
const killFn = vi.fn(() => true);
const disconnectFn = vi.fn();
const mockBrowser = {
process: () => ({ kill: killFn, killed: false }),
disconnect: disconnectFn,
} as any;
forceReleaseBrowser(mockBrowser);
expect(killFn).toHaveBeenCalledWith("SIGKILL");
expect(disconnectFn).toHaveBeenCalled();
});
it("tolerates an already-killed process", () => {
const killFn = vi.fn();
const disconnectFn = vi.fn();
const mockBrowser = {
process: () => ({ kill: killFn, killed: true }),
disconnect: disconnectFn,
} as any;
forceReleaseBrowser(mockBrowser);
expect(killFn).not.toHaveBeenCalled();
expect(disconnectFn).toHaveBeenCalled();
});
});
describe("browser pool", () => {
function makeMockBrowser(): Browser {
return {
connected: true,
newPage: vi.fn(),
version: vi.fn().mockResolvedValue("HeadlessChrome/131.0.0.0"),
close: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn(),
process: () => ({ kill: vi.fn(), killed: false }),
} as unknown as Browser;
}
// forceScreenshot: true bypasses the BeginFrame probe path, which on Linux
// CI would trigger a second ppt.launch() when the mock's newPage() doesn't
// return a real page and the probe falls back to screenshot mode.
const poolCfg = { enableBrowserPool: true, forceScreenshot: true } as const;
let launchFn: ReturnType<typeof vi.fn>;
beforeEach(() => {
_resetBrowserPoolForTests();
const mockBrowser = makeMockBrowser();
launchFn = vi.fn().mockResolvedValue(mockBrowser);
_setPuppeteerForTests({ launch: launchFn } as unknown as PuppeteerNode);
});
afterEach(async () => {
await drainBrowserPool();
_setPuppeteerForTests(undefined);
});
it("keeps the launch diagnostic on stderr when a command emits JSON", async () => {
const stdout = vi.spyOn(console, "log").mockImplementation(() => {});
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const result = await acquireBrowser(["--no-sandbox"], poolCfg);
await result.release();
expect(stderr).toHaveBeenCalledWith(
expect.stringMatching(/^\[BrowserManager\] Browser launched/),
);
expect(stdout).not.toHaveBeenCalled();
} finally {
stdout.mockRestore();
stderr.mockRestore();
}
});
it("sequential acquires with pool enabled return the same browser", async () => {
const first = await acquireBrowser(["--no-sandbox"], poolCfg);
const second = await acquireBrowser(["--no-sandbox"], poolCfg);
expect(first.browser).toBe(second.browser);
expect(launchFn).toHaveBeenCalledTimes(1);
await first.release();
await second.release();
});
it("concurrent acquires via Promise.all trigger exactly one launch", async () => {
const [a, b, c] = await Promise.all([
acquireBrowser(["--no-sandbox"], poolCfg),
acquireBrowser(["--no-sandbox"], poolCfg),
acquireBrowser(["--no-sandbox"], poolCfg),
]);
expect(launchFn).toHaveBeenCalledTimes(1);
expect(a.browser).toBe(b.browser);
expect(b.browser).toBe(c.browser);
await a.release();
await b.release();
await c.release();
});
it("pool recovers from a disconnected browser", async () => {
const first = await acquireBrowser(["--no-sandbox"], poolCfg);
await first.release();
// Simulate Chrome crash
(first.browser as unknown as { connected: boolean }).connected = false;
const freshBrowser = makeMockBrowser();
launchFn.mockResolvedValue(freshBrowser);
const second = await acquireBrowser(["--no-sandbox"], poolCfg);
expect(second.browser).toBe(freshBrowser);
expect(second.browser).not.toBe(first.browser);
expect(launchFn).toHaveBeenCalledTimes(2);
await second.release();
});
it("release at refCount 0 closes the browser", async () => {
const result = await acquireBrowser(["--no-sandbox"], poolCfg);
const closeFn = result.browser.close as ReturnType<typeof vi.fn>;
await result.release();
expect(closeFn).toHaveBeenCalledTimes(1);
});
it("releaseBrowser preserves the sole-owner legacy path", async () => {
const result = await acquireBrowser(["--no-sandbox"], poolCfg);
const closeFn = result.browser.close as ReturnType<typeof vi.fn>;
await releaseBrowser(result.browser);
expect(closeFn).toHaveBeenCalledTimes(1);
await result.release();
expect(closeFn).toHaveBeenCalledTimes(1);
});
it("releaseBrowser rejects an ambiguous pooled browser handle", async () => {
const first = await acquireBrowser(["--no-sandbox"], poolCfg);
const second = await acquireBrowser(["--no-sandbox"], poolCfg);
const closeFn = first.browser.close as ReturnType<typeof vi.fn>;
await expect(releaseBrowser(first.browser)).rejects.toThrow(
"Cannot release a pooled browser by handle while 2 leases are active",
);
expect(closeFn).not.toHaveBeenCalled();
await first.release();
await second.release();
});
it("pool returns a separate browser when forceScreenshot mismatches pooled mode", async () => {
const first = await acquireBrowser(["--no-sandbox"], poolCfg);
expect(first.captureMode).toBe("screenshot");
// Second acquire with same forceScreenshot — same mode, should reuse
const second = await acquireBrowser(["--no-sandbox"], poolCfg);
expect(second.browser).toBe(first.browser);
expect(launchFn).toHaveBeenCalledTimes(1);
await first.release();
await second.release();
});
it("forceReleaseBrowser does not kill Chrome when other sessions hold refs", async () => {
const result = await acquireBrowser(["--no-sandbox"], poolCfg);
// Acquire a second ref
const second = await acquireBrowser(["--no-sandbox"], poolCfg);
const disconnectFn = result.browser.disconnect as ReturnType<typeof vi.fn>;
forceReleaseBrowser(result.browser);
// Should NOT have disconnected — other session still holds a ref
expect(disconnectFn).not.toHaveBeenCalled();
// Each owner releases its own identity; neither can consume the other.
result.forceRelease();
await second.release();
});
it("drainBrowserPool is safe to call when no browser is pooled", async () => {
await drainBrowserPool();
});
it("drainBrowserPool awaits in-flight launch before closing", async () => {
let resolveDeferred!: (browser: Browser) => void;
const deferred = new Promise<Browser>((resolve) => {
resolveDeferred = resolve;
});
launchFn.mockReturnValue(deferred);
// Start acquire — it will be pending
const acquirePromise = acquireBrowser(["--no-sandbox"], poolCfg);
// Drain while launch is in-flight
const drainPromise = drainBrowserPool();
// Resolve the pending launch
const mockBrowser = makeMockBrowser();
resolveDeferred(mockBrowser);
await drainPromise;
const closeFn = mockBrowser.close as ReturnType<typeof vi.fn>;
expect(closeFn).toHaveBeenCalled();
// The acquire should still resolve (the launch completed before drain closed it)
await acquirePromise.catch(() => {});
});
});
describe.runIf(process.platform === "linux")("BeginFrame launch on a slow first start", () => {
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const BEGINFRAME_ARGS = ["--no-sandbox", "--enable-begin-frame-control"];
// chromePath must exist for the launch to request BeginFrame; the mocked launch never runs it.
const BEGINFRAME_CONFIG = { enableBrowserPool: false, chromePath: process.execPath };
const base = {
connected: true,
version: vi.fn().mockResolvedValue("HeadlessChrome/131.0.0.0"),
close: vi.fn().mockResolvedValue(undefined),
disconnect: vi.fn(),
process: () => ({ kill: vi.fn(), killed: false }),
};
const healthyPage = () => ({
goto: vi.fn().mockResolvedValue(null),
createCDPSession: vi.fn().mockResolvedValue({
send: vi
.fn()
.mockResolvedValueOnce({})
.mockResolvedValueOnce({})
.mockResolvedValue({ hasDamage: true, screenshotData: png.toString("base64") }),
detach: vi.fn().mockResolvedValue(undefined),
}),
close: vi.fn().mockResolvedValue(undefined),
});
const slowPage = () => ({
goto: vi.fn().mockReturnValue(new Promise<never>(() => {})),
close: vi.fn().mockResolvedValue(undefined),
});
afterEach(async () => {
vi.useRealTimers();
await drainBrowserPool();
_setPuppeteerForTests(undefined);
});
it("probes again on a fresh launch instead of falling back to screenshot capture", async () => {
_resetBrowserPoolForTests();
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
const pages = [slowPage(), healthyPage()];
const launch = vi.fn().mockImplementation(async () => ({
...base,
newPage: vi.fn().mockResolvedValue(pages.shift()),
}));
_setPuppeteerForTests({ launch } as unknown as PuppeteerNode);
vi.useFakeTimers();
const pending = acquireBrowser(BEGINFRAME_ARGS, BEGINFRAME_CONFIG);
await vi.advanceTimersByTimeAsync(3000);
const result = await pending;
expect(result.captureMode).toBe("beginframe");
expect(launch).toHaveBeenCalledTimes(2);
});
it("falls back to screenshot capture when the longer probe also fails", async () => {
_resetBrowserPoolForTests();
vi.spyOn(console, "error").mockImplementation(() => {});
vi.spyOn(console, "warn").mockImplementation(() => {});
const launch = vi.fn().mockImplementation(async () => ({
...base,
newPage: vi.fn().mockResolvedValue(slowPage()),
}));
_setPuppeteerForTests({ launch } as unknown as PuppeteerNode);
vi.useFakeTimers();
const pending = acquireBrowser(BEGINFRAME_ARGS, BEGINFRAME_CONFIG);
await vi.advanceTimersByTimeAsync(12000);
const result = await pending;
expect(result.captureMode).toBe("screenshot");
expect(launch).toHaveBeenCalledTimes(3);
});
});