fix(browser): load Playwright runtime on demand (#127049)

* fix(browser): load Playwright runtime on demand

* test(browser): use lazy Playwright accessor

* fix(worker): bind bundled Playwright runtime

* style(worker): format build regression

* style(worker): apply repository formatter
This commit is contained in:
Dallin Romney
2026-08-21 10:45:18 -07:00
committed by GitHub
parent b9dda9d740
commit 8dd21b0f34
10 changed files with 60 additions and 32 deletions
@@ -1,4 +0,0 @@
// Build-visible bridge for playwright-core's private User-Agent helper.
import coreBundle from "playwright-core/lib/coreBundle";
export default coreBundle;
@@ -1,15 +1,22 @@
/**
* Playwright runtime loader.
*
* Static package imports keep the worker deploy build's executable closure visible
* to the bundler while normal package builds may still externalize the dependency.
* Loads playwright-core only when browser behavior needs it. The worker deploy
* build declares its static dependency closure in worker-deploy-build-plugin.mts.
*/
import playwrightCoreDefault from "playwright-core";
import { createRequire } from "node:module";
import type * as PlaywrightCore from "playwright-core";
import coreBundle from "./playwright-core-bundle.runtime.mjs";
/** Runtime playwright-core module instance. */
export const playwrightCore = playwrightCoreDefault as typeof PlaywrightCore;
const require = createRequire(import.meta.url);
/** Loads the Playwright runtime on first Browser use. */
export function getPlaywrightCore(): typeof PlaywrightCore {
return require("playwright-core") as typeof PlaywrightCore;
}
/** Dependency-owned User-Agent used by Playwright's native CDP WebSocket transport. */
export const getPlaywrightUserAgent = (coreBundle as { getUserAgent: () => string }).getUserAgent;
export function getPlaywrightUserAgent(): string {
return (
require("playwright-core/lib/coreBundle") as { getUserAgent: () => string }
).getUserAgent();
}
@@ -4,7 +4,7 @@ import type { AddressInfo } from "node:net";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test-support.js";
import { playwrightCore } from "./playwright-core.runtime.js";
import { getPlaywrightCore } from "./playwright-core.runtime.js";
import { ensurePageState } from "./pw-session-state.js";
import { closePlaywrightBrowserConnection, getPageForTargetId } from "./pw-session.js";
import { downloadViaPlaywright, waitForDownloadViaPlaywright } from "./pw-tools-core.downloads.js";
@@ -83,7 +83,7 @@ describe.runIf(runChromiumProof)("managed Chromium download cancellation", () =>
const cdpPort = await getFreePort();
const profileDir = path.join(rootDir, "profile");
const context = await playwrightCore.chromium.launchPersistentContext(profileDir, {
const context = await getPlaywrightCore().chromium.launchPersistentContext(profileDir, {
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
args: [`--remote-debugging-port=${cdpPort}`],
@@ -222,11 +222,14 @@ describe.runIf(runChromiumProof)("managed Chromium download cancellation", () =>
await context.close();
const restartedCdpPort = await getFreePort();
const restartedContext = await playwrightCore.chromium.launchPersistentContext(profileDir, {
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
args: [`--remote-debugging-port=${restartedCdpPort}`],
});
const restartedContext = await getPlaywrightCore().chromium.launchPersistentContext(
profileDir,
{
headless: true,
executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH,
args: [`--remote-debugging-port=${restartedCdpPort}`],
},
);
cleanup.push(async () => await restartedContext.close());
const restartedPage = restartedContext.pages()[0] ?? (await restartedContext.newPage());
await restartedPage.goto(`http://127.0.0.1:${downloadPort}/`);
@@ -4,9 +4,7 @@ import type { Browser, ConnectOverCDPTransport } from "playwright-core";
import WebSocket from "ws";
import { formatErrorMessage } from "../infra/errors.js";
import { openCdpWebSocket } from "./cdp.helpers.js";
import { playwrightCore } from "./playwright-core.runtime.js";
const { chromium } = playwrightCore;
import { getPlaywrightCore } from "./playwright-core.runtime.js";
type CdpSocketLookup = typeof dnsLookupCb;
export async function connectOverCdpPinnedTransport(
@@ -130,7 +128,7 @@ export async function connectOverCdpPinnedTransport(
ws.on("error", (error) => {
scheduleTransportClosed(formatErrorMessage(error));
});
return await chromium.connectOverCDP(transport, { timeout: opts.timeout });
return await getPlaywrightCore().chromium.connectOverCDP(transport, { timeout: opts.timeout });
} catch (error) {
ws.close();
throw error;
@@ -15,7 +15,7 @@ import {
} from "./cdp.helpers.js";
import { getChromeWebSocketEndpoint } from "./chrome.js";
import { BrowserTabNotFoundError } from "./errors.js";
import { playwrightCore } from "./playwright-core.runtime.js";
import { getPlaywrightCore } from "./playwright-core.runtime.js";
import { connectOverCdpPinnedTransport } from "./pw-session-cdp-transport.js";
import {
blockedPageRefsByCdpUrl,
@@ -40,7 +40,6 @@ import {
targetKey,
} from "./pw-session-state.js";
const { chromium } = playwrightCore;
type CdpEndpointPin = NonNullable<Awaited<ReturnType<typeof assertCdpEndpointAllowed>>>;
function resolveCdpConnectRetryDelayMs(attempt: number): number {
@@ -452,7 +451,10 @@ export async function connectBrowser(
lookup,
});
}
return await chromium.connectOverCDP(connectionUrl, { timeout, headers });
return await getPlaywrightCore().chromium.connectOverCDP(connectionUrl, {
timeout,
headers,
});
}),
);
};
@@ -14,12 +14,12 @@ export const getChromeWebSocketEndpointMock: MockFn = vi.fn();
vi.mock("./playwright-core.runtime.js", () => ({
getPlaywrightUserAgent: () => "Playwright/test",
playwrightCore: {
getPlaywrightCore: () => ({
chromium: {
connectOverCDP: (...args: unknown[]) => connectOverCdpMock(...args),
},
devices: {},
},
}),
}));
vi.mock("./chrome.js", () => ({
@@ -35,7 +35,7 @@ const stateMocks = vi.hoisted(() => ({
}));
vi.mock("./playwright-core.runtime.js", () => ({
playwrightCore: { devices: stateMocks.devices },
getPlaywrightCore: () => ({ devices: stateMocks.devices }),
}));
vi.mock("./pw-session.js", () => ({
@@ -3,12 +3,10 @@
*/
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { CDPSession, Page } from "playwright-core";
import { playwrightCore } from "./playwright-core.runtime.js";
import { getPlaywrightCore } from "./playwright-core.runtime.js";
import type { PageState } from "./pw-session-contracts.js";
import { ensurePageState, getPageForTargetId } from "./pw-session.js";
const { devices: playwrightDevices } = playwrightCore;
type DeviceSize = { width: number; height: number };
type PageCdpSend = (method: string, params?: Record<string, unknown>) => Promise<unknown>;
@@ -252,7 +250,7 @@ export async function setDeviceViaPlaywright(opts: {
if (!name) {
throw new Error("device name is required");
}
const descriptor = (playwrightDevices as Record<string, unknown>)[name] as
const descriptor = (getPlaywrightCore().devices as Record<string, unknown>)[name] as
| PlaywrightDeviceDescriptor
| undefined;
if (!descriptor) {
@@ -11,6 +11,10 @@ const PLAYWRIGHT_BROWSER_REGISTRY_INIT =
' registry = new Registry(require(import_path20.default.join(packageRoot, "browsers.json")));';
const WORKER_BROWSER_RUNTIME_COMPOSITION = `import { createAttachedBrowserToolRuntime } from "../../extensions/browser/runtime-api.js";
export default { createAttachedBrowserToolRuntime };`;
const WORKER_PLAYWRIGHT_RUNTIME = `import * as playwrightCore from "playwright-core";
import { getUserAgent } from "playwright-core/lib/coreBundle";
export function getPlaywrightCore() { return playwrightCore; }
export function getPlaywrightUserAgent() { return getUserAgent(); }`;
const UNDICI_REQUIRE_BOOTSTRAP = [
'import { createRequire } from "node:module";',
"const requireUndici = createRequire(import.meta.url);\n",
@@ -25,6 +29,9 @@ export function createWorkerDeployBuildPlugin(rootDir = process.cwd()) {
const browserRuntimeBridgePath = fs.realpathSync(
path.resolve("src/worker/worker-deploy-browser-runtime.ts"),
);
const playwrightRuntimePath = fs.realpathSync(
path.resolve("extensions/browser/src/browser/playwright-core.runtime.ts"),
);
const undiciDispatcherOptionsPath = fs.realpathSync(
path.resolve("src/infra/net/undici-dispatcher-options.ts"),
);
@@ -55,6 +62,9 @@ export function createWorkerDeployBuildPlugin(rootDir = process.cwd()) {
if (resolvedId === browserRuntimeBridgePath) {
return WORKER_BROWSER_RUNTIME_COMPOSITION;
}
if (resolvedId === playwrightRuntimePath) {
return WORKER_PLAYWRIGHT_RUNTIME;
}
if (resolvedId === undiciDispatcherOptionsPath) {
if (UNDICI_REQUIRE_BOOTSTRAP.some((fragment) => !code.includes(fragment))) {
this.error("undici dispatcher bootstrap changed; update the worker deploy transform");
@@ -35,6 +35,20 @@ describe("worker deploy build plugin", () => {
expect(transformed).not.toContain("was not composed by the build");
});
it("binds the lazy Playwright accessor to bundled modules", () => {
const runtimePath = path.resolve("extensions/browser/src/browser/playwright-core.runtime.ts");
const source = fs.readFileSync(runtimePath, "utf8");
const plugin = createWorkerDeployBuildPlugin();
const transformed = plugin.transform.call({ error: fail }, source, runtimePath);
expect(transformed).toContain('import * as playwrightCore from "playwright-core";');
expect(transformed).toContain('import { getUserAgent } from "playwright-core/lib/coreBundle";');
expect(transformed).toContain("return playwrightCore;");
expect(transformed).not.toContain("createRequire");
expect(transformed).not.toContain('require("playwright-core")');
});
it("bundles the undici dispatcher dependency without a worker runtime require", () => {
const dispatcherPath = path.resolve("src/infra/net/undici-dispatcher-options.ts");
const source = fs.readFileSync(dispatcherPath, "utf8");