browserclaw 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/index.cjs +334 -79
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +86 -1
- package/dist/index.d.ts +86 -1
- package/dist/index.js +336 -81
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -4,8 +4,8 @@ import { devices, chromium } from 'playwright-core';
|
|
|
4
4
|
import { spawn, execFileSync } from 'child_process';
|
|
5
5
|
import fs from 'fs';
|
|
6
6
|
import net from 'net';
|
|
7
|
-
import os from 'os';
|
|
8
|
-
import path, { posix, win32, resolve, dirname,
|
|
7
|
+
import os, { tmpdir } from 'os';
|
|
8
|
+
import path, { join, posix, win32, resolve, dirname, basename, relative, sep, isAbsolute as isAbsolute$1, normalize } from 'path';
|
|
9
9
|
import { randomUUID } from 'crypto';
|
|
10
10
|
import { lookup } from 'dns';
|
|
11
11
|
import { lookup as lookup$1 } from 'dns/promises';
|
|
@@ -1187,8 +1187,13 @@ function isWebSocketUrl(url) {
|
|
|
1187
1187
|
function isLoopbackHost(hostname) {
|
|
1188
1188
|
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
|
|
1189
1189
|
}
|
|
1190
|
-
|
|
1191
|
-
|
|
1190
|
+
var PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
|
|
1191
|
+
function hasProxyEnvConfigured(env = process.env) {
|
|
1192
|
+
for (const key of PROXY_ENV_KEYS) {
|
|
1193
|
+
const value = env[key];
|
|
1194
|
+
if (typeof value === "string" && value.trim().length > 0) return true;
|
|
1195
|
+
}
|
|
1196
|
+
return false;
|
|
1192
1197
|
}
|
|
1193
1198
|
function normalizeCdpWsUrl(wsUrl, cdpUrl) {
|
|
1194
1199
|
const ws = new URL(wsUrl);
|
|
@@ -1281,23 +1286,20 @@ async function fetchChromeVersion(cdpUrl, timeoutMs = 500, authToken) {
|
|
|
1281
1286
|
clearTimeout(t);
|
|
1282
1287
|
}
|
|
1283
1288
|
}
|
|
1289
|
+
var COMMON_CDP_PORTS = [9222, 9223, 9224, 9225, 9226, 9229];
|
|
1290
|
+
async function discoverChromeCdpUrl(timeoutMs = 500) {
|
|
1291
|
+
const results = await Promise.all(
|
|
1292
|
+
COMMON_CDP_PORTS.map(async (port) => {
|
|
1293
|
+
const url = `http://127.0.0.1:${String(port)}`;
|
|
1294
|
+
return await isChromeReachable(url, timeoutMs) ? url : null;
|
|
1295
|
+
})
|
|
1296
|
+
);
|
|
1297
|
+
return results.find((url) => url !== null) ?? null;
|
|
1298
|
+
}
|
|
1284
1299
|
async function isChromeReachable(cdpUrl, timeoutMs = 500, authToken) {
|
|
1285
1300
|
if (isWebSocketUrl(cdpUrl)) return await canOpenWebSocket(cdpUrl, timeoutMs);
|
|
1286
1301
|
const version = await fetchChromeVersion(cdpUrl, timeoutMs, authToken);
|
|
1287
|
-
|
|
1288
|
-
let isLoopback = false;
|
|
1289
|
-
try {
|
|
1290
|
-
const u = new URL(cdpUrl.startsWith("http") ? cdpUrl : `http://${cdpUrl}`);
|
|
1291
|
-
isLoopback = isLoopbackHost(u.hostname);
|
|
1292
|
-
} catch {
|
|
1293
|
-
}
|
|
1294
|
-
if (!isLoopback) return false;
|
|
1295
|
-
for (let i = 0; i < 2; i++) {
|
|
1296
|
-
await new Promise((r) => setTimeout(r, 150));
|
|
1297
|
-
const retry = await fetchChromeVersion(cdpUrl, timeoutMs, authToken);
|
|
1298
|
-
if (retry !== null) return true;
|
|
1299
|
-
}
|
|
1300
|
-
return false;
|
|
1302
|
+
return version !== null;
|
|
1301
1303
|
}
|
|
1302
1304
|
async function getChromeWebSocketUrl(cdpUrl, timeoutMs = 500, authToken) {
|
|
1303
1305
|
if (isWebSocketUrl(cdpUrl)) return cdpUrl;
|
|
@@ -1914,6 +1916,12 @@ function ensurePageState(page) {
|
|
|
1914
1916
|
rec.ok = false;
|
|
1915
1917
|
}
|
|
1916
1918
|
});
|
|
1919
|
+
page.on("dialog", (dialog) => {
|
|
1920
|
+
if (state.armIdDialog > 0) return;
|
|
1921
|
+
dialog.dismiss().catch((err) => {
|
|
1922
|
+
console.warn(`[browserclaw] Failed to dismiss dialog: ${err instanceof Error ? err.message : String(err)}`);
|
|
1923
|
+
});
|
|
1924
|
+
});
|
|
1917
1925
|
page.on("close", () => {
|
|
1918
1926
|
pageStates.delete(page);
|
|
1919
1927
|
observedPages.delete(page);
|
|
@@ -2042,6 +2050,8 @@ async function disconnectBrowser() {
|
|
|
2042
2050
|
}
|
|
2043
2051
|
}
|
|
2044
2052
|
for (const cur of cachedByCdpUrl.values()) {
|
|
2053
|
+
if (cur.onDisconnected && typeof cur.browser.off === "function")
|
|
2054
|
+
cur.browser.off("disconnected", cur.onDisconnected);
|
|
2045
2055
|
await cur.browser.close().catch(() => {
|
|
2046
2056
|
});
|
|
2047
2057
|
}
|
|
@@ -2351,48 +2361,52 @@ async function awaitEvalWithAbort(evalPromise, abortPromise) {
|
|
|
2351
2361
|
}
|
|
2352
2362
|
var BROWSER_EVALUATOR = new Function(
|
|
2353
2363
|
"args",
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
var candidate
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
}
|
|
2368
|
-
|
|
2369
|
-
}
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2364
|
+
[
|
|
2365
|
+
'"use strict";',
|
|
2366
|
+
"var fnBody = args.fnBody, timeoutMs = args.timeoutMs;",
|
|
2367
|
+
"try {",
|
|
2368
|
+
" var candidate;",
|
|
2369
|
+
' try { candidate = eval("(" + fnBody + ")"); }',
|
|
2370
|
+
" catch (_) { candidate = (0, eval)(fnBody); }",
|
|
2371
|
+
' var result = typeof candidate === "function" ? candidate() : candidate;',
|
|
2372
|
+
' if (result && typeof result.then === "function") {',
|
|
2373
|
+
" return Promise.race([",
|
|
2374
|
+
" result,",
|
|
2375
|
+
" new Promise(function(_, reject) {",
|
|
2376
|
+
' setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);',
|
|
2377
|
+
" })",
|
|
2378
|
+
" ]);",
|
|
2379
|
+
" }",
|
|
2380
|
+
" return result;",
|
|
2381
|
+
"} catch (err) {",
|
|
2382
|
+
' throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));',
|
|
2383
|
+
"}"
|
|
2384
|
+
].join("\n")
|
|
2373
2385
|
);
|
|
2374
2386
|
var ELEMENT_EVALUATOR = new Function(
|
|
2375
2387
|
"el",
|
|
2376
2388
|
"args",
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
var candidate
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
}
|
|
2391
|
-
|
|
2392
|
-
}
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2389
|
+
[
|
|
2390
|
+
'"use strict";',
|
|
2391
|
+
"var fnBody = args.fnBody, timeoutMs = args.timeoutMs;",
|
|
2392
|
+
"try {",
|
|
2393
|
+
" var candidate;",
|
|
2394
|
+
' try { candidate = eval("(" + fnBody + ")"); }',
|
|
2395
|
+
" catch (_) { candidate = (0, eval)(fnBody); }",
|
|
2396
|
+
' var result = typeof candidate === "function" ? candidate(el) : candidate;',
|
|
2397
|
+
' if (result && typeof result.then === "function") {',
|
|
2398
|
+
" return Promise.race([",
|
|
2399
|
+
" result,",
|
|
2400
|
+
" new Promise(function(_, reject) {",
|
|
2401
|
+
' setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);',
|
|
2402
|
+
" })",
|
|
2403
|
+
" ]);",
|
|
2404
|
+
" }",
|
|
2405
|
+
" return result;",
|
|
2406
|
+
"} catch (err) {",
|
|
2407
|
+
' throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));',
|
|
2408
|
+
"}"
|
|
2409
|
+
].join("\n")
|
|
2396
2410
|
);
|
|
2397
2411
|
async function evaluateViaPlaywright(opts) {
|
|
2398
2412
|
const fnText = opts.fn.trim();
|
|
@@ -2460,6 +2474,18 @@ async function evaluateViaPlaywright(opts) {
|
|
|
2460
2474
|
|
|
2461
2475
|
// src/security.ts
|
|
2462
2476
|
var ipaddr = __toESM(require_ipaddr());
|
|
2477
|
+
function resolveDefaultBrowserTmpDir() {
|
|
2478
|
+
try {
|
|
2479
|
+
if (process.platform === "linux" || process.platform === "darwin") {
|
|
2480
|
+
return "/tmp/browserclaw";
|
|
2481
|
+
}
|
|
2482
|
+
} catch {
|
|
2483
|
+
}
|
|
2484
|
+
return join(tmpdir(), "browserclaw");
|
|
2485
|
+
}
|
|
2486
|
+
var DEFAULT_BROWSER_TMP_DIR = resolveDefaultBrowserTmpDir();
|
|
2487
|
+
join(DEFAULT_BROWSER_TMP_DIR, "downloads");
|
|
2488
|
+
var DEFAULT_UPLOAD_DIR = join(DEFAULT_BROWSER_TMP_DIR, "uploads");
|
|
2463
2489
|
var InvalidBrowserNavigationUrlError = class extends Error {
|
|
2464
2490
|
constructor(message) {
|
|
2465
2491
|
super(message);
|
|
@@ -2471,7 +2497,7 @@ function withBrowserNavigationPolicy(ssrfPolicy) {
|
|
|
2471
2497
|
}
|
|
2472
2498
|
var NETWORK_NAVIGATION_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
|
|
2473
2499
|
var SAFE_NON_NETWORK_URLS = /* @__PURE__ */ new Set(["about:blank"]);
|
|
2474
|
-
var
|
|
2500
|
+
var PROXY_ENV_KEYS2 = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
|
|
2475
2501
|
var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "localhost.localdomain", "metadata.google.internal"]);
|
|
2476
2502
|
function isAllowedNonNetworkNavigationUrl(parsed) {
|
|
2477
2503
|
return SAFE_NON_NETWORK_URLS.has(parsed.href);
|
|
@@ -2480,7 +2506,7 @@ function isPrivateNetworkAllowedByPolicy(policy) {
|
|
|
2480
2506
|
return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true;
|
|
2481
2507
|
}
|
|
2482
2508
|
function hasProxyEnvConfigured2(env = process.env) {
|
|
2483
|
-
for (const key of
|
|
2509
|
+
for (const key of PROXY_ENV_KEYS2) {
|
|
2484
2510
|
const value = env[key];
|
|
2485
2511
|
if (typeof value === "string" && value.trim().length > 0) return true;
|
|
2486
2512
|
}
|
|
@@ -2685,6 +2711,8 @@ function dedupeAndPreferIpv4(results) {
|
|
|
2685
2711
|
}
|
|
2686
2712
|
function createPinnedLookup(params) {
|
|
2687
2713
|
const normalizedHost = normalizeHostname(params.hostname);
|
|
2714
|
+
if (params.addresses.length === 0)
|
|
2715
|
+
throw new Error(`Pinned lookup requires at least one address for ${params.hostname}`);
|
|
2688
2716
|
const fallback = params.fallback ?? lookup;
|
|
2689
2717
|
const records = params.addresses.map((address) => ({
|
|
2690
2718
|
address,
|
|
@@ -2859,6 +2887,48 @@ async function resolveStrictExistingUploadPaths(params) {
|
|
|
2859
2887
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
2860
2888
|
}
|
|
2861
2889
|
}
|
|
2890
|
+
function resolvePathWithinRoot(params) {
|
|
2891
|
+
const root = resolve(params.rootDir);
|
|
2892
|
+
const raw = params.requestedPath.trim();
|
|
2893
|
+
const effectivePath = raw === "" && params.defaultFileName != null && params.defaultFileName !== "" ? params.defaultFileName : raw;
|
|
2894
|
+
if (effectivePath === "") return { ok: false, error: `Empty path is not allowed (${params.scopeLabel}).` };
|
|
2895
|
+
const resolved = resolve(root, effectivePath);
|
|
2896
|
+
const rel = relative(root, resolved);
|
|
2897
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute$1(rel)) {
|
|
2898
|
+
return { ok: false, error: `Path escapes ${params.scopeLabel}: "${params.requestedPath}".` };
|
|
2899
|
+
}
|
|
2900
|
+
return { ok: true, path: resolved };
|
|
2901
|
+
}
|
|
2902
|
+
async function resolveStrictExistingPathsWithinRoot(params) {
|
|
2903
|
+
const root = resolve(params.rootDir);
|
|
2904
|
+
const resolved = [];
|
|
2905
|
+
for (const raw of params.requestedPaths) {
|
|
2906
|
+
const lexical = resolvePathWithinRoot({ rootDir: root, requestedPath: raw, scopeLabel: params.scopeLabel });
|
|
2907
|
+
if (!lexical.ok) return lexical;
|
|
2908
|
+
let real;
|
|
2909
|
+
try {
|
|
2910
|
+
real = await realpath(lexical.path);
|
|
2911
|
+
} catch (e) {
|
|
2912
|
+
if (e.code === "ENOENT") {
|
|
2913
|
+
return { ok: false, error: `Path does not exist (${params.scopeLabel}): "${raw}".` };
|
|
2914
|
+
}
|
|
2915
|
+
return { ok: false, error: `Cannot resolve "${raw}" (${params.scopeLabel}): ${e.message}` };
|
|
2916
|
+
}
|
|
2917
|
+
const rel = relative(root, real);
|
|
2918
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute$1(rel)) {
|
|
2919
|
+
return { ok: false, error: `Path escapes ${params.scopeLabel} via symlink: "${raw}".` };
|
|
2920
|
+
}
|
|
2921
|
+
const stat = await lstat(real);
|
|
2922
|
+
if (stat.isSymbolicLink()) {
|
|
2923
|
+
return { ok: false, error: `Path is a symbolic link (${params.scopeLabel}): "${raw}".` };
|
|
2924
|
+
}
|
|
2925
|
+
if (!stat.isFile()) {
|
|
2926
|
+
return { ok: false, error: `Path is not a regular file (${params.scopeLabel}): "${raw}".` };
|
|
2927
|
+
}
|
|
2928
|
+
resolved.push(real);
|
|
2929
|
+
}
|
|
2930
|
+
return { ok: true, paths: resolved };
|
|
2931
|
+
}
|
|
2862
2932
|
function sanitizeUntrustedFileName(fileName, fallbackName) {
|
|
2863
2933
|
const trimmed = fileName.trim();
|
|
2864
2934
|
if (trimmed === "") return fallbackName;
|
|
@@ -2939,6 +3009,35 @@ function resolveLocator(page, resolved) {
|
|
|
2939
3009
|
const sel = resolved.selector ?? "";
|
|
2940
3010
|
return page.locator(sel);
|
|
2941
3011
|
}
|
|
3012
|
+
async function mouseClickViaPlaywright(opts) {
|
|
3013
|
+
const page = await getRestoredPageForTarget(opts);
|
|
3014
|
+
await page.mouse.click(opts.x, opts.y, {
|
|
3015
|
+
button: opts.button,
|
|
3016
|
+
clickCount: opts.clickCount,
|
|
3017
|
+
delay: opts.delayMs
|
|
3018
|
+
});
|
|
3019
|
+
}
|
|
3020
|
+
async function clickByTextViaPlaywright(opts) {
|
|
3021
|
+
const page = await getRestoredPageForTarget(opts);
|
|
3022
|
+
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
|
|
3023
|
+
try {
|
|
3024
|
+
await page.getByText(opts.text, { exact: opts.exact }).click({ timeout, button: opts.button, modifiers: opts.modifiers });
|
|
3025
|
+
} catch (err) {
|
|
3026
|
+
throw toAIFriendlyError(err, `text="${opts.text}"`);
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
async function clickByRoleViaPlaywright(opts) {
|
|
3030
|
+
const page = await getRestoredPageForTarget(opts);
|
|
3031
|
+
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
|
|
3032
|
+
try {
|
|
3033
|
+
await page.getByRole(opts.role, { name: opts.name }).click({ timeout, button: opts.button, modifiers: opts.modifiers });
|
|
3034
|
+
} catch (err) {
|
|
3035
|
+
throw toAIFriendlyError(
|
|
3036
|
+
err,
|
|
3037
|
+
`role=${opts.role}${opts.name !== void 0 && opts.name !== "" ? ` name="${opts.name}"` : ""}`
|
|
3038
|
+
);
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
2942
3041
|
async function clickViaPlaywright(opts) {
|
|
2943
3042
|
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
|
2944
3043
|
const page = await getRestoredPageForTarget(opts);
|
|
@@ -3102,9 +3201,10 @@ async function setInputFilesViaPlaywright(opts) {
|
|
|
3102
3201
|
if (inputRef && element) throw new Error("ref and element are mutually exclusive");
|
|
3103
3202
|
if (!inputRef && !element) throw new Error("Either ref or element is required for setInputFiles");
|
|
3104
3203
|
const locator = inputRef ? refLocator(page, inputRef) : page.locator(element).first();
|
|
3105
|
-
const uploadPathsResult = await
|
|
3204
|
+
const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
|
|
3205
|
+
rootDir: DEFAULT_UPLOAD_DIR,
|
|
3106
3206
|
requestedPaths: opts.paths,
|
|
3107
|
-
scopeLabel:
|
|
3207
|
+
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`
|
|
3108
3208
|
});
|
|
3109
3209
|
if (!uploadPathsResult.ok) throw new Error(uploadPathsResult.error);
|
|
3110
3210
|
const resolvedPaths = uploadPathsResult.paths;
|
|
@@ -3132,9 +3232,14 @@ async function armDialogViaPlaywright(opts) {
|
|
|
3132
3232
|
const armId = state.armIdDialog;
|
|
3133
3233
|
page.waitForEvent("dialog", { timeout }).then(async (dialog) => {
|
|
3134
3234
|
if (state.armIdDialog !== armId) return;
|
|
3135
|
-
|
|
3136
|
-
|
|
3235
|
+
try {
|
|
3236
|
+
if (opts.accept) await dialog.accept(opts.promptText);
|
|
3237
|
+
else await dialog.dismiss();
|
|
3238
|
+
} finally {
|
|
3239
|
+
if (state.armIdDialog === armId) state.armIdDialog = 0;
|
|
3240
|
+
}
|
|
3137
3241
|
}).catch(() => {
|
|
3242
|
+
if (state.armIdDialog === armId) state.armIdDialog = 0;
|
|
3138
3243
|
});
|
|
3139
3244
|
}
|
|
3140
3245
|
async function armFileUploadViaPlaywright(opts) {
|
|
@@ -3152,9 +3257,10 @@ async function armFileUploadViaPlaywright(opts) {
|
|
|
3152
3257
|
}
|
|
3153
3258
|
return;
|
|
3154
3259
|
}
|
|
3155
|
-
const uploadPathsResult = await
|
|
3260
|
+
const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
|
|
3261
|
+
rootDir: DEFAULT_UPLOAD_DIR,
|
|
3156
3262
|
requestedPaths: opts.paths,
|
|
3157
|
-
scopeLabel:
|
|
3263
|
+
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`
|
|
3158
3264
|
});
|
|
3159
3265
|
if (!uploadPathsResult.ok) {
|
|
3160
3266
|
try {
|
|
@@ -3188,6 +3294,17 @@ async function pressKeyViaPlaywright(opts) {
|
|
|
3188
3294
|
}
|
|
3189
3295
|
|
|
3190
3296
|
// src/actions/navigation.ts
|
|
3297
|
+
var recordingContexts = /* @__PURE__ */ new Map();
|
|
3298
|
+
function clearRecordingContext(cdpUrl) {
|
|
3299
|
+
recordingContexts.delete(cdpUrl);
|
|
3300
|
+
}
|
|
3301
|
+
async function createRecordingContext(browser, cdpUrl, recordVideo) {
|
|
3302
|
+
const context = await browser.newContext({ recordVideo });
|
|
3303
|
+
observeContext(context);
|
|
3304
|
+
recordingContexts.set(cdpUrl, context);
|
|
3305
|
+
context.on("close", () => recordingContexts.delete(cdpUrl));
|
|
3306
|
+
return context;
|
|
3307
|
+
}
|
|
3191
3308
|
function isRetryableNavigateError(err) {
|
|
3192
3309
|
const msg = typeof err === "string" ? err.toLowerCase() : err instanceof Error ? err.message.toLowerCase() : "";
|
|
3193
3310
|
return msg.includes("frame has been detached") || msg.includes("target page, context or browser has been closed");
|
|
@@ -3240,7 +3357,7 @@ async function listPagesViaPlaywright(opts) {
|
|
|
3240
3357
|
}
|
|
3241
3358
|
async function createPageViaPlaywright(opts) {
|
|
3242
3359
|
const { browser } = await connectBrowser(opts.cdpUrl);
|
|
3243
|
-
const context = browser.contexts()[0] ?? await browser.newContext();
|
|
3360
|
+
const context = opts.recordVideo ? recordingContexts.get(opts.cdpUrl) ?? await createRecordingContext(browser, opts.cdpUrl, opts.recordVideo) : browser.contexts()[0] ?? await browser.newContext();
|
|
3244
3361
|
ensureContextState(context);
|
|
3245
3362
|
const page = await context.newPage();
|
|
3246
3363
|
ensurePageState(page);
|
|
@@ -3317,10 +3434,10 @@ async function waitForViaPlaywright(opts) {
|
|
|
3317
3434
|
await page.waitForTimeout(resolveBoundedDelayMs2(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS));
|
|
3318
3435
|
}
|
|
3319
3436
|
if (opts.text !== void 0 && opts.text !== "") {
|
|
3320
|
-
await page.
|
|
3437
|
+
await page.waitForFunction((text) => (document.body?.innerText ?? "").includes(text), opts.text, { timeout });
|
|
3321
3438
|
}
|
|
3322
3439
|
if (opts.textGone !== void 0 && opts.textGone !== "") {
|
|
3323
|
-
await page.
|
|
3440
|
+
await page.waitForFunction((text) => !(document.body?.innerText ?? "").includes(text), opts.textGone, { timeout });
|
|
3324
3441
|
}
|
|
3325
3442
|
if (opts.selector !== void 0 && opts.selector !== "") {
|
|
3326
3443
|
const selector = opts.selector.trim();
|
|
@@ -3592,7 +3709,7 @@ async function waitForDownloadViaPlaywright(opts) {
|
|
|
3592
3709
|
try {
|
|
3593
3710
|
const download = await waiter.promise;
|
|
3594
3711
|
if (state.armIdDownload !== armId) throw new Error("Download was superseded by another waiter");
|
|
3595
|
-
const savePath = opts.path ?? download.suggestedFilename();
|
|
3712
|
+
const savePath = opts.path ?? sanitizeUntrustedFileName(download.suggestedFilename() || "download.bin", "download.bin");
|
|
3596
3713
|
await assertSafeOutputPath(savePath, opts.allowedOutputRoots);
|
|
3597
3714
|
return await saveDownloadPayload(download, savePath);
|
|
3598
3715
|
} catch (err) {
|
|
@@ -4259,6 +4376,16 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
|
|
|
4259
4376
|
return match ? match[1] : null;
|
|
4260
4377
|
}
|
|
4261
4378
|
if (options.interactive === true) {
|
|
4379
|
+
let interactiveMaxRef = 0;
|
|
4380
|
+
for (const line of lines) {
|
|
4381
|
+
const refMatch = /\[ref=e(\d+)\]/.exec(line);
|
|
4382
|
+
if (refMatch) interactiveMaxRef = Math.max(interactiveMaxRef, Number.parseInt(refMatch[1], 10));
|
|
4383
|
+
}
|
|
4384
|
+
let interactiveCounter = interactiveMaxRef;
|
|
4385
|
+
const nextInteractiveRef = () => {
|
|
4386
|
+
interactiveCounter++;
|
|
4387
|
+
return `e${String(interactiveCounter)}`;
|
|
4388
|
+
};
|
|
4262
4389
|
const out2 = [];
|
|
4263
4390
|
for (const line of lines) {
|
|
4264
4391
|
const parsed = matchInteractiveSnapshotLine(line, options);
|
|
@@ -4266,13 +4393,32 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
|
|
|
4266
4393
|
const { roleRaw, role, name, suffix } = parsed;
|
|
4267
4394
|
if (!INTERACTIVE_ROLES.has(role)) continue;
|
|
4268
4395
|
const ref = parseAiSnapshotRef(suffix);
|
|
4269
|
-
if (ref === null) continue;
|
|
4270
4396
|
const prefix = /^(\s*-\s*)/.exec(line)?.[1] ?? "";
|
|
4271
|
-
|
|
4272
|
-
|
|
4397
|
+
if (ref !== null) {
|
|
4398
|
+
refs[ref] = { role, ...name !== void 0 && name !== "" ? { name } : {} };
|
|
4399
|
+
out2.push(`${prefix}${roleRaw}${name !== void 0 && name !== "" ? ` "${name}"` : ""}${suffix}`);
|
|
4400
|
+
} else {
|
|
4401
|
+
const generatedRef = nextInteractiveRef();
|
|
4402
|
+
refs[generatedRef] = { role, ...name !== void 0 && name !== "" ? { name } : {} };
|
|
4403
|
+
let enhanced = `${prefix}${roleRaw}`;
|
|
4404
|
+
if (name !== void 0 && name !== "") enhanced += ` "${name}"`;
|
|
4405
|
+
enhanced += ` [ref=${generatedRef}]`;
|
|
4406
|
+
if (suffix.trim() !== "") enhanced += suffix;
|
|
4407
|
+
out2.push(enhanced);
|
|
4408
|
+
}
|
|
4273
4409
|
}
|
|
4274
4410
|
return { snapshot: out2.join("\n") || "(no interactive elements)", refs };
|
|
4275
4411
|
}
|
|
4412
|
+
let maxRef = 0;
|
|
4413
|
+
for (const line of lines) {
|
|
4414
|
+
const refMatch = /\[ref=e(\d+)\]/.exec(line);
|
|
4415
|
+
if (refMatch) maxRef = Math.max(maxRef, Number.parseInt(refMatch[1], 10));
|
|
4416
|
+
}
|
|
4417
|
+
let generatedCounter = maxRef;
|
|
4418
|
+
const nextGeneratedRef = () => {
|
|
4419
|
+
generatedCounter++;
|
|
4420
|
+
return `e${String(generatedCounter)}`;
|
|
4421
|
+
};
|
|
4276
4422
|
const out = [];
|
|
4277
4423
|
for (const line of lines) {
|
|
4278
4424
|
const depth = getIndentLevel(line);
|
|
@@ -4282,7 +4428,7 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
|
|
|
4282
4428
|
out.push(line);
|
|
4283
4429
|
continue;
|
|
4284
4430
|
}
|
|
4285
|
-
const [, , roleRaw, name, suffix] = match;
|
|
4431
|
+
const [, prefix, roleRaw, name, suffix] = match;
|
|
4286
4432
|
if (roleRaw.startsWith("/")) {
|
|
4287
4433
|
out.push(line);
|
|
4288
4434
|
continue;
|
|
@@ -4291,8 +4437,20 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
|
|
|
4291
4437
|
const isStructural = STRUCTURAL_ROLES.has(role);
|
|
4292
4438
|
if (options.compact === true && isStructural && name === "") continue;
|
|
4293
4439
|
const ref = parseAiSnapshotRef(suffix);
|
|
4294
|
-
if (ref !== null)
|
|
4295
|
-
|
|
4440
|
+
if (ref !== null) {
|
|
4441
|
+
refs[ref] = { role, ...name !== "" ? { name } : {} };
|
|
4442
|
+
out.push(line);
|
|
4443
|
+
} else if (INTERACTIVE_ROLES.has(role)) {
|
|
4444
|
+
const generatedRef = nextGeneratedRef();
|
|
4445
|
+
refs[generatedRef] = { role, ...name !== "" ? { name } : {} };
|
|
4446
|
+
let enhanced = `${prefix}${roleRaw}`;
|
|
4447
|
+
if (name !== "") enhanced += ` "${name}"`;
|
|
4448
|
+
enhanced += ` [ref=${generatedRef}]`;
|
|
4449
|
+
if (suffix.trim() !== "") enhanced += suffix;
|
|
4450
|
+
out.push(enhanced);
|
|
4451
|
+
} else {
|
|
4452
|
+
out.push(line);
|
|
4453
|
+
}
|
|
4296
4454
|
}
|
|
4297
4455
|
const tree = out.join("\n") || "(empty)";
|
|
4298
4456
|
return { snapshot: options.compact === true ? compactTree(tree) : tree, refs };
|
|
@@ -4653,6 +4811,85 @@ var CrawlPage = class {
|
|
|
4653
4811
|
timeoutMs: opts?.timeoutMs
|
|
4654
4812
|
});
|
|
4655
4813
|
}
|
|
4814
|
+
/**
|
|
4815
|
+
* Click at specific page coordinates.
|
|
4816
|
+
*
|
|
4817
|
+
* Useful for canvas elements, custom widgets, or elements without ARIA roles.
|
|
4818
|
+
*
|
|
4819
|
+
* @param x - X coordinate in pixels
|
|
4820
|
+
* @param y - Y coordinate in pixels
|
|
4821
|
+
* @param opts - Click options (button, clickCount, delayMs)
|
|
4822
|
+
*
|
|
4823
|
+
* @example
|
|
4824
|
+
* ```ts
|
|
4825
|
+
* await page.mouseClick(100, 200);
|
|
4826
|
+
* await page.mouseClick(100, 200, { button: 'right' });
|
|
4827
|
+
* await page.mouseClick(100, 200, { clickCount: 2 }); // double-click
|
|
4828
|
+
* ```
|
|
4829
|
+
*/
|
|
4830
|
+
async mouseClick(x, y, opts) {
|
|
4831
|
+
return mouseClickViaPlaywright({
|
|
4832
|
+
cdpUrl: this.cdpUrl,
|
|
4833
|
+
targetId: this.targetId,
|
|
4834
|
+
x,
|
|
4835
|
+
y,
|
|
4836
|
+
button: opts?.button,
|
|
4837
|
+
clickCount: opts?.clickCount,
|
|
4838
|
+
delayMs: opts?.delayMs
|
|
4839
|
+
});
|
|
4840
|
+
}
|
|
4841
|
+
/**
|
|
4842
|
+
* Click an element by its visible text content (no snapshot/ref needed).
|
|
4843
|
+
*
|
|
4844
|
+
* Finds and clicks atomically — no stale ref problem.
|
|
4845
|
+
*
|
|
4846
|
+
* @param text - Text content to match
|
|
4847
|
+
* @param opts - Options (exact: require full match, button, modifiers)
|
|
4848
|
+
*
|
|
4849
|
+
* @example
|
|
4850
|
+
* ```ts
|
|
4851
|
+
* await page.clickByText('Submit');
|
|
4852
|
+
* await page.clickByText('Save Changes', { exact: true });
|
|
4853
|
+
* ```
|
|
4854
|
+
*/
|
|
4855
|
+
async clickByText(text, opts) {
|
|
4856
|
+
return clickByTextViaPlaywright({
|
|
4857
|
+
cdpUrl: this.cdpUrl,
|
|
4858
|
+
targetId: this.targetId,
|
|
4859
|
+
text,
|
|
4860
|
+
exact: opts?.exact,
|
|
4861
|
+
button: opts?.button,
|
|
4862
|
+
modifiers: opts?.modifiers,
|
|
4863
|
+
timeoutMs: opts?.timeoutMs
|
|
4864
|
+
});
|
|
4865
|
+
}
|
|
4866
|
+
/**
|
|
4867
|
+
* Click an element by its ARIA role and accessible name (no snapshot/ref needed).
|
|
4868
|
+
*
|
|
4869
|
+
* Finds and clicks atomically — no stale ref problem.
|
|
4870
|
+
*
|
|
4871
|
+
* @param role - ARIA role (e.g. `'button'`, `'link'`, `'menuitem'`)
|
|
4872
|
+
* @param name - Accessible name to match (optional)
|
|
4873
|
+
* @param opts - Click options
|
|
4874
|
+
*
|
|
4875
|
+
* @example
|
|
4876
|
+
* ```ts
|
|
4877
|
+
* await page.clickByRole('button', 'Save');
|
|
4878
|
+
* await page.clickByRole('link', 'Settings');
|
|
4879
|
+
* await page.clickByRole('menuitem', 'Delete');
|
|
4880
|
+
* ```
|
|
4881
|
+
*/
|
|
4882
|
+
async clickByRole(role, name, opts) {
|
|
4883
|
+
return clickByRoleViaPlaywright({
|
|
4884
|
+
cdpUrl: this.cdpUrl,
|
|
4885
|
+
targetId: this.targetId,
|
|
4886
|
+
role,
|
|
4887
|
+
name,
|
|
4888
|
+
button: opts?.button,
|
|
4889
|
+
modifiers: opts?.modifiers,
|
|
4890
|
+
timeoutMs: opts?.timeoutMs
|
|
4891
|
+
});
|
|
4892
|
+
}
|
|
4656
4893
|
/**
|
|
4657
4894
|
* Type text into an input element by ref.
|
|
4658
4895
|
*
|
|
@@ -5488,11 +5725,13 @@ var CrawlPage = class {
|
|
|
5488
5725
|
var BrowserClaw = class _BrowserClaw {
|
|
5489
5726
|
cdpUrl;
|
|
5490
5727
|
ssrfPolicy;
|
|
5728
|
+
recordVideo;
|
|
5491
5729
|
chrome;
|
|
5492
|
-
constructor(cdpUrl, chrome, ssrfPolicy) {
|
|
5730
|
+
constructor(cdpUrl, chrome, ssrfPolicy, recordVideo) {
|
|
5493
5731
|
this.cdpUrl = cdpUrl;
|
|
5494
5732
|
this.chrome = chrome;
|
|
5495
5733
|
this.ssrfPolicy = ssrfPolicy;
|
|
5734
|
+
this.recordVideo = recordVideo;
|
|
5496
5735
|
}
|
|
5497
5736
|
/**
|
|
5498
5737
|
* Launch a new Chrome instance and connect to it.
|
|
@@ -5521,7 +5760,7 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
5521
5760
|
const chrome = await launchChrome(opts);
|
|
5522
5761
|
const cdpUrl = `http://127.0.0.1:${String(chrome.cdpPort)}`;
|
|
5523
5762
|
const ssrfPolicy = opts.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts.ssrfPolicy;
|
|
5524
|
-
return new _BrowserClaw(cdpUrl, chrome, ssrfPolicy);
|
|
5763
|
+
return new _BrowserClaw(cdpUrl, chrome, ssrfPolicy, opts.recordVideo);
|
|
5525
5764
|
}
|
|
5526
5765
|
/**
|
|
5527
5766
|
* Connect to an already-running Chrome instance via its CDP endpoint.
|
|
@@ -5538,12 +5777,22 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
5538
5777
|
* ```
|
|
5539
5778
|
*/
|
|
5540
5779
|
static async connect(cdpUrl, opts) {
|
|
5541
|
-
|
|
5542
|
-
|
|
5780
|
+
let resolvedUrl = cdpUrl;
|
|
5781
|
+
if (resolvedUrl === void 0 || resolvedUrl === "") {
|
|
5782
|
+
const discovered = await discoverChromeCdpUrl();
|
|
5783
|
+
if (discovered === null) {
|
|
5784
|
+
throw new Error(
|
|
5785
|
+
"No Chrome instance found on common CDP ports (9222-9226, 9229). Start Chrome with --remote-debugging-port=9222, or pass a CDP URL."
|
|
5786
|
+
);
|
|
5787
|
+
}
|
|
5788
|
+
resolvedUrl = discovered;
|
|
5789
|
+
}
|
|
5790
|
+
if (!await isChromeReachable(resolvedUrl, 3e3, opts?.authToken)) {
|
|
5791
|
+
throw new Error(`Cannot connect to Chrome at ${resolvedUrl}. Is Chrome running with --remote-debugging-port?`);
|
|
5543
5792
|
}
|
|
5544
|
-
await connectBrowser(
|
|
5793
|
+
await connectBrowser(resolvedUrl, opts?.authToken);
|
|
5545
5794
|
const ssrfPolicy = opts?.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts?.ssrfPolicy;
|
|
5546
|
-
return new _BrowserClaw(
|
|
5795
|
+
return new _BrowserClaw(resolvedUrl, null, ssrfPolicy, opts?.recordVideo);
|
|
5547
5796
|
}
|
|
5548
5797
|
/**
|
|
5549
5798
|
* Open a URL in a new tab and return the page handle.
|
|
@@ -5558,7 +5807,12 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
5558
5807
|
* ```
|
|
5559
5808
|
*/
|
|
5560
5809
|
async open(url) {
|
|
5561
|
-
const tab = await createPageViaPlaywright({
|
|
5810
|
+
const tab = await createPageViaPlaywright({
|
|
5811
|
+
cdpUrl: this.cdpUrl,
|
|
5812
|
+
url,
|
|
5813
|
+
ssrfPolicy: this.ssrfPolicy,
|
|
5814
|
+
recordVideo: this.recordVideo
|
|
5815
|
+
});
|
|
5562
5816
|
return new CrawlPage(this.cdpUrl, tab.targetId, this.ssrfPolicy);
|
|
5563
5817
|
}
|
|
5564
5818
|
/**
|
|
@@ -5621,6 +5875,7 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
5621
5875
|
* Playwright connection is closed.
|
|
5622
5876
|
*/
|
|
5623
5877
|
async stop() {
|
|
5878
|
+
clearRecordingContext(this.cdpUrl);
|
|
5624
5879
|
await disconnectBrowser();
|
|
5625
5880
|
if (this.chrome) {
|
|
5626
5881
|
await stopChrome(this.chrome);
|