diff --git a/docs/developers/cli.mdx b/docs/developers/cli.mdx index 82e8af5d5d..ed1bc43144 100644 --- a/docs/developers/cli.mdx +++ b/docs/developers/cli.mdx @@ -50,9 +50,10 @@ Google Cloud Run. Most commands take `--json` when you need to read the result f Start with the simple command, then open the [full reference](/packages/cli) for the exact options needed by automation or infrastructure. -When embedding preview in another tool or sharing a machine with other browser -automation, set `HYPERFRAMES_NO_ORPHAN_CLEANUP=1` in the calling environment -to skip startup cleanup of detached headless Chrome processes: +Before preview and render, HyperFrames stops headless Chrome processes left +behind by crashed HyperFrames sessions. It only stops browsers that HyperFrames +launched, so detached browsers from other tools keep running. To skip this +cleanup, set `HYPERFRAMES_NO_ORPHAN_CLEANUP=1` in the calling environment: ```bash HYPERFRAMES_NO_ORPHAN_CLEANUP=1 npx hyperframes preview ./my-video diff --git a/packages/cli/src/browser/launch.noListen.test.ts b/packages/cli/src/browser/launch.noListen.test.ts index 2a9e4f2218..81e5637881 100644 --- a/packages/cli/src/browser/launch.noListen.test.ts +++ b/packages/cli/src/browser/launch.noListen.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import { HYPERFRAMES_BROWSER_MARKER_ARG } from "@hyperframes/core/browser-marker"; import type { LaunchOptions, PuppeteerNode } from "puppeteer-core"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -30,10 +31,15 @@ describe("the GPU probe where nothing may listen", () => { await resolveManagedGpuMode("auto", "/chrome"); expect(probe.options[0]).toMatchObject({ pipe: true, - args: ["--headless", "--single-process", "--disable-gpu-compositing"], + args: [ + "--headless", + "--single-process", + "--disable-gpu-compositing", + HYPERFRAMES_BROWSER_MARKER_ARG, + ], }); expect(probe.options[1]!.pipe).toBe(true); - expect(probe.options[1]!.args).toEqual(["--headless"]); + expect(probe.options[1]!.args).toEqual(["--headless", HYPERFRAMES_BROWSER_MARKER_ARG]); }); }); diff --git a/packages/cli/src/browser/launch.ts b/packages/cli/src/browser/launch.ts index 2cda8a9388..d54c0d857a 100644 --- a/packages/cli/src/browser/launch.ts +++ b/packages/cli/src/browser/launch.ts @@ -1,4 +1,5 @@ import type { Browser, LaunchOptions, PuppeteerNode } from "puppeteer-core"; +import { withHyperframesBrowserMarker } from "@hyperframes/core/browser-marker"; import { resolveCaptureBrowserGpuMode, type BrowserGpuMode, @@ -117,7 +118,14 @@ async function launchOwnedBrowser( const signal = options.signal ? AbortSignal.any([options.signal, abort.signal]) : abort.signal; const launchedAt = Date.now(); const launch = untilLaunchOrAbort( - puppeteer.launch({ ...options, pipe: true, signal, handleSIGINT: false, handleSIGTERM: false }), + puppeteer.launch({ + ...options, + args: withHyperframesBrowserMarker(options.args), + pipe: true, + signal, + handleSIGINT: false, + handleSIGTERM: false, + }), signal, ); const limit = options.timeout ?? LAUNCH_TIMEOUT_MS; diff --git a/packages/cli/src/browser/sandboxDenied.test.ts b/packages/cli/src/browser/sandboxDenied.test.ts index 4ec29fd0e3..64f5c2a05b 100644 --- a/packages/cli/src/browser/sandboxDenied.test.ts +++ b/packages/cli/src/browser/sandboxDenied.test.ts @@ -6,6 +6,7 @@ import { type PuppeteerNode, } from "puppeteer-core"; import { TargetCloseError } from "puppeteer-core/internal/common/Errors.js"; +import { HYPERFRAMES_BROWSER_MARKER_ARG } from "@hyperframes/core/browser-marker"; import { afterEach, describe, expect, it, vi } from "vitest"; import { launchManagedBrowser } from "./launch.js"; @@ -66,7 +67,12 @@ describe("a browser inside an agent's macOS seatbelt", () => { expect(launches).toHaveLength(1); expect(launches[0]).toMatchObject({ pipe: true, - args: ["--headless", "--single-process", "--disable-gpu-compositing"], + args: [ + "--headless", + "--single-process", + "--disable-gpu-compositing", + HYPERFRAMES_BROWSER_MARKER_ARG, + ], }); }); @@ -75,11 +81,12 @@ describe("a browser inside an agent's macOS seatbelt", () => { const { puppeteer, launches } = recordingPuppeteer([SEATBELT_LAUNCH_FAILURE]); await expect(launchManagedBrowser(puppeteer, { args: ["--headless"] })).resolves.toBeDefined(); expect(launches.map((options) => options.pipe ?? false)).toEqual([true, true]); - expect(launches[0]!.args).toEqual(["--headless"]); + expect(launches[0]!.args).toEqual(["--headless", HYPERFRAMES_BROWSER_MARKER_ARG]); expect(launches[1]!.args).toEqual([ "--headless", "--single-process", "--disable-gpu-compositing", + HYPERFRAMES_BROWSER_MARKER_ARG, ]); }); it("relaunches as one process when an unnamed sandbox closes a pipe launch before its first targets", async () => { @@ -90,8 +97,13 @@ describe("a browser inside an agent's macOS seatbelt", () => { ]); await expect(launchManagedBrowser(puppeteer, { args: ["--headless"] })).resolves.toBeDefined(); expect(launches.map((options) => options.args)).toEqual([ - ["--headless"], - ["--headless", "--single-process", "--disable-gpu-compositing"], + ["--headless", HYPERFRAMES_BROWSER_MARKER_ARG], + [ + "--headless", + "--single-process", + "--disable-gpu-compositing", + HYPERFRAMES_BROWSER_MARKER_ARG, + ], ]); }); diff --git a/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts b/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts index 0942a77430..d22a9be7b0 100644 --- a/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts +++ b/packages/cli/src/capture/captureCompositionFrame.browserFallback.test.ts @@ -1,4 +1,5 @@ import { EventEmitter } from "node:events"; +import { HYPERFRAMES_BROWSER_MARKER_ARG } from "@hyperframes/core/browser-marker"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ @@ -232,6 +233,7 @@ describe("openSettledCompositionPage when nothing may listen", () => { "--headless", "--single-process", "--disable-gpu-compositing", + HYPERFRAMES_BROWSER_MARKER_ARG, ]); expect(opened.page.setRequestInterception).toHaveBeenCalledWith(true); const onRequest = opened.page.on.mock.calls.find(([event]) => event === "request")![1]; @@ -263,7 +265,7 @@ describe("openSettledCompositionPage when nothing may listen", () => { mocks.launch.mockResolvedValue(opened.browser); await openSettledCompositionPage(HTML, "http://127.0.0.1:3000", OPTIONS); expect(launched().pipe).toBe(true); - expect(launched().args).toEqual(["--headless"]); + expect(launched().args).toEqual(["--headless", HYPERFRAMES_BROWSER_MARKER_ARG]); expect(opened.page.setRequestInterception).not.toHaveBeenCalled(); }); }); diff --git a/packages/cli/src/utils/orphanCleanup.scan.test.ts b/packages/cli/src/utils/orphanCleanup.scan.test.ts index 4124148a67..92f41296aa 100644 --- a/packages/cli/src/utils/orphanCleanup.scan.test.ts +++ b/packages/cli/src/utils/orphanCleanup.scan.test.ts @@ -1,3 +1,4 @@ +import { HYPERFRAMES_BROWSER_MARKER_ARG } from "@hyperframes/core/browser-marker"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const child = vi.hoisted(() => ({ execFileSync: vi.fn(), execSync: vi.fn() })); @@ -28,14 +29,17 @@ describe.skipIf(process.platform === "win32")("killOrphanedProcesses scan", () = expect(tree.terminateProcessTree).not.toHaveBeenCalled(); }); - it("finds orphaned browsers from one process-table read", () => { + it("kills only orphaned browsers HyperFrames marked, from one process-table read", () => { child.execFileSync.mockReturnValue( [ - " 101 1 /opt/chrome-headless-shell --headless", + ` 101 1 /opt/chrome-headless-shell --headless ${HYPERFRAMES_BROWSER_MARKER_ARG}`, " 102 101 /opt/chrome-headless-shell --type=renderer", - " 103 1 chrome --user-data-dir=/tmp/puppeteer_dev_chrome_profile-x", - " 104 900 chrome --user-data-dir=/tmp/puppeteer_dev_chrome_profile-y", + ` 103 1 chrome --user-data-dir=/tmp/puppeteer_dev_chrome_profile-x ${HYPERFRAMES_BROWSER_MARKER_ARG} about:blank`, + ` 104 900 chrome --user-data-dir=/tmp/puppeteer_dev_chrome_profile-y ${HYPERFRAMES_BROWSER_MARKER_ARG}`, " 105 1 /usr/bin/node server.js", + " 106 1 /opt/chrome-headless-shell --remote-debugging-port=0 about:blank", + " 107 1 chrome --user-data-dir=/tmp/puppeteer_dev_chrome_profile-z --headless", + ` 108 1 chrome ${HYPERFRAMES_BROWSER_MARKER_ARG}-lookalike`, ].join("\n"), ); @@ -44,4 +48,13 @@ describe.skipIf(process.platform === "win32")("killOrphanedProcesses scan", () = expect(child.execSync).not.toHaveBeenCalled(); expect(tree.terminateProcessTree.mock.calls.map(([pid]) => pid)).toEqual([101, 103]); }); + + it("leaves another tool's detached headless shell running", () => { + child.execFileSync.mockReturnValue( + " 201 1 /opt/chrome-headless-shell --remote-debugging-port=0 about:blank", + ); + + expect(killOrphanedProcesses()).toBe(0); + expect(tree.terminateProcessTree).not.toHaveBeenCalled(); + }); }); diff --git a/packages/cli/src/utils/orphanCleanup.ts b/packages/cli/src/utils/orphanCleanup.ts index b07c831748..8cd2cd6b03 100644 --- a/packages/cli/src/utils/orphanCleanup.ts +++ b/packages/cli/src/utils/orphanCleanup.ts @@ -1,24 +1,20 @@ // fallow-ignore-file code-duplication import { execFileSync } from "node:child_process"; import { readFileSync } from "node:fs"; +import { HYPERFRAMES_BROWSER_MARKER_ARG } from "@hyperframes/core/browser-marker"; import { runRenderSetupWorker } from "./cancellableProcess.js"; import { terminateProcessTree, windowsProcessTreeKillArgs } from "./processTree.js"; export { windowsProcessTreeKillArgs }; /** - * Kill orphaned Chrome processes (PPID 1: their parent died) left by crashed sessions, headless shell or - * Puppeteer's Chrome (`puppeteer_dev_chrome_profile`), with their helper subtrees. Only the current user's - * processes are read. Returns the count of killed process trees. + * Kill the current user's orphaned (PPID 1) Chrome trees that carry the HyperFrames launch marker, so detached + * browsers other tools started survive. Returns the count of killed process trees. */ export function killOrphanedProcesses(): number { if (process.env.HYPERFRAMES_NO_ORPHAN_CLEANUP === "1" || process.platform === "win32") return 0; - return killOrphansMatching([ - "chrome-headless-shell", - "chrome_headless_shell", - "puppeteer_dev_chrome_profile", - ]); + return killOrphansMatching(HYPERFRAMES_BROWSER_MARKER_ARG); } export async function killOrphanedProcessesForRender(signal: AbortSignal): Promise { @@ -230,7 +226,7 @@ export function isProcessDescendant( } /** One process-table read: a spawn per candidate PID cost half a second before `preview` listened. */ -function killOrphansMatching(markers: readonly string[]): number { +function killOrphansMatching(marker: string): number { const uid = process.getuid?.(); const owner = uid === undefined ? ["-A"] : ["-U", String(uid)]; let table: string; @@ -247,7 +243,7 @@ function killOrphansMatching(markers: readonly string[]): number { for (const line of table.split("\n")) { const match = line.match(/^\s*(\d+)\s+(\d+)\s+(.*)$/); if (!match || match[2] !== "1") continue; - if (!markers.some((marker) => match[3]!.includes(marker))) continue; + if (!match[3]!.split(/\s+/).includes(marker)) continue; killProcessTree(Number(match[1])); killed++; } diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index 95a6627d08..31699aeb0c 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -56,6 +56,12 @@ "types": "./dist/safePath.d.ts", "environments": ["bun", "node"] }, + "./browser-marker": { + "source": "./src/browserMarker.ts", + "runtime": "./dist/browserMarker.js", + "types": "./dist/browserMarker.d.ts", + "environments": ["bun", "node"] + }, "./atomic-file": { "source": "./src/atomicFile.ts", "runtime": "./dist/atomicFile.js", diff --git a/packages/core/package.json b/packages/core/package.json index 0192874494..d12a25d8a0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -68,6 +68,12 @@ "import": "./src/safePath.ts", "types": "./src/safePath.ts" }, + "./browser-marker": { + "bun": "./src/browserMarker.ts", + "node": "./dist/browserMarker.js", + "import": "./src/browserMarker.ts", + "types": "./src/browserMarker.ts" + }, "./atomic-file": { "bun": "./src/atomicFile.ts", "node": "./dist/atomicFile.js", @@ -594,6 +600,10 @@ "import": "./dist/safePath.js", "types": "./dist/safePath.d.ts" }, + "./browser-marker": { + "import": "./dist/browserMarker.js", + "types": "./dist/browserMarker.d.ts" + }, "./atomic-file": { "import": "./dist/atomicFile.js", "types": "./dist/atomicFile.d.ts" diff --git a/packages/core/src/browserMarker.test.ts b/packages/core/src/browserMarker.test.ts new file mode 100644 index 0000000000..ea58a3e082 --- /dev/null +++ b/packages/core/src/browserMarker.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { HYPERFRAMES_BROWSER_MARKER_ARG, withHyperframesBrowserMarker } from "./browserMarker.js"; + +describe("withHyperframesBrowserMarker", () => { + it("appends the marker to a copy of the args", () => { + const args = ["--no-sandbox"]; + expect(withHyperframesBrowserMarker(args)).toEqual([ + "--no-sandbox", + HYPERFRAMES_BROWSER_MARKER_ARG, + ]); + expect(args).toEqual(["--no-sandbox"]); + }); + + it("adds the marker once when a wrapper marks already-marked args", () => { + expect(withHyperframesBrowserMarker(withHyperframesBrowserMarker())).toEqual([ + HYPERFRAMES_BROWSER_MARKER_ARG, + ]); + }); +}); diff --git a/packages/core/src/browserMarker.ts b/packages/core/src/browserMarker.ts new file mode 100644 index 0000000000..7ab713dc7b --- /dev/null +++ b/packages/core/src/browserMarker.ts @@ -0,0 +1,8 @@ +/** An inert Chrome switch on every browser HyperFrames launches, so orphan cleanup can tell its own from other tools'. */ +export const HYPERFRAMES_BROWSER_MARKER_ARG = "--hyperframes-owned-browser"; + +export function withHyperframesBrowserMarker(args: readonly string[] = []): string[] { + return args.includes(HYPERFRAMES_BROWSER_MARKER_ARG) + ? [...args] + : [...args, HYPERFRAMES_BROWSER_MARKER_ARG]; +} diff --git a/packages/engine/src/services/browserManager-ownerMarker.test.ts b/packages/engine/src/services/browserManager-ownerMarker.test.ts new file mode 100644 index 0000000000..f9a539fb51 --- /dev/null +++ b/packages/engine/src/services/browserManager-ownerMarker.test.ts @@ -0,0 +1,74 @@ +import { HYPERFRAMES_BROWSER_MARKER_ARG } from "@hyperframes/core/browser-marker"; +import type { LaunchOptions, PuppeteerNode } from "puppeteer-core"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + _resetAutoBrowserGpuModeCacheForTests, + _resetBrowserPoolForTests, + _setPuppeteerForTests, + acquireBrowser, + drainBrowserPool, + resolveBrowserGpuMode, +} from "./browserManager.js"; + +function fakeChrome(newPage = vi.fn().mockResolvedValue({ evaluate: vi.fn() })) { + const launch = vi.fn(async (_options: LaunchOptions) => ({ + connected: true, + newPage, + version: vi.fn().mockResolvedValue("HeadlessChrome/152.0.7977.30"), + close: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn(), + process: () => ({ kill: vi.fn(), killed: false }), + })); + _setPuppeteerForTests({ launch } as unknown as PuppeteerNode); + return launch; +} + +const launchedArgs = (launch: ReturnType) => + launch.mock.calls.map(([options]) => options.args ?? []); + +describe("every engine Chrome carries the HyperFrames marker", () => { + beforeEach(() => { + vi.stubEnv("PRODUCER_HEADLESS_SHELL_PATH", process.execPath); + vi.spyOn(console, "log").mockImplementation(() => {}); + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + }); + + afterEach(async () => { + await drainBrowserPool(); + _resetBrowserPoolForTests(); + _resetAutoBrowserGpuModeCacheForTests(); + _setPuppeteerForTests(undefined); + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it("marks the GPU probe browser", async () => { + const launch = fakeChrome(); + await resolveBrowserGpuMode("auto", { platform: "linux" }); + expect(launchedArgs(launch)[0]).toContain(HYPERFRAMES_BROWSER_MARKER_ARG); + }); + + it("marks the capture browser without changing the caller's args", async () => { + const launch = fakeChrome(); + const args = ["--no-sandbox"]; + const lease = await acquireBrowser(args, { forceScreenshot: true }); + await lease.release(); + expect(launchedArgs(launch)).toEqual([["--no-sandbox", HYPERFRAMES_BROWSER_MARKER_ARG]]); + expect(args).toEqual(["--no-sandbox"]); + }); + + it.runIf(process.platform === "linux")( + "marks the screenshot relaunch after a failed beginFrame probe", + async () => { + const launch = fakeChrome(vi.fn().mockRejectedValue(new Error("no beginFrame"))); + const lease = await acquireBrowser(["--enable-begin-frame-control"], { + forceScreenshot: false, + }); + await lease.release(); + const args = launchedArgs(launch); + expect(args).toHaveLength(2); + for (const launched of args) expect(launched).toContain(HYPERFRAMES_BROWSER_MARKER_ARG); + }, + ); +}); diff --git a/packages/engine/src/services/browserManager.ts b/packages/engine/src/services/browserManager.ts index 71530f76da..6380a149a7 100644 --- a/packages/engine/src/services/browserManager.ts +++ b/packages/engine/src/services/browserManager.ts @@ -6,6 +6,7 @@ * launch args, pooled browser acquisition/release. */ +import { withHyperframesBrowserMarker } from "@hyperframes/core/browser-marker"; import { scanHtmlOpeningTags } from "@hyperframes/parsers/html-attribute-spans"; import type { Browser, LaunchOptions, Page, PuppeteerNode } from "puppeteer-core"; import { execSync } from "child_process"; @@ -111,7 +112,7 @@ async function probeHardwareWebGlInfo( const launchOptions: LaunchOptions = { ...sigintLaunchOptions(), headless: true, - args: options.args, + args: withHyperframesBrowserMarker(options.args), defaultViewport: { width: 64, height: 64 }, executablePath: options.executablePath, env: options.env, @@ -854,7 +855,7 @@ async function launchBrowser( browser = await ppt.launch({ ...sigintLaunchOptions(), headless: true, - args: [...fingerprint.args], + args: withHyperframesBrowserMarker(fingerprint.args), defaultViewport: null, executablePath: fingerprint.executablePath, env, @@ -887,7 +888,7 @@ async function launchBrowser( browser = await ppt.launch({ ...sigintLaunchOptions(), headless: true, - args: stripBeginFrameFlags([...fingerprint.args]), + args: withHyperframesBrowserMarker(stripBeginFrameFlags([...fingerprint.args])), defaultViewport: null, executablePath: fingerprint.executablePath, env, diff --git a/packages/engine/src/services/hdrCapture-ownerMarker.test.ts b/packages/engine/src/services/hdrCapture-ownerMarker.test.ts new file mode 100644 index 0000000000..1ecd70c311 --- /dev/null +++ b/packages/engine/src/services/hdrCapture-ownerMarker.test.ts @@ -0,0 +1,23 @@ +import { HYPERFRAMES_BROWSER_MARKER_ARG } from "@hyperframes/core/browser-marker"; +import type { LaunchOptions } from "puppeteer-core"; +import { expect, it, vi } from "vitest"; + +const launch = vi.hoisted(() => + vi.fn(async (_options: LaunchOptions) => ({ + newPage: async () => ({ setViewport: async () => undefined }), + })), +); +vi.mock("puppeteer", () => ({ default: { launch } })); +vi.mock("puppeteer-core", () => ({ default: { launch } })); +vi.mock("./browserManager.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveFullChromePath: () => "/opt/chrome/chrome", +})); + +const { launchHdrBrowser } = await import("./hdrCapture.js"); + +it("marks the HDR browser", async () => { + await launchHdrBrowser(64, 64); + expect(launch).toHaveBeenCalledTimes(1); + expect(launch.mock.calls[0]![0].args).toContain(HYPERFRAMES_BROWSER_MARKER_ARG); +}); diff --git a/packages/engine/src/services/hdrCapture.ts b/packages/engine/src/services/hdrCapture.ts index d71a7a9a78..bdb43f229f 100644 --- a/packages/engine/src/services/hdrCapture.ts +++ b/packages/engine/src/services/hdrCapture.ts @@ -19,6 +19,7 @@ * Performance: ~6 fps at 1080x1920 via base64 transfer. */ +import { withHyperframesBrowserMarker } from "@hyperframes/core/browser-marker"; import type { Page, Browser, PuppeteerNode } from "puppeteer-core"; import { resolveFullChromePath, sigintLaunchOptions } from "./browserManager.js"; @@ -249,7 +250,7 @@ export async function launchHdrBrowser( ...sigintLaunchOptions(), headless: false, executablePath: chromePath, - args: buildHdrChromeArgs(width, height), + args: withHyperframesBrowserMarker(buildHdrChromeArgs(width, height)), }); const page = await browser.newPage(); diff --git a/packages/inspector/src/service.ts b/packages/inspector/src/service.ts index 1d270b45f1..ab09b1a39f 100644 --- a/packages/inspector/src/service.ts +++ b/packages/inspector/src/service.ts @@ -1,4 +1,5 @@ import type { TimelineRequest } from "../../core/src/runtime/timelineInspection"; +import { withHyperframesBrowserMarker } from "../../core/src/browserMarker"; import { inspectComponents, inspectElementMap, @@ -176,12 +177,12 @@ export class Inspector { this.browser = await puppeteer.launch({ executablePath: this.options.executablePath ?? chromePath(), headless: true, - args: [ + args: withHyperframesBrowserMarker([ "--disable-dev-shm-usage", "--enable-webgl", "--use-gl=angle", "--use-angle=swiftshader", - ], + ]), }); this.stats.browserStarts++; } diff --git a/packages/inspector/tests/serviceFingerprint.test.ts b/packages/inspector/tests/serviceFingerprint.test.ts index 1ff77294ba..790fdf8124 100644 --- a/packages/inspector/tests/serviceFingerprint.test.ts +++ b/packages/inspector/tests/serviceFingerprint.test.ts @@ -2,26 +2,32 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; +import { HYPERFRAMES_BROWSER_MARKER_ARG } from "../../core/src/browserMarker"; import { Inspector } from "../src/service"; +const launches = vi.hoisted(() => [] as { args?: string[] }[]); + vi.mock("puppeteer-core", () => ({ default: { - launch: async () => ({ - close: async () => {}, - newPage: async () => { - let html = ""; - return { - setViewport: async () => {}, - goto: async (url: string) => { - html = await (await fetch(url)).text(); - }, - waitForFunction: async () => {}, - evaluate: async () => {}, - content: async () => html, - close: async () => {}, - }; - }, - }), + launch: async (options: { args?: string[] }) => { + launches.push(options); + return { + close: async () => {}, + newPage: async () => { + let html = ""; + return { + setViewport: async () => {}, + goto: async (url: string) => { + html = await (await fetch(url)).text(); + }, + waitForFunction: async () => {}, + evaluate: async () => {}, + content: async () => html, + close: async () => {}, + }; + }, + }; + }, }, })); @@ -50,3 +56,24 @@ it("reloads the standalone page when an HF ID is stamped onto disk", async () => rmSync(project, { recursive: true, force: true }); } }); + +it("launches its browser with the HyperFrames marker", async () => { + const project = mkdtempSync(join(tmpdir(), "inspect-marker-")); + writeFileSync( + join(project, "index.html"), + '
', + ); + const inspector = new Inspector(project, { + executablePath: "browser-stand-in", + instrument: false, + }); + try { + launches.length = 0; + await inspector.withPage((page) => page.content()); + expect(launches).toHaveLength(1); + expect(launches[0]!.args).toContain(HYPERFRAMES_BROWSER_MARKER_ARG); + } finally { + await inspector.dispose(); + rmSync(project, { recursive: true, force: true }); + } +}); diff --git a/packages/producer/src/parity-harness.ts b/packages/producer/src/parity-harness.ts index b0b7eb696f..996883feb3 100644 --- a/packages/producer/src/parity-harness.ts +++ b/packages/producer/src/parity-harness.ts @@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path"; import process from "node:process"; import { spawnSync } from "node:child_process"; import puppeteer, { type Browser, type Page } from "puppeteer-core"; +import { withHyperframesBrowserMarker } from "@hyperframes/core/browser-marker"; import { MEDIA_VISUAL_STYLE_PROPERTIES, quantizeTimeToFrame } from "./utils/parityContract.js"; type ParityHarnessOptions = { @@ -319,7 +320,7 @@ async function run(): Promise { height: options.height, deviceScaleFactor: 1, }, - args: [ + args: withHyperframesBrowserMarker([ "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", @@ -331,7 +332,7 @@ async function run(): Promise { "--font-render-hinting=none", "--force-color-profile=srgb", `--window-size=${options.width},${options.height}`, - ], + ]), }); try { diff --git a/packages/producer/src/verifyStaticPage.ts b/packages/producer/src/verifyStaticPage.ts index 677f27f61d..3fa65e473b 100644 --- a/packages/producer/src/verifyStaticPage.ts +++ b/packages/producer/src/verifyStaticPage.ts @@ -2,6 +2,7 @@ // error, catching a script-fetched asset a static markup scan can't see. import puppeteer, { type Browser } from "puppeteer"; +import { withHyperframesBrowserMarker } from "@hyperframes/core/browser-marker"; import { injectScriptsAtHeadStart } from "@hyperframes/core/compiler"; import { getVerifiedHyperframeRuntimeSource } from "./services/hyperframeRuntimeLoader.js"; @@ -13,7 +14,7 @@ export async function launchVerifyBrowser(): Promise { return puppeteer.launch({ headless: true, executablePath: process.env.PUPPETEER_EXECUTABLE_PATH || undefined, - args: ["--no-sandbox", "--disable-setuid-sandbox"], + args: withHyperframesBrowserMarker(["--no-sandbox", "--disable-setuid-sandbox"]), }); }