browser-pilot 0.0.10 → 0.0.11

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/cli.mjs CHANGED
@@ -25,8 +25,7 @@ INTERACTION
25
25
  Click element. Multi-selector tries each until success.
26
26
 
27
27
  {"action": "fill", "selector": "#input", "value": "text"}
28
- {"action": "fill", "selector": "#input", "value": "text", "clear": false}
29
- Fill input field. Clears first by default.
28
+ Fill input field. Always selects all text before inserting.
30
29
 
31
30
  {"action": "type", "selector": "#input", "value": "text", "delay": 50}
32
31
  Type character-by-character (for autocomplete).
@@ -360,7 +359,12 @@ var ElementNotFoundError = class extends Error {
360
359
  hints;
361
360
  constructor(selectors, hints) {
362
361
  const selectorList = Array.isArray(selectors) ? selectors : [selectors];
363
- super(`Element not found: ${selectorList.join(", ")}`);
362
+ let msg = `Element not found: ${selectorList.join(", ")}`;
363
+ if (hints?.length) {
364
+ msg += `. Did you mean: ${hints.slice(0, 3).map((h) => `${h.element.ref} (${h.element.role} "${h.element.name}")`).join(", ")}`;
365
+ }
366
+ msg += `. Run 'bp snapshot' to see available elements.`;
367
+ super(msg);
364
368
  this.name = "ElementNotFoundError";
365
369
  this.selectors = selectorList;
366
370
  this.hints = hints;
@@ -368,7 +372,8 @@ var ElementNotFoundError = class extends Error {
368
372
  };
369
373
  var TimeoutError = class extends Error {
370
374
  constructor(message = "Operation timed out") {
371
- super(message);
375
+ const msg = message.includes("bp snapshot") ? message : `${message}. Run 'bp snapshot' to check current page state.`;
376
+ super(msg);
372
377
  this.name = "TimeoutError";
373
378
  }
374
379
  };
@@ -445,7 +450,7 @@ var BatchExecutor = class {
445
450
  }
446
451
  case "click": {
447
452
  if (!step.selector) throw new Error("click requires selector");
448
- if (step.waitForNavigation) {
453
+ if (step.waitForNavigation === true) {
449
454
  const navPromise = this.page.waitForNavigation({ timeout, optional });
450
455
  await this.page.click(step.selector, { timeout, optional });
451
456
  await navPromise;
@@ -460,7 +465,6 @@ var BatchExecutor = class {
460
465
  await this.page.fill(step.selector, step.value, {
461
466
  timeout,
462
467
  optional,
463
- clear: step.clear ?? true,
464
468
  blur: step.blur
465
469
  });
466
470
  return { selectorUsed: this.getUsedSelector(step.selector) };
@@ -508,7 +512,8 @@ var BatchExecutor = class {
508
512
  await this.page.submit(step.selector, {
509
513
  timeout,
510
514
  optional,
511
- method: step.method ?? "enter+click"
515
+ method: step.method ?? "enter+click",
516
+ waitForNavigation: step.waitForNavigation
512
517
  });
513
518
  return { selectorUsed: this.getUsedSelector(step.selector) };
514
519
  }
@@ -730,7 +735,6 @@ var ACTION_RULES = {
730
735
  fill: {
731
736
  required: { selector: { type: "string|string[]" }, value: { type: "string" } },
732
737
  optional: {
733
- clear: { type: "boolean" },
734
738
  blur: { type: "boolean" }
735
739
  }
736
740
  },
@@ -761,7 +765,8 @@ var ACTION_RULES = {
761
765
  submit: {
762
766
  required: { selector: { type: "string|string[]" } },
763
767
  optional: {
764
- method: { type: "string", enum: ["enter", "click", "enter+click"] }
768
+ method: { type: "string", enum: ["enter", "click", "enter+click"] },
769
+ waitForNavigation: { type: "boolean|auto" }
765
770
  }
766
771
  },
767
772
  press: {
@@ -839,7 +844,6 @@ var KNOWN_STEP_FIELDS = /* @__PURE__ */ new Set([
839
844
  "timeout",
840
845
  "optional",
841
846
  "method",
842
- "clear",
843
847
  "blur",
844
848
  "delay",
845
849
  "waitForNavigation",
@@ -920,6 +924,11 @@ function checkFieldType(value, rule) {
920
924
  case "boolean":
921
925
  if (typeof value !== "boolean") return `expected boolean, got ${typeof value}`;
922
926
  return null;
927
+ case "boolean|auto":
928
+ if (typeof value !== "boolean" && value !== "auto") {
929
+ return `expected boolean or "auto", got ${typeof value}`;
930
+ }
931
+ return null;
923
932
  }
924
933
  }
925
934
  function validateSteps(steps) {
@@ -2643,6 +2652,34 @@ var BrowserlessProvider = class {
2643
2652
  };
2644
2653
 
2645
2654
  // src/providers/generic.ts
2655
+ function sleep2(ms) {
2656
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
2657
+ }
2658
+ async function fetchDevToolsJson(host, path, errorPrefix, options = {}) {
2659
+ const protocol = host.includes("://") ? "" : "http://";
2660
+ const attempts = options.attempts ?? 1;
2661
+ let delayMs = options.initialDelayMs ?? 50;
2662
+ const maxDelayMs = options.maxDelayMs ?? 250;
2663
+ let lastError;
2664
+ for (let attempt = 1; attempt <= attempts; attempt++) {
2665
+ try {
2666
+ const response = await fetch(`${protocol}${host}${path}`);
2667
+ if (response.ok) {
2668
+ return await response.json();
2669
+ }
2670
+ lastError = new Error(`${errorPrefix}: ${response.status}`);
2671
+ } catch (error) {
2672
+ lastError = new Error(
2673
+ `${errorPrefix}: ${error instanceof Error ? error.message : String(error)}`
2674
+ );
2675
+ }
2676
+ if (attempt < attempts) {
2677
+ await sleep2(delayMs);
2678
+ delayMs = Math.min(delayMs * 2, maxDelayMs);
2679
+ }
2680
+ }
2681
+ throw lastError ?? new Error(errorPrefix);
2682
+ }
2646
2683
  var GenericProvider = class {
2647
2684
  name = "generic";
2648
2685
  wsUrl;
@@ -2661,12 +2698,11 @@ var GenericProvider = class {
2661
2698
  }
2662
2699
  };
2663
2700
  async function getBrowserWebSocketUrl(host = "localhost:9222") {
2664
- const protocol = host.includes("://") ? "" : "http://";
2665
- const response = await fetch(`${protocol}${host}/json/version`);
2666
- if (!response.ok) {
2667
- throw new Error(`Failed to get browser info: ${response.status}`);
2668
- }
2669
- const info = await response.json();
2701
+ const info = await fetchDevToolsJson(host, "/json/version", "Failed to get browser info", {
2702
+ attempts: 10,
2703
+ initialDelayMs: 50,
2704
+ maxDelayMs: 250
2705
+ });
2670
2706
  return info.webSocketDebuggerUrl;
2671
2707
  }
2672
2708
 
@@ -2953,7 +2989,7 @@ async function isElementAttached(cdp, selector, contextId) {
2953
2989
  const result = await cdp.send("Runtime.evaluate", params);
2954
2990
  return result.result.value === true;
2955
2991
  }
2956
- function sleep2(ms) {
2992
+ function sleep3(ms) {
2957
2993
  return new Promise((resolve2) => setTimeout(resolve2, ms));
2958
2994
  }
2959
2995
  async function waitForAnyElement(cdp, selectors, options = {}) {
@@ -2981,7 +3017,7 @@ async function waitForAnyElement(cdp, selectors, options = {}) {
2981
3017
  return { success: true, selector, waitedMs: Date.now() - startTime };
2982
3018
  }
2983
3019
  }
2984
- await sleep2(pollInterval);
3020
+ await sleep3(pollInterval);
2985
3021
  }
2986
3022
  return { success: false, waitedMs: Date.now() - startTime };
2987
3023
  }
@@ -3030,7 +3066,7 @@ async function waitForNavigation(cdp, options = {}) {
3030
3066
  }
3031
3067
  const pollUrl = async () => {
3032
3068
  while (!resolved && Date.now() < startTime + timeout) {
3033
- await sleep2(100);
3069
+ await sleep3(100);
3034
3070
  if (resolved) return;
3035
3071
  try {
3036
3072
  const currentUrl = await getCurrentUrl(cdp);
@@ -3090,6 +3126,335 @@ async function waitForNetworkIdle(cdp, options = {}) {
3090
3126
  });
3091
3127
  }
3092
3128
 
3129
+ // src/browser/actionability.ts
3130
+ var ActionabilityError = class extends Error {
3131
+ failureType;
3132
+ coveringElement;
3133
+ constructor(message, failureType, coveringElement) {
3134
+ super(message);
3135
+ this.name = "ActionabilityError";
3136
+ this.failureType = failureType;
3137
+ this.coveringElement = coveringElement;
3138
+ }
3139
+ };
3140
+ var CHECK_VISIBLE = `function() {
3141
+ // checkVisibility handles display:none, visibility:hidden, content-visibility up the tree
3142
+ if (typeof this.checkVisibility === 'function' && !this.checkVisibility()) {
3143
+ return { actionable: false, reason: 'Element is not visible (checkVisibility failed). Try scrolling or check if a prior action is needed to reveal it.' };
3144
+ }
3145
+
3146
+ var style = getComputedStyle(this);
3147
+
3148
+ if (style.visibility !== 'visible') {
3149
+ return { actionable: false, reason: 'Element has visibility: ' + style.visibility + '. Try scrolling or check if a prior action is needed to reveal it.' };
3150
+ }
3151
+
3152
+ // display:contents elements have no box themselves \u2014 check children
3153
+ if (style.display === 'contents') {
3154
+ var children = this.children;
3155
+ if (children.length === 0) {
3156
+ return { actionable: false, reason: 'Element has display:contents with no children. Try scrolling or check if a prior action is needed to reveal it.' };
3157
+ }
3158
+ for (var i = 0; i < children.length; i++) {
3159
+ var childRect = children[i].getBoundingClientRect();
3160
+ if (childRect.width > 0 && childRect.height > 0) {
3161
+ return { actionable: true };
3162
+ }
3163
+ }
3164
+ return { actionable: false, reason: 'Element has display:contents but no visible children. Try scrolling or check if a prior action is needed to reveal it.' };
3165
+ }
3166
+
3167
+ var rect = this.getBoundingClientRect();
3168
+ if (rect.width <= 0 || rect.height <= 0) {
3169
+ return { actionable: false, reason: 'Element has zero size (' + rect.width + 'x' + rect.height + '). Try scrolling or check if a prior action is needed to reveal it.' };
3170
+ }
3171
+
3172
+ return { actionable: true };
3173
+ }`;
3174
+ var CHECK_ENABLED = `function() {
3175
+ // Native disabled property
3176
+ var disableable = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'OPTGROUP'];
3177
+ if (disableable.indexOf(this.tagName) !== -1 && this.disabled) {
3178
+ return { actionable: false, reason: 'Element is disabled. Check if a prerequisite field needs to be filled first.' };
3179
+ }
3180
+
3181
+ // Check ancestor FIELDSET[disabled]
3182
+ var parent = this.parentElement;
3183
+ while (parent) {
3184
+ if (parent.tagName === 'FIELDSET' && parent.disabled) {
3185
+ // Exception: elements inside the first <legend> of a disabled fieldset are NOT disabled
3186
+ var legend = parent.querySelector(':scope > legend');
3187
+ if (!legend || !legend.contains(this)) {
3188
+ return { actionable: false, reason: 'Element is inside a disabled fieldset. Check if a prerequisite field needs to be filled first.' };
3189
+ }
3190
+ }
3191
+ parent = parent.parentElement;
3192
+ }
3193
+
3194
+ // aria-disabled="true" walking up ancestor chain (crosses shadow DOM)
3195
+ var node = this;
3196
+ while (node) {
3197
+ if (node.nodeType === 1 && node.getAttribute && node.getAttribute('aria-disabled') === 'true') {
3198
+ return { actionable: false, reason: 'Element or ancestor has aria-disabled="true". Check if a prerequisite field needs to be filled first.' };
3199
+ }
3200
+ if (node.parentElement) {
3201
+ node = node.parentElement;
3202
+ } else if (node.getRootNode && node.getRootNode() !== node) {
3203
+ // Cross shadow DOM boundary
3204
+ var root = node.getRootNode();
3205
+ node = root.host || null;
3206
+ } else {
3207
+ break;
3208
+ }
3209
+ }
3210
+
3211
+ return { actionable: true };
3212
+ }`;
3213
+ var CHECK_STABLE = `function() {
3214
+ var self = this;
3215
+ return new Promise(function(resolve) {
3216
+ var maxFrames = 30;
3217
+ var prev = null;
3218
+ var frame = 0;
3219
+ var resolved = false;
3220
+
3221
+ var fallbackTimer = setTimeout(function() {
3222
+ if (!resolved) {
3223
+ resolved = true;
3224
+ resolve({ actionable: false, reason: 'Element stability check timed out (tab may be backgrounded)' });
3225
+ }
3226
+ }, 2000);
3227
+
3228
+ function check() {
3229
+ if (resolved) return;
3230
+ frame++;
3231
+ if (frame > maxFrames) {
3232
+ resolved = true;
3233
+ clearTimeout(fallbackTimer);
3234
+ resolve({ actionable: false, reason: 'Element position not stable after ' + maxFrames + ' frames' });
3235
+ return;
3236
+ }
3237
+
3238
+ var rect = self.getBoundingClientRect();
3239
+ var cur = { x: rect.x, y: rect.y, w: rect.width, h: rect.height };
3240
+
3241
+ if (prev !== null &&
3242
+ prev.x === cur.x && prev.y === cur.y &&
3243
+ prev.w === cur.w && prev.h === cur.h) {
3244
+ resolved = true;
3245
+ clearTimeout(fallbackTimer);
3246
+ resolve({ actionable: true });
3247
+ return;
3248
+ }
3249
+
3250
+ prev = cur;
3251
+ requestAnimationFrame(check);
3252
+ }
3253
+
3254
+ requestAnimationFrame(check);
3255
+ });
3256
+ }`;
3257
+ var CHECK_HIT_TARGET = `function(x, y) {
3258
+ // Compute click center if coordinates not provided
3259
+ if (x === undefined || y === undefined) {
3260
+ var rect = this.getBoundingClientRect();
3261
+ x = rect.x + rect.width / 2;
3262
+ y = rect.y + rect.height / 2;
3263
+ }
3264
+
3265
+ function checkPoint(root, px, py) {
3266
+ var method = root.elementsFromPoint || root.msElementsFromPoint;
3267
+ if (!method) return [];
3268
+ return method.call(root, px, py) || [];
3269
+ }
3270
+
3271
+ // Follow only the top-most hit through nested shadow roots.
3272
+ // Accepting any hit in the stack creates false positives for covered elements.
3273
+ var root = document;
3274
+ var topHits = [];
3275
+ var seenRoots = [];
3276
+ while (root && seenRoots.indexOf(root) === -1) {
3277
+ seenRoots.push(root);
3278
+ var hits = checkPoint(root, x, y);
3279
+ if (!hits.length) break;
3280
+ var top = hits[0];
3281
+ topHits.push(top);
3282
+ if (top && top.shadowRoot) {
3283
+ root = top.shadowRoot;
3284
+ continue;
3285
+ }
3286
+ break;
3287
+ }
3288
+
3289
+ // Target must be the top-most hit element or an ancestor/descendant
3290
+ for (var j = 0; j < topHits.length; j++) {
3291
+ var hit = topHits[j];
3292
+ if (hit === this || this.contains(hit) || hit.contains(this)) {
3293
+ return { actionable: true };
3294
+ }
3295
+ }
3296
+
3297
+ // Report the covering element
3298
+ var top = topHits.length > 0 ? topHits[topHits.length - 1] : null;
3299
+ if (top) {
3300
+ return {
3301
+ actionable: false,
3302
+ reason: 'Element is covered by <' + top.tagName.toLowerCase() + '>' +
3303
+ (top.id ? '#' + top.id : '') +
3304
+ (top.className && typeof top.className === 'string' ? '.' + top.className.split(' ').join('.') : '') +
3305
+ '. Try dismissing overlays first.',
3306
+ coveringElement: {
3307
+ tag: top.tagName.toLowerCase(),
3308
+ id: top.id || undefined,
3309
+ className: (typeof top.className === 'string' && top.className) || undefined
3310
+ }
3311
+ };
3312
+ }
3313
+
3314
+ return { actionable: false, reason: 'No element found at click point (' + x + ', ' + y + '). Try scrolling the element into view first.' };
3315
+ }`;
3316
+ var CHECK_EDITABLE = `function() {
3317
+ // Must be an editable element type
3318
+ var tag = this.tagName;
3319
+ var isEditable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' ||
3320
+ this.isContentEditable;
3321
+ if (!isEditable) {
3322
+ return { actionable: false, reason: 'Element is not an editable type (<' + tag.toLowerCase() + '>). Target an <input>, <textarea>, <select>, or [contenteditable] element instead.' };
3323
+ }
3324
+
3325
+ // Check disabled
3326
+ var disableable = ['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTION', 'OPTGROUP'];
3327
+ if (disableable.indexOf(tag) !== -1 && this.disabled) {
3328
+ return { actionable: false, reason: 'Element is disabled. Check if a prerequisite field needs to be filled first.' };
3329
+ }
3330
+
3331
+ // Check ancestor FIELDSET[disabled]
3332
+ var parent = this.parentElement;
3333
+ while (parent) {
3334
+ if (parent.tagName === 'FIELDSET' && parent.disabled) {
3335
+ var legend = parent.querySelector(':scope > legend');
3336
+ if (!legend || !legend.contains(this)) {
3337
+ return { actionable: false, reason: 'Element is inside a disabled fieldset. Check if a prerequisite field needs to be filled first.' };
3338
+ }
3339
+ }
3340
+ parent = parent.parentElement;
3341
+ }
3342
+
3343
+ // aria-disabled walking up (crosses shadow DOM)
3344
+ var node = this;
3345
+ while (node) {
3346
+ if (node.nodeType === 1 && node.getAttribute && node.getAttribute('aria-disabled') === 'true') {
3347
+ return { actionable: false, reason: 'Element or ancestor has aria-disabled="true". Check if a prerequisite field needs to be filled first.' };
3348
+ }
3349
+ if (node.parentElement) {
3350
+ node = node.parentElement;
3351
+ } else if (node.getRootNode && node.getRootNode() !== node) {
3352
+ var root = node.getRootNode();
3353
+ node = root.host || null;
3354
+ } else {
3355
+ break;
3356
+ }
3357
+ }
3358
+
3359
+ // Check readonly
3360
+ if (this.hasAttribute && this.hasAttribute('readonly')) {
3361
+ return { actionable: false, reason: 'Cannot fill a readonly input. Remove the readonly attribute or target a different element.' };
3362
+ }
3363
+ if (this.getAttribute && this.getAttribute('aria-readonly') === 'true') {
3364
+ return { actionable: false, reason: 'Cannot fill a readonly input (aria-readonly="true"). Remove the attribute or target a different element.' };
3365
+ }
3366
+
3367
+ return { actionable: true };
3368
+ }`;
3369
+ function sleep4(ms) {
3370
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
3371
+ }
3372
+ var BACKOFF = [0, 20, 100, 100];
3373
+ async function runCheck(cdp, objectId, check, options) {
3374
+ let script;
3375
+ let awaitPromise = false;
3376
+ const args = [];
3377
+ switch (check) {
3378
+ case "visible":
3379
+ script = CHECK_VISIBLE;
3380
+ break;
3381
+ case "enabled":
3382
+ script = CHECK_ENABLED;
3383
+ break;
3384
+ case "stable":
3385
+ script = CHECK_STABLE;
3386
+ awaitPromise = true;
3387
+ break;
3388
+ case "hitTarget":
3389
+ script = CHECK_HIT_TARGET;
3390
+ if (options?.coordinates) {
3391
+ args.push({ value: options.coordinates.x });
3392
+ args.push({ value: options.coordinates.y });
3393
+ } else {
3394
+ args.push({ value: void 0 });
3395
+ args.push({ value: void 0 });
3396
+ }
3397
+ break;
3398
+ case "editable":
3399
+ script = CHECK_EDITABLE;
3400
+ break;
3401
+ default: {
3402
+ const _exhaustive = check;
3403
+ throw new Error(`Unknown actionability check: ${_exhaustive}`);
3404
+ }
3405
+ }
3406
+ const params = {
3407
+ functionDeclaration: script,
3408
+ objectId,
3409
+ returnByValue: true,
3410
+ arguments: args
3411
+ };
3412
+ if (awaitPromise) {
3413
+ params["awaitPromise"] = true;
3414
+ }
3415
+ const response = await cdp.send("Runtime.callFunctionOn", params);
3416
+ if (response.exceptionDetails) {
3417
+ return {
3418
+ actionable: false,
3419
+ reason: `Check "${check}" threw: ${response.exceptionDetails.text}`,
3420
+ failureType: check
3421
+ };
3422
+ }
3423
+ const result = response.result.value;
3424
+ if (!result.actionable) {
3425
+ result.failureType = check;
3426
+ }
3427
+ return result;
3428
+ }
3429
+ async function runChecks(cdp, objectId, checks, options) {
3430
+ for (const check of checks) {
3431
+ const result = await runCheck(cdp, objectId, check, options);
3432
+ if (!result.actionable) {
3433
+ return result;
3434
+ }
3435
+ }
3436
+ return { actionable: true };
3437
+ }
3438
+ async function ensureActionable(cdp, objectId, checks, options) {
3439
+ const timeout = options?.timeout ?? 3e4;
3440
+ const start = Date.now();
3441
+ let attempt = 0;
3442
+ while (true) {
3443
+ const result = await runChecks(cdp, objectId, checks, options);
3444
+ if (result.actionable) return;
3445
+ if (Date.now() - start >= timeout) {
3446
+ throw new ActionabilityError(
3447
+ `Element not actionable: ${result.reason}`,
3448
+ result.failureType,
3449
+ result.coveringElement
3450
+ );
3451
+ }
3452
+ const delay = attempt < BACKOFF.length ? BACKOFF[attempt] ?? 0 : 500;
3453
+ if (delay > 0) await sleep4(delay);
3454
+ attempt++;
3455
+ }
3456
+ }
3457
+
3093
3458
  // src/browser/fuzzy-match.ts
3094
3459
  function jaroWinkler(a, b) {
3095
3460
  if (a.length === 0 && b.length === 0) return 0;
@@ -3335,8 +3700,180 @@ async function generateHints(page, failedSelectors, actionType, maxHints = 3) {
3335
3700
  return diversifyHints(matches, maxHints);
3336
3701
  }
3337
3702
 
3703
+ // src/browser/keyboard.ts
3704
+ var US_KEYBOARD = {
3705
+ // Letters (lowercase)
3706
+ a: { key: "a", code: "KeyA", keyCode: 65, text: "a" },
3707
+ b: { key: "b", code: "KeyB", keyCode: 66, text: "b" },
3708
+ c: { key: "c", code: "KeyC", keyCode: 67, text: "c" },
3709
+ d: { key: "d", code: "KeyD", keyCode: 68, text: "d" },
3710
+ e: { key: "e", code: "KeyE", keyCode: 69, text: "e" },
3711
+ f: { key: "f", code: "KeyF", keyCode: 70, text: "f" },
3712
+ g: { key: "g", code: "KeyG", keyCode: 71, text: "g" },
3713
+ h: { key: "h", code: "KeyH", keyCode: 72, text: "h" },
3714
+ i: { key: "i", code: "KeyI", keyCode: 73, text: "i" },
3715
+ j: { key: "j", code: "KeyJ", keyCode: 74, text: "j" },
3716
+ k: { key: "k", code: "KeyK", keyCode: 75, text: "k" },
3717
+ l: { key: "l", code: "KeyL", keyCode: 76, text: "l" },
3718
+ m: { key: "m", code: "KeyM", keyCode: 77, text: "m" },
3719
+ n: { key: "n", code: "KeyN", keyCode: 78, text: "n" },
3720
+ o: { key: "o", code: "KeyO", keyCode: 79, text: "o" },
3721
+ p: { key: "p", code: "KeyP", keyCode: 80, text: "p" },
3722
+ q: { key: "q", code: "KeyQ", keyCode: 81, text: "q" },
3723
+ r: { key: "r", code: "KeyR", keyCode: 82, text: "r" },
3724
+ s: { key: "s", code: "KeyS", keyCode: 83, text: "s" },
3725
+ t: { key: "t", code: "KeyT", keyCode: 84, text: "t" },
3726
+ u: { key: "u", code: "KeyU", keyCode: 85, text: "u" },
3727
+ v: { key: "v", code: "KeyV", keyCode: 86, text: "v" },
3728
+ w: { key: "w", code: "KeyW", keyCode: 87, text: "w" },
3729
+ x: { key: "x", code: "KeyX", keyCode: 88, text: "x" },
3730
+ y: { key: "y", code: "KeyY", keyCode: 89, text: "y" },
3731
+ z: { key: "z", code: "KeyZ", keyCode: 90, text: "z" },
3732
+ // Letters (uppercase)
3733
+ A: { key: "A", code: "KeyA", keyCode: 65, text: "A" },
3734
+ B: { key: "B", code: "KeyB", keyCode: 66, text: "B" },
3735
+ C: { key: "C", code: "KeyC", keyCode: 67, text: "C" },
3736
+ D: { key: "D", code: "KeyD", keyCode: 68, text: "D" },
3737
+ E: { key: "E", code: "KeyE", keyCode: 69, text: "E" },
3738
+ F: { key: "F", code: "KeyF", keyCode: 70, text: "F" },
3739
+ G: { key: "G", code: "KeyG", keyCode: 71, text: "G" },
3740
+ H: { key: "H", code: "KeyH", keyCode: 72, text: "H" },
3741
+ I: { key: "I", code: "KeyI", keyCode: 73, text: "I" },
3742
+ J: { key: "J", code: "KeyJ", keyCode: 74, text: "J" },
3743
+ K: { key: "K", code: "KeyK", keyCode: 75, text: "K" },
3744
+ L: { key: "L", code: "KeyL", keyCode: 76, text: "L" },
3745
+ M: { key: "M", code: "KeyM", keyCode: 77, text: "M" },
3746
+ N: { key: "N", code: "KeyN", keyCode: 78, text: "N" },
3747
+ O: { key: "O", code: "KeyO", keyCode: 79, text: "O" },
3748
+ P: { key: "P", code: "KeyP", keyCode: 80, text: "P" },
3749
+ Q: { key: "Q", code: "KeyQ", keyCode: 81, text: "Q" },
3750
+ R: { key: "R", code: "KeyR", keyCode: 82, text: "R" },
3751
+ S: { key: "S", code: "KeyS", keyCode: 83, text: "S" },
3752
+ T: { key: "T", code: "KeyT", keyCode: 84, text: "T" },
3753
+ U: { key: "U", code: "KeyU", keyCode: 85, text: "U" },
3754
+ V: { key: "V", code: "KeyV", keyCode: 86, text: "V" },
3755
+ W: { key: "W", code: "KeyW", keyCode: 87, text: "W" },
3756
+ X: { key: "X", code: "KeyX", keyCode: 88, text: "X" },
3757
+ Y: { key: "Y", code: "KeyY", keyCode: 89, text: "Y" },
3758
+ Z: { key: "Z", code: "KeyZ", keyCode: 90, text: "Z" },
3759
+ // Numbers
3760
+ "0": { key: "0", code: "Digit0", keyCode: 48, text: "0" },
3761
+ "1": { key: "1", code: "Digit1", keyCode: 49, text: "1" },
3762
+ "2": { key: "2", code: "Digit2", keyCode: 50, text: "2" },
3763
+ "3": { key: "3", code: "Digit3", keyCode: 51, text: "3" },
3764
+ "4": { key: "4", code: "Digit4", keyCode: 52, text: "4" },
3765
+ "5": { key: "5", code: "Digit5", keyCode: 53, text: "5" },
3766
+ "6": { key: "6", code: "Digit6", keyCode: 54, text: "6" },
3767
+ "7": { key: "7", code: "Digit7", keyCode: 55, text: "7" },
3768
+ "8": { key: "8", code: "Digit8", keyCode: 56, text: "8" },
3769
+ "9": { key: "9", code: "Digit9", keyCode: 57, text: "9" },
3770
+ // Punctuation
3771
+ " ": { key: " ", code: "Space", keyCode: 32, text: " " },
3772
+ ".": { key: ".", code: "Period", keyCode: 190, text: "." },
3773
+ ",": { key: ",", code: "Comma", keyCode: 188, text: "," },
3774
+ "/": { key: "/", code: "Slash", keyCode: 191, text: "/" },
3775
+ ";": { key: ";", code: "Semicolon", keyCode: 186, text: ";" },
3776
+ "'": { key: "'", code: "Quote", keyCode: 222, text: "'" },
3777
+ "[": { key: "[", code: "BracketLeft", keyCode: 219, text: "[" },
3778
+ "]": { key: "]", code: "BracketRight", keyCode: 221, text: "]" },
3779
+ "\\": { key: "\\", code: "Backslash", keyCode: 220, text: "\\" },
3780
+ "-": { key: "-", code: "Minus", keyCode: 189, text: "-" },
3781
+ "=": { key: "=", code: "Equal", keyCode: 187, text: "=" },
3782
+ "`": { key: "`", code: "Backquote", keyCode: 192, text: "`" },
3783
+ // Shifted punctuation
3784
+ "!": { key: "!", code: "Digit1", keyCode: 49, text: "!" },
3785
+ "@": { key: "@", code: "Digit2", keyCode: 50, text: "@" },
3786
+ "#": { key: "#", code: "Digit3", keyCode: 51, text: "#" },
3787
+ $: { key: "$", code: "Digit4", keyCode: 52, text: "$" },
3788
+ "%": { key: "%", code: "Digit5", keyCode: 53, text: "%" },
3789
+ "^": { key: "^", code: "Digit6", keyCode: 54, text: "^" },
3790
+ "&": { key: "&", code: "Digit7", keyCode: 55, text: "&" },
3791
+ "*": { key: "*", code: "Digit8", keyCode: 56, text: "*" },
3792
+ "(": { key: "(", code: "Digit9", keyCode: 57, text: "(" },
3793
+ ")": { key: ")", code: "Digit0", keyCode: 48, text: ")" },
3794
+ _: { key: "_", code: "Minus", keyCode: 189, text: "_" },
3795
+ "+": { key: "+", code: "Equal", keyCode: 187, text: "+" },
3796
+ "{": { key: "{", code: "BracketLeft", keyCode: 219, text: "{" },
3797
+ "}": { key: "}", code: "BracketRight", keyCode: 221, text: "}" },
3798
+ "|": { key: "|", code: "Backslash", keyCode: 220, text: "|" },
3799
+ ":": { key: ":", code: "Semicolon", keyCode: 186, text: ":" },
3800
+ '"': { key: '"', code: "Quote", keyCode: 222, text: '"' },
3801
+ "<": { key: "<", code: "Comma", keyCode: 188, text: "<" },
3802
+ ">": { key: ">", code: "Period", keyCode: 190, text: ">" },
3803
+ "?": { key: "?", code: "Slash", keyCode: 191, text: "?" },
3804
+ "~": { key: "~", code: "Backquote", keyCode: 192, text: "~" },
3805
+ // Special keys (non-text: use rawKeyDown, no text field)
3806
+ Enter: { key: "Enter", code: "Enter", keyCode: 13 },
3807
+ Tab: { key: "Tab", code: "Tab", keyCode: 9 },
3808
+ Backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
3809
+ Delete: { key: "Delete", code: "Delete", keyCode: 46 },
3810
+ Escape: { key: "Escape", code: "Escape", keyCode: 27 },
3811
+ ArrowUp: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
3812
+ ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
3813
+ ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
3814
+ ArrowRight: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 },
3815
+ Home: { key: "Home", code: "Home", keyCode: 36 },
3816
+ End: { key: "End", code: "End", keyCode: 35 },
3817
+ PageUp: { key: "PageUp", code: "PageUp", keyCode: 33 },
3818
+ PageDown: { key: "PageDown", code: "PageDown", keyCode: 34 }
3819
+ };
3820
+
3338
3821
  // src/browser/page.ts
3339
3822
  var DEFAULT_TIMEOUT2 = 3e4;
3823
+ var EVENT_LISTENER_TRACKER_SCRIPT = `(() => {
3824
+ if (globalThis.__bpEventListenerTrackerInstalled) return;
3825
+ Object.defineProperty(globalThis, '__bpEventListenerTrackerInstalled', {
3826
+ value: true,
3827
+ configurable: true,
3828
+ });
3829
+
3830
+ const storeKey = '__bpEventListeners';
3831
+ const originalAddEventListener = EventTarget.prototype.addEventListener;
3832
+ const originalRemoveEventListener = EventTarget.prototype.removeEventListener;
3833
+
3834
+ function ensureStore(target) {
3835
+ if (!Object.prototype.hasOwnProperty.call(target, storeKey)) {
3836
+ Object.defineProperty(target, storeKey, {
3837
+ value: Object.create(null),
3838
+ configurable: true,
3839
+ });
3840
+ }
3841
+ return target[storeKey];
3842
+ }
3843
+
3844
+ EventTarget.prototype.addEventListener = function(type, listener, options) {
3845
+ try {
3846
+ if (listener) {
3847
+ const store = ensureStore(this);
3848
+ const bucket = store[type] || (store[type] = []);
3849
+ const capture =
3850
+ typeof options === 'boolean' ? options : !!(options && options.capture);
3851
+ const exists = bucket.some((entry) => entry.listener === listener && entry.capture === capture);
3852
+ if (!exists) {
3853
+ bucket.push({ listener, capture });
3854
+ }
3855
+ }
3856
+ } catch {}
3857
+
3858
+ return originalAddEventListener.call(this, type, listener, options);
3859
+ };
3860
+
3861
+ EventTarget.prototype.removeEventListener = function(type, listener, options) {
3862
+ try {
3863
+ const store = this[storeKey];
3864
+ const bucket = store && store[type];
3865
+ const capture =
3866
+ typeof options === 'boolean' ? options : !!(options && options.capture);
3867
+ if (Array.isArray(bucket)) {
3868
+ store[type] = bucket.filter((entry) => {
3869
+ return !(entry.listener === listener && entry.capture === capture);
3870
+ });
3871
+ }
3872
+ } catch {}
3873
+
3874
+ return originalRemoveEventListener.call(this, type, listener, options);
3875
+ };
3876
+ })();`;
3340
3877
  var Page = class {
3341
3878
  cdp;
3342
3879
  _targetId;
@@ -3360,6 +3897,8 @@ var Page = class {
3360
3897
  currentFrameContextId = null;
3361
3898
  /** Last matched selector from findElement (for selectorUsed tracking) */
3362
3899
  _lastMatchedSelector;
3900
+ /** Last snapshot for stale ref recovery */
3901
+ lastSnapshot;
3363
3902
  /** Audio input controller (lazy-initialized) */
3364
3903
  _audioInput;
3365
3904
  /** Audio output controller (lazy-initialized) */
@@ -3404,6 +3943,9 @@ var Page = class {
3404
3943
  for (const [frameId, ctxId] of this.frameExecutionContexts.entries()) {
3405
3944
  if (ctxId === contextId) {
3406
3945
  this.frameExecutionContexts.delete(frameId);
3946
+ if (this.currentFrameContextId === contextId) {
3947
+ this.currentFrameContextId = null;
3948
+ }
3407
3949
  break;
3408
3950
  }
3409
3951
  }
@@ -3415,6 +3957,18 @@ var Page = class {
3415
3957
  this.cdp.send("Runtime.enable"),
3416
3958
  this.cdp.send("Network.enable")
3417
3959
  ]);
3960
+ await this.installEventListenerTracker();
3961
+ }
3962
+ async installEventListenerTracker() {
3963
+ await this.cdp.send("Page.addScriptToEvaluateOnNewDocument", {
3964
+ source: EVENT_LISTENER_TRACKER_SCRIPT
3965
+ });
3966
+ try {
3967
+ await this.cdp.send("Runtime.evaluate", {
3968
+ expression: EVENT_LISTENER_TRACKER_SCRIPT
3969
+ });
3970
+ } catch {
3971
+ }
3418
3972
  }
3419
3973
  // ============ Navigation ============
3420
3974
  /**
@@ -3430,6 +3984,9 @@ var Page = class {
3430
3984
  }
3431
3985
  this.rootNodeId = null;
3432
3986
  this.refMap.clear();
3987
+ this.currentFrame = null;
3988
+ this.currentFrameContextId = null;
3989
+ this.frameContexts.clear();
3433
3990
  }
3434
3991
  /**
3435
3992
  * Get the current URL
@@ -3500,8 +4057,9 @@ var Page = class {
3500
4057
  /**
3501
4058
  * Click an element (supports multi-selector)
3502
4059
  *
3503
- * Uses CDP mouse events for regular elements. For form submit buttons,
3504
- * uses dispatchEvent to reliably trigger form submission in headless Chrome.
4060
+ * Uses CDP mouse events (mouseMoved + mousePressed + mouseReleased) to
4061
+ * simulate a real click. Real mouse events on submit buttons naturally
4062
+ * trigger native form submission — no JS dispatch needed.
3505
4063
  */
3506
4064
  async click(selector, options = {}) {
3507
4065
  return this.withStaleNodeRetry(async () => {
@@ -3513,27 +4071,45 @@ var Page = class {
3513
4071
  throw new ElementNotFoundError(selector, hints);
3514
4072
  }
3515
4073
  await this.scrollIntoView(element.nodeId);
3516
- const submitResult = await this.evaluateInFrame(
3517
- `(() => {
3518
- const el = document.querySelector(${JSON.stringify(element.selector)});
3519
- if (!el) return { isSubmit: false };
3520
-
3521
- // Check if this is a form submit button
3522
- const isSubmitButton = (el instanceof HTMLButtonElement && (el.type === 'submit' || (el.form && el.type !== 'button'))) ||
3523
- (el instanceof HTMLInputElement && el.type === 'submit');
3524
-
3525
- if (isSubmitButton && el.form) {
3526
- // Dispatch submit event directly - works reliably in headless Chrome
3527
- el.form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
3528
- return { isSubmit: true };
3529
- }
3530
- return { isSubmit: false };
3531
- })()`
3532
- );
3533
- const isSubmit = submitResult.result.value?.isSubmit;
3534
- if (!isSubmit) {
3535
- await this.clickElement(element.nodeId);
4074
+ const objectId = await this.resolveObjectId(element.nodeId);
4075
+ try {
4076
+ await ensureActionable(this.cdp, objectId, ["visible", "enabled", "stable"], {
4077
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
4078
+ });
4079
+ } catch (e) {
4080
+ if (options.optional) return false;
4081
+ throw e;
4082
+ }
4083
+ let clickX;
4084
+ let clickY;
4085
+ try {
4086
+ const { quads } = await this.cdp.send("DOM.getContentQuads", {
4087
+ objectId
4088
+ });
4089
+ if (quads?.length > 0) {
4090
+ const quad = quads[0];
4091
+ clickX = (quad[0] + quad[2] + quad[4] + quad[6]) / 4;
4092
+ clickY = (quad[1] + quad[3] + quad[5] + quad[7]) / 4;
4093
+ } else {
4094
+ throw new Error("No quads");
4095
+ }
4096
+ } catch {
4097
+ const box = await this.getBoxModel(element.nodeId);
4098
+ if (!box) throw new Error("Could not get element position");
4099
+ clickX = box.content[0] + box.width / 2;
4100
+ clickY = box.content[1] + box.height / 2;
4101
+ }
4102
+ const hitTargetCoordinates = this.currentFrame ? void 0 : { x: clickX, y: clickY };
4103
+ try {
4104
+ await ensureActionable(this.cdp, objectId, ["hitTarget"], {
4105
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2,
4106
+ coordinates: hitTargetCoordinates
4107
+ });
4108
+ } catch (e) {
4109
+ if (options.optional) return false;
4110
+ throw e;
3536
4111
  }
4112
+ await this.clickElement(element.nodeId);
3537
4113
  return true;
3538
4114
  });
3539
4115
  }
@@ -3541,7 +4117,7 @@ var Page = class {
3541
4117
  * Fill an input field (clears first by default)
3542
4118
  */
3543
4119
  async fill(selector, value, options = {}) {
3544
- const { clear = true, blur = false } = options;
4120
+ const { blur = false } = options;
3545
4121
  return this.withStaleNodeRetry(async () => {
3546
4122
  const element = await this.findElement(selector, options);
3547
4123
  if (!element) {
@@ -3550,71 +4126,152 @@ var Page = class {
3550
4126
  const hints = await generateHints(this, selectorList, "fill");
3551
4127
  throw new ElementNotFoundError(selector, hints);
3552
4128
  }
3553
- await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
3554
- if (clear) {
3555
- await this.evaluateInFrame(
3556
- `(() => {
3557
- const el = document.querySelector(${JSON.stringify(element.selector)});
3558
- if (el) {
3559
- el.value = '';
3560
- el.dispatchEvent(new InputEvent('input', {
3561
- bubbles: true,
3562
- cancelable: true,
3563
- inputType: 'deleteContent'
3564
- }));
3565
- }
3566
- })()`
3567
- );
4129
+ const { object } = await this.cdp.send("DOM.resolveNode", {
4130
+ nodeId: element.nodeId
4131
+ });
4132
+ const objectId = object.objectId;
4133
+ try {
4134
+ await ensureActionable(this.cdp, objectId, ["visible", "enabled", "editable"], {
4135
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
4136
+ });
4137
+ } catch (e) {
4138
+ if (options.optional) return false;
4139
+ throw e;
3568
4140
  }
3569
- await this.cdp.send("Input.insertText", { text: value });
3570
- await this.evaluateInFrame(
3571
- `(() => {
3572
- const el = document.querySelector(${JSON.stringify(element.selector)});
3573
- if (el) {
3574
- el.dispatchEvent(new InputEvent('input', {
3575
- bubbles: true,
3576
- cancelable: true,
3577
- inputType: 'insertText',
3578
- data: ${JSON.stringify(value)}
3579
- }));
3580
- el.dispatchEvent(new Event('change', { bubbles: true }));
4141
+ const tagInfo = await this.cdp.send("Runtime.callFunctionOn", {
4142
+ objectId,
4143
+ functionDeclaration: `function() {
4144
+ return { tagName: this.tagName?.toLowerCase() || '', inputType: (this.type || '').toLowerCase() };
4145
+ }`,
4146
+ returnByValue: true
4147
+ });
4148
+ const { tagName, inputType } = tagInfo.result.value;
4149
+ const specialInputTypes = /* @__PURE__ */ new Set([
4150
+ "date",
4151
+ "datetime-local",
4152
+ "month",
4153
+ "week",
4154
+ "time",
4155
+ "color",
4156
+ "range",
4157
+ "file"
4158
+ ]);
4159
+ const isSpecialInput = tagName === "input" && specialInputTypes.has(inputType);
4160
+ if (isSpecialInput) {
4161
+ await this.cdp.send("Runtime.callFunctionOn", {
4162
+ objectId,
4163
+ functionDeclaration: `function(val) {
4164
+ this.value = val;
4165
+ this.dispatchEvent(new Event('input', { bubbles: true }));
4166
+ this.dispatchEvent(new Event('change', { bubbles: true }));
4167
+ }`,
4168
+ arguments: [{ value }],
4169
+ returnByValue: true
4170
+ });
4171
+ } else {
4172
+ await this.selectEditableContent(objectId);
4173
+ if (value === "") {
4174
+ await this.dispatchKey("Delete");
4175
+ } else {
4176
+ await this.cdp.send("Input.insertText", { text: value });
4177
+ }
4178
+ }
4179
+ if (options.verify !== false) {
4180
+ let actualValue = await this.readEditableValue(objectId);
4181
+ if (actualValue !== value && !isSpecialInput) {
4182
+ if (value === "") {
4183
+ await this.clearEditableSelection(objectId, "Backspace");
4184
+ } else {
4185
+ await this.typeEditableFallback(element.nodeId, objectId, value);
3581
4186
  }
3582
- })()`
3583
- );
4187
+ actualValue = await this.readEditableValue(objectId);
4188
+ }
4189
+ if (actualValue !== value) {
4190
+ if (options.optional) return false;
4191
+ throw new Error(
4192
+ `Fill value did not stick. Expected ${JSON.stringify(value)} but got ${JSON.stringify(actualValue)}.`
4193
+ );
4194
+ }
4195
+ }
3584
4196
  if (blur) {
3585
- await this.evaluateInFrame(
3586
- `document.querySelector(${JSON.stringify(element.selector)})?.blur()`
3587
- );
4197
+ await this.cdp.send("Runtime.callFunctionOn", {
4198
+ objectId,
4199
+ functionDeclaration: "function() { this.blur(); }"
4200
+ });
3588
4201
  }
3589
4202
  return true;
3590
4203
  });
3591
4204
  }
3592
4205
  /**
3593
4206
  * Type text character by character (for autocomplete fields, etc.)
4207
+ *
4208
+ * Uses proper keyDown/rawKeyDown distinction with US keyboard layout.
4209
+ * Printable chars use 'keyDown' with text, non-text keys use 'rawKeyDown',
4210
+ * and non-layout chars (emoji, CJK) fall back to Input.insertText.
3594
4211
  */
3595
4212
  async type(selector, text, options = {}) {
3596
- const { delay = 50 } = options;
3597
- const element = await this.findElement(selector, options);
3598
- if (!element) {
3599
- if (options.optional) return false;
3600
- throw new ElementNotFoundError(selector);
3601
- }
3602
- await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
3603
- for (const char of text) {
3604
- await this.cdp.send("Input.dispatchKeyEvent", {
3605
- type: "keyDown",
3606
- key: char,
3607
- text: char
3608
- });
3609
- await this.cdp.send("Input.dispatchKeyEvent", {
3610
- type: "keyUp",
3611
- key: char
3612
- });
3613
- if (delay > 0) {
3614
- await sleep3(delay);
4213
+ return this.withStaleNodeRetry(async () => {
4214
+ const { delay = 50 } = options;
4215
+ const element = await this.findElement(selector, options);
4216
+ if (!element) {
4217
+ if (options.optional) return false;
4218
+ throw new ElementNotFoundError(selector);
3615
4219
  }
3616
- }
3617
- return true;
4220
+ try {
4221
+ const objectId = await this.resolveObjectId(element.nodeId);
4222
+ await ensureActionable(this.cdp, objectId, ["visible", "enabled"], {
4223
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
4224
+ });
4225
+ } catch (e) {
4226
+ if (options.optional) return false;
4227
+ throw e;
4228
+ }
4229
+ await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
4230
+ for (const char of text) {
4231
+ const def = US_KEYBOARD[char];
4232
+ if (def) {
4233
+ if (def.text !== void 0) {
4234
+ await this.cdp.send("Input.dispatchKeyEvent", {
4235
+ type: "keyDown",
4236
+ key: def.key,
4237
+ code: def.code,
4238
+ text: def.text,
4239
+ unmodifiedText: def.text,
4240
+ windowsVirtualKeyCode: def.keyCode,
4241
+ modifiers: 0,
4242
+ autoRepeat: false,
4243
+ location: def.location ?? 0,
4244
+ isKeypad: false
4245
+ });
4246
+ } else {
4247
+ await this.cdp.send("Input.dispatchKeyEvent", {
4248
+ type: "rawKeyDown",
4249
+ key: def.key,
4250
+ code: def.code,
4251
+ windowsVirtualKeyCode: def.keyCode,
4252
+ modifiers: 0,
4253
+ autoRepeat: false,
4254
+ location: def.location ?? 0,
4255
+ isKeypad: false
4256
+ });
4257
+ }
4258
+ await this.cdp.send("Input.dispatchKeyEvent", {
4259
+ type: "keyUp",
4260
+ key: def.key,
4261
+ code: def.code,
4262
+ windowsVirtualKeyCode: def.keyCode,
4263
+ modifiers: 0,
4264
+ location: def.location ?? 0
4265
+ });
4266
+ } else {
4267
+ await this.cdp.send("Input.insertText", { text: char });
4268
+ }
4269
+ if (delay > 0) {
4270
+ await sleep5(delay);
4271
+ }
4272
+ }
4273
+ return true;
4274
+ });
3618
4275
  }
3619
4276
  async select(selectorOrConfig, valueOrOptions, maybeOptions) {
3620
4277
  if (typeof selectorOrConfig === "object" && !Array.isArray(selectorOrConfig) && "trigger" in selectorOrConfig) {
@@ -3623,108 +4280,227 @@ var Page = class {
3623
4280
  const selector = selectorOrConfig;
3624
4281
  const value = valueOrOptions;
3625
4282
  const options = maybeOptions ?? {};
3626
- const element = await this.findElement(selector, options);
3627
- if (!element) {
3628
- if (options.optional) return false;
3629
- const selectorList = Array.isArray(selector) ? selector : [selector];
3630
- const hints = await generateHints(this, selectorList, "select");
3631
- throw new ElementNotFoundError(selector, hints);
3632
- }
3633
- const values = Array.isArray(value) ? value : [value];
3634
- await this.cdp.send("Runtime.evaluate", {
3635
- expression: `(() => {
3636
- const el = document.querySelector(${JSON.stringify(element.selector)});
3637
- if (!el || el.tagName !== 'SELECT') return false;
3638
- const values = ${JSON.stringify(values)};
3639
- for (const opt of el.options) {
3640
- opt.selected = values.includes(opt.value) || values.includes(opt.text);
3641
- }
3642
- el.dispatchEvent(new Event('change', { bubbles: true }));
3643
- return true;
3644
- })()`,
3645
- returnByValue: true
3646
- });
3647
- return true;
3648
- }
3649
- /**
3650
- * Handle custom (non-native) select/dropdown components
3651
- */
3652
- async selectCustom(config, options = {}) {
4283
+ return this.withStaleNodeRetry(async () => {
4284
+ const element = await this.findElement(selector, options);
4285
+ if (!element) {
4286
+ if (options.optional) return false;
4287
+ const selectorList = Array.isArray(selector) ? selector : [selector];
4288
+ const hints = await generateHints(this, selectorList, "select");
4289
+ throw new ElementNotFoundError(selector, hints);
4290
+ }
4291
+ const values = Array.isArray(value) ? value : [value];
4292
+ const objectId = await this.resolveObjectId(element.nodeId);
4293
+ try {
4294
+ await this.scrollIntoView(element.nodeId);
4295
+ await ensureActionable(this.cdp, objectId, ["visible", "enabled"], {
4296
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
4297
+ });
4298
+ } catch (e) {
4299
+ if (options.optional) return false;
4300
+ throw e;
4301
+ }
4302
+ const metadata = await this.getNativeSelectMetadata(objectId, values);
4303
+ if (!metadata.isSelect) {
4304
+ throw new Error("select() target must be a native <select> element");
4305
+ }
4306
+ if (metadata.missing.length > 0) {
4307
+ throw new Error(`No option found for: ${metadata.missing.join(", ")}`);
4308
+ }
4309
+ if (metadata.disabled.length > 0) {
4310
+ throw new Error(`Cannot select disabled option(s): ${metadata.disabled.join(", ")}`);
4311
+ }
4312
+ if (!metadata.multiple && metadata.targetIndexes.length > 1) {
4313
+ throw new Error("Cannot select multiple values on a single-select element");
4314
+ }
4315
+ const expectedValues = metadata.targetIndexes.map((idx) => metadata.options[idx].value);
4316
+ if (this.selectValuesMatch(metadata.selectedValues, expectedValues, metadata.multiple)) {
4317
+ return true;
4318
+ }
4319
+ if (!metadata.multiple && metadata.targetIndexes.length === 1) {
4320
+ await this.applyNativeSelectByKeyboard(
4321
+ element.nodeId,
4322
+ objectId,
4323
+ metadata.currentIndex,
4324
+ metadata.targetIndexes[0]
4325
+ );
4326
+ }
4327
+ let selectedValues = await this.readNativeSelectValues(objectId);
4328
+ if (!this.selectValuesMatch(selectedValues, expectedValues, metadata.multiple)) {
4329
+ await this.applyNativeSelectFallback(objectId, metadata.targetIndexes);
4330
+ selectedValues = await this.readNativeSelectValues(objectId);
4331
+ }
4332
+ if (!this.selectValuesMatch(selectedValues, expectedValues, metadata.multiple)) {
4333
+ await this.applyRecordedSelectFallback(objectId, metadata.targetIndexes);
4334
+ selectedValues = await this.readNativeSelectValues(objectId);
4335
+ }
4336
+ if (!this.selectValuesMatch(selectedValues, expectedValues, metadata.multiple)) {
4337
+ if (options.optional) return false;
4338
+ throw new Error(
4339
+ `Select value did not stick. Expected ${expectedValues.join(", ") || "(empty)"} but got ${selectedValues.join(", ") || "(empty)"}.`
4340
+ );
4341
+ }
4342
+ return true;
4343
+ });
4344
+ }
4345
+ /**
4346
+ * Handle custom (non-native) select/dropdown components
4347
+ */
4348
+ async selectCustom(config, options = {}) {
3653
4349
  const { trigger, option, value, match = "text" } = config;
3654
- await this.click(trigger, options);
3655
- await sleep3(100);
3656
- let optionSelector;
3657
- const optionSelectors = Array.isArray(option) ? option : [option];
3658
- if (match === "contains") {
3659
- optionSelector = optionSelectors.map((s) => `${s}:has-text("${value}")`).join(", ");
3660
- } else if (match === "value") {
3661
- optionSelector = optionSelectors.map((s) => `${s}[data-value="${value}"], ${s}[value="${value}"]`).join(", ");
3662
- } else {
3663
- optionSelector = optionSelectors.map((s) => `${s}`).join(", ");
3664
- }
3665
- const result = await this.cdp.send("Runtime.evaluate", {
3666
- expression: `(() => {
3667
- const options = document.querySelectorAll(${JSON.stringify(optionSelector)});
3668
- for (const opt of options) {
3669
- const text = opt.textContent?.trim();
3670
- if (${match === "text" ? `text === ${JSON.stringify(value)}` : match === "contains" ? `text?.includes(${JSON.stringify(value)})` : "true"}) {
3671
- opt.click();
3672
- return true;
4350
+ return this.withStaleNodeRetry(async () => {
4351
+ await this.click(trigger, options);
4352
+ await sleep5(100);
4353
+ const optionSelectors = Array.isArray(option) ? option : [option];
4354
+ const optionHandle = await this.evaluateInFrame(
4355
+ `(() => {
4356
+ const selectors = ${JSON.stringify(optionSelectors)};
4357
+ const wanted = ${JSON.stringify(value)};
4358
+ const mode = ${JSON.stringify(match)};
4359
+
4360
+ for (const selector of selectors) {
4361
+ const candidates = document.querySelectorAll(selector);
4362
+ for (const candidate of candidates) {
4363
+ const text = candidate.textContent?.trim() || '';
4364
+ const candidateValue =
4365
+ candidate.getAttribute?.('data-value') ??
4366
+ candidate.getAttribute?.('value') ??
4367
+ candidate.value ??
4368
+ '';
4369
+ const matches =
4370
+ mode === 'value'
4371
+ ? candidateValue === wanted
4372
+ : mode === 'contains'
4373
+ ? text.includes(wanted)
4374
+ : text === wanted;
4375
+
4376
+ if (matches) {
4377
+ return candidate;
4378
+ }
4379
+ }
3673
4380
  }
4381
+
4382
+ return null;
4383
+ })()`,
4384
+ { returnByValue: false }
4385
+ );
4386
+ if (!optionHandle.result.objectId) {
4387
+ if (options.optional) return false;
4388
+ throw new ElementNotFoundError(`Option with ${match} "${value}"`);
4389
+ }
4390
+ const nodeResult = await this.cdp.send("DOM.requestNode", {
4391
+ objectId: optionHandle.result.objectId
4392
+ });
4393
+ if (!nodeResult.nodeId) {
4394
+ if (options.optional) return false;
4395
+ throw new ElementNotFoundError(`Option with ${match} "${value}"`);
4396
+ }
4397
+ await this.scrollIntoView(nodeResult.nodeId);
4398
+ await ensureActionable(
4399
+ this.cdp,
4400
+ optionHandle.result.objectId,
4401
+ ["visible", "enabled", "stable"],
4402
+ {
4403
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
3674
4404
  }
3675
- return false;
3676
- })()`,
3677
- returnByValue: true
4405
+ );
4406
+ await this.clickElement(nodeResult.nodeId);
4407
+ return true;
3678
4408
  });
3679
- if (!result.result.value) {
3680
- if (options.optional) return false;
3681
- throw new ElementNotFoundError(`Option with ${match} "${value}"`);
3682
- }
3683
- return true;
3684
4409
  }
3685
4410
  /**
3686
- * Check a checkbox or radio button
4411
+ * Check a checkbox or radio button using real mouse click.
4412
+ * No-op if already checked. Verifies state changed after click.
3687
4413
  */
3688
4414
  async check(selector, options = {}) {
3689
- const element = await this.findElement(selector, options);
3690
- if (!element) {
3691
- if (options.optional) return false;
3692
- const selectorList = Array.isArray(selector) ? selector : [selector];
3693
- const hints = await generateHints(this, selectorList, "check");
3694
- throw new ElementNotFoundError(selector, hints);
3695
- }
3696
- const result = await this.cdp.send("Runtime.evaluate", {
3697
- expression: `(() => {
3698
- const el = document.querySelector(${JSON.stringify(element.selector)});
3699
- if (!el) return false;
3700
- if (!el.checked) el.click();
3701
- return true;
3702
- })()`,
3703
- returnByValue: true
4415
+ return this.withStaleNodeRetry(async () => {
4416
+ const element = await this.findElement(selector, options);
4417
+ if (!element) {
4418
+ if (options.optional) return false;
4419
+ const selectorList = Array.isArray(selector) ? selector : [selector];
4420
+ const hints = await generateHints(this, selectorList, "check");
4421
+ throw new ElementNotFoundError(selector, hints);
4422
+ }
4423
+ const { object } = await this.cdp.send("DOM.resolveNode", {
4424
+ nodeId: element.nodeId
4425
+ });
4426
+ try {
4427
+ await ensureActionable(this.cdp, object.objectId, ["visible", "enabled"], {
4428
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
4429
+ });
4430
+ } catch (e) {
4431
+ if (options.optional) return false;
4432
+ throw e;
4433
+ }
4434
+ const before = await this.cdp.send("Runtime.callFunctionOn", {
4435
+ objectId: object.objectId,
4436
+ functionDeclaration: "function() { return !!this.checked; }",
4437
+ returnByValue: true
4438
+ });
4439
+ if (before.result.value) return true;
4440
+ await this.scrollIntoView(element.nodeId);
4441
+ await this.clickElement(element.nodeId);
4442
+ const after = await this.cdp.send("Runtime.callFunctionOn", {
4443
+ objectId: object.objectId,
4444
+ functionDeclaration: "function() { return !!this.checked; }",
4445
+ returnByValue: true
4446
+ });
4447
+ if (!after.result.value) {
4448
+ throw new Error("Clicking the checkbox did not change its state");
4449
+ }
4450
+ return true;
3704
4451
  });
3705
- return result.result.value;
3706
4452
  }
3707
4453
  /**
3708
- * Uncheck a checkbox
4454
+ * Uncheck a checkbox using real mouse click.
4455
+ * No-op if already unchecked. Radio buttons can't be unchecked (returns true).
3709
4456
  */
3710
4457
  async uncheck(selector, options = {}) {
3711
- const element = await this.findElement(selector, options);
3712
- if (!element) {
3713
- if (options.optional) return false;
3714
- const selectorList = Array.isArray(selector) ? selector : [selector];
3715
- const hints = await generateHints(this, selectorList, "uncheck");
3716
- throw new ElementNotFoundError(selector, hints);
3717
- }
3718
- const result = await this.cdp.send("Runtime.evaluate", {
3719
- expression: `(() => {
3720
- const el = document.querySelector(${JSON.stringify(element.selector)});
3721
- if (!el) return false;
3722
- if (el.checked) el.click();
3723
- return true;
3724
- })()`,
3725
- returnByValue: true
4458
+ return this.withStaleNodeRetry(async () => {
4459
+ const element = await this.findElement(selector, options);
4460
+ if (!element) {
4461
+ if (options.optional) return false;
4462
+ const selectorList = Array.isArray(selector) ? selector : [selector];
4463
+ const hints = await generateHints(this, selectorList, "uncheck");
4464
+ throw new ElementNotFoundError(selector, hints);
4465
+ }
4466
+ const { object } = await this.cdp.send("DOM.resolveNode", {
4467
+ nodeId: element.nodeId
4468
+ });
4469
+ try {
4470
+ await ensureActionable(this.cdp, object.objectId, ["visible", "enabled"], {
4471
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
4472
+ });
4473
+ } catch (e) {
4474
+ if (options.optional) return false;
4475
+ throw e;
4476
+ }
4477
+ const isRadio = await this.cdp.send(
4478
+ "Runtime.callFunctionOn",
4479
+ {
4480
+ objectId: object.objectId,
4481
+ functionDeclaration: 'function() { return this.type === "radio"; }',
4482
+ returnByValue: true
4483
+ }
4484
+ );
4485
+ if (isRadio.result.value) return true;
4486
+ const before = await this.cdp.send("Runtime.callFunctionOn", {
4487
+ objectId: object.objectId,
4488
+ functionDeclaration: "function() { return !!this.checked; }",
4489
+ returnByValue: true
4490
+ });
4491
+ if (!before.result.value) return true;
4492
+ await this.scrollIntoView(element.nodeId);
4493
+ await this.clickElement(element.nodeId);
4494
+ const after = await this.cdp.send("Runtime.callFunctionOn", {
4495
+ objectId: object.objectId,
4496
+ functionDeclaration: "function() { return !!this.checked; }",
4497
+ returnByValue: true
4498
+ });
4499
+ if (after.result.value) {
4500
+ throw new Error("Clicking the checkbox did not change its state");
4501
+ }
4502
+ return true;
3726
4503
  });
3727
- return result.result.value;
3728
4504
  }
3729
4505
  /**
3730
4506
  * Submit a form (tries Enter key first, then click)
@@ -3738,97 +4514,84 @@ var Page = class {
3738
4514
  * the submit event and triggers HTML5 validation.
3739
4515
  */
3740
4516
  async submit(selector, options = {}) {
3741
- const { method = "enter+click", waitForNavigation: shouldWait = "auto" } = options;
3742
- const element = await this.findElement(selector, options);
3743
- if (!element) {
3744
- if (options.optional) return false;
3745
- const selectorList = Array.isArray(selector) ? selector : [selector];
3746
- const hints = await generateHints(this, selectorList, "submit");
3747
- throw new ElementNotFoundError(selector, hints);
3748
- }
3749
- const isFormElement = await this.evaluateInFrame(
3750
- `(() => {
3751
- const el = document.querySelector(${JSON.stringify(element.selector)});
3752
- return el instanceof HTMLFormElement;
3753
- })()`
3754
- );
3755
- if (isFormElement.result.value) {
3756
- await this.evaluateInFrame(
3757
- `(() => {
3758
- const form = document.querySelector(${JSON.stringify(element.selector)});
3759
- if (form && form instanceof HTMLFormElement) {
3760
- form.requestSubmit();
3761
- }
3762
- })()`
3763
- );
3764
- if (shouldWait === true) {
3765
- await this.waitForNavigation({ timeout: options.timeout ?? DEFAULT_TIMEOUT2 });
3766
- } else if (shouldWait === "auto") {
3767
- await Promise.race([this.waitForNavigation({ timeout: 1e3, optional: true }), sleep3(500)]);
4517
+ return this.withStaleNodeRetry(async () => {
4518
+ const { method = "enter+click", waitForNavigation: shouldWait = "auto" } = options;
4519
+ const element = await this.findElement(selector, options);
4520
+ if (!element) {
4521
+ if (options.optional) return false;
4522
+ const selectorList = Array.isArray(selector) ? selector : [selector];
4523
+ const hints = await generateHints(this, selectorList, "submit");
4524
+ throw new ElementNotFoundError(selector, hints);
3768
4525
  }
3769
- return true;
3770
- }
3771
- await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
3772
- if (method.includes("enter")) {
3773
- await this.press("Enter");
3774
- if (shouldWait === true) {
3775
- try {
4526
+ const objectId = await this.resolveObjectId(element.nodeId);
4527
+ const isFormElement = await this.cdp.send(
4528
+ "Runtime.callFunctionOn",
4529
+ {
4530
+ objectId,
4531
+ functionDeclaration: "function() { return this instanceof HTMLFormElement; }",
4532
+ returnByValue: true
4533
+ }
4534
+ );
4535
+ if (isFormElement.result.value) {
4536
+ await this.cdp.send("Runtime.callFunctionOn", {
4537
+ objectId,
4538
+ functionDeclaration: `function() {
4539
+ if (typeof this.requestSubmit === 'function') {
4540
+ this.requestSubmit();
4541
+ } else {
4542
+ this.submit();
4543
+ }
4544
+ }`
4545
+ });
4546
+ if (shouldWait === true) {
3776
4547
  await this.waitForNavigation({ timeout: options.timeout ?? DEFAULT_TIMEOUT2 });
3777
- return true;
3778
- } catch {
4548
+ } else if (shouldWait === "auto") {
4549
+ await Promise.race([
4550
+ this.waitForNavigation({ timeout: 1e3, optional: true }),
4551
+ sleep5(500)
4552
+ ]);
3779
4553
  }
3780
- } else if (shouldWait === "auto") {
3781
- const navigationDetected = await Promise.race([
3782
- this.waitForNavigation({ timeout: 1e3, optional: true }).then(
3783
- (success) => success ? "nav" : null
3784
- ),
3785
- sleep3(500).then(() => "timeout")
3786
- ]);
3787
- if (navigationDetected === "nav") {
4554
+ return true;
4555
+ }
4556
+ await this.cdp.send("DOM.focus", { nodeId: element.nodeId });
4557
+ if (method.includes("enter")) {
4558
+ await this.press("Enter");
4559
+ if (shouldWait === true) {
4560
+ try {
4561
+ await this.waitForNavigation({ timeout: options.timeout ?? DEFAULT_TIMEOUT2 });
4562
+ return true;
4563
+ } catch {
4564
+ }
4565
+ } else if (shouldWait === "auto") {
4566
+ const navigationDetected = await Promise.race([
4567
+ this.waitForNavigation({ timeout: 1e3, optional: true }).then(
4568
+ (success) => success ? "nav" : null
4569
+ ),
4570
+ sleep5(500).then(() => "timeout")
4571
+ ]);
4572
+ if (navigationDetected === "nav") {
4573
+ return true;
4574
+ }
4575
+ } else if (method === "enter") {
3788
4576
  return true;
3789
4577
  }
3790
- } else {
3791
- if (method === "enter") return true;
3792
4578
  }
3793
- }
3794
- if (method.includes("click")) {
3795
- await this.click(element.selector, { ...options, optional: false });
3796
- if (shouldWait === true) {
3797
- await this.waitForNavigation({ timeout: options.timeout ?? DEFAULT_TIMEOUT2 });
3798
- } else if (shouldWait === "auto") {
3799
- await sleep3(100);
4579
+ if (method.includes("click")) {
4580
+ await this.click(element.selector, { ...options, optional: false });
4581
+ if (shouldWait === true) {
4582
+ await this.waitForNavigation({ timeout: options.timeout ?? DEFAULT_TIMEOUT2 });
4583
+ } else if (shouldWait === "auto") {
4584
+ await sleep5(100);
4585
+ }
3800
4586
  }
3801
- }
3802
- return true;
4587
+ return true;
4588
+ });
3803
4589
  }
3804
4590
  /**
3805
4591
  * Press a key
3806
4592
  */
3807
4593
  async press(key) {
3808
- const keyMap = {
3809
- Enter: { key: "Enter", code: "Enter", keyCode: 13 },
3810
- Tab: { key: "Tab", code: "Tab", keyCode: 9 },
3811
- Escape: { key: "Escape", code: "Escape", keyCode: 27 },
3812
- Backspace: { key: "Backspace", code: "Backspace", keyCode: 8 },
3813
- Delete: { key: "Delete", code: "Delete", keyCode: 46 },
3814
- ArrowUp: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 },
3815
- ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 },
3816
- ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 },
3817
- ArrowRight: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 }
3818
- };
3819
- const keyInfo = keyMap[key] ?? { key, code: key, keyCode: 0 };
3820
- await this.cdp.send("Input.dispatchKeyEvent", {
3821
- type: "keyDown",
3822
- key: keyInfo.key,
3823
- code: keyInfo.code,
3824
- windowsVirtualKeyCode: keyInfo.keyCode
3825
- });
3826
- await this.cdp.send("Input.dispatchKeyEvent", {
3827
- type: "keyUp",
3828
- key: keyInfo.key,
3829
- code: keyInfo.code,
3830
- windowsVirtualKeyCode: keyInfo.keyCode
3831
- });
4594
+ await this.dispatchKey(key);
3832
4595
  }
3833
4596
  /**
3834
4597
  * Focus an element
@@ -3857,6 +4620,15 @@ var Page = class {
3857
4620
  throw new ElementNotFoundError(selector, hints);
3858
4621
  }
3859
4622
  await this.scrollIntoView(element.nodeId);
4623
+ try {
4624
+ const objectId = await this.resolveObjectId(element.nodeId);
4625
+ await ensureActionable(this.cdp, objectId, ["visible", "stable"], {
4626
+ timeout: options.timeout ?? DEFAULT_TIMEOUT2
4627
+ });
4628
+ } catch (e) {
4629
+ if (options.optional) return false;
4630
+ throw e;
4631
+ }
3860
4632
  const box = await this.getBoxModel(element.nodeId);
3861
4633
  if (!box) {
3862
4634
  if (options.optional) return false;
@@ -4047,53 +4819,424 @@ var Page = class {
4047
4819
  * Get text content from the page or a specific element
4048
4820
  */
4049
4821
  async text(selector) {
4050
- const expression = selector ? `document.querySelector(${JSON.stringify(selector)})?.innerText ?? ''` : "document.body.innerText";
4051
- const result = await this.evaluateInFrame(expression);
4052
- return result.result.value ?? "";
4822
+ if (!selector) {
4823
+ const result = await this.evaluateInFrame(
4824
+ "document.body.innerText"
4825
+ );
4826
+ return result.result.value ?? "";
4827
+ }
4828
+ return this.withStaleNodeRetry(async () => {
4829
+ const element = await this.findElement(selector, { timeout: DEFAULT_TIMEOUT2 });
4830
+ if (!element) return "";
4831
+ const objectId = await this.resolveObjectId(element.nodeId);
4832
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
4833
+ objectId,
4834
+ functionDeclaration: 'function() { return this.innerText || this.textContent || ""; }',
4835
+ returnByValue: true
4836
+ });
4837
+ return result.result.value ?? "";
4838
+ });
4053
4839
  }
4054
4840
  // ============ File Handling ============
4055
4841
  /**
4056
4842
  * Set files on a file input
4057
4843
  */
4058
4844
  async setInputFiles(selector, files, options = {}) {
4059
- const element = await this.findElement(selector, options);
4060
- if (!element) {
4061
- if (options.optional) return false;
4062
- throw new ElementNotFoundError(selector);
4845
+ return this.withStaleNodeRetry(async () => {
4846
+ const element = await this.findElement(selector, options);
4847
+ if (!element) {
4848
+ if (options.optional) return false;
4849
+ throw new ElementNotFoundError(selector);
4850
+ }
4851
+ const fileData = await Promise.all(
4852
+ files.map(async (f) => {
4853
+ let base64;
4854
+ if (typeof f.buffer === "string") {
4855
+ base64 = f.buffer;
4856
+ } else {
4857
+ const bytes = new Uint8Array(f.buffer);
4858
+ base64 = btoa(String.fromCharCode(...bytes));
4859
+ }
4860
+ return { name: f.name, mimeType: f.mimeType, data: base64 };
4861
+ })
4862
+ );
4863
+ const objectId = await this.resolveObjectId(element.nodeId);
4864
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
4865
+ objectId,
4866
+ functionDeclaration: `function(files) {
4867
+ if (!(this instanceof HTMLInputElement) || this.type !== 'file') {
4868
+ return { ok: false, fileCount: 0 };
4869
+ }
4870
+
4871
+ const dt = new DataTransfer();
4872
+ for (const f of files) {
4873
+ const bytes = Uint8Array.from(atob(f.data), function(c) { return c.charCodeAt(0); });
4874
+ const file = new File([bytes], f.name, { type: f.mimeType });
4875
+ dt.items.add(file);
4876
+ }
4877
+
4878
+ var descriptor = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'files');
4879
+ if (descriptor && descriptor.set) {
4880
+ descriptor.set.call(this, dt.files);
4881
+ } else {
4882
+ this.files = dt.files;
4883
+ }
4884
+
4885
+ this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
4886
+ this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
4887
+ return {
4888
+ ok: (this.files && this.files.length === files.length) || files.length === 0,
4889
+ fileCount: this.files ? this.files.length : 0
4890
+ };
4891
+ }`,
4892
+ arguments: [{ value: fileData }],
4893
+ returnByValue: true
4894
+ });
4895
+ if (!result.result.value.ok) {
4896
+ if (options.optional) return false;
4897
+ throw new Error("Failed to set files on input");
4898
+ }
4899
+ return true;
4900
+ });
4901
+ }
4902
+ async getNativeSelectMetadata(objectId, targets) {
4903
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
4904
+ objectId,
4905
+ functionDeclaration: `function(targetValues) {
4906
+ if (!(this instanceof HTMLSelectElement)) {
4907
+ return {
4908
+ currentIndex: -1,
4909
+ currentValue: '',
4910
+ disabled: [],
4911
+ isSelect: false,
4912
+ missing: Array.isArray(targetValues) ? targetValues.map(String) : [],
4913
+ multiple: false,
4914
+ options: [],
4915
+ selectedValues: [],
4916
+ targetIndexes: []
4917
+ };
4918
+ }
4919
+
4920
+ var allOptions = Array.from(this.options).map(function(opt, index) {
4921
+ return { index: index, label: opt.label || opt.text || '', value: opt.value || '' };
4922
+ });
4923
+ var targetIndexes = [];
4924
+ var missing = [];
4925
+ var disabled = [];
4926
+
4927
+ for (var i = 0; i < targetValues.length; i++) {
4928
+ var target = String(targetValues[i]);
4929
+ var idx = -1;
4930
+
4931
+ for (var j = 0; j < this.options.length; j++) {
4932
+ var opt = this.options[j];
4933
+ if (opt.value === target || opt.text === target || opt.label === target) {
4934
+ idx = j;
4935
+ break;
4936
+ }
4937
+ }
4938
+
4939
+ if (idx === -1 && /^\\d+$/.test(target)) {
4940
+ var numericIndex = parseInt(target, 10);
4941
+ if (numericIndex >= 0 && numericIndex < this.options.length) {
4942
+ idx = numericIndex;
4943
+ }
4944
+ }
4945
+
4946
+ if (idx === -1) {
4947
+ missing.push(target);
4948
+ continue;
4949
+ }
4950
+
4951
+ if (this.options[idx] && this.options[idx].disabled) {
4952
+ disabled.push(target);
4953
+ continue;
4954
+ }
4955
+
4956
+ if (targetIndexes.indexOf(idx) === -1) {
4957
+ targetIndexes.push(idx);
4958
+ }
4959
+ }
4960
+
4961
+ return {
4962
+ currentIndex: this.selectedIndex,
4963
+ currentValue: this.value || '',
4964
+ disabled: disabled,
4965
+ isSelect: true,
4966
+ missing: missing,
4967
+ multiple: !!this.multiple,
4968
+ options: allOptions,
4969
+ selectedValues: Array.from(this.selectedOptions).map(function(opt) { return opt.value || ''; }),
4970
+ targetIndexes: targetIndexes
4971
+ };
4972
+ }`,
4973
+ arguments: [{ value: targets }],
4974
+ returnByValue: true
4975
+ });
4976
+ return result.result.value;
4977
+ }
4978
+ async readNativeSelectValues(objectId) {
4979
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
4980
+ objectId,
4981
+ functionDeclaration: 'function() { return this instanceof HTMLSelectElement ? Array.from(this.selectedOptions).map(function(opt) { return opt.value || ""; }) : []; }',
4982
+ returnByValue: true
4983
+ });
4984
+ return result.result.value ?? [];
4985
+ }
4986
+ selectValuesMatch(actual, expected, multiple) {
4987
+ if (!multiple) {
4988
+ return (actual[0] ?? "") === (expected[0] ?? "");
4989
+ }
4990
+ if (actual.length !== expected.length) {
4991
+ return false;
4992
+ }
4993
+ const actualSorted = [...actual].sort();
4994
+ const expectedSorted = [...expected].sort();
4995
+ return actualSorted.every((value, index) => value === expectedSorted[index]);
4996
+ }
4997
+ async applyNativeSelectByKeyboard(nodeId, objectId, currentIndex, targetIndex) {
4998
+ await this.cdp.send("DOM.focus", { nodeId });
4999
+ if (targetIndex !== currentIndex) {
5000
+ let effectiveIndex = currentIndex;
5001
+ if (effectiveIndex < 0 || targetIndex < effectiveIndex) {
5002
+ await this.dispatchKey("Home");
5003
+ effectiveIndex = 0;
5004
+ }
5005
+ const steps = targetIndex - effectiveIndex;
5006
+ const direction = steps >= 0 ? "ArrowDown" : "ArrowUp";
5007
+ for (let i = 0; i < Math.abs(steps); i++) {
5008
+ await this.dispatchKey(direction);
5009
+ }
5010
+ }
5011
+ const selectedValues = await this.readNativeSelectValues(objectId);
5012
+ return selectedValues[0] !== void 0;
5013
+ }
5014
+ async applyNativeSelectFallback(objectId, targetIndexes) {
5015
+ await this.cdp.send("Runtime.callFunctionOn", {
5016
+ objectId,
5017
+ functionDeclaration: `function(indexes) {
5018
+ if (!(this instanceof HTMLSelectElement)) return false;
5019
+
5020
+ var wanted = new Set(indexes.map(function(index) { return Number(index); }));
5021
+ for (var i = 0; i < this.options.length; i++) {
5022
+ this.options[i].selected = wanted.has(i);
5023
+ }
5024
+ if (!this.multiple && indexes.length === 1) {
5025
+ this.selectedIndex = indexes[0];
5026
+ }
5027
+ this.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
5028
+ this.dispatchEvent(new Event('change', { bubbles: true, composed: true }));
5029
+ return true;
5030
+ }`,
5031
+ arguments: [{ value: targetIndexes }],
5032
+ returnByValue: true
5033
+ });
5034
+ }
5035
+ async selectEditableContent(objectId) {
5036
+ await this.cdp.send("Runtime.callFunctionOn", {
5037
+ objectId,
5038
+ functionDeclaration: `function() {
5039
+ if (this.isContentEditable) {
5040
+ this.focus();
5041
+ const range = document.createRange();
5042
+ range.selectNodeContents(this);
5043
+ const selection = window.getSelection();
5044
+ if (selection) {
5045
+ selection.removeAllRanges();
5046
+ selection.addRange(range);
5047
+ }
5048
+ return;
5049
+ }
5050
+
5051
+ if (this.tagName === 'TEXTAREA') {
5052
+ this.selectionStart = 0;
5053
+ this.selectionEnd = this.value.length;
5054
+ this.focus();
5055
+ return;
5056
+ }
5057
+
5058
+ if (typeof this.select === 'function') {
5059
+ this.select();
5060
+ }
5061
+ this.focus();
5062
+ }`
5063
+ });
5064
+ }
5065
+ async clearEditableSelection(objectId, key) {
5066
+ await this.selectEditableContent(objectId);
5067
+ await this.dispatchKey(key);
5068
+ }
5069
+ async readEditableValue(objectId) {
5070
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
5071
+ objectId,
5072
+ functionDeclaration: `function() {
5073
+ if (this.isContentEditable) {
5074
+ return this.textContent || '';
5075
+ }
5076
+ return this.value || '';
5077
+ }`,
5078
+ returnByValue: true
5079
+ });
5080
+ return result.result.value ?? "";
5081
+ }
5082
+ async typeEditableFallback(nodeId, objectId, value) {
5083
+ await this.selectEditableContent(objectId);
5084
+ await this.cdp.send("DOM.focus", { nodeId });
5085
+ for (const char of value) {
5086
+ await this.dispatchKey(char);
4063
5087
  }
4064
- const fileData = await Promise.all(
4065
- files.map(async (f) => {
4066
- let base64;
4067
- if (typeof f.buffer === "string") {
4068
- base64 = f.buffer;
4069
- } else {
4070
- const bytes = new Uint8Array(f.buffer);
4071
- base64 = btoa(String.fromCharCode(...bytes));
5088
+ }
5089
+ async applyRecordedSelectFallback(objectId, targetIndexes) {
5090
+ await this.cdp.send("Runtime.callFunctionOn", {
5091
+ objectId,
5092
+ functionDeclaration: `function(indexes) {
5093
+ if (!(this instanceof HTMLSelectElement)) return false;
5094
+
5095
+ var wanted = new Set(indexes.map(function(index) { return Number(index); }));
5096
+ for (var i = 0; i < this.options.length; i++) {
5097
+ this.options[i].selected = wanted.has(i);
4072
5098
  }
4073
- return { name: f.name, mimeType: f.mimeType, data: base64 };
4074
- })
4075
- );
4076
- await this.cdp.send("Runtime.evaluate", {
4077
- expression: `(() => {
4078
- const input = document.querySelector(${JSON.stringify(element.selector)});
4079
- if (!input) return false;
5099
+ if (!this.multiple && indexes.length === 1) {
5100
+ this.selectedIndex = indexes[0];
5101
+ }
5102
+ return true;
5103
+ }`,
5104
+ arguments: [{ value: targetIndexes }],
5105
+ returnByValue: true
5106
+ });
5107
+ return this.invokeRecordedEventListeners(objectId, ["input", "change"]);
5108
+ }
5109
+ async invokeRecordedEventListeners(objectId, eventTypes) {
5110
+ const result = await this.cdp.send("Runtime.callFunctionOn", {
5111
+ objectId,
5112
+ functionDeclaration: `function(types) {
5113
+ function buildPath(target) {
5114
+ var path = [];
5115
+ var node = target;
4080
5116
 
4081
- const files = ${JSON.stringify(fileData)};
4082
- const dt = new DataTransfer();
5117
+ while (node) {
5118
+ path.push(node);
4083
5119
 
4084
- for (const f of files) {
4085
- const bytes = Uint8Array.from(atob(f.data), c => c.charCodeAt(0));
4086
- const file = new File([bytes], f.name, { type: f.mimeType });
4087
- dt.items.add(file);
5120
+ if (node.parentElement) {
5121
+ node = node.parentElement;
5122
+ continue;
5123
+ }
5124
+
5125
+ if (node === document) {
5126
+ node = window;
5127
+ continue;
5128
+ }
5129
+
5130
+ if (node.defaultView && node !== node.defaultView) {
5131
+ node = node.defaultView;
5132
+ continue;
5133
+ }
5134
+
5135
+ if (node.ownerDocument && node !== node.ownerDocument) {
5136
+ node = node.ownerDocument;
5137
+ continue;
5138
+ }
5139
+
5140
+ var root = node.getRootNode && node.getRootNode();
5141
+ if (root && root !== node && root.host) {
5142
+ node = root.host;
5143
+ continue;
5144
+ }
5145
+
5146
+ node = null;
5147
+ }
5148
+
5149
+ return path;
4088
5150
  }
4089
5151
 
4090
- input.files = dt.files;
4091
- input.dispatchEvent(new Event('change', { bubbles: true }));
4092
- return true;
4093
- })()`,
5152
+ function createEvent(type, target, currentTarget, path, phase) {
5153
+ return {
5154
+ type: type,
5155
+ target: target,
5156
+ currentTarget: currentTarget,
5157
+ srcElement: target,
5158
+ isTrusted: true,
5159
+ bubbles: true,
5160
+ cancelable: true,
5161
+ composed: true,
5162
+ defaultPrevented: false,
5163
+ eventPhase: phase,
5164
+ timeStamp: Date.now(),
5165
+ preventDefault: function() {
5166
+ this.defaultPrevented = true;
5167
+ },
5168
+ stopPropagation: function() {
5169
+ this.__stopped = true;
5170
+ },
5171
+ stopImmediatePropagation: function() {
5172
+ this.__stopped = true;
5173
+ this.__immediateStopped = true;
5174
+ },
5175
+ composedPath: function() {
5176
+ return path.slice();
5177
+ }
5178
+ };
5179
+ }
5180
+
5181
+ function invokePhase(type, nodes, capture, target, path) {
5182
+ var invoked = false;
5183
+
5184
+ for (var i = 0; i < nodes.length; i++) {
5185
+ var currentTarget = nodes[i];
5186
+ var store = currentTarget && currentTarget.__bpEventListeners;
5187
+ var entries = store && store[type];
5188
+ if (!Array.isArray(entries) || entries.length === 0) continue;
5189
+
5190
+ var phase = currentTarget === target ? 2 : capture ? 1 : 3;
5191
+ var event = createEvent(type, target, currentTarget, path, phase);
5192
+
5193
+ for (var j = 0; j < entries.length; j++) {
5194
+ var entry = entries[j];
5195
+ if (!!entry.capture !== capture) continue;
5196
+
5197
+ var listener = entry.listener;
5198
+ if (typeof listener === 'function') {
5199
+ listener.call(currentTarget, event);
5200
+ invoked = true;
5201
+ } else if (listener && typeof listener.handleEvent === 'function') {
5202
+ listener.handleEvent(event);
5203
+ invoked = true;
5204
+ }
5205
+
5206
+ if (event.__immediateStopped) {
5207
+ break;
5208
+ }
5209
+ }
5210
+
5211
+ if (event.__stopped) {
5212
+ break;
5213
+ }
5214
+ }
5215
+
5216
+ return invoked;
5217
+ }
5218
+
5219
+ var path = buildPath(this);
5220
+ var capturePath = path.slice().reverse();
5221
+ var bubblePath = path.slice();
5222
+ var invokedAny = false;
5223
+
5224
+ for (var i = 0; i < types.length; i++) {
5225
+ var type = String(types[i]);
5226
+ if (invokePhase(type, capturePath, true, this, path)) {
5227
+ invokedAny = true;
5228
+ }
5229
+ if (invokePhase(type, bubblePath, false, this, path)) {
5230
+ invokedAny = true;
5231
+ }
5232
+ }
5233
+
5234
+ return invokedAny;
5235
+ }`,
5236
+ arguments: [{ value: eventTypes }],
4094
5237
  returnByValue: true
4095
5238
  });
4096
- return true;
5239
+ return result.result.value ?? false;
4097
5240
  }
4098
5241
  /**
4099
5242
  * Wait for a download to complete, triggered by an action
@@ -4253,7 +5396,7 @@ var Page = class {
4253
5396
  return lines.join("\n");
4254
5397
  };
4255
5398
  const text = formatTree(accessibilityTree);
4256
- return {
5399
+ const result = {
4257
5400
  url,
4258
5401
  title,
4259
5402
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
@@ -4261,6 +5404,8 @@ var Page = class {
4261
5404
  interactiveElements,
4262
5405
  text
4263
5406
  };
5407
+ this.lastSnapshot = result;
5408
+ return result;
4264
5409
  }
4265
5410
  /**
4266
5411
  * Export the current ref map for cross-exec reuse (CLI).
@@ -4789,11 +5934,13 @@ var Page = class {
4789
5934
  try {
4790
5935
  return await fn();
4791
5936
  } catch (e) {
4792
- if (e instanceof Error && (e.message.includes("Could not find node with given id") || e.message.includes("Node with given id does not belong to the document") || e.message.includes("No node with given id found"))) {
5937
+ const message = e instanceof Error ? e.message : "";
5938
+ if (e instanceof Error && (message.includes("Could not find node with given id") || message.includes("Node with given id does not belong to the document") || message.includes("No node with given id found") || message.includes("Could not find object with given id") || message.includes("Cannot find context with specified id") || message.includes("Cannot find context with given id") || message.includes("Execution context was destroyed") || message.includes("No execution context with given id") || message.includes("Argument should belong to the same JavaScript world"))) {
4793
5939
  lastError = e;
4794
5940
  if (attempt < retries) {
4795
5941
  this.rootNodeId = null;
4796
- await sleep3(delay);
5942
+ this.currentFrameContextId = null;
5943
+ await sleep5(delay);
4797
5944
  continue;
4798
5945
  }
4799
5946
  }
@@ -4838,6 +5985,39 @@ var Page = class {
4838
5985
  }
4839
5986
  }
4840
5987
  }
5988
+ if (selectorList.every((s) => s.startsWith("ref:")) && this.lastSnapshot) {
5989
+ for (const selector of selectorList) {
5990
+ const ref = selector.slice(4);
5991
+ const originalElement = this.lastSnapshot.interactiveElements.find((e) => e.ref === ref);
5992
+ if (!originalElement) continue;
5993
+ const freshSnapshot = await this.snapshot();
5994
+ const match = freshSnapshot.interactiveElements.find(
5995
+ (e) => e.role === originalElement.role && e.name === originalElement.name
5996
+ );
5997
+ if (match) {
5998
+ const newBackendNodeId = this.refMap.get(match.ref);
5999
+ if (newBackendNodeId) {
6000
+ try {
6001
+ await this.ensureRootNode();
6002
+ const pushResult = await this.cdp.send(
6003
+ "DOM.pushNodesByBackendIdsToFrontend",
6004
+ { backendNodeIds: [newBackendNodeId] }
6005
+ );
6006
+ if (pushResult.nodeIds?.[0]) {
6007
+ this._lastMatchedSelector = `ref:${match.ref}`;
6008
+ return {
6009
+ nodeId: pushResult.nodeIds[0],
6010
+ backendNodeId: newBackendNodeId,
6011
+ selector: `ref:${match.ref}`,
6012
+ waitedMs: 0
6013
+ };
6014
+ }
6015
+ } catch {
6016
+ }
6017
+ }
6018
+ }
6019
+ }
6020
+ }
4841
6021
  const cssSelectors = selectorList.filter((s) => !s.startsWith("ref:"));
4842
6022
  if (cssSelectors.length === 0) {
4843
6023
  return null;
@@ -4901,6 +6081,38 @@ var Page = class {
4901
6081
  */
4902
6082
  async ensureRootNode() {
4903
6083
  if (this.rootNodeId) return;
6084
+ if (this.currentFrame) {
6085
+ const mainDocument = await this.cdp.send("DOM.getDocument", {
6086
+ depth: 0
6087
+ });
6088
+ const iframeNode = await this.cdp.send("DOM.querySelector", {
6089
+ nodeId: mainDocument.root.nodeId,
6090
+ selector: this.currentFrame
6091
+ });
6092
+ if (iframeNode.nodeId) {
6093
+ const frameResult = await this.cdp.send("DOM.describeNode", {
6094
+ nodeId: iframeNode.nodeId,
6095
+ depth: 1
6096
+ });
6097
+ if (frameResult.node.contentDocument?.nodeId) {
6098
+ this.rootNodeId = frameResult.node.contentDocument.nodeId;
6099
+ if (frameResult.node.frameId) {
6100
+ let contextId = this.frameExecutionContexts.get(frameResult.node.frameId);
6101
+ if (!contextId) {
6102
+ for (let i = 0; i < 10; i++) {
6103
+ await sleep5(100);
6104
+ contextId = this.frameExecutionContexts.get(frameResult.node.frameId);
6105
+ if (contextId) break;
6106
+ }
6107
+ }
6108
+ this.currentFrameContextId = contextId ?? null;
6109
+ }
6110
+ return;
6111
+ }
6112
+ }
6113
+ this.currentFrame = null;
6114
+ this.currentFrameContextId = null;
6115
+ }
4904
6116
  const doc = await this.cdp.send("DOM.getDocument", {
4905
6117
  depth: 0
4906
6118
  });
@@ -4941,30 +6153,115 @@ var Page = class {
4941
6153
  }
4942
6154
  }
4943
6155
  /**
4944
- * Click an element by node ID
6156
+ * Click an element by node ID using Playwright's 3-event sequence:
6157
+ * mouseMoved → mousePressed → mouseReleased (sequential).
6158
+ * Uses DOM.getContentQuads for accurate coordinates (handles CSS transforms).
6159
+ * Falls back to JS this.click() if CDP mouse dispatch fails.
4945
6160
  */
4946
6161
  async clickElement(nodeId) {
4947
- const box = await this.getBoxModel(nodeId);
4948
- if (!box) {
4949
- throw new Error("Could not get element box model for click");
4950
- }
4951
- const x = box.content[0] + box.width / 2;
4952
- const y = box.content[1] + box.height / 2;
4953
- await this.cdp.send("Input.dispatchMouseEvent", {
4954
- type: "mousePressed",
4955
- x,
4956
- y,
4957
- button: "left",
4958
- clickCount: 1
6162
+ const { object } = await this.cdp.send("DOM.resolveNode", {
6163
+ nodeId
6164
+ });
6165
+ let x;
6166
+ let y;
6167
+ try {
6168
+ const { quads } = await this.cdp.send("DOM.getContentQuads", {
6169
+ objectId: object.objectId
6170
+ });
6171
+ if (quads && quads.length > 0) {
6172
+ const quad = quads[0];
6173
+ x = (quad[0] + quad[2] + quad[4] + quad[6]) / 4;
6174
+ y = (quad[1] + quad[3] + quad[5] + quad[7]) / 4;
6175
+ } else {
6176
+ throw new Error("No quads");
6177
+ }
6178
+ } catch {
6179
+ const box = await this.getBoxModel(nodeId);
6180
+ if (!box) throw new Error("Could not get element position for click");
6181
+ x = box.content[0] + box.width / 2;
6182
+ y = box.content[1] + box.height / 2;
6183
+ }
6184
+ try {
6185
+ await this.cdp.send("Input.dispatchMouseEvent", {
6186
+ type: "mouseMoved",
6187
+ x,
6188
+ y,
6189
+ button: "none",
6190
+ buttons: 0,
6191
+ modifiers: 0
6192
+ });
6193
+ await this.cdp.send("Input.dispatchMouseEvent", {
6194
+ type: "mousePressed",
6195
+ x,
6196
+ y,
6197
+ button: "left",
6198
+ buttons: 1,
6199
+ clickCount: 1,
6200
+ modifiers: 0
6201
+ });
6202
+ await this.cdp.send("Input.dispatchMouseEvent", {
6203
+ type: "mouseReleased",
6204
+ x,
6205
+ y,
6206
+ button: "left",
6207
+ buttons: 0,
6208
+ clickCount: 1,
6209
+ modifiers: 0
6210
+ });
6211
+ } catch {
6212
+ await this.cdp.send("Runtime.callFunctionOn", {
6213
+ objectId: object.objectId,
6214
+ functionDeclaration: "function() { this.click(); }"
6215
+ });
6216
+ }
6217
+ await this.cdp.send("Runtime.evaluate", { expression: "0" });
6218
+ }
6219
+ /**
6220
+ * Resolve a nodeId to a Remote Object ID for use with Runtime.callFunctionOn
6221
+ */
6222
+ async resolveObjectId(nodeId) {
6223
+ const { object } = await this.cdp.send("DOM.resolveNode", {
6224
+ nodeId
4959
6225
  });
4960
- await this.cdp.send("Input.dispatchMouseEvent", {
4961
- type: "mouseReleased",
4962
- x,
4963
- y,
4964
- button: "left",
4965
- clickCount: 1
6226
+ return object.objectId;
6227
+ }
6228
+ async dispatchKeyDefinition(def) {
6229
+ const downParams = {
6230
+ type: def.text !== void 0 ? "keyDown" : "rawKeyDown",
6231
+ key: def.key,
6232
+ code: def.code,
6233
+ windowsVirtualKeyCode: def.keyCode,
6234
+ modifiers: 0,
6235
+ autoRepeat: false,
6236
+ location: def.location ?? 0,
6237
+ isKeypad: false
6238
+ };
6239
+ if (def.text !== void 0) {
6240
+ downParams["text"] = def.text;
6241
+ downParams["unmodifiedText"] = def.text;
6242
+ }
6243
+ await this.cdp.send("Input.dispatchKeyEvent", downParams);
6244
+ await this.cdp.send("Input.dispatchKeyEvent", {
6245
+ type: "keyUp",
6246
+ key: def.key,
6247
+ code: def.code,
6248
+ windowsVirtualKeyCode: def.keyCode,
6249
+ modifiers: 0,
6250
+ location: def.location ?? 0
4966
6251
  });
4967
6252
  }
6253
+ async dispatchKey(key) {
6254
+ const def = US_KEYBOARD[key];
6255
+ if (def) {
6256
+ await this.dispatchKeyDefinition(def);
6257
+ return;
6258
+ }
6259
+ if ([...key].length === 1) {
6260
+ await this.cdp.send("Input.insertText", { text: key });
6261
+ return;
6262
+ }
6263
+ await this.dispatchKeyDefinition({ key, code: key, keyCode: 0 });
6264
+ }
4968
6265
  // ============ Audio I/O ============
4969
6266
  /**
4970
6267
  * Audio input controller (fake microphone).
@@ -5037,7 +6334,7 @@ var Page = class {
5037
6334
  const start = Date.now();
5038
6335
  await this.audioOutput.start();
5039
6336
  if (options.preDelay && options.preDelay > 0) {
5040
- await sleep3(options.preDelay);
6337
+ await sleep5(options.preDelay);
5041
6338
  }
5042
6339
  const inputDone = this.audioInput.play(options.input, {
5043
6340
  waitForEnd: !!options.sendSelector
@@ -5065,11 +6362,27 @@ var Page = class {
5065
6362
  };
5066
6363
  }
5067
6364
  };
5068
- function sleep3(ms) {
6365
+ function sleep5(ms) {
5069
6366
  return new Promise((resolve2) => setTimeout(resolve2, ms));
5070
6367
  }
5071
6368
 
5072
6369
  // src/browser/browser.ts
6370
+ function scoreTarget(t) {
6371
+ let score = 0;
6372
+ if (t.url.startsWith("http://") || t.url.startsWith("https://")) score += 10;
6373
+ if (t.url.startsWith("chrome://")) score -= 20;
6374
+ if (t.url.startsWith("chrome-extension://")) score -= 15;
6375
+ if (t.url.startsWith("devtools://")) score -= 25;
6376
+ if (t.url === "about:blank") score -= 5;
6377
+ if (!t.attached) score += 3;
6378
+ if (t.title && t.title.length > 0) score += 2;
6379
+ return score;
6380
+ }
6381
+ function pickBestTarget(targets) {
6382
+ if (targets.length === 0) return void 0;
6383
+ const sorted = [...targets].sort((a, b) => scoreTarget(b) - scoreTarget(a));
6384
+ return sorted[0].targetId;
6385
+ }
5073
6386
  var Browser = class _Browser {
5074
6387
  cdp;
5075
6388
  providerSession;
@@ -5091,28 +6404,46 @@ var Browser = class _Browser {
5091
6404
  return new _Browser(cdp, provider, session, options);
5092
6405
  }
5093
6406
  /**
5094
- * Get or create a page by name
5095
- * If no name is provided, returns the first available page or creates a new one
6407
+ * Get or create a page by name.
6408
+ * If no name is provided, returns the first available page or creates a new one.
6409
+ *
6410
+ * Target selection heuristics (when no targetId is specified):
6411
+ * - Prefer http/https URLs over chrome://, devtools://, about:blank
6412
+ * - Prefer unattached targets (not already controlled by another client)
6413
+ * - Filter by targetUrl if provided
5096
6414
  */
5097
6415
  async page(name, options) {
5098
6416
  const pageName = name ?? "default";
5099
6417
  const cached = this.pages.get(pageName);
5100
6418
  if (cached) return cached;
5101
6419
  const targets = await this.cdp.send("Target.getTargets");
5102
- const pageTargets = targets.targetInfos.filter((t) => t.type === "page");
6420
+ let pageTargets = targets.targetInfos.filter((t) => t.type === "page");
6421
+ if (options?.targetUrl) {
6422
+ const urlFilter = options.targetUrl;
6423
+ const filtered = pageTargets.filter((t) => t.url.includes(urlFilter));
6424
+ if (filtered.length > 0) {
6425
+ pageTargets = filtered;
6426
+ } else {
6427
+ console.warn(
6428
+ `[browser-pilot] No targets match URL filter "${urlFilter}", falling back to all page targets`
6429
+ );
6430
+ }
6431
+ }
5103
6432
  let targetId;
5104
6433
  if (options?.targetId) {
5105
- const targetExists = pageTargets.some((t) => t.targetId === options.targetId);
6434
+ const targetExists = targets.targetInfos.some(
6435
+ (t) => t.type === "page" && t.targetId === options.targetId
6436
+ );
5106
6437
  if (targetExists) {
5107
6438
  targetId = options.targetId;
5108
6439
  } else {
5109
6440
  console.warn(`[browser-pilot] Target ${options.targetId} no longer exists, falling back`);
5110
- targetId = pageTargets.length > 0 ? pageTargets[0].targetId : (await this.cdp.send("Target.createTarget", {
6441
+ targetId = pickBestTarget(pageTargets) ?? (await this.cdp.send("Target.createTarget", {
5111
6442
  url: "about:blank"
5112
6443
  })).targetId;
5113
6444
  }
5114
6445
  } else if (pageTargets.length > 0) {
5115
- targetId = pageTargets[0].targetId;
6446
+ targetId = pickBestTarget(pageTargets);
5116
6447
  } else {
5117
6448
  const result = await this.cdp.send("Target.createTarget", {
5118
6449
  url: "about:blank"
@@ -5122,6 +6453,21 @@ var Browser = class _Browser {
5122
6453
  await this.cdp.attachToTarget(targetId);
5123
6454
  const page = new Page(this.cdp, targetId);
5124
6455
  await page.init();
6456
+ const minViewport = options?.minViewport !== void 0 ? options.minViewport : { width: 200, height: 200 };
6457
+ if (minViewport !== false) {
6458
+ try {
6459
+ const viewport = await page.evaluate(
6460
+ "({ w: window.innerWidth, h: window.innerHeight })"
6461
+ );
6462
+ if (viewport.w < minViewport.width || viewport.h < minViewport.height) {
6463
+ console.warn(
6464
+ `[browser-pilot] Attached target has small viewport (${viewport.w}x${viewport.h}). Applying default viewport override (1280x720). Use { minViewport: false } to disable this check.`
6465
+ );
6466
+ await page.setViewport({ width: 1280, height: 720 });
6467
+ }
6468
+ } catch {
6469
+ }
6470
+ }
5125
6471
  this.pages.set(pageName, page);
5126
6472
  return page;
5127
6473
  }
@@ -5764,7 +7110,7 @@ async function audioCommand(args, globalOptions) {
5764
7110
  let capture;
5765
7111
  if (options.duration && options.duration > 0) {
5766
7112
  await page.audioOutput.start();
5767
- await sleep4(options.duration);
7113
+ await sleep6(options.duration);
5768
7114
  capture = await page.audioOutput.stop();
5769
7115
  } else {
5770
7116
  capture = await page.audioOutput.captureUntilSilence({
@@ -5914,7 +7260,7 @@ Available: setup, play, capture, roundtrip, check`
5914
7260
  await browser.disconnect();
5915
7261
  }
5916
7262
  }
5917
- function sleep4(ms) {
7263
+ function sleep6(ms) {
5918
7264
  return new Promise((resolve2) => setTimeout(resolve2, ms));
5919
7265
  }
5920
7266
 
@@ -6065,6 +7411,7 @@ Options:
6065
7411
  --url <ws-url> WebSocket URL (auto-discovered for generic provider)
6066
7412
  -n, --name <id> Custom session name (default: auto-generated)
6067
7413
  -r, --resume <id> Resume an existing session by ID
7414
+ --target-url <str> Filter targets to those whose URL contains this string
6068
7415
  --api-key <key> API key for cloud providers
6069
7416
  --project-id <id> Project ID for BrowserBase provider
6070
7417
  --export-log <path> Export session log to file on close
@@ -6077,6 +7424,7 @@ Examples:
6077
7424
  bp connect --provider generic --name dev # Connect with custom session name
6078
7425
  bp connect --url ws://localhost:9222/devtools # Explicit WebSocket URL
6079
7426
  bp connect --resume dev # Resume a previous session
7427
+ bp connect --target-url localhost:3000 # Attach to tab matching URL
6080
7428
  `.trimEnd();
6081
7429
  function parseConnectArgs(args) {
6082
7430
  const options = {};
@@ -6090,6 +7438,8 @@ function parseConnectArgs(args) {
6090
7438
  options.name = args[++i];
6091
7439
  } else if (arg === "--resume" || arg === "-r") {
6092
7440
  options.resume = args[++i];
7441
+ } else if (arg === "--target-url") {
7442
+ options.targetUrl = args[++i];
6093
7443
  } else if (arg === "--api-key") {
6094
7444
  options.apiKey = args[++i];
6095
7445
  } else if (arg === "--project-id") {
@@ -6140,7 +7490,8 @@ async function connectCommand(args, globalOptions) {
6140
7490
  projectId: options.projectId
6141
7491
  };
6142
7492
  const browser = await connect(connectOptions);
6143
- const page = await browser.page();
7493
+ const pageOptions = options.targetUrl ? { targetUrl: options.targetUrl } : void 0;
7494
+ const page = await browser.page(void 0, pageOptions);
6144
7495
  const currentUrl = await page.url();
6145
7496
  const sessionId = options.name ?? generateSessionId();
6146
7497
  const session = {
@@ -7176,7 +8527,7 @@ Run 'bp actions' for complete action reference.`
7176
8527
  } catch {
7177
8528
  const snippet = actionsJson.substring(0, 80);
7178
8529
  const looksLikeEvaluate = /evaluate/i.test(actionsJson);
7179
- const evalTip = looksLikeEvaluate ? "\n\nTip: For JavaScript evaluation, use 'bp eval' instead \u2014 no JSON wrapping needed:\n bp eval 'your.expression.here'" : "";
8530
+ const evalTip = looksLikeEvaluate ? "\n\nTip: If you truly need raw JavaScript evaluation, use 'bp eval' instead \u2014 no JSON wrapping needed:\n bp eval 'your.expression.here'\nUse high-level actions plus refs first whenever possible." : "";
7180
8531
  throw new Error(
7181
8532
  `Invalid JSON: ${snippet}${actionsJson.length > 80 ? "..." : ""}
7182
8533
 
@@ -7292,7 +8643,7 @@ Session file has been cleaned up. Run "bp connect" to create a new session.`
7292
8643
  if (failedEval) {
7293
8644
  console.error(
7294
8645
  `
7295
- Tip: Use "bp eval 'expression'" for simpler JavaScript evaluation (no JSON escaping needed).`
8646
+ Tip: Use "bp eval 'expression'" for simpler JavaScript inspection/debugging (no JSON escaping needed). Prefer high-level actions for interactions.`
7296
8647
  );
7297
8648
  }
7298
8649
  } finally {
@@ -8674,13 +10025,26 @@ var RECORDER_SCRIPT = `(function() {
8674
10025
  // src/recording/recorder.ts
8675
10026
  var Recorder = class {
8676
10027
  cdp;
10028
+ options;
8677
10029
  events = [];
8678
10030
  recording = false;
8679
10031
  startTime = 0;
8680
10032
  startUrl = "";
8681
10033
  bindingHandler = null;
8682
- constructor(cdp) {
10034
+ // Network capture state
10035
+ listenOpts = null;
10036
+ networkRequests = [];
10037
+ networkResponses = [];
10038
+ wsEvents = [];
10039
+ wsFrames = [];
10040
+ networkHandlers = [];
10041
+ matchRegex = null;
10042
+ pendingBodies = [];
10043
+ wsUrls = /* @__PURE__ */ new Map();
10044
+ httpUrls = /* @__PURE__ */ new Map();
10045
+ constructor(cdp, options) {
8683
10046
  this.cdp = cdp;
10047
+ this.options = options ?? {};
8684
10048
  }
8685
10049
  /**
8686
10050
  * Check if recording is currently active.
@@ -8726,6 +10090,13 @@ var Recorder = class {
8726
10090
  }
8727
10091
  };
8728
10092
  this.cdp.on("Runtime.bindingCalled", this.bindingHandler);
10093
+ if (this.options.listen) {
10094
+ const listenOpts = typeof this.options.listen === "boolean" ? { mode: "all" } : this.options.listen;
10095
+ this.listenOpts = listenOpts;
10096
+ this.matchRegex = listenOpts.match ? globToRegex2(listenOpts.match) : null;
10097
+ await this.cdp.send("Network.enable");
10098
+ this.setupNetworkListeners(listenOpts);
10099
+ }
8729
10100
  }
8730
10101
  /**
8731
10102
  * Stop recording and return aggregated output.
@@ -8742,13 +10113,39 @@ var Recorder = class {
8742
10113
  this.cdp.off("Runtime.bindingCalled", this.bindingHandler);
8743
10114
  this.bindingHandler = null;
8744
10115
  }
10116
+ for (const { event, handler } of this.networkHandlers) {
10117
+ this.cdp.off(event, handler);
10118
+ }
10119
+ this.networkHandlers = [];
10120
+ if (this.listenOpts) {
10121
+ await this.cdp.send("Network.disable");
10122
+ }
10123
+ await Promise.allSettled(this.pendingBodies);
10124
+ this.pendingBodies = [];
8745
10125
  const steps = aggregateEvents(this.events, this.startUrl);
8746
- return {
10126
+ const result = {
8747
10127
  recordedAt: new Date(this.startTime).toISOString(),
8748
10128
  startUrl: this.startUrl,
8749
10129
  duration,
8750
10130
  steps
8751
10131
  };
10132
+ if (this.listenOpts) {
10133
+ const mode = this.listenOpts.mode ?? "all";
10134
+ if (mode === "http" || mode === "all") {
10135
+ result.network = {
10136
+ requests: this.networkRequests,
10137
+ responses: this.networkResponses
10138
+ };
10139
+ }
10140
+ if (mode === "ws" || mode === "all") {
10141
+ result.websockets = {
10142
+ events: this.wsEvents,
10143
+ frames: this.wsFrames
10144
+ };
10145
+ }
10146
+ result.timeline = this.buildTimeline();
10147
+ }
10148
+ return result;
8752
10149
  }
8753
10150
  /**
8754
10151
  * Get raw recorded events (for debugging).
@@ -8767,7 +10164,201 @@ var Recorder = class {
8767
10164
  } catch {
8768
10165
  }
8769
10166
  }
10167
+ /** Subscribe to a CDP event, tracking for cleanup. */
10168
+ subscribeNetwork(event, handler) {
10169
+ this.cdp.on(event, handler);
10170
+ this.networkHandlers.push({ event, handler });
10171
+ }
10172
+ /** Check if a URL matches the configured filter. */
10173
+ matchesUrl(url) {
10174
+ if (!this.matchRegex) return true;
10175
+ return this.matchRegex.test(url);
10176
+ }
10177
+ /** Elapsed milliseconds since recording started. */
10178
+ elapsed() {
10179
+ return Date.now() - this.startTime;
10180
+ }
10181
+ /** Format a WebSocket payload, truncating or replacing binary data. */
10182
+ formatPayload(payloadData, opcode) {
10183
+ const data = payloadData ?? "";
10184
+ const maxPayload = this.listenOpts?.maxPayload ?? 256;
10185
+ if (opcode === 2) {
10186
+ const byteLength = Math.floor(data.length * 3 / 4);
10187
+ return { payload: `[binary: ${byteLength} bytes]`, length: data.length };
10188
+ }
10189
+ const length = data.length;
10190
+ if (length > maxPayload) {
10191
+ return {
10192
+ payload: `${data.slice(0, maxPayload)}... [truncated, ${length} total]`,
10193
+ length
10194
+ };
10195
+ }
10196
+ return { payload: data, length };
10197
+ }
10198
+ /** Set up CDP event listeners for network traffic capture. */
10199
+ setupNetworkListeners(opts) {
10200
+ const mode = opts.mode ?? "all";
10201
+ if (mode === "ws" || mode === "all") {
10202
+ this.subscribeNetwork("Network.webSocketCreated", (params) => {
10203
+ const url = params["url"];
10204
+ const requestId = params["requestId"];
10205
+ if (!this.matchesUrl(url)) return;
10206
+ this.wsUrls.set(requestId, url);
10207
+ const now = Date.now();
10208
+ this.wsEvents.push({
10209
+ requestId,
10210
+ timestamp: now,
10211
+ elapsedMs: this.elapsed(),
10212
+ type: "created",
10213
+ url
10214
+ });
10215
+ });
10216
+ this.subscribeNetwork("Network.webSocketFrameSent", (params) => {
10217
+ const requestId = params["requestId"];
10218
+ if (!this.wsUrls.has(requestId)) return;
10219
+ const response = params["response"];
10220
+ const opcode = response?.opcode ?? 1;
10221
+ const { payload, length } = this.formatPayload(response?.payloadData, opcode);
10222
+ const now = Date.now();
10223
+ this.wsFrames.push({
10224
+ requestId,
10225
+ timestamp: now,
10226
+ elapsedMs: this.elapsed(),
10227
+ direction: "sent",
10228
+ opcode,
10229
+ payload,
10230
+ length
10231
+ });
10232
+ });
10233
+ this.subscribeNetwork("Network.webSocketFrameReceived", (params) => {
10234
+ const requestId = params["requestId"];
10235
+ if (!this.wsUrls.has(requestId)) return;
10236
+ const response = params["response"];
10237
+ const opcode = response?.opcode ?? 1;
10238
+ const { payload, length } = this.formatPayload(response?.payloadData, opcode);
10239
+ const now = Date.now();
10240
+ this.wsFrames.push({
10241
+ requestId,
10242
+ timestamp: now,
10243
+ elapsedMs: this.elapsed(),
10244
+ direction: "received",
10245
+ opcode,
10246
+ payload,
10247
+ length
10248
+ });
10249
+ });
10250
+ this.subscribeNetwork("Network.webSocketClosed", (params) => {
10251
+ const requestId = params["requestId"];
10252
+ if (!this.wsUrls.has(requestId)) return;
10253
+ this.wsUrls.delete(requestId);
10254
+ const now = Date.now();
10255
+ this.wsEvents.push({
10256
+ requestId,
10257
+ timestamp: now,
10258
+ elapsedMs: this.elapsed(),
10259
+ type: "closed"
10260
+ });
10261
+ });
10262
+ }
10263
+ if (mode === "http" || mode === "all") {
10264
+ this.subscribeNetwork("Network.requestWillBeSent", (params) => {
10265
+ const request = params["request"];
10266
+ const url = request?.url ?? "";
10267
+ const requestId = params["requestId"];
10268
+ if (!this.matchesUrl(url)) return;
10269
+ this.httpUrls.set(requestId, url);
10270
+ const now = Date.now();
10271
+ this.networkRequests.push({
10272
+ requestId,
10273
+ timestamp: now,
10274
+ elapsedMs: this.elapsed(),
10275
+ method: request?.method ?? "GET",
10276
+ url,
10277
+ headers: request?.headers,
10278
+ body: request?.postData
10279
+ });
10280
+ });
10281
+ this.subscribeNetwork("Network.responseReceived", (params) => {
10282
+ const requestId = params["requestId"];
10283
+ if (!this.httpUrls.has(requestId)) return;
10284
+ const response = params["response"];
10285
+ const now = Date.now();
10286
+ this.networkResponses.push({
10287
+ requestId,
10288
+ timestamp: now,
10289
+ elapsedMs: this.elapsed(),
10290
+ status: response?.status ?? 0,
10291
+ headers: response?.headers,
10292
+ mimeType: response?.mimeType
10293
+ });
10294
+ if (this.listenOpts?.captureResponseBodies) {
10295
+ const bodyPromise = this.cdp.send("Network.getResponseBody", {
10296
+ requestId
10297
+ }).then((result) => {
10298
+ const resp = this.networkResponses.find((r) => r.requestId === requestId);
10299
+ if (resp) {
10300
+ resp.body = result.base64Encoded ? `[base64: ${result.body.length} chars]` : result.body;
10301
+ resp.bodySize = result.body.length;
10302
+ }
10303
+ }).catch(() => {
10304
+ });
10305
+ this.pendingBodies.push(bodyPromise);
10306
+ }
10307
+ });
10308
+ }
10309
+ }
10310
+ /** Build a merged timeline from action events and network events. */
10311
+ buildTimeline() {
10312
+ const entries = [];
10313
+ for (const event of this.events) {
10314
+ entries.push({
10315
+ timestamp: event.timestamp,
10316
+ elapsedMs: event.timestamp - this.startTime,
10317
+ type: "action",
10318
+ data: { kind: event.kind, url: event.url, selectors: event.selectors, value: event.value }
10319
+ });
10320
+ }
10321
+ for (const req of this.networkRequests) {
10322
+ entries.push({
10323
+ timestamp: req.timestamp,
10324
+ elapsedMs: req.elapsedMs,
10325
+ type: "network-request",
10326
+ data: req
10327
+ });
10328
+ }
10329
+ for (const resp of this.networkResponses) {
10330
+ entries.push({
10331
+ timestamp: resp.timestamp,
10332
+ elapsedMs: resp.elapsedMs,
10333
+ type: "network-response",
10334
+ data: resp
10335
+ });
10336
+ }
10337
+ for (const evt of this.wsEvents) {
10338
+ entries.push({
10339
+ timestamp: evt.timestamp,
10340
+ elapsedMs: evt.elapsedMs,
10341
+ type: "ws-event",
10342
+ data: evt
10343
+ });
10344
+ }
10345
+ for (const frame of this.wsFrames) {
10346
+ entries.push({
10347
+ timestamp: frame.timestamp,
10348
+ elapsedMs: frame.elapsedMs,
10349
+ type: "ws-frame",
10350
+ data: frame
10351
+ });
10352
+ }
10353
+ entries.sort((a, b) => a.timestamp - b.timestamp);
10354
+ return entries;
10355
+ }
8770
10356
  };
10357
+ function globToRegex2(pattern) {
10358
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
10359
+ const withWildcards = escaped.replace(/\*/g, ".*");
10360
+ return new RegExp(`^${withWildcards}$`);
10361
+ }
8771
10362
 
8772
10363
  // src/cli/commands/record.ts
8773
10364
  var RECORD_HELP = `
@@ -8783,16 +10374,25 @@ Options:
8783
10374
  - -s <id>: use specific session
8784
10375
  -f, --file <path> Output file (default: recording.json)
8785
10376
  --timeout <ms> Auto-stop after timeout (optional)
10377
+ --listen [mode] Capture network traffic: ws, http, or all (default: all)
10378
+ --bodies Capture HTTP response bodies (requires --listen)
10379
+ -m, --match <glob> Filter network URLs by glob pattern (requires --listen)
10380
+ --max-payload <n> Max WebSocket payload preview length (default: 256)
8786
10381
  -h, --help Show this help
8787
10382
 
8788
10383
  Examples:
8789
- bp record # Auto-connect to local Chrome
8790
- bp record -s # Use most recent session
8791
- bp record -s mysession # Use specific session
8792
- bp record -f login.json # Save to specific file
8793
- bp record --timeout 60000 # Auto-stop after 60s
10384
+ bp record # Auto-connect to local Chrome
10385
+ bp record -s # Use most recent session
10386
+ bp record -s mysession # Use specific session
10387
+ bp record -f login.json # Save to specific file
10388
+ bp record --timeout 60000 # Auto-stop after 60s
10389
+ bp record --listen # Record actions + all network traffic
10390
+ bp record --listen ws -m "*voice*" # Record actions + matching WS traffic
10391
+ bp record --listen http --bodies # Record actions + HTTP with bodies
8794
10392
 
8795
10393
  Recording captures: clicks, inputs, form submissions, navigation.
10394
+ When --listen is enabled, network traffic is captured alongside actions
10395
+ and merged into a unified timeline in the output file.
8796
10396
  Password fields are automatically redacted as [REDACTED].
8797
10397
 
8798
10398
  Press Ctrl+C to stop recording and save.
@@ -8812,6 +10412,20 @@ function parseRecordArgs(args) {
8812
10412
  if (!nextArg || nextArg.startsWith("-")) {
8813
10413
  options.useLatestSession = true;
8814
10414
  }
10415
+ } else if (arg === "--listen") {
10416
+ const nextArg = args[i + 1];
10417
+ if (nextArg && (nextArg === "ws" || nextArg === "http" || nextArg === "all")) {
10418
+ options.listen = nextArg;
10419
+ i++;
10420
+ } else {
10421
+ options.listen = true;
10422
+ }
10423
+ } else if (arg === "--bodies") {
10424
+ options.bodies = true;
10425
+ } else if (arg === "-m" || arg === "--match") {
10426
+ options.match = args[++i];
10427
+ } else if (arg === "--max-payload") {
10428
+ options.maxPayload = Number.parseInt(args[++i] ?? "", 10);
8815
10429
  }
8816
10430
  }
8817
10431
  return options;
@@ -8884,7 +10498,17 @@ async function recordCommand(args, globalOptions) {
8884
10498
  }
8885
10499
  const page = await browser.page();
8886
10500
  const cdp = page.cdpClient;
8887
- const recorder = new Recorder(cdp);
10501
+ let listenConfig;
10502
+ if (options.listen) {
10503
+ const listenOpts = {
10504
+ mode: typeof options.listen === "string" ? options.listen : "all",
10505
+ match: options.match,
10506
+ captureResponseBodies: options.bodies,
10507
+ maxPayload: options.maxPayload
10508
+ };
10509
+ listenConfig = listenOpts;
10510
+ }
10511
+ const recorder = new Recorder(cdp, listenConfig ? { listen: listenConfig } : void 0);
8888
10512
  let stopping = false;
8889
10513
  async function stopAndSave() {
8890
10514
  if (stopping) return;
@@ -8896,15 +10520,23 @@ async function recordCommand(args, globalOptions) {
8896
10520
  const currentUrl = await page.url();
8897
10521
  await updateSession(session.id, { currentUrl });
8898
10522
  await browser.disconnect();
8899
- console.log(`
8900
- Saved ${recording.steps.length} steps to ${outputFile}`);
10523
+ const networkInfo = recording.network ? `, ${recording.network.requests.length} HTTP requests` : "";
10524
+ const wsInfo = recording.websockets ? `, ${recording.websockets.frames.length} WS frames` : "";
10525
+ const timelineInfo = recording.timeline ? ` (${recording.timeline.length} timeline entries)` : "";
10526
+ console.log(
10527
+ `
10528
+ Saved ${recording.steps.length} steps${networkInfo}${wsInfo}${timelineInfo} to ${outputFile}`
10529
+ );
8901
10530
  if (globalOptions.format === "json") {
8902
10531
  output(
8903
10532
  {
8904
10533
  success: true,
8905
10534
  file: outputFile,
8906
10535
  steps: recording.steps.length,
8907
- duration: recording.duration
10536
+ duration: recording.duration,
10537
+ networkRequests: recording.network?.requests.length ?? 0,
10538
+ wsFrames: recording.websockets?.frames.length ?? 0,
10539
+ timelineEntries: recording.timeline?.length ?? 0
8908
10540
  },
8909
10541
  "json"
8910
10542
  );
@@ -8915,13 +10547,24 @@ Saved ${recording.steps.length} steps to ${outputFile}`);
8915
10547
  process.exit(1);
8916
10548
  }
8917
10549
  }
8918
- process.on("SIGINT", stopAndSave);
8919
- process.on("SIGTERM", stopAndSave);
10550
+ const handleSignal = () => {
10551
+ stopAndSave().catch((err) => {
10552
+ console.error("Error during shutdown:", err);
10553
+ process.exit(1);
10554
+ });
10555
+ };
10556
+ process.on("SIGINT", handleSignal);
10557
+ process.on("SIGTERM", handleSignal);
8920
10558
  if (options.timeout && options.timeout > 0) {
8921
10559
  setTimeout(stopAndSave, options.timeout);
8922
10560
  }
8923
10561
  await recorder.start();
8924
10562
  console.log(`Recording... Press Ctrl+C to stop and save to ${outputFile}`);
10563
+ if (options.listen) {
10564
+ const listenMode = typeof options.listen === "string" ? options.listen : "all";
10565
+ const matchLabel = options.match ? ` matching "${options.match}"` : "";
10566
+ console.log(`Network capture: ${listenMode} traffic${matchLabel}`);
10567
+ }
8925
10568
  console.log(`Session: ${session.id}`);
8926
10569
  console.log(`URL: ${await page.url()}`);
8927
10570
  }
@@ -9313,7 +10956,7 @@ function parseSnapshotArgs(args) {
9313
10956
  }
9314
10957
  return options;
9315
10958
  }
9316
- function sleep5(ms) {
10959
+ function sleep7(ms) {
9317
10960
  return new Promise((resolve2) => setTimeout(resolve2, ms));
9318
10961
  }
9319
10962
  async function snapshotCommand(args, globalOptions) {
@@ -9372,7 +11015,7 @@ async function snapshotCommand(args, globalOptions) {
9372
11015
  );
9373
11016
  } else {
9374
11017
  console.log("Overlay will be removed in 10 seconds...");
9375
- await sleep5(1e4);
11018
+ await sleep7(1e4);
9376
11019
  await removeRefOverlay(page);
9377
11020
  console.log("Overlay removed.");
9378
11021
  }