From 58037cc89d100a77154186b455acaa591cbe6d49 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Apr 2026 08:50:47 +0100 Subject: [PATCH] fix: resolve browser playwright runtime deps --- CHANGELOG.md | 1 + .../src/browser/playwright-core.runtime.ts | 6 + .../browser/src/browser/pw-ai.e2e.test.ts | 28 +-- .../src/browser/pw-session.mock-setup.ts | 9 +- extensions/browser/src/browser/pw-session.ts | 4 +- .../src/browser/pw-tools-core.state.ts | 4 +- scripts/test-built-bundled-runtime-deps.mjs | 206 ++++++++++++++++++ 7 files changed, 235 insertions(+), 23 deletions(-) create mode 100644 extensions/browser/src/browser/playwright-core.runtime.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d39a4b3edd..a17323ecb58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai - Build/plugins: preserve active bundled runtime-dependency staging temp directories owned by live build processes so overlapping postbuild runs no longer delete each other's staged deps mid-prune. Supersedes #72220. Thanks @VACInc. - Plugins/install: hide bundled runtime-dependency npm child windows on Windows across Gateway startup, postinstall, and packaged staging paths so Telegram/Anthropic dependency repair no longer flashes shell windows. Fixes #72315. Thanks @athuljayaram and @joshfeng. - Plugins/Windows: normalize lazy plugin service override imports before Node ESM loading so drive-letter browser-control module paths no longer fail with `ERR_UNSUPPORTED_ESM_URL_SCHEME`. Fixes #72573; supersedes #72599 and #72582. Thanks @llzzww316, @feineryonah-byte, and @WuKongAI-CMU. +- Browser/plugins: load `playwright-core` through the browser runtime shim so packaged installs can run Playwright actions from staged plugin runtime deps after doctor/startup repair. Fixes #72168; supersedes #72238. Thanks @zdg1110 and @yetval. - Plugins/install: stage bundled plugin runtime dependencies before Gateway startup, drain update restarts, and materialize plugin-owned root chunks in external mirrors so staged deps resolve under native ESM. Fixes #72058; supersedes #72084. Thanks @amnesia106 and @drvoss. - TTS/SecretRef: resolve `messages.tts.providers.*.apiKey` from the active runtime snapshot so SecretRef-backed MiniMax and other TTS provider keys work in runtime reply/audio paths. Fixes #68690. Thanks @joshavant. - Gateway/install: surface systemd user-bus recovery hints during Linux service activation and retry via the machine user scope when `systemctl --user` reports no-medium bus failures. Fixes #39673; refs #44417 and #63561. Thanks @Arbor4, @myrsu, and @mssteuer. diff --git a/extensions/browser/src/browser/playwright-core.runtime.ts b/extensions/browser/src/browser/playwright-core.runtime.ts new file mode 100644 index 00000000000..3bcfd4bec07 --- /dev/null +++ b/extensions/browser/src/browser/playwright-core.runtime.ts @@ -0,0 +1,6 @@ +import { createRequire } from "node:module"; +import type * as PlaywrightCore from "playwright-core"; + +const require = createRequire(import.meta.url); + +export const playwrightCore = require("playwright-core") as typeof PlaywrightCore; diff --git a/extensions/browser/src/browser/pw-ai.e2e.test.ts b/extensions/browser/src/browser/pw-ai.e2e.test.ts index 1de0725ee18..0fd52908217 100644 --- a/extensions/browser/src/browser/pw-ai.e2e.test.ts +++ b/extensions/browser/src/browser/pw-ai.e2e.test.ts @@ -1,10 +1,5 @@ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; - -vi.mock("playwright-core", () => ({ - chromium: { - connectOverCDP: vi.fn(), - }, -})); +import { connectOverCdpMock, getChromeWebSocketUrlMock } from "./pw-session.mock-setup.js"; type FakeSession = { send: ReturnType; @@ -55,14 +50,12 @@ function createBrowser(pages: unknown[]) { } as unknown as import("playwright-core").Browser; } -let chromiumMock: typeof import("playwright-core").chromium; let snapshotAiViaPlaywright: typeof import("./pw-tools-core.snapshot.js").snapshotAiViaPlaywright; let clickViaPlaywright: typeof import("./pw-tools-core.interactions.js").clickViaPlaywright; let closePlaywrightBrowserConnection: typeof import("./pw-session.js").closePlaywrightBrowserConnection; beforeAll(async () => { - const pw = await import("playwright-core"); - chromiumMock = pw.chromium; + getChromeWebSocketUrlMock.mockResolvedValue(null); ({ snapshotAiViaPlaywright } = await import("./pw-tools-core.snapshot.js")); ({ clickViaPlaywright } = await import("./pw-tools-core.interactions.js")); ({ closePlaywrightBrowserConnection } = await import("./pw-session.js")); @@ -79,7 +72,7 @@ describe("pw-ai", () => { const p2 = createPage({ targetId: "T2", snapshotFull: "TWO" }); const browser = createBrowser([p1.page, p2.page]); - (chromiumMock.connectOverCDP as unknown as ReturnType).mockResolvedValue(browser); + connectOverCdpMock.mockResolvedValue(browser); const res = await snapshotAiViaPlaywright({ cdpUrl: "http://127.0.0.1:18792", @@ -96,7 +89,7 @@ describe("pw-ai", () => { const p1 = createPage({ targetId: "T1", snapshotFull: snapshot }); const browser = createBrowser([p1.page]); - (chromiumMock.connectOverCDP as unknown as ReturnType).mockResolvedValue(browser); + connectOverCdpMock.mockResolvedValue(browser); const res = await snapshotAiViaPlaywright({ cdpUrl: "http://127.0.0.1:18792", @@ -123,7 +116,7 @@ describe("pw-ai", () => { const p1 = createPage({ targetId: "T1", snapshotFull: longSnapshot }); const browser = createBrowser([p1.page]); - (chromiumMock.connectOverCDP as unknown as ReturnType).mockResolvedValue(browser); + connectOverCdpMock.mockResolvedValue(browser); const res = await snapshotAiViaPlaywright({ cdpUrl: "http://127.0.0.1:18792", @@ -140,7 +133,7 @@ describe("pw-ai", () => { const snapshot = ['- button "OK" [ref=1]', '- link "Docs" [ref=2]'].join("\n"); const p1 = createPage({ targetId: "T1", snapshotFull: snapshot }); const browser = createBrowser([p1.page]); - (chromiumMock.connectOverCDP as unknown as ReturnType).mockResolvedValue(browser); + connectOverCdpMock.mockResolvedValue(browser); const res = await snapshotAiViaPlaywright({ cdpUrl: "http://127.0.0.1:18792", @@ -167,7 +160,7 @@ describe("pw-ai", () => { it("clicks a ref using aria-ref locator", async () => { const p1 = createPage({ targetId: "T1" }); const browser = createBrowser([p1.page]); - (chromiumMock.connectOverCDP as unknown as ReturnType).mockResolvedValue(browser); + connectOverCdpMock.mockResolvedValue(browser); await clickViaPlaywright({ cdpUrl: "http://127.0.0.1:18792", @@ -182,7 +175,7 @@ describe("pw-ai", () => { it("uses Playwright's public AI aria snapshot API", async () => { const p1 = createPage({ targetId: "T1", snapshotFull: "ONE" }); const browser = createBrowser([p1.page]); - (chromiumMock.connectOverCDP as unknown as ReturnType).mockResolvedValue(browser); + connectOverCdpMock.mockResolvedValue(browser); await snapshotAiViaPlaywright({ cdpUrl: "http://127.0.0.1:18792", @@ -199,8 +192,7 @@ describe("pw-ai", () => { it("reuses the CDP connection for repeated calls", async () => { const p1 = createPage({ targetId: "T1", snapshotFull: "ONE" }); const browser = createBrowser([p1.page]); - const connect = vi.spyOn(chromiumMock, "connectOverCDP"); - connect.mockResolvedValue(browser); + connectOverCdpMock.mockResolvedValue(browser); await snapshotAiViaPlaywright({ cdpUrl: "http://127.0.0.1:18792", @@ -212,6 +204,6 @@ describe("pw-ai", () => { ref: "1", }); - expect(connect).toHaveBeenCalledTimes(1); + expect(connectOverCdpMock).toHaveBeenCalledTimes(1); }); }); diff --git a/extensions/browser/src/browser/pw-session.mock-setup.ts b/extensions/browser/src/browser/pw-session.mock-setup.ts index 0b176d536db..c1e368b9497 100644 --- a/extensions/browser/src/browser/pw-session.mock-setup.ts +++ b/extensions/browser/src/browser/pw-session.mock-setup.ts @@ -4,9 +4,12 @@ import type { MockFn } from "../test-utils/vitest-mock-fn.js"; export const connectOverCdpMock: MockFn = vi.fn(); export const getChromeWebSocketUrlMock: MockFn = vi.fn(); -vi.mock("playwright-core", () => ({ - chromium: { - connectOverCDP: (...args: unknown[]) => connectOverCdpMock(...args), +vi.mock("./playwright-core.runtime.js", () => ({ + playwrightCore: { + chromium: { + connectOverCDP: (...args: unknown[]) => connectOverCdpMock(...args), + }, + devices: {}, }, })); diff --git a/extensions/browser/src/browser/pw-session.ts b/extensions/browser/src/browser/pw-session.ts index b20ba6d05bf..6dd1e7ef8dc 100644 --- a/extensions/browser/src/browser/pw-session.ts +++ b/extensions/browser/src/browser/pw-session.ts @@ -11,7 +11,6 @@ import type { Response, Route, } from "playwright-core"; -import { chromium } from "playwright-core"; import { formatErrorMessage } from "../infra/errors.js"; import { SsrFBlockedError, type SsrFPolicy } from "../infra/net/ssrf.js"; import { withNoProxyForCdpUrl } from "./cdp-proxy-bypass.js"; @@ -36,9 +35,12 @@ import { withBrowserNavigationPolicy, } from "./navigation-guard.js"; import { DEFAULT_DOWNLOAD_DIR } from "./paths.js"; +import { playwrightCore } from "./playwright-core.runtime.js"; import { BROWSER_REF_MARKER_ATTRIBUTE, withPageScopedCdpClient } from "./pw-session.page-cdp.js"; import { sanitizeUntrustedFileName } from "./safe-filename.js"; +const { chromium } = playwrightCore; + export type BrowserConsoleMessage = { type: string; text: string; diff --git a/extensions/browser/src/browser/pw-tools-core.state.ts b/extensions/browser/src/browser/pw-tools-core.state.ts index b00d4c9cff7..930fffc917a 100644 --- a/extensions/browser/src/browser/pw-tools-core.state.ts +++ b/extensions/browser/src/browser/pw-tools-core.state.ts @@ -1,8 +1,10 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; -import { devices as playwrightDevices } from "playwright-core"; +import { playwrightCore } from "./playwright-core.runtime.js"; import { ensurePageState, getPageForTargetId } from "./pw-session.js"; import { withPageScopedCdpClient } from "./pw-session.page-cdp.js"; +const { devices: playwrightDevices } = playwrightCore; + export async function setOfflineViaPlaywright(opts: { cdpUrl: string; targetId?: string; diff --git a/scripts/test-built-bundled-runtime-deps.mjs b/scripts/test-built-bundled-runtime-deps.mjs index 0e86b08471e..a2272e3cc4d 100644 --- a/scripts/test-built-bundled-runtime-deps.mjs +++ b/scripts/test-built-bundled-runtime-deps.mjs @@ -1,6 +1,9 @@ import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { collectBuiltBundledPluginStagedRuntimeDependencyErrors, collectBundledPluginRootRuntimeMirrorErrors, @@ -39,6 +42,209 @@ const errors = [ ]; assert.deepEqual(errors, [], errors.join("\n")); + +function packageNodeModulesPath(nodeModulesDir, packageName) { + return path.join(nodeModulesDir, ...packageName.split("/")); +} + +function stageBrowserRuntimeDependencyStub(stageNodeModulesDir, packageName) { + const packageDir = packageNodeModulesPath(stageNodeModulesDir, packageName); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync( + path.join(packageDir, "package.json"), + `${JSON.stringify( + { + name: packageName, + version: "0.0.0", + main: "./index.cjs", + }, + null, + 2, + )}\n`, + "utf8", + ); + + if (packageName === "playwright-core") { + fs.writeFileSync( + path.join(packageDir, "index.cjs"), + [ + "module.exports = {", + " chromium: { marker: 'stub-chromium' },", + " devices: { 'Stub Device': { marker: 'stub-device' } },", + "};", + "", + ].join("\n"), + "utf8", + ); + return; + } + + if (packageName === "typebox") { + fs.writeFileSync( + path.join(packageDir, "index.cjs"), + [ + "const createSchema = (kind, value = {}) => ({ kind, ...value });", + "const Type = new Proxy(function Type() {}, {", + " get(_target, prop) {", + " if (prop === Symbol.toStringTag) {", + " return 'Type';", + " }", + " return (...args) => createSchema(String(prop), { args });", + " },", + "});", + "module.exports = { Type };", + "", + ].join("\n"), + "utf8", + ); + return; + } + + fs.writeFileSync(path.join(packageDir, "index.cjs"), "module.exports = {};\n", "utf8"); +} + +function findBuiltBrowserEntryPath(distDir) { + const candidates = fs + .readdirSync(distDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && /^pw-ai-(?!state-).*\.js$/u.test(entry.name)) + .map((entry) => path.join(distDir, entry.name)) + .toSorted((left, right) => left.localeCompare(right)); + if (candidates.length === 0) { + throw new assert.AssertionError({ + message: `missing built pw-ai entry under ${distDir}`, + }); + } + return candidates[0]; +} + +function createBuiltBrowserImportSmokeFixture(packageRoot) { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-built-browser-smoke-")); + const tempDistDir = path.join(tempRoot, "dist"); + const tempNodeModulesDir = path.join(tempRoot, "node_modules"); + const stageNodeModulesDir = path.join( + tempRoot, + ".openclaw", + "plugin-runtime-deps", + "browser", + "node_modules", + ); + + fs.cpSync(path.join(packageRoot, "dist"), tempDistDir, { + recursive: true, + dereference: true, + }); + fs.copyFileSync(path.join(packageRoot, "package.json"), path.join(tempRoot, "package.json")); + fs.cpSync(path.join(packageRoot, "node_modules"), tempNodeModulesDir, { + recursive: true, + dereference: true, + }); + fs.rmSync(path.join(tempNodeModulesDir, "playwright-core"), { + force: true, + recursive: true, + }); + + assert.ok(!fs.existsSync(path.join(tempNodeModulesDir, "playwright-core"))); + fs.mkdirSync(stageNodeModulesDir, { recursive: true }); + assert.deepEqual(fs.readdirSync(stageNodeModulesDir), []); + + const browserPackageJson = JSON.parse( + fs.readFileSync(path.join(tempDistDir, "extensions", "browser", "package.json"), "utf8"), + ); + const browserRuntimeDeps = new Map( + [ + ...Object.entries(browserPackageJson.dependencies ?? {}), + ...Object.entries(browserPackageJson.optionalDependencies ?? {}), + ].filter((entry) => typeof entry[1] === "string" && entry[1].length > 0), + ); + const missingBrowserRuntimeDeps = [...browserRuntimeDeps.keys()] + .filter((packageName) => { + const rootSentinel = path.join(tempNodeModulesDir, ...packageName.split("/"), "package.json"); + const stagedSentinel = path.join( + stageNodeModulesDir, + ...packageName.split("/"), + "package.json", + ); + return !fs.existsSync(rootSentinel) && !fs.existsSync(stagedSentinel); + }) + .toSorted((left, right) => left.localeCompare(right)); + + for (const packageName of missingBrowserRuntimeDeps) { + stageBrowserRuntimeDependencyStub(stageNodeModulesDir, packageName); + } + + return { + entryPath: findBuiltBrowserEntryPath(tempDistDir), + stageNodeModulesDir, + tempRoot, + }; +} + +function runNodeEval(params) { + return spawnSync(process.execPath, ["--input-type=module", "--eval", params.source], { + cwd: params.cwd, + encoding: "utf8", + env: params.env, + }); +} + +function runBuiltBrowserImportSmoke(packageRoot) { + const fixture = createBuiltBrowserImportSmokeFixture(packageRoot); + try { + assert.ok(fs.existsSync(fixture.entryPath), `missing built pw-ai entry: ${fixture.entryPath}`); + assert.ok( + !fs.existsSync(path.join(fixture.tempRoot, "node_modules", "playwright-core")), + "package-root playwright-core should be absent in the smoke fixture", + ); + assert.ok( + fs.existsSync(path.join(fixture.stageNodeModulesDir, "playwright-core", "package.json")), + "staged playwright-core should be present in the smoke fixture", + ); + + const rootEsmResult = runNodeEval({ + cwd: fixture.tempRoot, + env: { ...process.env, NODE_PATH: fixture.stageNodeModulesDir }, + source: + "await import('playwright-core')" + + ".then(() => { process.exitCode = 1; })" + + ".catch((error) => { if (error?.code !== 'ERR_MODULE_NOT_FOUND') throw error; });", + }); + assert.equal( + rootEsmResult.status, + 0, + [ + "[build-smoke] native ESM unexpectedly resolved staged playwright-core", + rootEsmResult.stdout.trim(), + rootEsmResult.stderr.trim(), + ] + .filter(Boolean) + .join("\n"), + ); + + const builtImportResult = runNodeEval({ + cwd: fixture.tempRoot, + env: { ...process.env, NODE_PATH: fixture.stageNodeModulesDir }, + source: `await import(${JSON.stringify(pathToFileURL(fixture.entryPath).href)});`, + }); + assert.equal( + builtImportResult.status, + 0, + [ + "[build-smoke] built browser pw-ai import failed", + `status=${String(builtImportResult.status)}`, + `signal=${String(builtImportResult.signal)}`, + builtImportResult.stdout.trim(), + builtImportResult.stderr.trim(), + ] + .filter(Boolean) + .join("\n"), + ); + } finally { + fs.rmSync(fixture.tempRoot, { recursive: true, force: true }); + } +} + +runBuiltBrowserImportSmoke(packageRoot); + process.stdout.write( `[build-smoke] bundled runtime dependency smoke passed packageRoot=${packageRoot}\n`, );