diff --git a/extensions/browser/src/browser/playwright-core-bundle.runtime.mjs b/extensions/browser/src/browser/playwright-core-bundle.runtime.mjs deleted file mode 100644 index 2f72f1c31712..000000000000 --- a/extensions/browser/src/browser/playwright-core-bundle.runtime.mjs +++ /dev/null @@ -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; diff --git a/extensions/browser/src/browser/playwright-core.runtime.ts b/extensions/browser/src/browser/playwright-core.runtime.ts index 14655966ab68..3cd0cf28127f 100644 --- a/extensions/browser/src/browser/playwright-core.runtime.ts +++ b/extensions/browser/src/browser/playwright-core.runtime.ts @@ -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(); +} diff --git a/extensions/browser/src/browser/pw-download-cancel.chromium.test.ts b/extensions/browser/src/browser/pw-download-cancel.chromium.test.ts index ca8c86a89657..494a75c130c4 100644 --- a/extensions/browser/src/browser/pw-download-cancel.chromium.test.ts +++ b/extensions/browser/src/browser/pw-download-cancel.chromium.test.ts @@ -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}/`); diff --git a/extensions/browser/src/browser/pw-session-cdp-transport.ts b/extensions/browser/src/browser/pw-session-cdp-transport.ts index f5e9f1828049..9664578ae58e 100644 --- a/extensions/browser/src/browser/pw-session-cdp-transport.ts +++ b/extensions/browser/src/browser/pw-session-cdp-transport.ts @@ -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; diff --git a/extensions/browser/src/browser/pw-session-connection.ts b/extensions/browser/src/browser/pw-session-connection.ts index ab62a5384100..ab4d4f03f7b9 100644 --- a/extensions/browser/src/browser/pw-session-connection.ts +++ b/extensions/browser/src/browser/pw-session-connection.ts @@ -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>>; 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, + }); }), ); }; diff --git a/extensions/browser/src/browser/pw-session.mock-setup.ts b/extensions/browser/src/browser/pw-session.mock-setup.ts index 72b389b86127..8f9953d800c5 100644 --- a/extensions/browser/src/browser/pw-session.mock-setup.ts +++ b/extensions/browser/src/browser/pw-session.mock-setup.ts @@ -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", () => ({ diff --git a/extensions/browser/src/browser/pw-tools-core.state.test.ts b/extensions/browser/src/browser/pw-tools-core.state.test.ts index c065cfc6e7b0..3d6fb15e0023 100644 --- a/extensions/browser/src/browser/pw-tools-core.state.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.state.test.ts @@ -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", () => ({ diff --git a/extensions/browser/src/browser/pw-tools-core.state.ts b/extensions/browser/src/browser/pw-tools-core.state.ts index fef272a538dd..7182330ac39c 100644 --- a/extensions/browser/src/browser/pw-tools-core.state.ts +++ b/extensions/browser/src/browser/pw-tools-core.state.ts @@ -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) => Promise; @@ -252,7 +250,7 @@ export async function setDeviceViaPlaywright(opts: { if (!name) { throw new Error("device name is required"); } - const descriptor = (playwrightDevices as Record)[name] as + const descriptor = (getPlaywrightCore().devices as Record)[name] as | PlaywrightDeviceDescriptor | undefined; if (!descriptor) { diff --git a/scripts/lib/worker-deploy-build-plugin.mts b/scripts/lib/worker-deploy-build-plugin.mts index a4026c31fc78..501f10e7f365 100644 --- a/scripts/lib/worker-deploy-build-plugin.mts +++ b/scripts/lib/worker-deploy-build-plugin.mts @@ -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"); diff --git a/test/scripts/worker-deploy-build-plugin.test.ts b/test/scripts/worker-deploy-build-plugin.test.ts index 753403d0f37a..ab6eaa65d16c 100644 --- a/test/scripts/worker-deploy-build-plugin.test.ts +++ b/test/scripts/worker-deploy-build-plugin.test.ts @@ -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");