browserclaw 0.7.1 → 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,6 +1297,16 @@ 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);
@@ -1912,6 +1927,12 @@ function ensurePageState(page) {
1912
1927
  rec.ok = false;
1913
1928
  }
1914
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
+ });
1915
1936
  page.on("close", () => {
1916
1937
  pageStates.delete(page);
1917
1938
  observedPages.delete(page);
@@ -2351,48 +2372,52 @@ async function awaitEvalWithAbort(evalPromise, abortPromise) {
2351
2372
  }
2352
2373
  var BROWSER_EVALUATOR = new Function(
2353
2374
  "args",
2354
- `
2355
- "use strict";
2356
- var fnBody = args.fnBody, timeoutMs = args.timeoutMs;
2357
- try {
2358
- var candidate = eval("(" + fnBody + ")");
2359
- var result = typeof candidate === "function" ? candidate() : candidate;
2360
- if (result && typeof result.then === "function") {
2361
- return Promise.race([
2362
- result,
2363
- new Promise(function(_, reject) {
2364
- setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);
2365
- })
2366
- ]);
2367
- }
2368
- return result;
2369
- } catch (err) {
2370
- throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));
2371
- }
2372
- `
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")
2373
2396
  );
2374
2397
  var ELEMENT_EVALUATOR = new Function(
2375
2398
  "el",
2376
2399
  "args",
2377
- `
2378
- "use strict";
2379
- var fnBody = args.fnBody, timeoutMs = args.timeoutMs;
2380
- try {
2381
- var candidate = eval("(" + fnBody + ")");
2382
- var result = typeof candidate === "function" ? candidate(el) : 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
- `
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")
2396
2421
  );
2397
2422
  async function evaluateViaPlaywright(opts) {
2398
2423
  const fnText = opts.fn.trim();
@@ -2460,6 +2485,18 @@ async function evaluateViaPlaywright(opts) {
2460
2485
 
2461
2486
  // src/security.ts
2462
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");
2463
2500
  var InvalidBrowserNavigationUrlError = class extends Error {
2464
2501
  constructor(message) {
2465
2502
  super(message);
@@ -2471,7 +2508,7 @@ function withBrowserNavigationPolicy(ssrfPolicy) {
2471
2508
  }
2472
2509
  var NETWORK_NAVIGATION_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
2473
2510
  var SAFE_NON_NETWORK_URLS = /* @__PURE__ */ new Set(["about:blank"]);
2474
- 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"];
2475
2512
  var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "localhost.localdomain", "metadata.google.internal"]);
2476
2513
  function isAllowedNonNetworkNavigationUrl(parsed) {
2477
2514
  return SAFE_NON_NETWORK_URLS.has(parsed.href);
@@ -2480,7 +2517,7 @@ function isPrivateNetworkAllowedByPolicy(policy) {
2480
2517
  return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true;
2481
2518
  }
2482
2519
  function hasProxyEnvConfigured2(env = process.env) {
2483
- for (const key of PROXY_ENV_KEYS) {
2520
+ for (const key of PROXY_ENV_KEYS2) {
2484
2521
  const value = env[key];
2485
2522
  if (typeof value === "string" && value.trim().length > 0) return true;
2486
2523
  }
@@ -2685,6 +2722,8 @@ function dedupeAndPreferIpv4(results) {
2685
2722
  }
2686
2723
  function createPinnedLookup(params) {
2687
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}`);
2688
2727
  const fallback = params.fallback ?? dns.lookup;
2689
2728
  const records = params.addresses.map((address) => ({
2690
2729
  address,
@@ -2859,6 +2898,48 @@ async function resolveStrictExistingUploadPaths(params) {
2859
2898
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
2860
2899
  }
2861
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
+ }
2862
2943
  function sanitizeUntrustedFileName(fileName, fallbackName) {
2863
2944
  const trimmed = fileName.trim();
2864
2945
  if (trimmed === "") return fallbackName;
@@ -2939,6 +3020,35 @@ function resolveLocator(page, resolved) {
2939
3020
  const sel = resolved.selector ?? "";
2940
3021
  return page.locator(sel);
2941
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
+ }
2942
3052
  async function clickViaPlaywright(opts) {
2943
3053
  const resolved = requireRefOrSelector(opts.ref, opts.selector);
2944
3054
  const page = await getRestoredPageForTarget(opts);
@@ -3102,9 +3212,10 @@ async function setInputFilesViaPlaywright(opts) {
3102
3212
  if (inputRef && element) throw new Error("ref and element are mutually exclusive");
3103
3213
  if (!inputRef && !element) throw new Error("Either ref or element is required for setInputFiles");
3104
3214
  const locator = inputRef ? refLocator(page, inputRef) : page.locator(element).first();
3105
- const uploadPathsResult = await resolveStrictExistingUploadPaths({
3215
+ const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
3216
+ rootDir: DEFAULT_UPLOAD_DIR,
3106
3217
  requestedPaths: opts.paths,
3107
- scopeLabel: "uploads directory"
3218
+ scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`
3108
3219
  });
3109
3220
  if (!uploadPathsResult.ok) throw new Error(uploadPathsResult.error);
3110
3221
  const resolvedPaths = uploadPathsResult.paths;
@@ -3132,9 +3243,14 @@ async function armDialogViaPlaywright(opts) {
3132
3243
  const armId = state.armIdDialog;
3133
3244
  page.waitForEvent("dialog", { timeout }).then(async (dialog) => {
3134
3245
  if (state.armIdDialog !== armId) return;
3135
- if (opts.accept) await dialog.accept(opts.promptText);
3136
- 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
+ }
3137
3252
  }).catch(() => {
3253
+ if (state.armIdDialog === armId) state.armIdDialog = 0;
3138
3254
  });
3139
3255
  }
3140
3256
  async function armFileUploadViaPlaywright(opts) {
@@ -3152,9 +3268,10 @@ async function armFileUploadViaPlaywright(opts) {
3152
3268
  }
3153
3269
  return;
3154
3270
  }
3155
- const uploadPathsResult = await resolveStrictExistingUploadPaths({
3271
+ const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
3272
+ rootDir: DEFAULT_UPLOAD_DIR,
3156
3273
  requestedPaths: opts.paths,
3157
- scopeLabel: "uploads directory"
3274
+ scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`
3158
3275
  });
3159
3276
  if (!uploadPathsResult.ok) {
3160
3277
  try {
@@ -3188,6 +3305,17 @@ async function pressKeyViaPlaywright(opts) {
3188
3305
  }
3189
3306
 
3190
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
+ }
3191
3319
  function isRetryableNavigateError(err) {
3192
3320
  const msg = typeof err === "string" ? err.toLowerCase() : err instanceof Error ? err.message.toLowerCase() : "";
3193
3321
  return msg.includes("frame has been detached") || msg.includes("target page, context or browser has been closed");
@@ -3240,7 +3368,7 @@ async function listPagesViaPlaywright(opts) {
3240
3368
  }
3241
3369
  async function createPageViaPlaywright(opts) {
3242
3370
  const { browser } = await connectBrowser(opts.cdpUrl);
3243
- 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();
3244
3372
  ensureContextState(context);
3245
3373
  const page = await context.newPage();
3246
3374
  ensurePageState(page);
@@ -3317,10 +3445,10 @@ async function waitForViaPlaywright(opts) {
3317
3445
  await page.waitForTimeout(resolveBoundedDelayMs2(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS));
3318
3446
  }
3319
3447
  if (opts.text !== void 0 && opts.text !== "") {
3320
- await page.getByText(opts.text).first().waitFor({ state: "visible", timeout });
3448
+ await page.waitForFunction((text) => (document.body?.innerText ?? "").includes(text), opts.text, { timeout });
3321
3449
  }
3322
3450
  if (opts.textGone !== void 0 && opts.textGone !== "") {
3323
- await page.getByText(opts.textGone).first().waitFor({ state: "hidden", timeout });
3451
+ await page.waitForFunction((text) => !(document.body?.innerText ?? "").includes(text), opts.textGone, { timeout });
3324
3452
  }
3325
3453
  if (opts.selector !== void 0 && opts.selector !== "") {
3326
3454
  const selector = opts.selector.trim();
@@ -3592,7 +3720,7 @@ async function waitForDownloadViaPlaywright(opts) {
3592
3720
  try {
3593
3721
  const download = await waiter.promise;
3594
3722
  if (state.armIdDownload !== armId) throw new Error("Download was superseded by another waiter");
3595
- const savePath = opts.path ?? download.suggestedFilename();
3723
+ const savePath = opts.path ?? sanitizeUntrustedFileName(download.suggestedFilename() || "download.bin", "download.bin");
3596
3724
  await assertSafeOutputPath(savePath, opts.allowedOutputRoots);
3597
3725
  return await saveDownloadPayload(download, savePath);
3598
3726
  } catch (err) {
@@ -4259,6 +4387,16 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
4259
4387
  return match ? match[1] : null;
4260
4388
  }
4261
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
+ };
4262
4400
  const out2 = [];
4263
4401
  for (const line of lines) {
4264
4402
  const parsed = matchInteractiveSnapshotLine(line, options);
@@ -4266,13 +4404,32 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
4266
4404
  const { roleRaw, role, name, suffix } = parsed;
4267
4405
  if (!INTERACTIVE_ROLES.has(role)) continue;
4268
4406
  const ref = parseAiSnapshotRef(suffix);
4269
- if (ref === null) continue;
4270
4407
  const prefix = /^(\s*-\s*)/.exec(line)?.[1] ?? "";
4271
- refs[ref] = { role, ...name !== void 0 && name !== "" ? { name } : {} };
4272
- 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
+ }
4273
4420
  }
4274
4421
  return { snapshot: out2.join("\n") || "(no interactive elements)", refs };
4275
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
+ };
4276
4433
  const out = [];
4277
4434
  for (const line of lines) {
4278
4435
  const depth = getIndentLevel(line);
@@ -4282,7 +4439,7 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
4282
4439
  out.push(line);
4283
4440
  continue;
4284
4441
  }
4285
- const [, , roleRaw, name, suffix] = match;
4442
+ const [, prefix, roleRaw, name, suffix] = match;
4286
4443
  if (roleRaw.startsWith("/")) {
4287
4444
  out.push(line);
4288
4445
  continue;
@@ -4291,8 +4448,20 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
4291
4448
  const isStructural = STRUCTURAL_ROLES.has(role);
4292
4449
  if (options.compact === true && isStructural && name === "") continue;
4293
4450
  const ref = parseAiSnapshotRef(suffix);
4294
- if (ref !== null) refs[ref] = { role, ...name !== "" ? { name } : {} };
4295
- 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
+ }
4296
4465
  }
4297
4466
  const tree = out.join("\n") || "(empty)";
4298
4467
  return { snapshot: options.compact === true ? compactTree(tree) : tree, refs };
@@ -4653,6 +4822,85 @@ var CrawlPage = class {
4653
4822
  timeoutMs: opts?.timeoutMs
4654
4823
  });
4655
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
+ }
4656
4904
  /**
4657
4905
  * Type text into an input element by ref.
4658
4906
  *
@@ -5488,11 +5736,13 @@ var CrawlPage = class {
5488
5736
  var BrowserClaw = class _BrowserClaw {
5489
5737
  cdpUrl;
5490
5738
  ssrfPolicy;
5739
+ recordVideo;
5491
5740
  chrome;
5492
- constructor(cdpUrl, chrome, ssrfPolicy) {
5741
+ constructor(cdpUrl, chrome, ssrfPolicy, recordVideo) {
5493
5742
  this.cdpUrl = cdpUrl;
5494
5743
  this.chrome = chrome;
5495
5744
  this.ssrfPolicy = ssrfPolicy;
5745
+ this.recordVideo = recordVideo;
5496
5746
  }
5497
5747
  /**
5498
5748
  * Launch a new Chrome instance and connect to it.
@@ -5521,7 +5771,7 @@ var BrowserClaw = class _BrowserClaw {
5521
5771
  const chrome = await launchChrome(opts);
5522
5772
  const cdpUrl = `http://127.0.0.1:${String(chrome.cdpPort)}`;
5523
5773
  const ssrfPolicy = opts.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts.ssrfPolicy;
5524
- return new _BrowserClaw(cdpUrl, chrome, ssrfPolicy);
5774
+ return new _BrowserClaw(cdpUrl, chrome, ssrfPolicy, opts.recordVideo);
5525
5775
  }
5526
5776
  /**
5527
5777
  * Connect to an already-running Chrome instance via its CDP endpoint.
@@ -5538,12 +5788,22 @@ var BrowserClaw = class _BrowserClaw {
5538
5788
  * ```
5539
5789
  */
5540
5790
  static async connect(cdpUrl, opts) {
5541
- if (!await isChromeReachable(cdpUrl, 3e3, opts?.authToken)) {
5542
- 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?`);
5543
5803
  }
5544
- await connectBrowser(cdpUrl, opts?.authToken);
5804
+ await connectBrowser(resolvedUrl, opts?.authToken);
5545
5805
  const ssrfPolicy = opts?.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts?.ssrfPolicy;
5546
- return new _BrowserClaw(cdpUrl, null, ssrfPolicy);
5806
+ return new _BrowserClaw(resolvedUrl, null, ssrfPolicy, opts?.recordVideo);
5547
5807
  }
5548
5808
  /**
5549
5809
  * Open a URL in a new tab and return the page handle.
@@ -5558,7 +5818,12 @@ var BrowserClaw = class _BrowserClaw {
5558
5818
  * ```
5559
5819
  */
5560
5820
  async open(url) {
5561
- 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
+ });
5562
5827
  return new CrawlPage(this.cdpUrl, tab.targetId, this.ssrfPolicy);
5563
5828
  }
5564
5829
  /**
@@ -5621,6 +5886,7 @@ var BrowserClaw = class _BrowserClaw {
5621
5886
  * Playwright connection is closed.
5622
5887
  */
5623
5888
  async stop() {
5889
+ clearRecordingContext(this.cdpUrl);
5624
5890
  await disconnectBrowser();
5625
5891
  if (this.chrome) {
5626
5892
  await stopChrome(this.chrome);