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 CHANGED
@@ -1,13 +1,15 @@
1
1
  <h2 align="center">🦞 BrowserClaw — Standalone OpenClaw browser module</h2>
2
2
 
3
3
  <p align="center">
4
- <a href="https://browserclaw.agent"><img src="https://img.shields.io/badge/Live-browserclaw.agent-orange" alt="Live" /></a>
4
+ <a href="https://browserclaw.org"><img src="https://img.shields.io/badge/Live-browserclaw.org-orange" alt="Live" /></a>
5
5
  <a href="https://www.npmjs.com/package/browserclaw"><img src="https://img.shields.io/npm/v/browserclaw.svg" alt="npm version" /></a>
6
6
  <a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT" /></a>
7
7
  <a href="https://www.npmjs.com/package/browserclaw"><img src="https://img.shields.io/npm/dw/browserclaw" alt="npm downloads" /></a>
8
8
  <a href="https://github.com/idan-rubin/browserclaw/stargazers"><img src="https://img.shields.io/github/stars/idan-rubin/browserclaw" alt="GitHub stars" /></a>
9
9
  </p>
10
10
 
11
+ > **DISCLAIMER: This project is NOT affiliated with browserclaw.com in any form. We have no connection to that site and recommend treating it with caution.**
12
+
11
13
  Extracted and refined from [OpenClaw](https://github.com/openclaw/openclaw)'s browser automation module. A standalone, typed library for AI-friendly browser control with **snapshot + ref targeting** — no CSS selectors, no XPath, no vision, just numbered refs that map to interactive elements.
12
14
 
13
15
  ```typescript
package/dist/index.cjs CHANGED
@@ -1198,8 +1198,13 @@ function isWebSocketUrl(url) {
1198
1198
  function isLoopbackHost(hostname) {
1199
1199
  return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
1200
1200
  }
1201
- function hasProxyEnvConfigured() {
1202
- return (process.env.HTTP_PROXY ?? process.env.HTTPS_PROXY ?? process.env.http_proxy ?? process.env.https_proxy ?? "") !== "";
1201
+ var PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
1202
+ function hasProxyEnvConfigured(env = process.env) {
1203
+ for (const key of PROXY_ENV_KEYS) {
1204
+ const value = env[key];
1205
+ if (typeof value === "string" && value.trim().length > 0) return true;
1206
+ }
1207
+ return false;
1203
1208
  }
1204
1209
  function normalizeCdpWsUrl(wsUrl, cdpUrl) {
1205
1210
  const ws = new URL(wsUrl);
@@ -1292,23 +1297,20 @@ async function fetchChromeVersion(cdpUrl, timeoutMs = 500, authToken) {
1292
1297
  clearTimeout(t);
1293
1298
  }
1294
1299
  }
1300
+ var COMMON_CDP_PORTS = [9222, 9223, 9224, 9225, 9226, 9229];
1301
+ async function discoverChromeCdpUrl(timeoutMs = 500) {
1302
+ const results = await Promise.all(
1303
+ COMMON_CDP_PORTS.map(async (port) => {
1304
+ const url = `http://127.0.0.1:${String(port)}`;
1305
+ return await isChromeReachable(url, timeoutMs) ? url : null;
1306
+ })
1307
+ );
1308
+ return results.find((url) => url !== null) ?? null;
1309
+ }
1295
1310
  async function isChromeReachable(cdpUrl, timeoutMs = 500, authToken) {
1296
1311
  if (isWebSocketUrl(cdpUrl)) return await canOpenWebSocket(cdpUrl, timeoutMs);
1297
1312
  const version = await fetchChromeVersion(cdpUrl, timeoutMs, authToken);
1298
- if (version !== null) return true;
1299
- let isLoopback = false;
1300
- try {
1301
- const u = new URL(cdpUrl.startsWith("http") ? cdpUrl : `http://${cdpUrl}`);
1302
- isLoopback = isLoopbackHost(u.hostname);
1303
- } catch {
1304
- }
1305
- if (!isLoopback) return false;
1306
- for (let i = 0; i < 2; i++) {
1307
- await new Promise((r) => setTimeout(r, 150));
1308
- const retry = await fetchChromeVersion(cdpUrl, timeoutMs, authToken);
1309
- if (retry !== null) return true;
1310
- }
1311
- return false;
1313
+ return version !== null;
1312
1314
  }
1313
1315
  async function getChromeWebSocketUrl(cdpUrl, timeoutMs = 500, authToken) {
1314
1316
  if (isWebSocketUrl(cdpUrl)) return cdpUrl;
@@ -1925,6 +1927,12 @@ function ensurePageState(page) {
1925
1927
  rec.ok = false;
1926
1928
  }
1927
1929
  });
1930
+ page.on("dialog", (dialog) => {
1931
+ if (state.armIdDialog > 0) return;
1932
+ dialog.dismiss().catch((err) => {
1933
+ console.warn(`[browserclaw] Failed to dismiss dialog: ${err instanceof Error ? err.message : String(err)}`);
1934
+ });
1935
+ });
1928
1936
  page.on("close", () => {
1929
1937
  pageStates.delete(page);
1930
1938
  observedPages.delete(page);
@@ -2053,6 +2061,8 @@ async function disconnectBrowser() {
2053
2061
  }
2054
2062
  }
2055
2063
  for (const cur of cachedByCdpUrl.values()) {
2064
+ if (cur.onDisconnected && typeof cur.browser.off === "function")
2065
+ cur.browser.off("disconnected", cur.onDisconnected);
2056
2066
  await cur.browser.close().catch(() => {
2057
2067
  });
2058
2068
  }
@@ -2362,48 +2372,52 @@ async function awaitEvalWithAbort(evalPromise, abortPromise) {
2362
2372
  }
2363
2373
  var BROWSER_EVALUATOR = new Function(
2364
2374
  "args",
2365
- `
2366
- "use strict";
2367
- var fnBody = args.fnBody, timeoutMs = args.timeoutMs;
2368
- try {
2369
- var candidate = eval("(" + fnBody + ")");
2370
- var result = typeof candidate === "function" ? candidate() : candidate;
2371
- if (result && typeof result.then === "function") {
2372
- return Promise.race([
2373
- result,
2374
- new Promise(function(_, reject) {
2375
- setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);
2376
- })
2377
- ]);
2378
- }
2379
- return result;
2380
- } catch (err) {
2381
- throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));
2382
- }
2383
- `
2375
+ [
2376
+ '"use strict";',
2377
+ "var fnBody = args.fnBody, timeoutMs = args.timeoutMs;",
2378
+ "try {",
2379
+ " var candidate;",
2380
+ ' try { candidate = eval("(" + fnBody + ")"); }',
2381
+ " catch (_) { candidate = (0, eval)(fnBody); }",
2382
+ ' var result = typeof candidate === "function" ? candidate() : candidate;',
2383
+ ' if (result && typeof result.then === "function") {',
2384
+ " return Promise.race([",
2385
+ " result,",
2386
+ " new Promise(function(_, reject) {",
2387
+ ' setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);',
2388
+ " })",
2389
+ " ]);",
2390
+ " }",
2391
+ " return result;",
2392
+ "} catch (err) {",
2393
+ ' throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));',
2394
+ "}"
2395
+ ].join("\n")
2384
2396
  );
2385
2397
  var ELEMENT_EVALUATOR = new Function(
2386
2398
  "el",
2387
2399
  "args",
2388
- `
2389
- "use strict";
2390
- var fnBody = args.fnBody, timeoutMs = args.timeoutMs;
2391
- try {
2392
- var candidate = eval("(" + fnBody + ")");
2393
- var result = typeof candidate === "function" ? candidate(el) : candidate;
2394
- if (result && typeof result.then === "function") {
2395
- return Promise.race([
2396
- result,
2397
- new Promise(function(_, reject) {
2398
- setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);
2399
- })
2400
- ]);
2401
- }
2402
- return result;
2403
- } catch (err) {
2404
- throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));
2405
- }
2406
- `
2400
+ [
2401
+ '"use strict";',
2402
+ "var fnBody = args.fnBody, timeoutMs = args.timeoutMs;",
2403
+ "try {",
2404
+ " var candidate;",
2405
+ ' try { candidate = eval("(" + fnBody + ")"); }',
2406
+ " catch (_) { candidate = (0, eval)(fnBody); }",
2407
+ ' var result = typeof candidate === "function" ? candidate(el) : candidate;',
2408
+ ' if (result && typeof result.then === "function") {',
2409
+ " return Promise.race([",
2410
+ " result,",
2411
+ " new Promise(function(_, reject) {",
2412
+ ' setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);',
2413
+ " })",
2414
+ " ]);",
2415
+ " }",
2416
+ " return result;",
2417
+ "} catch (err) {",
2418
+ ' throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));',
2419
+ "}"
2420
+ ].join("\n")
2407
2421
  );
2408
2422
  async function evaluateViaPlaywright(opts) {
2409
2423
  const fnText = opts.fn.trim();
@@ -2471,6 +2485,18 @@ async function evaluateViaPlaywright(opts) {
2471
2485
 
2472
2486
  // src/security.ts
2473
2487
  var ipaddr = __toESM(require_ipaddr());
2488
+ function resolveDefaultBrowserTmpDir() {
2489
+ try {
2490
+ if (process.platform === "linux" || process.platform === "darwin") {
2491
+ return "/tmp/browserclaw";
2492
+ }
2493
+ } catch {
2494
+ }
2495
+ return path.join(os.tmpdir(), "browserclaw");
2496
+ }
2497
+ var DEFAULT_BROWSER_TMP_DIR = resolveDefaultBrowserTmpDir();
2498
+ path.join(DEFAULT_BROWSER_TMP_DIR, "downloads");
2499
+ var DEFAULT_UPLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "uploads");
2474
2500
  var InvalidBrowserNavigationUrlError = class extends Error {
2475
2501
  constructor(message) {
2476
2502
  super(message);
@@ -2482,7 +2508,7 @@ function withBrowserNavigationPolicy(ssrfPolicy) {
2482
2508
  }
2483
2509
  var NETWORK_NAVIGATION_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
2484
2510
  var SAFE_NON_NETWORK_URLS = /* @__PURE__ */ new Set(["about:blank"]);
2485
- var PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
2511
+ var PROXY_ENV_KEYS2 = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
2486
2512
  var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "localhost.localdomain", "metadata.google.internal"]);
2487
2513
  function isAllowedNonNetworkNavigationUrl(parsed) {
2488
2514
  return SAFE_NON_NETWORK_URLS.has(parsed.href);
@@ -2491,7 +2517,7 @@ function isPrivateNetworkAllowedByPolicy(policy) {
2491
2517
  return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true;
2492
2518
  }
2493
2519
  function hasProxyEnvConfigured2(env = process.env) {
2494
- for (const key of PROXY_ENV_KEYS) {
2520
+ for (const key of PROXY_ENV_KEYS2) {
2495
2521
  const value = env[key];
2496
2522
  if (typeof value === "string" && value.trim().length > 0) return true;
2497
2523
  }
@@ -2696,6 +2722,8 @@ function dedupeAndPreferIpv4(results) {
2696
2722
  }
2697
2723
  function createPinnedLookup(params) {
2698
2724
  const normalizedHost = normalizeHostname(params.hostname);
2725
+ if (params.addresses.length === 0)
2726
+ throw new Error(`Pinned lookup requires at least one address for ${params.hostname}`);
2699
2727
  const fallback = params.fallback ?? dns.lookup;
2700
2728
  const records = params.addresses.map((address) => ({
2701
2729
  address,
@@ -2870,6 +2898,48 @@ async function resolveStrictExistingUploadPaths(params) {
2870
2898
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
2871
2899
  }
2872
2900
  }
2901
+ function resolvePathWithinRoot(params) {
2902
+ const root = path.resolve(params.rootDir);
2903
+ const raw = params.requestedPath.trim();
2904
+ const effectivePath = raw === "" && params.defaultFileName != null && params.defaultFileName !== "" ? params.defaultFileName : raw;
2905
+ if (effectivePath === "") return { ok: false, error: `Empty path is not allowed (${params.scopeLabel}).` };
2906
+ const resolved = path.resolve(root, effectivePath);
2907
+ const rel = path.relative(root, resolved);
2908
+ if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
2909
+ return { ok: false, error: `Path escapes ${params.scopeLabel}: "${params.requestedPath}".` };
2910
+ }
2911
+ return { ok: true, path: resolved };
2912
+ }
2913
+ async function resolveStrictExistingPathsWithinRoot(params) {
2914
+ const root = path.resolve(params.rootDir);
2915
+ const resolved = [];
2916
+ for (const raw of params.requestedPaths) {
2917
+ const lexical = resolvePathWithinRoot({ rootDir: root, requestedPath: raw, scopeLabel: params.scopeLabel });
2918
+ if (!lexical.ok) return lexical;
2919
+ let real;
2920
+ try {
2921
+ real = await promises$1.realpath(lexical.path);
2922
+ } catch (e) {
2923
+ if (e.code === "ENOENT") {
2924
+ return { ok: false, error: `Path does not exist (${params.scopeLabel}): "${raw}".` };
2925
+ }
2926
+ return { ok: false, error: `Cannot resolve "${raw}" (${params.scopeLabel}): ${e.message}` };
2927
+ }
2928
+ const rel = path.relative(root, real);
2929
+ if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
2930
+ return { ok: false, error: `Path escapes ${params.scopeLabel} via symlink: "${raw}".` };
2931
+ }
2932
+ const stat = await promises$1.lstat(real);
2933
+ if (stat.isSymbolicLink()) {
2934
+ return { ok: false, error: `Path is a symbolic link (${params.scopeLabel}): "${raw}".` };
2935
+ }
2936
+ if (!stat.isFile()) {
2937
+ return { ok: false, error: `Path is not a regular file (${params.scopeLabel}): "${raw}".` };
2938
+ }
2939
+ resolved.push(real);
2940
+ }
2941
+ return { ok: true, paths: resolved };
2942
+ }
2873
2943
  function sanitizeUntrustedFileName(fileName, fallbackName) {
2874
2944
  const trimmed = fileName.trim();
2875
2945
  if (trimmed === "") return fallbackName;
@@ -2950,6 +3020,35 @@ function resolveLocator(page, resolved) {
2950
3020
  const sel = resolved.selector ?? "";
2951
3021
  return page.locator(sel);
2952
3022
  }
3023
+ async function mouseClickViaPlaywright(opts) {
3024
+ const page = await getRestoredPageForTarget(opts);
3025
+ await page.mouse.click(opts.x, opts.y, {
3026
+ button: opts.button,
3027
+ clickCount: opts.clickCount,
3028
+ delay: opts.delayMs
3029
+ });
3030
+ }
3031
+ async function clickByTextViaPlaywright(opts) {
3032
+ const page = await getRestoredPageForTarget(opts);
3033
+ const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
3034
+ try {
3035
+ await page.getByText(opts.text, { exact: opts.exact }).click({ timeout, button: opts.button, modifiers: opts.modifiers });
3036
+ } catch (err) {
3037
+ throw toAIFriendlyError(err, `text="${opts.text}"`);
3038
+ }
3039
+ }
3040
+ async function clickByRoleViaPlaywright(opts) {
3041
+ const page = await getRestoredPageForTarget(opts);
3042
+ const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
3043
+ try {
3044
+ await page.getByRole(opts.role, { name: opts.name }).click({ timeout, button: opts.button, modifiers: opts.modifiers });
3045
+ } catch (err) {
3046
+ throw toAIFriendlyError(
3047
+ err,
3048
+ `role=${opts.role}${opts.name !== void 0 && opts.name !== "" ? ` name="${opts.name}"` : ""}`
3049
+ );
3050
+ }
3051
+ }
2953
3052
  async function clickViaPlaywright(opts) {
2954
3053
  const resolved = requireRefOrSelector(opts.ref, opts.selector);
2955
3054
  const page = await getRestoredPageForTarget(opts);
@@ -3113,9 +3212,10 @@ async function setInputFilesViaPlaywright(opts) {
3113
3212
  if (inputRef && element) throw new Error("ref and element are mutually exclusive");
3114
3213
  if (!inputRef && !element) throw new Error("Either ref or element is required for setInputFiles");
3115
3214
  const locator = inputRef ? refLocator(page, inputRef) : page.locator(element).first();
3116
- const uploadPathsResult = await resolveStrictExistingUploadPaths({
3215
+ const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
3216
+ rootDir: DEFAULT_UPLOAD_DIR,
3117
3217
  requestedPaths: opts.paths,
3118
- scopeLabel: "uploads directory"
3218
+ scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`
3119
3219
  });
3120
3220
  if (!uploadPathsResult.ok) throw new Error(uploadPathsResult.error);
3121
3221
  const resolvedPaths = uploadPathsResult.paths;
@@ -3143,9 +3243,14 @@ async function armDialogViaPlaywright(opts) {
3143
3243
  const armId = state.armIdDialog;
3144
3244
  page.waitForEvent("dialog", { timeout }).then(async (dialog) => {
3145
3245
  if (state.armIdDialog !== armId) return;
3146
- if (opts.accept) await dialog.accept(opts.promptText);
3147
- else await dialog.dismiss();
3246
+ try {
3247
+ if (opts.accept) await dialog.accept(opts.promptText);
3248
+ else await dialog.dismiss();
3249
+ } finally {
3250
+ if (state.armIdDialog === armId) state.armIdDialog = 0;
3251
+ }
3148
3252
  }).catch(() => {
3253
+ if (state.armIdDialog === armId) state.armIdDialog = 0;
3149
3254
  });
3150
3255
  }
3151
3256
  async function armFileUploadViaPlaywright(opts) {
@@ -3163,9 +3268,10 @@ async function armFileUploadViaPlaywright(opts) {
3163
3268
  }
3164
3269
  return;
3165
3270
  }
3166
- const uploadPathsResult = await resolveStrictExistingUploadPaths({
3271
+ const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
3272
+ rootDir: DEFAULT_UPLOAD_DIR,
3167
3273
  requestedPaths: opts.paths,
3168
- scopeLabel: "uploads directory"
3274
+ scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`
3169
3275
  });
3170
3276
  if (!uploadPathsResult.ok) {
3171
3277
  try {
@@ -3199,6 +3305,17 @@ async function pressKeyViaPlaywright(opts) {
3199
3305
  }
3200
3306
 
3201
3307
  // src/actions/navigation.ts
3308
+ var recordingContexts = /* @__PURE__ */ new Map();
3309
+ function clearRecordingContext(cdpUrl) {
3310
+ recordingContexts.delete(cdpUrl);
3311
+ }
3312
+ async function createRecordingContext(browser, cdpUrl, recordVideo) {
3313
+ const context = await browser.newContext({ recordVideo });
3314
+ observeContext(context);
3315
+ recordingContexts.set(cdpUrl, context);
3316
+ context.on("close", () => recordingContexts.delete(cdpUrl));
3317
+ return context;
3318
+ }
3202
3319
  function isRetryableNavigateError(err) {
3203
3320
  const msg = typeof err === "string" ? err.toLowerCase() : err instanceof Error ? err.message.toLowerCase() : "";
3204
3321
  return msg.includes("frame has been detached") || msg.includes("target page, context or browser has been closed");
@@ -3251,7 +3368,7 @@ async function listPagesViaPlaywright(opts) {
3251
3368
  }
3252
3369
  async function createPageViaPlaywright(opts) {
3253
3370
  const { browser } = await connectBrowser(opts.cdpUrl);
3254
- const context = browser.contexts()[0] ?? await browser.newContext();
3371
+ const context = opts.recordVideo ? recordingContexts.get(opts.cdpUrl) ?? await createRecordingContext(browser, opts.cdpUrl, opts.recordVideo) : browser.contexts()[0] ?? await browser.newContext();
3255
3372
  ensureContextState(context);
3256
3373
  const page = await context.newPage();
3257
3374
  ensurePageState(page);
@@ -3328,10 +3445,10 @@ async function waitForViaPlaywright(opts) {
3328
3445
  await page.waitForTimeout(resolveBoundedDelayMs2(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS));
3329
3446
  }
3330
3447
  if (opts.text !== void 0 && opts.text !== "") {
3331
- await page.getByText(opts.text).first().waitFor({ state: "visible", timeout });
3448
+ await page.waitForFunction((text) => (document.body?.innerText ?? "").includes(text), opts.text, { timeout });
3332
3449
  }
3333
3450
  if (opts.textGone !== void 0 && opts.textGone !== "") {
3334
- await page.getByText(opts.textGone).first().waitFor({ state: "hidden", timeout });
3451
+ await page.waitForFunction((text) => !(document.body?.innerText ?? "").includes(text), opts.textGone, { timeout });
3335
3452
  }
3336
3453
  if (opts.selector !== void 0 && opts.selector !== "") {
3337
3454
  const selector = opts.selector.trim();
@@ -3603,7 +3720,7 @@ async function waitForDownloadViaPlaywright(opts) {
3603
3720
  try {
3604
3721
  const download = await waiter.promise;
3605
3722
  if (state.armIdDownload !== armId) throw new Error("Download was superseded by another waiter");
3606
- const savePath = opts.path ?? download.suggestedFilename();
3723
+ const savePath = opts.path ?? sanitizeUntrustedFileName(download.suggestedFilename() || "download.bin", "download.bin");
3607
3724
  await assertSafeOutputPath(savePath, opts.allowedOutputRoots);
3608
3725
  return await saveDownloadPayload(download, savePath);
3609
3726
  } catch (err) {
@@ -4270,6 +4387,16 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
4270
4387
  return match ? match[1] : null;
4271
4388
  }
4272
4389
  if (options.interactive === true) {
4390
+ let interactiveMaxRef = 0;
4391
+ for (const line of lines) {
4392
+ const refMatch = /\[ref=e(\d+)\]/.exec(line);
4393
+ if (refMatch) interactiveMaxRef = Math.max(interactiveMaxRef, Number.parseInt(refMatch[1], 10));
4394
+ }
4395
+ let interactiveCounter = interactiveMaxRef;
4396
+ const nextInteractiveRef = () => {
4397
+ interactiveCounter++;
4398
+ return `e${String(interactiveCounter)}`;
4399
+ };
4273
4400
  const out2 = [];
4274
4401
  for (const line of lines) {
4275
4402
  const parsed = matchInteractiveSnapshotLine(line, options);
@@ -4277,13 +4404,32 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
4277
4404
  const { roleRaw, role, name, suffix } = parsed;
4278
4405
  if (!INTERACTIVE_ROLES.has(role)) continue;
4279
4406
  const ref = parseAiSnapshotRef(suffix);
4280
- if (ref === null) continue;
4281
4407
  const prefix = /^(\s*-\s*)/.exec(line)?.[1] ?? "";
4282
- refs[ref] = { role, ...name !== void 0 && name !== "" ? { name } : {} };
4283
- out2.push(`${prefix}${roleRaw}${name !== void 0 && name !== "" ? ` "${name}"` : ""}${suffix}`);
4408
+ if (ref !== null) {
4409
+ refs[ref] = { role, ...name !== void 0 && name !== "" ? { name } : {} };
4410
+ out2.push(`${prefix}${roleRaw}${name !== void 0 && name !== "" ? ` "${name}"` : ""}${suffix}`);
4411
+ } else {
4412
+ const generatedRef = nextInteractiveRef();
4413
+ refs[generatedRef] = { role, ...name !== void 0 && name !== "" ? { name } : {} };
4414
+ let enhanced = `${prefix}${roleRaw}`;
4415
+ if (name !== void 0 && name !== "") enhanced += ` "${name}"`;
4416
+ enhanced += ` [ref=${generatedRef}]`;
4417
+ if (suffix.trim() !== "") enhanced += suffix;
4418
+ out2.push(enhanced);
4419
+ }
4284
4420
  }
4285
4421
  return { snapshot: out2.join("\n") || "(no interactive elements)", refs };
4286
4422
  }
4423
+ let maxRef = 0;
4424
+ for (const line of lines) {
4425
+ const refMatch = /\[ref=e(\d+)\]/.exec(line);
4426
+ if (refMatch) maxRef = Math.max(maxRef, Number.parseInt(refMatch[1], 10));
4427
+ }
4428
+ let generatedCounter = maxRef;
4429
+ const nextGeneratedRef = () => {
4430
+ generatedCounter++;
4431
+ return `e${String(generatedCounter)}`;
4432
+ };
4287
4433
  const out = [];
4288
4434
  for (const line of lines) {
4289
4435
  const depth = getIndentLevel(line);
@@ -4293,7 +4439,7 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
4293
4439
  out.push(line);
4294
4440
  continue;
4295
4441
  }
4296
- const [, , roleRaw, name, suffix] = match;
4442
+ const [, prefix, roleRaw, name, suffix] = match;
4297
4443
  if (roleRaw.startsWith("/")) {
4298
4444
  out.push(line);
4299
4445
  continue;
@@ -4302,8 +4448,20 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
4302
4448
  const isStructural = STRUCTURAL_ROLES.has(role);
4303
4449
  if (options.compact === true && isStructural && name === "") continue;
4304
4450
  const ref = parseAiSnapshotRef(suffix);
4305
- if (ref !== null) refs[ref] = { role, ...name !== "" ? { name } : {} };
4306
- out.push(line);
4451
+ if (ref !== null) {
4452
+ refs[ref] = { role, ...name !== "" ? { name } : {} };
4453
+ out.push(line);
4454
+ } else if (INTERACTIVE_ROLES.has(role)) {
4455
+ const generatedRef = nextGeneratedRef();
4456
+ refs[generatedRef] = { role, ...name !== "" ? { name } : {} };
4457
+ let enhanced = `${prefix}${roleRaw}`;
4458
+ if (name !== "") enhanced += ` "${name}"`;
4459
+ enhanced += ` [ref=${generatedRef}]`;
4460
+ if (suffix.trim() !== "") enhanced += suffix;
4461
+ out.push(enhanced);
4462
+ } else {
4463
+ out.push(line);
4464
+ }
4307
4465
  }
4308
4466
  const tree = out.join("\n") || "(empty)";
4309
4467
  return { snapshot: options.compact === true ? compactTree(tree) : tree, refs };
@@ -4664,6 +4822,85 @@ var CrawlPage = class {
4664
4822
  timeoutMs: opts?.timeoutMs
4665
4823
  });
4666
4824
  }
4825
+ /**
4826
+ * Click at specific page coordinates.
4827
+ *
4828
+ * Useful for canvas elements, custom widgets, or elements without ARIA roles.
4829
+ *
4830
+ * @param x - X coordinate in pixels
4831
+ * @param y - Y coordinate in pixels
4832
+ * @param opts - Click options (button, clickCount, delayMs)
4833
+ *
4834
+ * @example
4835
+ * ```ts
4836
+ * await page.mouseClick(100, 200);
4837
+ * await page.mouseClick(100, 200, { button: 'right' });
4838
+ * await page.mouseClick(100, 200, { clickCount: 2 }); // double-click
4839
+ * ```
4840
+ */
4841
+ async mouseClick(x, y, opts) {
4842
+ return mouseClickViaPlaywright({
4843
+ cdpUrl: this.cdpUrl,
4844
+ targetId: this.targetId,
4845
+ x,
4846
+ y,
4847
+ button: opts?.button,
4848
+ clickCount: opts?.clickCount,
4849
+ delayMs: opts?.delayMs
4850
+ });
4851
+ }
4852
+ /**
4853
+ * Click an element by its visible text content (no snapshot/ref needed).
4854
+ *
4855
+ * Finds and clicks atomically — no stale ref problem.
4856
+ *
4857
+ * @param text - Text content to match
4858
+ * @param opts - Options (exact: require full match, button, modifiers)
4859
+ *
4860
+ * @example
4861
+ * ```ts
4862
+ * await page.clickByText('Submit');
4863
+ * await page.clickByText('Save Changes', { exact: true });
4864
+ * ```
4865
+ */
4866
+ async clickByText(text, opts) {
4867
+ return clickByTextViaPlaywright({
4868
+ cdpUrl: this.cdpUrl,
4869
+ targetId: this.targetId,
4870
+ text,
4871
+ exact: opts?.exact,
4872
+ button: opts?.button,
4873
+ modifiers: opts?.modifiers,
4874
+ timeoutMs: opts?.timeoutMs
4875
+ });
4876
+ }
4877
+ /**
4878
+ * Click an element by its ARIA role and accessible name (no snapshot/ref needed).
4879
+ *
4880
+ * Finds and clicks atomically — no stale ref problem.
4881
+ *
4882
+ * @param role - ARIA role (e.g. `'button'`, `'link'`, `'menuitem'`)
4883
+ * @param name - Accessible name to match (optional)
4884
+ * @param opts - Click options
4885
+ *
4886
+ * @example
4887
+ * ```ts
4888
+ * await page.clickByRole('button', 'Save');
4889
+ * await page.clickByRole('link', 'Settings');
4890
+ * await page.clickByRole('menuitem', 'Delete');
4891
+ * ```
4892
+ */
4893
+ async clickByRole(role, name, opts) {
4894
+ return clickByRoleViaPlaywright({
4895
+ cdpUrl: this.cdpUrl,
4896
+ targetId: this.targetId,
4897
+ role,
4898
+ name,
4899
+ button: opts?.button,
4900
+ modifiers: opts?.modifiers,
4901
+ timeoutMs: opts?.timeoutMs
4902
+ });
4903
+ }
4667
4904
  /**
4668
4905
  * Type text into an input element by ref.
4669
4906
  *
@@ -5499,11 +5736,13 @@ var CrawlPage = class {
5499
5736
  var BrowserClaw = class _BrowserClaw {
5500
5737
  cdpUrl;
5501
5738
  ssrfPolicy;
5739
+ recordVideo;
5502
5740
  chrome;
5503
- constructor(cdpUrl, chrome, ssrfPolicy) {
5741
+ constructor(cdpUrl, chrome, ssrfPolicy, recordVideo) {
5504
5742
  this.cdpUrl = cdpUrl;
5505
5743
  this.chrome = chrome;
5506
5744
  this.ssrfPolicy = ssrfPolicy;
5745
+ this.recordVideo = recordVideo;
5507
5746
  }
5508
5747
  /**
5509
5748
  * Launch a new Chrome instance and connect to it.
@@ -5532,7 +5771,7 @@ var BrowserClaw = class _BrowserClaw {
5532
5771
  const chrome = await launchChrome(opts);
5533
5772
  const cdpUrl = `http://127.0.0.1:${String(chrome.cdpPort)}`;
5534
5773
  const ssrfPolicy = opts.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts.ssrfPolicy;
5535
- return new _BrowserClaw(cdpUrl, chrome, ssrfPolicy);
5774
+ return new _BrowserClaw(cdpUrl, chrome, ssrfPolicy, opts.recordVideo);
5536
5775
  }
5537
5776
  /**
5538
5777
  * Connect to an already-running Chrome instance via its CDP endpoint.
@@ -5549,12 +5788,22 @@ var BrowserClaw = class _BrowserClaw {
5549
5788
  * ```
5550
5789
  */
5551
5790
  static async connect(cdpUrl, opts) {
5552
- if (!await isChromeReachable(cdpUrl, 3e3, opts?.authToken)) {
5553
- throw new Error(`Cannot connect to Chrome at ${cdpUrl}. Is Chrome running with --remote-debugging-port?`);
5791
+ let resolvedUrl = cdpUrl;
5792
+ if (resolvedUrl === void 0 || resolvedUrl === "") {
5793
+ const discovered = await discoverChromeCdpUrl();
5794
+ if (discovered === null) {
5795
+ throw new Error(
5796
+ "No Chrome instance found on common CDP ports (9222-9226, 9229). Start Chrome with --remote-debugging-port=9222, or pass a CDP URL."
5797
+ );
5798
+ }
5799
+ resolvedUrl = discovered;
5800
+ }
5801
+ if (!await isChromeReachable(resolvedUrl, 3e3, opts?.authToken)) {
5802
+ throw new Error(`Cannot connect to Chrome at ${resolvedUrl}. Is Chrome running with --remote-debugging-port?`);
5554
5803
  }
5555
- await connectBrowser(cdpUrl, opts?.authToken);
5804
+ await connectBrowser(resolvedUrl, opts?.authToken);
5556
5805
  const ssrfPolicy = opts?.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts?.ssrfPolicy;
5557
- return new _BrowserClaw(cdpUrl, null, ssrfPolicy);
5806
+ return new _BrowserClaw(resolvedUrl, null, ssrfPolicy, opts?.recordVideo);
5558
5807
  }
5559
5808
  /**
5560
5809
  * Open a URL in a new tab and return the page handle.
@@ -5569,7 +5818,12 @@ var BrowserClaw = class _BrowserClaw {
5569
5818
  * ```
5570
5819
  */
5571
5820
  async open(url) {
5572
- const tab = await createPageViaPlaywright({ cdpUrl: this.cdpUrl, url, ssrfPolicy: this.ssrfPolicy });
5821
+ const tab = await createPageViaPlaywright({
5822
+ cdpUrl: this.cdpUrl,
5823
+ url,
5824
+ ssrfPolicy: this.ssrfPolicy,
5825
+ recordVideo: this.recordVideo
5826
+ });
5573
5827
  return new CrawlPage(this.cdpUrl, tab.targetId, this.ssrfPolicy);
5574
5828
  }
5575
5829
  /**
@@ -5632,6 +5886,7 @@ var BrowserClaw = class _BrowserClaw {
5632
5886
  * Playwright connection is closed.
5633
5887
  */
5634
5888
  async stop() {
5889
+ clearRecordingContext(this.cdpUrl);
5635
5890
  await disconnectBrowser();
5636
5891
  if (this.chrome) {
5637
5892
  await stopChrome(this.chrome);