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/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,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
- if (version !== null) return true;
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
- "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
- `
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
- "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
- `
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 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"];
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 PROXY_ENV_KEYS) {
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 resolveStrictExistingUploadPaths({
3204
+ const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
3205
+ rootDir: DEFAULT_UPLOAD_DIR,
3106
3206
  requestedPaths: opts.paths,
3107
- scopeLabel: "uploads directory"
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
- if (opts.accept) await dialog.accept(opts.promptText);
3136
- 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
+ }
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 resolveStrictExistingUploadPaths({
3260
+ const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
3261
+ rootDir: DEFAULT_UPLOAD_DIR,
3156
3262
  requestedPaths: opts.paths,
3157
- scopeLabel: "uploads directory"
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.getByText(opts.text).first().waitFor({ state: "visible", timeout });
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.getByText(opts.textGone).first().waitFor({ state: "hidden", timeout });
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
- refs[ref] = { role, ...name !== void 0 && name !== "" ? { name } : {} };
4272
- 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
+ }
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) refs[ref] = { role, ...name !== "" ? { name } : {} };
4295
- 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
+ }
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
- if (!await isChromeReachable(cdpUrl, 3e3, opts?.authToken)) {
5542
- 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?`);
5543
5792
  }
5544
- await connectBrowser(cdpUrl, opts?.authToken);
5793
+ await connectBrowser(resolvedUrl, opts?.authToken);
5545
5794
  const ssrfPolicy = opts?.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts?.ssrfPolicy;
5546
- return new _BrowserClaw(cdpUrl, null, ssrfPolicy);
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({ 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
+ });
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);