fix: resolve browser playwright runtime deps

This commit is contained in:
Peter Steinberger
2026-04-27 08:50:47 +01:00
parent c1d827844c
commit 58037cc89d
7 changed files with 235 additions and 23 deletions

View File

@@ -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.

View File

@@ -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;

View File

@@ -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<typeof vi.fn>;
@@ -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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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);
});
});

View File

@@ -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: {},
},
}));

View File

@@ -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;

View File

@@ -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;

View File

@@ -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`,
);