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/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, join, basename, relative, sep, normalize } from 'path';
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
- function hasProxyEnvConfigured() {
1191
- return (process.env.HTTP_PROXY ?? process.env.HTTPS_PROXY ?? process.env.http_proxy ?? process.env.https_proxy ?? "") !== "";
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,6 +1286,16 @@ 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);
@@ -1901,6 +1916,12 @@ function ensurePageState(page) {
1901
1916
  rec.ok = false;
1902
1917
  }
1903
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
+ });
1904
1925
  page.on("close", () => {
1905
1926
  pageStates.delete(page);
1906
1927
  observedPages.delete(page);
@@ -2340,48 +2361,52 @@ async function awaitEvalWithAbort(evalPromise, abortPromise) {
2340
2361
  }
2341
2362
  var BROWSER_EVALUATOR = new Function(
2342
2363
  "args",
2343
- `
2344
- "use strict";
2345
- var fnBody = args.fnBody, timeoutMs = args.timeoutMs;
2346
- try {
2347
- var candidate = eval("(" + fnBody + ")");
2348
- var result = typeof candidate === "function" ? candidate() : candidate;
2349
- if (result && typeof result.then === "function") {
2350
- return Promise.race([
2351
- result,
2352
- new Promise(function(_, reject) {
2353
- setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);
2354
- })
2355
- ]);
2356
- }
2357
- return result;
2358
- } catch (err) {
2359
- throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));
2360
- }
2361
- `
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")
2362
2385
  );
2363
2386
  var ELEMENT_EVALUATOR = new Function(
2364
2387
  "el",
2365
2388
  "args",
2366
- `
2367
- "use strict";
2368
- var fnBody = args.fnBody, timeoutMs = args.timeoutMs;
2369
- try {
2370
- var candidate = eval("(" + fnBody + ")");
2371
- var result = typeof candidate === "function" ? candidate(el) : 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
- `
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")
2385
2410
  );
2386
2411
  async function evaluateViaPlaywright(opts) {
2387
2412
  const fnText = opts.fn.trim();
@@ -2449,6 +2474,18 @@ async function evaluateViaPlaywright(opts) {
2449
2474
 
2450
2475
  // src/security.ts
2451
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");
2452
2489
  var InvalidBrowserNavigationUrlError = class extends Error {
2453
2490
  constructor(message) {
2454
2491
  super(message);
@@ -2460,7 +2497,7 @@ function withBrowserNavigationPolicy(ssrfPolicy) {
2460
2497
  }
2461
2498
  var NETWORK_NAVIGATION_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
2462
2499
  var SAFE_NON_NETWORK_URLS = /* @__PURE__ */ new Set(["about:blank"]);
2463
- var PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
2500
+ var PROXY_ENV_KEYS2 = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
2464
2501
  var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "localhost.localdomain", "metadata.google.internal"]);
2465
2502
  function isAllowedNonNetworkNavigationUrl(parsed) {
2466
2503
  return SAFE_NON_NETWORK_URLS.has(parsed.href);
@@ -2469,7 +2506,7 @@ function isPrivateNetworkAllowedByPolicy(policy) {
2469
2506
  return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true;
2470
2507
  }
2471
2508
  function hasProxyEnvConfigured2(env = process.env) {
2472
- for (const key of PROXY_ENV_KEYS) {
2509
+ for (const key of PROXY_ENV_KEYS2) {
2473
2510
  const value = env[key];
2474
2511
  if (typeof value === "string" && value.trim().length > 0) return true;
2475
2512
  }
@@ -2674,6 +2711,8 @@ function dedupeAndPreferIpv4(results) {
2674
2711
  }
2675
2712
  function createPinnedLookup(params) {
2676
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}`);
2677
2716
  const fallback = params.fallback ?? lookup;
2678
2717
  const records = params.addresses.map((address) => ({
2679
2718
  address,
@@ -2848,6 +2887,48 @@ async function resolveStrictExistingUploadPaths(params) {
2848
2887
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
2849
2888
  }
2850
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
+ }
2851
2932
  function sanitizeUntrustedFileName(fileName, fallbackName) {
2852
2933
  const trimmed = fileName.trim();
2853
2934
  if (trimmed === "") return fallbackName;
@@ -2928,6 +3009,35 @@ function resolveLocator(page, resolved) {
2928
3009
  const sel = resolved.selector ?? "";
2929
3010
  return page.locator(sel);
2930
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
+ }
2931
3041
  async function clickViaPlaywright(opts) {
2932
3042
  const resolved = requireRefOrSelector(opts.ref, opts.selector);
2933
3043
  const page = await getRestoredPageForTarget(opts);
@@ -3091,9 +3201,10 @@ async function setInputFilesViaPlaywright(opts) {
3091
3201
  if (inputRef && element) throw new Error("ref and element are mutually exclusive");
3092
3202
  if (!inputRef && !element) throw new Error("Either ref or element is required for setInputFiles");
3093
3203
  const locator = inputRef ? refLocator(page, inputRef) : page.locator(element).first();
3094
- const uploadPathsResult = await resolveStrictExistingUploadPaths({
3204
+ const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
3205
+ rootDir: DEFAULT_UPLOAD_DIR,
3095
3206
  requestedPaths: opts.paths,
3096
- scopeLabel: "uploads directory"
3207
+ scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`
3097
3208
  });
3098
3209
  if (!uploadPathsResult.ok) throw new Error(uploadPathsResult.error);
3099
3210
  const resolvedPaths = uploadPathsResult.paths;
@@ -3121,9 +3232,14 @@ async function armDialogViaPlaywright(opts) {
3121
3232
  const armId = state.armIdDialog;
3122
3233
  page.waitForEvent("dialog", { timeout }).then(async (dialog) => {
3123
3234
  if (state.armIdDialog !== armId) return;
3124
- if (opts.accept) await dialog.accept(opts.promptText);
3125
- else await dialog.dismiss();
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
+ }
3126
3241
  }).catch(() => {
3242
+ if (state.armIdDialog === armId) state.armIdDialog = 0;
3127
3243
  });
3128
3244
  }
3129
3245
  async function armFileUploadViaPlaywright(opts) {
@@ -3141,9 +3257,10 @@ async function armFileUploadViaPlaywright(opts) {
3141
3257
  }
3142
3258
  return;
3143
3259
  }
3144
- const uploadPathsResult = await resolveStrictExistingUploadPaths({
3260
+ const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
3261
+ rootDir: DEFAULT_UPLOAD_DIR,
3145
3262
  requestedPaths: opts.paths,
3146
- scopeLabel: "uploads directory"
3263
+ scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`
3147
3264
  });
3148
3265
  if (!uploadPathsResult.ok) {
3149
3266
  try {
@@ -3177,6 +3294,17 @@ async function pressKeyViaPlaywright(opts) {
3177
3294
  }
3178
3295
 
3179
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
+ }
3180
3308
  function isRetryableNavigateError(err) {
3181
3309
  const msg = typeof err === "string" ? err.toLowerCase() : err instanceof Error ? err.message.toLowerCase() : "";
3182
3310
  return msg.includes("frame has been detached") || msg.includes("target page, context or browser has been closed");
@@ -3229,7 +3357,7 @@ async function listPagesViaPlaywright(opts) {
3229
3357
  }
3230
3358
  async function createPageViaPlaywright(opts) {
3231
3359
  const { browser } = await connectBrowser(opts.cdpUrl);
3232
- 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();
3233
3361
  ensureContextState(context);
3234
3362
  const page = await context.newPage();
3235
3363
  ensurePageState(page);
@@ -3306,10 +3434,10 @@ async function waitForViaPlaywright(opts) {
3306
3434
  await page.waitForTimeout(resolveBoundedDelayMs2(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS));
3307
3435
  }
3308
3436
  if (opts.text !== void 0 && opts.text !== "") {
3309
- await page.getByText(opts.text).first().waitFor({ state: "visible", timeout });
3437
+ await page.waitForFunction((text) => (document.body?.innerText ?? "").includes(text), opts.text, { timeout });
3310
3438
  }
3311
3439
  if (opts.textGone !== void 0 && opts.textGone !== "") {
3312
- await page.getByText(opts.textGone).first().waitFor({ state: "hidden", timeout });
3440
+ await page.waitForFunction((text) => !(document.body?.innerText ?? "").includes(text), opts.textGone, { timeout });
3313
3441
  }
3314
3442
  if (opts.selector !== void 0 && opts.selector !== "") {
3315
3443
  const selector = opts.selector.trim();
@@ -3581,7 +3709,7 @@ async function waitForDownloadViaPlaywright(opts) {
3581
3709
  try {
3582
3710
  const download = await waiter.promise;
3583
3711
  if (state.armIdDownload !== armId) throw new Error("Download was superseded by another waiter");
3584
- const savePath = opts.path ?? download.suggestedFilename();
3712
+ const savePath = opts.path ?? sanitizeUntrustedFileName(download.suggestedFilename() || "download.bin", "download.bin");
3585
3713
  await assertSafeOutputPath(savePath, opts.allowedOutputRoots);
3586
3714
  return await saveDownloadPayload(download, savePath);
3587
3715
  } catch (err) {
@@ -4248,6 +4376,16 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
4248
4376
  return match ? match[1] : null;
4249
4377
  }
4250
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
+ };
4251
4389
  const out2 = [];
4252
4390
  for (const line of lines) {
4253
4391
  const parsed = matchInteractiveSnapshotLine(line, options);
@@ -4255,13 +4393,32 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
4255
4393
  const { roleRaw, role, name, suffix } = parsed;
4256
4394
  if (!INTERACTIVE_ROLES.has(role)) continue;
4257
4395
  const ref = parseAiSnapshotRef(suffix);
4258
- if (ref === null) continue;
4259
4396
  const prefix = /^(\s*-\s*)/.exec(line)?.[1] ?? "";
4260
- refs[ref] = { role, ...name !== void 0 && name !== "" ? { name } : {} };
4261
- out2.push(`${prefix}${roleRaw}${name !== void 0 && name !== "" ? ` "${name}"` : ""}${suffix}`);
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
+ }
4262
4409
  }
4263
4410
  return { snapshot: out2.join("\n") || "(no interactive elements)", refs };
4264
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
+ };
4265
4422
  const out = [];
4266
4423
  for (const line of lines) {
4267
4424
  const depth = getIndentLevel(line);
@@ -4271,7 +4428,7 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
4271
4428
  out.push(line);
4272
4429
  continue;
4273
4430
  }
4274
- const [, , roleRaw, name, suffix] = match;
4431
+ const [, prefix, roleRaw, name, suffix] = match;
4275
4432
  if (roleRaw.startsWith("/")) {
4276
4433
  out.push(line);
4277
4434
  continue;
@@ -4280,8 +4437,20 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
4280
4437
  const isStructural = STRUCTURAL_ROLES.has(role);
4281
4438
  if (options.compact === true && isStructural && name === "") continue;
4282
4439
  const ref = parseAiSnapshotRef(suffix);
4283
- if (ref !== null) refs[ref] = { role, ...name !== "" ? { name } : {} };
4284
- out.push(line);
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
+ }
4285
4454
  }
4286
4455
  const tree = out.join("\n") || "(empty)";
4287
4456
  return { snapshot: options.compact === true ? compactTree(tree) : tree, refs };
@@ -4642,6 +4811,85 @@ var CrawlPage = class {
4642
4811
  timeoutMs: opts?.timeoutMs
4643
4812
  });
4644
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
+ }
4645
4893
  /**
4646
4894
  * Type text into an input element by ref.
4647
4895
  *
@@ -5477,11 +5725,13 @@ var CrawlPage = class {
5477
5725
  var BrowserClaw = class _BrowserClaw {
5478
5726
  cdpUrl;
5479
5727
  ssrfPolicy;
5728
+ recordVideo;
5480
5729
  chrome;
5481
- constructor(cdpUrl, chrome, ssrfPolicy) {
5730
+ constructor(cdpUrl, chrome, ssrfPolicy, recordVideo) {
5482
5731
  this.cdpUrl = cdpUrl;
5483
5732
  this.chrome = chrome;
5484
5733
  this.ssrfPolicy = ssrfPolicy;
5734
+ this.recordVideo = recordVideo;
5485
5735
  }
5486
5736
  /**
5487
5737
  * Launch a new Chrome instance and connect to it.
@@ -5510,7 +5760,7 @@ var BrowserClaw = class _BrowserClaw {
5510
5760
  const chrome = await launchChrome(opts);
5511
5761
  const cdpUrl = `http://127.0.0.1:${String(chrome.cdpPort)}`;
5512
5762
  const ssrfPolicy = opts.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts.ssrfPolicy;
5513
- return new _BrowserClaw(cdpUrl, chrome, ssrfPolicy);
5763
+ return new _BrowserClaw(cdpUrl, chrome, ssrfPolicy, opts.recordVideo);
5514
5764
  }
5515
5765
  /**
5516
5766
  * Connect to an already-running Chrome instance via its CDP endpoint.
@@ -5527,12 +5777,22 @@ var BrowserClaw = class _BrowserClaw {
5527
5777
  * ```
5528
5778
  */
5529
5779
  static async connect(cdpUrl, opts) {
5530
- if (!await isChromeReachable(cdpUrl, 3e3, opts?.authToken)) {
5531
- throw new Error(`Cannot connect to Chrome at ${cdpUrl}. Is Chrome running with --remote-debugging-port?`);
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?`);
5532
5792
  }
5533
- await connectBrowser(cdpUrl, opts?.authToken);
5793
+ await connectBrowser(resolvedUrl, opts?.authToken);
5534
5794
  const ssrfPolicy = opts?.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts?.ssrfPolicy;
5535
- return new _BrowserClaw(cdpUrl, null, ssrfPolicy);
5795
+ return new _BrowserClaw(resolvedUrl, null, ssrfPolicy, opts?.recordVideo);
5536
5796
  }
5537
5797
  /**
5538
5798
  * Open a URL in a new tab and return the page handle.
@@ -5547,7 +5807,12 @@ var BrowserClaw = class _BrowserClaw {
5547
5807
  * ```
5548
5808
  */
5549
5809
  async open(url) {
5550
- const tab = await createPageViaPlaywright({ cdpUrl: this.cdpUrl, url, ssrfPolicy: this.ssrfPolicy });
5810
+ const tab = await createPageViaPlaywright({
5811
+ cdpUrl: this.cdpUrl,
5812
+ url,
5813
+ ssrfPolicy: this.ssrfPolicy,
5814
+ recordVideo: this.recordVideo
5815
+ });
5551
5816
  return new CrawlPage(this.cdpUrl, tab.targetId, this.ssrfPolicy);
5552
5817
  }
5553
5818
  /**
@@ -5610,6 +5875,7 @@ var BrowserClaw = class _BrowserClaw {
5610
5875
  * Playwright connection is closed.
5611
5876
  */
5612
5877
  async stop() {
5878
+ clearRecordingContext(this.cdpUrl);
5613
5879
  await disconnectBrowser();
5614
5880
  if (this.chrome) {
5615
5881
  await stopChrome(this.chrome);