browserclaw 0.7.1 → 0.8.1

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.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,43 @@ function ensurePageState(page) {
1912
1927
  rec.ok = false;
1913
1928
  }
1914
1929
  });
1930
+ page.on("dialog", (dialog) => {
1931
+ if (state.armIdDialog > 0) return;
1932
+ if (state.dialogHandler) {
1933
+ let handled = false;
1934
+ const event = {
1935
+ type: dialog.type(),
1936
+ message: dialog.message(),
1937
+ defaultValue: dialog.defaultValue(),
1938
+ accept: (promptText) => {
1939
+ handled = true;
1940
+ return dialog.accept(promptText);
1941
+ },
1942
+ dismiss: () => {
1943
+ handled = true;
1944
+ return dialog.dismiss();
1945
+ }
1946
+ };
1947
+ Promise.resolve(state.dialogHandler(event)).then(() => {
1948
+ if (!handled) {
1949
+ dialog.dismiss().catch((err) => {
1950
+ console.warn(`[browserclaw] Failed to auto-dismiss dialog: ${err instanceof Error ? err.message : String(err)}`);
1951
+ });
1952
+ }
1953
+ }).catch((err) => {
1954
+ console.warn(`[browserclaw] onDialog handler error: ${err instanceof Error ? err.message : String(err)}`);
1955
+ if (!handled) {
1956
+ dialog.dismiss().catch((dismissErr) => {
1957
+ console.warn(`[browserclaw] Failed to dismiss dialog after handler error: ${dismissErr instanceof Error ? dismissErr.message : String(dismissErr)}`);
1958
+ });
1959
+ }
1960
+ });
1961
+ return;
1962
+ }
1963
+ dialog.dismiss().catch((err) => {
1964
+ console.warn(`[browserclaw] Failed to dismiss dialog: ${err instanceof Error ? err.message : String(err)}`);
1965
+ });
1966
+ });
1915
1967
  page.on("close", () => {
1916
1968
  pageStates.delete(page);
1917
1969
  observedPages.delete(page);
@@ -1919,6 +1971,11 @@ function ensurePageState(page) {
1919
1971
  }
1920
1972
  return state;
1921
1973
  }
1974
+ async function setDialogHandler(opts) {
1975
+ const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1976
+ const state = ensurePageState(page);
1977
+ state.dialogHandler = opts.handler;
1978
+ }
1922
1979
  function applyStealthToPage(page) {
1923
1980
  page.evaluate(STEALTH_SCRIPT).catch((e) => {
1924
1981
  if (process.env.DEBUG !== void 0 && process.env.DEBUG !== "")
@@ -2351,48 +2408,52 @@ async function awaitEvalWithAbort(evalPromise, abortPromise) {
2351
2408
  }
2352
2409
  var BROWSER_EVALUATOR = new Function(
2353
2410
  "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
- `
2411
+ [
2412
+ '"use strict";',
2413
+ "var fnBody = args.fnBody, timeoutMs = args.timeoutMs;",
2414
+ "try {",
2415
+ " var candidate;",
2416
+ ' try { candidate = eval("(" + fnBody + ")"); }',
2417
+ " catch (_) { candidate = (0, eval)(fnBody); }",
2418
+ ' var result = typeof candidate === "function" ? candidate() : candidate;',
2419
+ ' if (result && typeof result.then === "function") {',
2420
+ " return Promise.race([",
2421
+ " result,",
2422
+ " new Promise(function(_, reject) {",
2423
+ ' setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);',
2424
+ " })",
2425
+ " ]);",
2426
+ " }",
2427
+ " return result;",
2428
+ "} catch (err) {",
2429
+ ' throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));',
2430
+ "}"
2431
+ ].join("\n")
2373
2432
  );
2374
2433
  var ELEMENT_EVALUATOR = new Function(
2375
2434
  "el",
2376
2435
  "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
- `
2436
+ [
2437
+ '"use strict";',
2438
+ "var fnBody = args.fnBody, timeoutMs = args.timeoutMs;",
2439
+ "try {",
2440
+ " var candidate;",
2441
+ ' try { candidate = eval("(" + fnBody + ")"); }',
2442
+ " catch (_) { candidate = (0, eval)(fnBody); }",
2443
+ ' var result = typeof candidate === "function" ? candidate(el) : candidate;',
2444
+ ' if (result && typeof result.then === "function") {',
2445
+ " return Promise.race([",
2446
+ " result,",
2447
+ " new Promise(function(_, reject) {",
2448
+ ' setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);',
2449
+ " })",
2450
+ " ]);",
2451
+ " }",
2452
+ " return result;",
2453
+ "} catch (err) {",
2454
+ ' throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));',
2455
+ "}"
2456
+ ].join("\n")
2396
2457
  );
2397
2458
  async function evaluateViaPlaywright(opts) {
2398
2459
  const fnText = opts.fn.trim();
@@ -2460,6 +2521,18 @@ async function evaluateViaPlaywright(opts) {
2460
2521
 
2461
2522
  // src/security.ts
2462
2523
  var ipaddr = __toESM(require_ipaddr());
2524
+ function resolveDefaultBrowserTmpDir() {
2525
+ try {
2526
+ if (process.platform === "linux" || process.platform === "darwin") {
2527
+ return "/tmp/browserclaw";
2528
+ }
2529
+ } catch {
2530
+ }
2531
+ return path.join(os.tmpdir(), "browserclaw");
2532
+ }
2533
+ var DEFAULT_BROWSER_TMP_DIR = resolveDefaultBrowserTmpDir();
2534
+ path.join(DEFAULT_BROWSER_TMP_DIR, "downloads");
2535
+ var DEFAULT_UPLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "uploads");
2463
2536
  var InvalidBrowserNavigationUrlError = class extends Error {
2464
2537
  constructor(message) {
2465
2538
  super(message);
@@ -2471,7 +2544,7 @@ function withBrowserNavigationPolicy(ssrfPolicy) {
2471
2544
  }
2472
2545
  var NETWORK_NAVIGATION_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
2473
2546
  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"];
2547
+ var PROXY_ENV_KEYS2 = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
2475
2548
  var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "localhost.localdomain", "metadata.google.internal"]);
2476
2549
  function isAllowedNonNetworkNavigationUrl(parsed) {
2477
2550
  return SAFE_NON_NETWORK_URLS.has(parsed.href);
@@ -2480,7 +2553,7 @@ function isPrivateNetworkAllowedByPolicy(policy) {
2480
2553
  return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true;
2481
2554
  }
2482
2555
  function hasProxyEnvConfigured2(env = process.env) {
2483
- for (const key of PROXY_ENV_KEYS) {
2556
+ for (const key of PROXY_ENV_KEYS2) {
2484
2557
  const value = env[key];
2485
2558
  if (typeof value === "string" && value.trim().length > 0) return true;
2486
2559
  }
@@ -2685,6 +2758,8 @@ function dedupeAndPreferIpv4(results) {
2685
2758
  }
2686
2759
  function createPinnedLookup(params) {
2687
2760
  const normalizedHost = normalizeHostname(params.hostname);
2761
+ if (params.addresses.length === 0)
2762
+ throw new Error(`Pinned lookup requires at least one address for ${params.hostname}`);
2688
2763
  const fallback = params.fallback ?? dns.lookup;
2689
2764
  const records = params.addresses.map((address) => ({
2690
2765
  address,
@@ -2859,6 +2934,48 @@ async function resolveStrictExistingUploadPaths(params) {
2859
2934
  return { ok: false, error: err instanceof Error ? err.message : String(err) };
2860
2935
  }
2861
2936
  }
2937
+ function resolvePathWithinRoot(params) {
2938
+ const root = path.resolve(params.rootDir);
2939
+ const raw = params.requestedPath.trim();
2940
+ const effectivePath = raw === "" && params.defaultFileName != null && params.defaultFileName !== "" ? params.defaultFileName : raw;
2941
+ if (effectivePath === "") return { ok: false, error: `Empty path is not allowed (${params.scopeLabel}).` };
2942
+ const resolved = path.resolve(root, effectivePath);
2943
+ const rel = path.relative(root, resolved);
2944
+ if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
2945
+ return { ok: false, error: `Path escapes ${params.scopeLabel}: "${params.requestedPath}".` };
2946
+ }
2947
+ return { ok: true, path: resolved };
2948
+ }
2949
+ async function resolveStrictExistingPathsWithinRoot(params) {
2950
+ const root = path.resolve(params.rootDir);
2951
+ const resolved = [];
2952
+ for (const raw of params.requestedPaths) {
2953
+ const lexical = resolvePathWithinRoot({ rootDir: root, requestedPath: raw, scopeLabel: params.scopeLabel });
2954
+ if (!lexical.ok) return lexical;
2955
+ let real;
2956
+ try {
2957
+ real = await promises$1.realpath(lexical.path);
2958
+ } catch (e) {
2959
+ if (e.code === "ENOENT") {
2960
+ return { ok: false, error: `Path does not exist (${params.scopeLabel}): "${raw}".` };
2961
+ }
2962
+ return { ok: false, error: `Cannot resolve "${raw}" (${params.scopeLabel}): ${e.message}` };
2963
+ }
2964
+ const rel = path.relative(root, real);
2965
+ if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
2966
+ return { ok: false, error: `Path escapes ${params.scopeLabel} via symlink: "${raw}".` };
2967
+ }
2968
+ const stat = await promises$1.lstat(real);
2969
+ if (stat.isSymbolicLink()) {
2970
+ return { ok: false, error: `Path is a symbolic link (${params.scopeLabel}): "${raw}".` };
2971
+ }
2972
+ if (!stat.isFile()) {
2973
+ return { ok: false, error: `Path is not a regular file (${params.scopeLabel}): "${raw}".` };
2974
+ }
2975
+ resolved.push(real);
2976
+ }
2977
+ return { ok: true, paths: resolved };
2978
+ }
2862
2979
  function sanitizeUntrustedFileName(fileName, fallbackName) {
2863
2980
  const trimmed = fileName.trim();
2864
2981
  if (trimmed === "") return fallbackName;
@@ -2939,6 +3056,35 @@ function resolveLocator(page, resolved) {
2939
3056
  const sel = resolved.selector ?? "";
2940
3057
  return page.locator(sel);
2941
3058
  }
3059
+ async function mouseClickViaPlaywright(opts) {
3060
+ const page = await getRestoredPageForTarget(opts);
3061
+ await page.mouse.click(opts.x, opts.y, {
3062
+ button: opts.button,
3063
+ clickCount: opts.clickCount,
3064
+ delay: opts.delayMs
3065
+ });
3066
+ }
3067
+ async function clickByTextViaPlaywright(opts) {
3068
+ const page = await getRestoredPageForTarget(opts);
3069
+ const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
3070
+ try {
3071
+ await page.getByText(opts.text, { exact: opts.exact }).click({ timeout, button: opts.button, modifiers: opts.modifiers });
3072
+ } catch (err) {
3073
+ throw toAIFriendlyError(err, `text="${opts.text}"`);
3074
+ }
3075
+ }
3076
+ async function clickByRoleViaPlaywright(opts) {
3077
+ const page = await getRestoredPageForTarget(opts);
3078
+ const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
3079
+ try {
3080
+ await page.getByRole(opts.role, { name: opts.name }).click({ timeout, button: opts.button, modifiers: opts.modifiers });
3081
+ } catch (err) {
3082
+ throw toAIFriendlyError(
3083
+ err,
3084
+ `role=${opts.role}${opts.name !== void 0 && opts.name !== "" ? ` name="${opts.name}"` : ""}`
3085
+ );
3086
+ }
3087
+ }
2942
3088
  async function clickViaPlaywright(opts) {
2943
3089
  const resolved = requireRefOrSelector(opts.ref, opts.selector);
2944
3090
  const page = await getRestoredPageForTarget(opts);
@@ -3102,9 +3248,10 @@ async function setInputFilesViaPlaywright(opts) {
3102
3248
  if (inputRef && element) throw new Error("ref and element are mutually exclusive");
3103
3249
  if (!inputRef && !element) throw new Error("Either ref or element is required for setInputFiles");
3104
3250
  const locator = inputRef ? refLocator(page, inputRef) : page.locator(element).first();
3105
- const uploadPathsResult = await resolveStrictExistingUploadPaths({
3251
+ const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
3252
+ rootDir: DEFAULT_UPLOAD_DIR,
3106
3253
  requestedPaths: opts.paths,
3107
- scopeLabel: "uploads directory"
3254
+ scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`
3108
3255
  });
3109
3256
  if (!uploadPathsResult.ok) throw new Error(uploadPathsResult.error);
3110
3257
  const resolvedPaths = uploadPathsResult.paths;
@@ -3132,9 +3279,14 @@ async function armDialogViaPlaywright(opts) {
3132
3279
  const armId = state.armIdDialog;
3133
3280
  page.waitForEvent("dialog", { timeout }).then(async (dialog) => {
3134
3281
  if (state.armIdDialog !== armId) return;
3135
- if (opts.accept) await dialog.accept(opts.promptText);
3136
- else await dialog.dismiss();
3282
+ try {
3283
+ if (opts.accept) await dialog.accept(opts.promptText);
3284
+ else await dialog.dismiss();
3285
+ } finally {
3286
+ if (state.armIdDialog === armId) state.armIdDialog = 0;
3287
+ }
3137
3288
  }).catch(() => {
3289
+ if (state.armIdDialog === armId) state.armIdDialog = 0;
3138
3290
  });
3139
3291
  }
3140
3292
  async function armFileUploadViaPlaywright(opts) {
@@ -3152,9 +3304,10 @@ async function armFileUploadViaPlaywright(opts) {
3152
3304
  }
3153
3305
  return;
3154
3306
  }
3155
- const uploadPathsResult = await resolveStrictExistingUploadPaths({
3307
+ const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
3308
+ rootDir: DEFAULT_UPLOAD_DIR,
3156
3309
  requestedPaths: opts.paths,
3157
- scopeLabel: "uploads directory"
3310
+ scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`
3158
3311
  });
3159
3312
  if (!uploadPathsResult.ok) {
3160
3313
  try {
@@ -3188,6 +3341,17 @@ async function pressKeyViaPlaywright(opts) {
3188
3341
  }
3189
3342
 
3190
3343
  // src/actions/navigation.ts
3344
+ var recordingContexts = /* @__PURE__ */ new Map();
3345
+ function clearRecordingContext(cdpUrl) {
3346
+ recordingContexts.delete(cdpUrl);
3347
+ }
3348
+ async function createRecordingContext(browser, cdpUrl, recordVideo) {
3349
+ const context = await browser.newContext({ recordVideo });
3350
+ observeContext(context);
3351
+ recordingContexts.set(cdpUrl, context);
3352
+ context.on("close", () => recordingContexts.delete(cdpUrl));
3353
+ return context;
3354
+ }
3191
3355
  function isRetryableNavigateError(err) {
3192
3356
  const msg = typeof err === "string" ? err.toLowerCase() : err instanceof Error ? err.message.toLowerCase() : "";
3193
3357
  return msg.includes("frame has been detached") || msg.includes("target page, context or browser has been closed");
@@ -3240,7 +3404,7 @@ async function listPagesViaPlaywright(opts) {
3240
3404
  }
3241
3405
  async function createPageViaPlaywright(opts) {
3242
3406
  const { browser } = await connectBrowser(opts.cdpUrl);
3243
- const context = browser.contexts()[0] ?? await browser.newContext();
3407
+ const context = opts.recordVideo ? recordingContexts.get(opts.cdpUrl) ?? await createRecordingContext(browser, opts.cdpUrl, opts.recordVideo) : browser.contexts()[0] ?? await browser.newContext();
3244
3408
  ensureContextState(context);
3245
3409
  const page = await context.newPage();
3246
3410
  ensurePageState(page);
@@ -3317,10 +3481,10 @@ async function waitForViaPlaywright(opts) {
3317
3481
  await page.waitForTimeout(resolveBoundedDelayMs2(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS));
3318
3482
  }
3319
3483
  if (opts.text !== void 0 && opts.text !== "") {
3320
- await page.getByText(opts.text).first().waitFor({ state: "visible", timeout });
3484
+ await page.waitForFunction((text) => (document.body?.innerText ?? "").includes(text), opts.text, { timeout });
3321
3485
  }
3322
3486
  if (opts.textGone !== void 0 && opts.textGone !== "") {
3323
- await page.getByText(opts.textGone).first().waitFor({ state: "hidden", timeout });
3487
+ await page.waitForFunction((text) => !(document.body?.innerText ?? "").includes(text), opts.textGone, { timeout });
3324
3488
  }
3325
3489
  if (opts.selector !== void 0 && opts.selector !== "") {
3326
3490
  const selector = opts.selector.trim();
@@ -3592,7 +3756,7 @@ async function waitForDownloadViaPlaywright(opts) {
3592
3756
  try {
3593
3757
  const download = await waiter.promise;
3594
3758
  if (state.armIdDownload !== armId) throw new Error("Download was superseded by another waiter");
3595
- const savePath = opts.path ?? download.suggestedFilename();
3759
+ const savePath = opts.path ?? sanitizeUntrustedFileName(download.suggestedFilename() || "download.bin", "download.bin");
3596
3760
  await assertSafeOutputPath(savePath, opts.allowedOutputRoots);
3597
3761
  return await saveDownloadPayload(download, savePath);
3598
3762
  } catch (err) {
@@ -4259,6 +4423,16 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
4259
4423
  return match ? match[1] : null;
4260
4424
  }
4261
4425
  if (options.interactive === true) {
4426
+ let interactiveMaxRef = 0;
4427
+ for (const line of lines) {
4428
+ const refMatch = /\[ref=e(\d+)\]/.exec(line);
4429
+ if (refMatch) interactiveMaxRef = Math.max(interactiveMaxRef, Number.parseInt(refMatch[1], 10));
4430
+ }
4431
+ let interactiveCounter = interactiveMaxRef;
4432
+ const nextInteractiveRef = () => {
4433
+ interactiveCounter++;
4434
+ return `e${String(interactiveCounter)}`;
4435
+ };
4262
4436
  const out2 = [];
4263
4437
  for (const line of lines) {
4264
4438
  const parsed = matchInteractiveSnapshotLine(line, options);
@@ -4266,13 +4440,32 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
4266
4440
  const { roleRaw, role, name, suffix } = parsed;
4267
4441
  if (!INTERACTIVE_ROLES.has(role)) continue;
4268
4442
  const ref = parseAiSnapshotRef(suffix);
4269
- if (ref === null) continue;
4270
4443
  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}`);
4444
+ if (ref !== null) {
4445
+ refs[ref] = { role, ...name !== void 0 && name !== "" ? { name } : {} };
4446
+ out2.push(`${prefix}${roleRaw}${name !== void 0 && name !== "" ? ` "${name}"` : ""}${suffix}`);
4447
+ } else {
4448
+ const generatedRef = nextInteractiveRef();
4449
+ refs[generatedRef] = { role, ...name !== void 0 && name !== "" ? { name } : {} };
4450
+ let enhanced = `${prefix}${roleRaw}`;
4451
+ if (name !== void 0 && name !== "") enhanced += ` "${name}"`;
4452
+ enhanced += ` [ref=${generatedRef}]`;
4453
+ if (suffix.trim() !== "") enhanced += suffix;
4454
+ out2.push(enhanced);
4455
+ }
4273
4456
  }
4274
4457
  return { snapshot: out2.join("\n") || "(no interactive elements)", refs };
4275
4458
  }
4459
+ let maxRef = 0;
4460
+ for (const line of lines) {
4461
+ const refMatch = /\[ref=e(\d+)\]/.exec(line);
4462
+ if (refMatch) maxRef = Math.max(maxRef, Number.parseInt(refMatch[1], 10));
4463
+ }
4464
+ let generatedCounter = maxRef;
4465
+ const nextGeneratedRef = () => {
4466
+ generatedCounter++;
4467
+ return `e${String(generatedCounter)}`;
4468
+ };
4276
4469
  const out = [];
4277
4470
  for (const line of lines) {
4278
4471
  const depth = getIndentLevel(line);
@@ -4282,7 +4475,7 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
4282
4475
  out.push(line);
4283
4476
  continue;
4284
4477
  }
4285
- const [, , roleRaw, name, suffix] = match;
4478
+ const [, prefix, roleRaw, name, suffix] = match;
4286
4479
  if (roleRaw.startsWith("/")) {
4287
4480
  out.push(line);
4288
4481
  continue;
@@ -4291,8 +4484,20 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
4291
4484
  const isStructural = STRUCTURAL_ROLES.has(role);
4292
4485
  if (options.compact === true && isStructural && name === "") continue;
4293
4486
  const ref = parseAiSnapshotRef(suffix);
4294
- if (ref !== null) refs[ref] = { role, ...name !== "" ? { name } : {} };
4295
- out.push(line);
4487
+ if (ref !== null) {
4488
+ refs[ref] = { role, ...name !== "" ? { name } : {} };
4489
+ out.push(line);
4490
+ } else if (INTERACTIVE_ROLES.has(role)) {
4491
+ const generatedRef = nextGeneratedRef();
4492
+ refs[generatedRef] = { role, ...name !== "" ? { name } : {} };
4493
+ let enhanced = `${prefix}${roleRaw}`;
4494
+ if (name !== "") enhanced += ` "${name}"`;
4495
+ enhanced += ` [ref=${generatedRef}]`;
4496
+ if (suffix.trim() !== "") enhanced += suffix;
4497
+ out.push(enhanced);
4498
+ } else {
4499
+ out.push(line);
4500
+ }
4296
4501
  }
4297
4502
  const tree = out.join("\n") || "(empty)";
4298
4503
  return { snapshot: options.compact === true ? compactTree(tree) : tree, refs };
@@ -4653,6 +4858,111 @@ var CrawlPage = class {
4653
4858
  timeoutMs: opts?.timeoutMs
4654
4859
  });
4655
4860
  }
4861
+ /**
4862
+ * Click an element by CSS selector (no snapshot/ref needed).
4863
+ *
4864
+ * Finds and clicks atomically — no stale ref problem.
4865
+ *
4866
+ * @param selector - CSS selector (e.g. `'#submit-btn'`, `'.modal button'`)
4867
+ * @param opts - Click options (double-click, button, modifiers)
4868
+ *
4869
+ * @example
4870
+ * ```ts
4871
+ * await page.clickBySelector('#submit-btn');
4872
+ * await page.clickBySelector('.modal .close', { button: 'right' });
4873
+ * ```
4874
+ */
4875
+ async clickBySelector(selector, opts) {
4876
+ return clickViaPlaywright({
4877
+ cdpUrl: this.cdpUrl,
4878
+ targetId: this.targetId,
4879
+ selector,
4880
+ doubleClick: opts?.doubleClick,
4881
+ button: opts?.button,
4882
+ modifiers: opts?.modifiers,
4883
+ delayMs: opts?.delayMs,
4884
+ timeoutMs: opts?.timeoutMs
4885
+ });
4886
+ }
4887
+ /**
4888
+ * Click at specific page coordinates.
4889
+ *
4890
+ * Useful for canvas elements, custom widgets, or elements without ARIA roles.
4891
+ *
4892
+ * @param x - X coordinate in pixels
4893
+ * @param y - Y coordinate in pixels
4894
+ * @param opts - Click options (button, clickCount, delayMs)
4895
+ *
4896
+ * @example
4897
+ * ```ts
4898
+ * await page.mouseClick(100, 200);
4899
+ * await page.mouseClick(100, 200, { button: 'right' });
4900
+ * await page.mouseClick(100, 200, { clickCount: 2 }); // double-click
4901
+ * ```
4902
+ */
4903
+ async mouseClick(x, y, opts) {
4904
+ return mouseClickViaPlaywright({
4905
+ cdpUrl: this.cdpUrl,
4906
+ targetId: this.targetId,
4907
+ x,
4908
+ y,
4909
+ button: opts?.button,
4910
+ clickCount: opts?.clickCount,
4911
+ delayMs: opts?.delayMs
4912
+ });
4913
+ }
4914
+ /**
4915
+ * Click an element by its visible text content (no snapshot/ref needed).
4916
+ *
4917
+ * Finds and clicks atomically — no stale ref problem.
4918
+ *
4919
+ * @param text - Text content to match
4920
+ * @param opts - Options (exact: require full match, button, modifiers)
4921
+ *
4922
+ * @example
4923
+ * ```ts
4924
+ * await page.clickByText('Submit');
4925
+ * await page.clickByText('Save Changes', { exact: true });
4926
+ * ```
4927
+ */
4928
+ async clickByText(text, opts) {
4929
+ return clickByTextViaPlaywright({
4930
+ cdpUrl: this.cdpUrl,
4931
+ targetId: this.targetId,
4932
+ text,
4933
+ exact: opts?.exact,
4934
+ button: opts?.button,
4935
+ modifiers: opts?.modifiers,
4936
+ timeoutMs: opts?.timeoutMs
4937
+ });
4938
+ }
4939
+ /**
4940
+ * Click an element by its ARIA role and accessible name (no snapshot/ref needed).
4941
+ *
4942
+ * Finds and clicks atomically — no stale ref problem.
4943
+ *
4944
+ * @param role - ARIA role (e.g. `'button'`, `'link'`, `'menuitem'`)
4945
+ * @param name - Accessible name to match (optional)
4946
+ * @param opts - Click options
4947
+ *
4948
+ * @example
4949
+ * ```ts
4950
+ * await page.clickByRole('button', 'Save');
4951
+ * await page.clickByRole('link', 'Settings');
4952
+ * await page.clickByRole('menuitem', 'Delete');
4953
+ * ```
4954
+ */
4955
+ async clickByRole(role, name, opts) {
4956
+ return clickByRoleViaPlaywright({
4957
+ cdpUrl: this.cdpUrl,
4958
+ targetId: this.targetId,
4959
+ role,
4960
+ name,
4961
+ button: opts?.button,
4962
+ modifiers: opts?.modifiers,
4963
+ timeoutMs: opts?.timeoutMs
4964
+ });
4965
+ }
4656
4966
  /**
4657
4967
  * Type text into an input element by ref.
4658
4968
  *
@@ -4817,6 +5127,48 @@ var CrawlPage = class {
4817
5127
  timeoutMs: opts.timeoutMs
4818
5128
  });
4819
5129
  }
5130
+ /**
5131
+ * Register a persistent dialog handler for all dialogs (alert, confirm, prompt, beforeunload).
5132
+ *
5133
+ * Unlike `armDialog()` which handles a single expected dialog, `onDialog()` handles
5134
+ * every dialog that appears until cleared. This prevents unexpected dialogs from
5135
+ * blocking the page.
5136
+ *
5137
+ * The handler receives a `DialogEvent` with `accept()` and `dismiss()` methods.
5138
+ * If the handler throws or doesn't call either, the dialog is auto-dismissed.
5139
+ *
5140
+ * Pass `undefined` or `null` to clear the handler and restore default auto-dismiss.
5141
+ *
5142
+ * Note: `armDialog()` takes priority — if a one-shot handler is armed, it handles
5143
+ * the next dialog instead of the persistent handler.
5144
+ *
5145
+ * @param handler - Callback for dialog events, or `undefined`/`null` to clear
5146
+ *
5147
+ * @example
5148
+ * ```ts
5149
+ * // Accept all confirm dialogs, dismiss everything else
5150
+ * page.onDialog((event) => {
5151
+ * if (event.type === 'confirm') event.accept();
5152
+ * else event.dismiss();
5153
+ * });
5154
+ *
5155
+ * // Log and auto-accept all dialogs
5156
+ * page.onDialog(async (event) => {
5157
+ * console.log(`Dialog: ${event.type} — ${event.message}`);
5158
+ * await event.accept();
5159
+ * });
5160
+ *
5161
+ * // Clear the handler (restore default auto-dismiss)
5162
+ * page.onDialog(undefined);
5163
+ * ```
5164
+ */
5165
+ async onDialog(handler) {
5166
+ return setDialogHandler({
5167
+ cdpUrl: this.cdpUrl,
5168
+ targetId: this.targetId,
5169
+ handler: handler ?? void 0
5170
+ });
5171
+ }
4820
5172
  /**
4821
5173
  * Arm a one-shot file chooser handler.
4822
5174
  *
@@ -5484,15 +5836,69 @@ var CrawlPage = class {
5484
5836
  pollMs: opts?.pollMs
5485
5837
  });
5486
5838
  }
5839
+ // ── Playwright Escape Hatches ─────────────────────────────────
5840
+ /**
5841
+ * Get the underlying Playwright `Page` object for this tab.
5842
+ *
5843
+ * Use this when browserclaw's API doesn't cover your use case and you need
5844
+ * direct access to Playwright's full API (custom locator strategies,
5845
+ * frame manipulation, request interception, etc.).
5846
+ *
5847
+ * **Warning:** Modifications made via the raw Playwright page may conflict
5848
+ * with browserclaw's internal state (e.g. ref tracking). Use with care.
5849
+ *
5850
+ * @returns The Playwright `Page` instance
5851
+ *
5852
+ * @example
5853
+ * ```ts
5854
+ * const pwPage = await page.playwrightPage();
5855
+ *
5856
+ * // Use Playwright's full API directly
5857
+ * await pwPage.locator('.my-component').waitFor({ state: 'visible' });
5858
+ * await pwPage.route('**\/api/**', route => route.fulfill({ body: '{}' }));
5859
+ *
5860
+ * // Access frames
5861
+ * const frame = pwPage.frameLocator('#my-iframe');
5862
+ * ```
5863
+ */
5864
+ async playwrightPage() {
5865
+ return getRestoredPageForTarget({ cdpUrl: this.cdpUrl, targetId: this.targetId });
5866
+ }
5867
+ /**
5868
+ * Create a Playwright `Locator` for a CSS selector on this page.
5869
+ *
5870
+ * Convenience method that returns a Playwright locator without needing
5871
+ * to first obtain the Page object. Useful for one-off Playwright operations.
5872
+ *
5873
+ * @param selector - CSS selector or Playwright selector string
5874
+ * @returns A Playwright `Locator` instance
5875
+ *
5876
+ * @example
5877
+ * ```ts
5878
+ * const loc = await page.locator('.modal-dialog button.confirm');
5879
+ * await loc.waitFor({ state: 'visible' });
5880
+ * await loc.click();
5881
+ *
5882
+ * // Use Playwright selectors
5883
+ * const input = await page.locator('input[name="email"]');
5884
+ * await input.fill('test@example.com');
5885
+ * ```
5886
+ */
5887
+ async locator(selector) {
5888
+ const pwPage = await getRestoredPageForTarget({ cdpUrl: this.cdpUrl, targetId: this.targetId });
5889
+ return pwPage.locator(selector);
5890
+ }
5487
5891
  };
5488
5892
  var BrowserClaw = class _BrowserClaw {
5489
5893
  cdpUrl;
5490
5894
  ssrfPolicy;
5895
+ recordVideo;
5491
5896
  chrome;
5492
- constructor(cdpUrl, chrome, ssrfPolicy) {
5897
+ constructor(cdpUrl, chrome, ssrfPolicy, recordVideo) {
5493
5898
  this.cdpUrl = cdpUrl;
5494
5899
  this.chrome = chrome;
5495
5900
  this.ssrfPolicy = ssrfPolicy;
5901
+ this.recordVideo = recordVideo;
5496
5902
  }
5497
5903
  /**
5498
5904
  * Launch a new Chrome instance and connect to it.
@@ -5521,7 +5927,7 @@ var BrowserClaw = class _BrowserClaw {
5521
5927
  const chrome = await launchChrome(opts);
5522
5928
  const cdpUrl = `http://127.0.0.1:${String(chrome.cdpPort)}`;
5523
5929
  const ssrfPolicy = opts.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts.ssrfPolicy;
5524
- return new _BrowserClaw(cdpUrl, chrome, ssrfPolicy);
5930
+ return new _BrowserClaw(cdpUrl, chrome, ssrfPolicy, opts.recordVideo);
5525
5931
  }
5526
5932
  /**
5527
5933
  * Connect to an already-running Chrome instance via its CDP endpoint.
@@ -5538,12 +5944,22 @@ var BrowserClaw = class _BrowserClaw {
5538
5944
  * ```
5539
5945
  */
5540
5946
  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?`);
5947
+ let resolvedUrl = cdpUrl;
5948
+ if (resolvedUrl === void 0 || resolvedUrl === "") {
5949
+ const discovered = await discoverChromeCdpUrl();
5950
+ if (discovered === null) {
5951
+ throw new Error(
5952
+ "No Chrome instance found on common CDP ports (9222-9226, 9229). Start Chrome with --remote-debugging-port=9222, or pass a CDP URL."
5953
+ );
5954
+ }
5955
+ resolvedUrl = discovered;
5956
+ }
5957
+ if (!await isChromeReachable(resolvedUrl, 3e3, opts?.authToken)) {
5958
+ throw new Error(`Cannot connect to Chrome at ${resolvedUrl}. Is Chrome running with --remote-debugging-port?`);
5543
5959
  }
5544
- await connectBrowser(cdpUrl, opts?.authToken);
5960
+ await connectBrowser(resolvedUrl, opts?.authToken);
5545
5961
  const ssrfPolicy = opts?.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts?.ssrfPolicy;
5546
- return new _BrowserClaw(cdpUrl, null, ssrfPolicy);
5962
+ return new _BrowserClaw(resolvedUrl, null, ssrfPolicy, opts?.recordVideo);
5547
5963
  }
5548
5964
  /**
5549
5965
  * Open a URL in a new tab and return the page handle.
@@ -5558,7 +5974,12 @@ var BrowserClaw = class _BrowserClaw {
5558
5974
  * ```
5559
5975
  */
5560
5976
  async open(url) {
5561
- const tab = await createPageViaPlaywright({ cdpUrl: this.cdpUrl, url, ssrfPolicy: this.ssrfPolicy });
5977
+ const tab = await createPageViaPlaywright({
5978
+ cdpUrl: this.cdpUrl,
5979
+ url,
5980
+ ssrfPolicy: this.ssrfPolicy,
5981
+ recordVideo: this.recordVideo
5982
+ });
5562
5983
  return new CrawlPage(this.cdpUrl, tab.targetId, this.ssrfPolicy);
5563
5984
  }
5564
5985
  /**
@@ -5621,6 +6042,7 @@ var BrowserClaw = class _BrowserClaw {
5621
6042
  * Playwright connection is closed.
5622
6043
  */
5623
6044
  async stop() {
6045
+ clearRecordingContext(this.cdpUrl);
5624
6046
  await disconnectBrowser();
5625
6047
  if (this.chrome) {
5626
6048
  await stopChrome(this.chrome);
@@ -5659,6 +6081,7 @@ exports.resolvePageByTargetIdOrThrow = resolvePageByTargetIdOrThrow;
5659
6081
  exports.resolvePinnedHostnameWithPolicy = resolvePinnedHostnameWithPolicy;
5660
6082
  exports.resolveStrictExistingUploadPaths = resolveStrictExistingUploadPaths;
5661
6083
  exports.sanitizeUntrustedFileName = sanitizeUntrustedFileName;
6084
+ exports.setDialogHandler = setDialogHandler;
5662
6085
  exports.waitForChallengeViaPlaywright = waitForChallengeViaPlaywright;
5663
6086
  exports.withBrowserNavigationPolicy = withBrowserNavigationPolicy;
5664
6087
  exports.withPageScopedCdpClient = withPageScopedCdpClient;