@xbrowser/cli 1.9.8 → 1.9.9

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.
Files changed (39) hide show
  1. package/dist/{anti-bot-MCVM6IFB.js → anti-bot-DR56Y63V.js} +1 -1
  2. package/dist/{browser-GT4BMX6X.js → browser-FFYPFTBV.js} +1 -1
  3. package/dist/{browser-43YXUIML.js → browser-RHWUAMTB.js} +2 -2
  4. package/dist/{browser-SJWPJFN7.js → browser-YCO4GJMZ.js} +2 -2
  5. package/dist/{cdp-driver-WAFYSIIW.js → cdp-driver-2T4P4ZE2.js} +87 -1
  6. package/dist/{cdp-driver-PUZLDSQU.js → cdp-driver-J3YQ4LJV.js} +1 -1
  7. package/dist/chunk-3OD76SUE.js +20 -0
  8. package/dist/chunk-43TEMG4U.js +9 -0
  9. package/dist/{chunk-C3LIGO4V.js → chunk-4452SPFI.js} +906 -16
  10. package/dist/{chunk-VEWTHYDG.js → chunk-5UGR6MUK.js} +906 -16
  11. package/dist/{chunk-ENCEMM7Z.js → chunk-6OZWKXSW.js} +15 -16
  12. package/dist/{chunk-7ZDWM6T2.js → chunk-BNO7OKO4.js} +5 -6
  13. package/dist/{chunk-24SY6PE5.js → chunk-CG3D3CHY.js} +1 -8
  14. package/dist/{chunk-ZJHQPMCY.js → chunk-DWDXEGVK.js} +5 -6
  15. package/dist/{chunk-A4YHJW3Z.js → chunk-GUTBIYFW.js} +101 -16
  16. package/dist/{chunk-DPJNCMLC.js → chunk-INTQPBYF.js} +7 -0
  17. package/dist/{human-interaction-OU7LZ6OZ.js → chunk-MWFHZUIY.js} +6 -7
  18. package/dist/chunk-Q2DFJQGS.js +128 -0
  19. package/dist/{chunk-GM3HWQ5U.js → chunk-RRBXV7KE.js} +87 -1
  20. package/dist/{chunk-PBDID7SE.js → chunk-RZM5CNIY.js} +15 -16
  21. package/dist/{human-interaction-HJTXFVQS.js → chunk-SQHSZENE.js} +2 -2
  22. package/dist/chunk-TZPKFUBT.js +20 -0
  23. package/dist/{chunk-YAUG3NSI.js → chunk-YMUSHPU4.js} +16 -128
  24. package/dist/cli.js +41 -28
  25. package/dist/{daemon-client-4HJENICO.js → daemon-client-GKEPT4NY.js} +15 -3
  26. package/dist/{daemon-client-CMRAWXTN.js → daemon-client-O6BYVRXV.js} +4 -1
  27. package/dist/daemon-main.js +635 -57
  28. package/dist/human-interaction-5AO42MBA.js +8 -0
  29. package/dist/{human-interaction-2DZK4MW7.js → human-interaction-C5OEGXGO.js} +1 -1
  30. package/dist/human-interaction-ISXZTAYY.js +8 -0
  31. package/dist/index.d.ts +167 -2
  32. package/dist/index.js +173 -63
  33. package/dist/recovery-EF33LKRJ.js +77 -0
  34. package/dist/recovery-J2ISVGUL.js +77 -0
  35. package/dist/recovery-ZJVVHP7N.js +76 -0
  36. package/dist/{session-recorder-K3K63Q33.js → session-recorder-H3KEYU26.js} +1 -1
  37. package/dist/{session-recorder-AG6CH6EI.js → session-recorder-QRZMKFVL.js} +1 -1
  38. package/dist/{session-replayer-V4MP6M6I.js → session-replayer-LJUC4TI7.js} +24 -7
  39. package/package.json +1 -1
@@ -431,6 +431,22 @@ function getSelectorGeneratorScript() {
431
431
  /^semi-[a-z]{4,}/
432
432
  ];
433
433
  var STABLE_ATTRS = ['data-testid', 'data-test-id', 'data-cy', 'data-qa', 'data-el'];
434
+ // Dynamic attrs known to change per page load (skip even if unique)
435
+ var UNSTABLE_ATTRS = [
436
+ 'data-spm-anchor-id', 'data-reactid', 'data-reactroot',
437
+ 'data-n-head', 'data-server-rendered',
438
+ ];
439
+ // Patterns catching other dynamic attrs by name shape
440
+ var UNSTABLE_ATTR_PATTERNS = [
441
+ /^data-spm/, /^data-track/, /^data-log/, /^data-pf/, /^data-key$/, /^data-rbd/,
442
+ ];
443
+ function isUnstableAttr(name) {
444
+ if (UNSTABLE_ATTRS.indexOf(name) !== -1) return true;
445
+ for (var i = 0; i < UNSTABLE_ATTR_PATTERNS.length; i++) {
446
+ if (UNSTABLE_ATTR_PATTERNS[i].test(name)) return true;
447
+ }
448
+ return false;
449
+ }
434
450
  var MAX_DEPTH = 5;
435
451
 
436
452
  function isStableClass(cls) {
@@ -478,6 +494,8 @@ function getSelectorGeneratorScript() {
478
494
  var a = el.attributes[ai];
479
495
  if (!a.name.startsWith('data-')) continue;
480
496
  if (STABLE_ATTRS.indexOf(a.name) !== -1) continue;
497
+ // Skip dynamic attrs (e.g. data-spm-anchor-id changes per page load)
498
+ if (isUnstableAttr(a.name)) continue;
481
499
  if (!a.value || a.value.length > 60 || a.value.indexOf('http') === 0) continue;
482
500
  var s = '[' + a.name + '="' + esc(a.value) + '"]';
483
501
  if (isUnique(root, s)) return { selector: s, strategy: 'data-attr', confidence: 'high' };
@@ -782,8 +800,11 @@ var ACTION_SIGNAL_SCRIPT = `
782
800
  selector = uniqueSelector(el);
783
801
  }
784
802
 
785
- // For low-confidence selectors (nth-of-type), generate a text-based fallback
786
- // when the element has short, unique text (e.g. menu items "\u5220\u9664", "\u786E\u8BA4")
803
+ // For low-confidence selectors (nth-of-type) OR selectors from dynamic
804
+ // attributes (which may not survive page reload), generate a text-based
805
+ // fallback when the element has short, unique text (e.g. menu items
806
+ // "\u5220\u9664", "\u786E\u8BA4", "\u6700\u65B0"). This lets replay fall back to text matching
807
+ // when the primary selector can't be found.
787
808
  var textFallback;
788
809
  var popupContext;
789
810
 
@@ -793,7 +814,18 @@ var ACTION_SIGNAL_SCRIPT = `
793
814
  popupEl = el.closest('[role="menu"], [role="listbox"], [role="dialog"], [role="tooltip"], [role="list"], [class*="popover"], [class*="popup"], [class*="dropdown"], [class*="menu"], [class*="modal"], [id*="menu"], [id*="dropdown"], [id*="popup"], [id*="modal"]');
794
815
  } catch(e) {}
795
816
 
796
- if (confidence === 'low') {
817
+ // Determine if primary selector is "fragile" \u2014 won't survive page reload
818
+ // because it comes from a dynamic attribute (e.g. data-spm-anchor-id).
819
+ var selectorIsFragile = false;
820
+ if (selector && confidence === 'high' && strategy === 'data-attr') {
821
+ // Fragile if the selector references a known dynamic attribute
822
+ selectorIsFragile = selector.indexOf('[data-spm-anchor-id') !== -1
823
+ || selector.indexOf('[data-reactid') !== -1
824
+ || selector.indexOf('[data-track') !== -1
825
+ || selector.indexOf('[data-log') !== -1;
826
+ }
827
+
828
+ if (confidence === 'low' || selectorIsFragile) {
797
829
  var rawText = (el.textContent || '').trim();
798
830
  if (rawText && rawText.length >= 1 && rawText.length <= 30 && el.children.length === 0) {
799
831
  try {
@@ -1035,6 +1067,10 @@ var ACTION_SIGNAL_SCRIPT = `
1035
1067
  '.popover','.popup','.dropdown','.menu','.modal','.tooltip','.panel',
1036
1068
  '[class*="popover"]','[class*="popup"]','[class*="dropdown"]','[class*="menu"]','[class*="tooltip"]',
1037
1069
  '[class*="modal"]','[class*="panel"]','[class*="overlay"]','[class*="sheet"]',
1070
+ // Modern SPA conventions: many libraries expose the open menu via class
1071
+ // fragments like "items-container", "options", "list", "select-popup".
1072
+ '[class*="items-container"]','[class*="options"]','[class*="select-popup"]',
1073
+ '[class*="select-items"]','[class*="menu-items"]','[class*="list-box"]',
1038
1074
  '[data-popup]','[data-dropdown]','[data-menu]','[data-popover]',
1039
1075
  '.semi-dropdown','.semi-popover','.semi-modal','.semi-select-option',
1040
1076
  '.ant-dropdown','.ant-popover','.ant-modal','.ant-select-dropdown',
@@ -1076,6 +1112,11 @@ var ACTION_SIGNAL_SCRIPT = `
1076
1112
  if (child.getAttribute('aria-disabled') === 'true') childInfo.disabled = true;
1077
1113
  if (child.tagName) childInfo.tag = child.tagName.toLowerCase();
1078
1114
  if (child.href) childInfo.href = child.href.substring(0, 80);
1115
+ // Attach a selector so replay / AI can target this item precisely.
1116
+ try {
1117
+ var childSel = uniqueSelector(child);
1118
+ if (childSel) childInfo.selector = childSel;
1119
+ } catch(e) {}
1079
1120
  items.push(childInfo);
1080
1121
  }
1081
1122
  result.appeared.push({
@@ -1137,6 +1178,9 @@ var ACTION_SIGNAL_SCRIPT = `
1137
1178
 
1138
1179
  pushAction('click', { element: describe(resolveMeaningful(e)), x: cx, y: cy });
1139
1180
 
1181
+ // Mark this element as user-interacted in discovered_filters (proactive sensing)
1182
+ try { __xb_markTriggerInteracted(e.target); } catch(_) {}
1183
+
1140
1184
  // After 200ms, capture what changed
1141
1185
  setTimeout(function() {
1142
1186
  try {
@@ -1275,12 +1319,102 @@ var ACTION_SIGNAL_SCRIPT = `
1275
1319
  }, true);
1276
1320
 
1277
1321
  // \u2500\u2500 Hover (throttled to 800ms) \u2500\u2500
1322
+ // After a hover we asynchronously sample for popups/dropdowns that appear
1323
+ // (200/500/1000ms) and run a short-lived MutationObserver. Any popup that
1324
+ // surfaces is recorded on the hover action as hoverContext, mirroring the
1325
+ // clickContext flow used after clicks.
1278
1326
  var __xb_last_hover = 0;
1327
+ var __xb_hover_observer = null; // current hover's MutationObserver
1328
+ var __xb_hover_observer_timer = null; // auto-disconnect timer
1329
+ var __xb_hover_action_ref = null; // ref to the hover action being enriched
1330
+ var __xb_hover_seen_popups = {}; // dedup popup selectors for current hover
1331
+ var __xb_hover_cx = 0, __xb_hover_cy = 0;
1332
+
1333
+ function __xb_merge_hover_context(ctx) {
1334
+ if (!__xb_hover_action_ref || !ctx) return;
1335
+ if (ctx.appeared.length === 0 && ctx.stateChanges.length === 0) return;
1336
+ if (!__xb_hover_action_ref.hoverContext) {
1337
+ __xb_hover_action_ref.hoverContext = { appeared: [], disappeared: [], stateChanges: [] };
1338
+ }
1339
+ var hc = __xb_hover_action_ref.hoverContext;
1340
+ ctx.appeared.forEach(function(p) {
1341
+ var key = p.selector || p.text;
1342
+ if (!key || __xb_hover_seen_popups[key]) return;
1343
+ __xb_hover_seen_popups[key] = true;
1344
+ hc.appeared.push(p);
1345
+ });
1346
+ ctx.stateChanges.forEach(function(s) {
1347
+ hc.stateChanges.push(s);
1348
+ });
1349
+ }
1350
+
1351
+ function __xb_capture_hover_popups() {
1352
+ if (!__xb_hover_action_ref) return;
1353
+ try {
1354
+ var ctx = captureVisibleContext(__xb_hover_cx, __xb_hover_cy);
1355
+ __xb_merge_hover_context(ctx);
1356
+ } catch(e) {}
1357
+ }
1358
+ // Expose for flush-time sweep (server-side flushPendingActions calls this
1359
+ // before draining the pending queue so any in-flight hover enrichment lands).
1360
+ window.__xb_capture_hover_popups = __xb_capture_hover_popups;
1361
+
1362
+ function __xb_start_hover_observer() {
1363
+ if (!('MutationObserver' in window)) return;
1364
+ if (__xb_hover_observer) {
1365
+ __xb_hover_observer.disconnect();
1366
+ __xb_hover_observer = null;
1367
+ }
1368
+ if (__xb_hover_observer_timer) {
1369
+ clearTimeout(__xb_hover_observer_timer);
1370
+ __xb_hover_observer_timer = null;
1371
+ }
1372
+ __xb_hover_observer = new MutationObserver(function(mutations) {
1373
+ if (!__xb_hover_action_ref) return;
1374
+ for (var i = 0; i < mutations.length; i++) {
1375
+ var added = mutations[i].addedNodes;
1376
+ for (var j = 0; j < added.length; j++) {
1377
+ var node = added[j];
1378
+ if (node.nodeType !== 1) continue;
1379
+ var matches = false;
1380
+ // Is the added node itself a popover?
1381
+ for (var k = 0; k < POPOVER_SELECTORS.length; k++) {
1382
+ try { if (node.matches && node.matches(POPOVER_SELECTORS[k])) { matches = true; break; } } catch(e) {}
1383
+ }
1384
+ // Or does it contain a popover?
1385
+ if (!matches && node.querySelector) {
1386
+ for (var k2 = 0; k2 < POPOVER_SELECTORS.length; k2++) {
1387
+ try { if (node.querySelector(POPOVER_SELECTORS[k2])) { matches = true; break; } } catch(e) {}
1388
+ }
1389
+ }
1390
+ if (matches) {
1391
+ __xb_capture_hover_popups();
1392
+ break;
1393
+ }
1394
+ }
1395
+ }
1396
+ });
1397
+ try {
1398
+ __xb_hover_observer.observe(document.body, { childList: true, subtree: true });
1399
+ } catch(e) {}
1400
+ // Auto-disconnect after 1.2s \u2014 hover popups rarely appear later than this.
1401
+ __xb_hover_observer_timer = setTimeout(function() {
1402
+ if (__xb_hover_observer) {
1403
+ __xb_hover_observer.disconnect();
1404
+ __xb_hover_observer = null;
1405
+ }
1406
+ __xb_hover_observer_timer = null;
1407
+ }, 1200);
1408
+ }
1409
+
1279
1410
  document.addEventListener('mouseover', function(e) {
1280
1411
  if (Date.now() - __xb_last_hover < 800) return;
1281
- __xb_last_hover = Date.now();
1282
1412
  var target = resolveMeaningful(e);
1283
- // Only record hover on interactive elements
1413
+ // Only record hover on interactive elements. Modern SPAs often implement
1414
+ // clickable items as <span>/<div> with onClick handlers and no semantic
1415
+ // role, so we also accept elements whose computed style suggests they are
1416
+ // meant to be interacted with (pointer cursor, or a class name that looks
1417
+ // like a UI control).
1284
1418
  var tag = target.tagName && target.tagName.toLowerCase();
1285
1419
  var isInteractive = tag === 'a' || tag === 'button' || tag === 'input'
1286
1420
  || tag === 'select' || tag === 'textarea' || tag === 'summary'
@@ -1290,12 +1424,69 @@ var ACTION_SIGNAL_SCRIPT = `
1290
1424
  || target.getAttribute('role') === 'tab'
1291
1425
  || target.getAttribute('role') === 'option'
1292
1426
  || !!target.closest('[role="menu"], [role="menubar"], [role="tablist"], [role="listbox"], [role="tree"], nav, menu');
1427
+ // Heuristic fallback for span/div/li that look interactive (onClick,
1428
+ // pointer cursor, or class names hinting at a UI control like
1429
+ // "select", "trigger", "tab", "sort", "menu", "item").
1430
+ if (!isInteractive && (tag === 'span' || tag === 'div' || tag === 'li')) {
1431
+ try {
1432
+ if (target.onclick) isInteractive = true;
1433
+ } catch(e2) {}
1434
+ if (!isInteractive) {
1435
+ try {
1436
+ var cs = window.getComputedStyle(target);
1437
+ if (cs.cursor === 'pointer') isInteractive = true;
1438
+ } catch(e3) {}
1439
+ }
1440
+ if (!isInteractive) {
1441
+ try {
1442
+ var cls = (target.className && typeof target.className === 'string')
1443
+ ? target.className.toLowerCase() : '';
1444
+ if (/(?:^|[-_])(select|trigger|tab|sort|menu|item|dropdown|popover|title|toggle|action|btn)(?:[-_]|$)/.test(cls)) {
1445
+ // But only if an ancestor looks like a toolbar/menu/sort bar \u2014
1446
+ // otherwise this would match almost every list item on the web.
1447
+ if (target.closest('[class*="select"], [class*="sort"], [class*="tab"], [class*="menu"], [class*="toolbar"], [class*="filter"], [class*="dropdown"]')) {
1448
+ isInteractive = true;
1449
+ }
1450
+ }
1451
+ } catch(e4) {}
1452
+ }
1453
+ }
1293
1454
  if (isInteractive) {
1455
+ // Reset state for the new hover target
1456
+ if (__xb_hover_observer) {
1457
+ __xb_hover_observer.disconnect();
1458
+ __xb_hover_observer = null;
1459
+ }
1460
+ if (__xb_hover_observer_timer) {
1461
+ clearTimeout(__xb_hover_observer_timer);
1462
+ __xb_hover_observer_timer = null;
1463
+ }
1464
+ __xb_hover_seen_popups = {};
1465
+
1466
+ var cx = e.clientX, cy = e.clientY;
1467
+ __xb_hover_cx = cx;
1468
+ __xb_hover_cy = cy;
1469
+
1294
1470
  pushAction('hover', {
1295
1471
  element: describe(target),
1296
- x: e.clientX,
1297
- y: e.clientY,
1472
+ x: cx,
1473
+ y: cy,
1298
1474
  });
1475
+ // Mark this element as user-interacted in discovered_filters
1476
+ try { __xb_markTriggerInteracted(target); } catch(_) {}
1477
+ __xb_last_hover = Date.now();
1478
+
1479
+ // Grab a reference to the just-pushed action so we can enrich it async.
1480
+ var pending = window.__xb_pending_actions;
1481
+ __xb_hover_action_ref = pending.length > 0 ? pending[pending.length - 1] : null;
1482
+
1483
+ // 1. Async sampling: 200/500/1000ms covers CSS transitions + JS render delays.
1484
+ setTimeout(__xb_capture_hover_popups, 200);
1485
+ setTimeout(__xb_capture_hover_popups, 500);
1486
+ setTimeout(__xb_capture_hover_popups, 1000);
1487
+
1488
+ // 2. Short-lived MutationObserver for popups that appear on their own schedule.
1489
+ __xb_start_hover_observer();
1299
1490
  }
1300
1491
  }, true);
1301
1492
 
@@ -1404,6 +1595,511 @@ var ACTION_SIGNAL_SCRIPT = `
1404
1595
  visibility: { state: document.hidden ? 'hidden' : 'visible' },
1405
1596
  });
1406
1597
  }, true);
1598
+
1599
+ // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1600
+ // PROACTIVE SENSING \u2014 discover interactive regions without user action.
1601
+ //
1602
+ // Three layers:
1603
+ // 1. __xb_scanFilters() \u2014 baseline scan, called on start/navigation
1604
+ // 2. Long-lived MutationObserver \u2014 watches popover visible transitions
1605
+ // 3. mousemove throttled diff \u2014 senses triggers near the cursor
1606
+ //
1607
+ // Results:
1608
+ // - 'discovered_filters' action \u2192 merged into RecordingData.discoveredFilters
1609
+ // - 'popup_appear' action \u2192 added to actions stream (with cause tag)
1610
+ // \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1611
+
1612
+ var FILTER_CONTAINER_SELECTORS = [
1613
+ '[role="tablist"]', '[role="menu"]', '[role="menubar"]', '[role="toolbar"]',
1614
+ '[role="navigation"]', '[role="listbox"]',
1615
+ '[class*="filter"]', '[class*="sort"]', '[class*="toolbar"]',
1616
+ '[class*="select-bar"]', '[class*="tabs"]', '[class*="nav-bar"]',
1617
+ '[class*="sidebar"]', '[class*="menu-bar"]', '[class*="select-container"]',
1618
+ 'fieldset',
1619
+ ];
1620
+
1621
+ var TRIGGER_SELECTORS = [
1622
+ '[role="tab"]', '[role="menuitem"]', '[role="menuitemcheckbox"]',
1623
+ '[role="menuitemradio"]', '[role="option"]', '[role="button"]',
1624
+ '[role="link"]', '[role="switch"]', '[role="checkbox"]',
1625
+ 'button', 'a', 'select',
1626
+ // Modern SPA span/div triggers (cursor:pointer is a strong signal)
1627
+ 'span[class*="title"]', 'div[class*="title"]',
1628
+ 'div[class*="trigger"]', 'span[class*="trigger"]',
1629
+ 'div[class*="item"]', 'li',
1630
+ ];
1631
+
1632
+ /** Categorize a container/trigger by class name + role heuristics. */
1633
+ function categorizeElement(el) {
1634
+ try {
1635
+ var cls = ((el.className && typeof el.className === 'string') ? el.className : '').toLowerCase();
1636
+ var role = (el.getAttribute && el.getAttribute('role')) || '';
1637
+ if (role === 'tab' || /(^|[-_])tab(s|list)?([-_]|$)/.test(cls)) return 'tab';
1638
+ if (role === 'menuitem' || role === 'menu' || /menu/.test(cls)) return 'menu';
1639
+ if (/sort/.test(cls)) return 'sort';
1640
+ if (/filter/.test(cls)) return 'filter';
1641
+ if (role === 'navigation' || /nav([-_]|$)/.test(cls)) return 'navigation';
1642
+ // Common compound patterns
1643
+ if (/search-filter|search-select|filter-up|filter-select|filter-checkbox/.test(cls)) return 'filter';
1644
+ if (/sort-bar|sort-container|sort-trigger/.test(cls)) return 'sort';
1645
+ if (/checkbox-group|radio-group/.test(cls)) return 'filter';
1646
+ if (/sidebar/.test(cls)) return 'navigation';
1647
+ } catch(e) {}
1648
+ return 'unknown';
1649
+ }
1650
+
1651
+ /** Build a DiscoveredTrigger from an element. */
1652
+ function buildTrigger(el) {
1653
+ var text = (el.textContent || '').trim();
1654
+ var trigger = {
1655
+ selector: uniqueSelector(el),
1656
+ text: text.substring(0, 40),
1657
+ role: el.getAttribute ? (el.getAttribute('role') || undefined) : undefined,
1658
+ category: categorizeElement(el),
1659
+ hasPopup: false,
1660
+ userInteracted: false,
1661
+ explored: false,
1662
+ };
1663
+ return trigger;
1664
+ }
1665
+
1666
+ /** All DiscoveredFilters found so far. Keyed by containerSelector for dedup. */
1667
+ var __xb_discovered_filters = {};
1668
+ /** True if a 'discovered_filters' push is pending (debounced). */
1669
+ var __xb_scan_scheduled = false;
1670
+ /** Track which DOM nodes we've already scanned (by identity, not selector). */
1671
+ var __xb_scanned_nodes = (typeof WeakSet !== 'undefined') ? new WeakSet() : null;
1672
+
1673
+ /** Scan the page for filter/sort/tab/menu containers + their triggers.
1674
+ * Dedup by DOM node identity (WeakSet) so the same element reached via
1675
+ * different selectors is recorded once. Nested containers are skipped \u2014
1676
+ * the outermost container wins. */
1677
+ function __xb_scanFilters() {
1678
+ try {
1679
+ // First pass: collect all candidate container nodes (dedup by identity)
1680
+ var candidates = [];
1681
+ var seenNodes = new Set(); // fallback if WeakSet unavailable
1682
+ for (var ci = 0; ci < FILTER_CONTAINER_SELECTORS.length; ci++) {
1683
+ var containers;
1684
+ try { containers = document.querySelectorAll(FILTER_CONTAINER_SELECTORS[ci]); } catch(e) { continue; }
1685
+ for (var cj = 0; cj < containers.length; cj++) {
1686
+ var c = containers[cj];
1687
+ // Dedup by identity
1688
+ if (__xb_scanned_nodes) {
1689
+ if (__xb_scanned_nodes.has(c)) continue;
1690
+ } else {
1691
+ if (seenNodes.has(c)) continue;
1692
+ seenNodes.add(c);
1693
+ }
1694
+ // Skip non-visible containers
1695
+ if (!c.offsetParent && c.tagName.toLowerCase() !== 'body') continue;
1696
+ // Skip if an ancestor is already a candidate (avoid nested dup)
1697
+ var isNested = false;
1698
+ for (var ck = 0; ck < candidates.length; ck++) {
1699
+ if (candidates[ck].contains(c)) { isNested = true; break; }
1700
+ }
1701
+ if (isNested) continue;
1702
+ // If this candidate contains existing ones, remove them
1703
+ candidates = candidates.filter(function(p) { return !c.contains(p); });
1704
+ candidates.push(c);
1705
+ }
1706
+ }
1707
+
1708
+ // Second pass: for each candidate, mark as scanned + extract triggers
1709
+ for (var ck2 = 0; ck2 < candidates.length; ck2++) {
1710
+ var c2 = candidates[ck2];
1711
+ if (__xb_scanned_nodes) __xb_scanned_nodes.add(c2);
1712
+
1713
+ var containerSel = uniqueSelector(c2);
1714
+ // If we already have this in discovered map (by selector), skip
1715
+ if (__xb_discovered_filters[containerSel]) continue;
1716
+
1717
+ // Extract triggers: prefer leaf-ish elements with short text
1718
+ var triggers = [];
1719
+ var seenTrigSels = {};
1720
+ // Use a single querySelectorAll with comma-joined selectors for speed
1721
+ var allCandidates = c2.querySelectorAll(TRIGGER_SELECTORS.join(', '));
1722
+ for (var ti = 0; ti < allCandidates.length; ti++) {
1723
+ var el = allCandidates[ti];
1724
+ if (!el.offsetParent) continue;
1725
+ // Skip elements whose nearest ancestor in the container is also a
1726
+ // candidate (would create nested triggers).
1727
+ var skip = false;
1728
+ var p = el.parentElement;
1729
+ while (p && p !== c2) {
1730
+ if (seenTrigSels[uniqueSelector(p)]) { skip = true; break; }
1731
+ p = p.parentElement;
1732
+ }
1733
+ if (skip) continue;
1734
+ var txt = (el.textContent || '').trim();
1735
+ if (!txt || txt.length === 0 || txt.length > 40) continue;
1736
+ // Skip if this looks like a full item card (long combined text)
1737
+ if (el.children.length > 3) continue;
1738
+ var sel = uniqueSelector(el);
1739
+ if (seenTrigSels[sel]) continue;
1740
+ seenTrigSels[sel] = true;
1741
+ triggers.push(buildTrigger(el));
1742
+ if (triggers.length >= 30) break;
1743
+ }
1744
+ if (triggers.length === 0) continue;
1745
+
1746
+ __xb_discovered_filters[containerSel] = {
1747
+ containerSelector: containerSel,
1748
+ category: categorizeElement(c2),
1749
+ containerText: (c2.textContent || '').trim().substring(0, 60),
1750
+ triggers: triggers,
1751
+ };
1752
+ }
1753
+ // Third pass: dedup containers whose trigger set is a subset of another.
1754
+ // This handles the case where two overlapping containers (e.g. outer
1755
+ // ".filter-up-container" and inner ".filter-select-container") share
1756
+ // triggers \u2014 keep the larger one, drop the redundant subset.
1757
+ var newFilters = [];
1758
+ var addedKeys = [];
1759
+ for (var key in __xb_discovered_filters) {
1760
+ var f = __xb_discovered_filters[key];
1761
+ var isSubset = false;
1762
+ for (var otherKey in __xb_discovered_filters) {
1763
+ if (otherKey === key) continue;
1764
+ var other = __xb_discovered_filters[otherKey];
1765
+ if (f.triggers.length > other.triggers.length) continue;
1766
+ // Check if every trigger in f is also in other (by selector)
1767
+ var allInOther = true;
1768
+ for (var ti2 = 0; ti2 < f.triggers.length; ti2++) {
1769
+ var found = false;
1770
+ for (var to2 = 0; to2 < other.triggers.length; to2++) {
1771
+ if (other.triggers[to2].selector === f.triggers[ti2].selector) { found = true; break; }
1772
+ }
1773
+ if (!found) { allInOther = false; break; }
1774
+ }
1775
+ if (allInOther && f.triggers.length < other.triggers.length) {
1776
+ isSubset = true;
1777
+ break;
1778
+ }
1779
+ // Same size + same triggers \u2014 keep the one with a more "container-y" selector
1780
+ if (allInOther && f.triggers.length === other.triggers.length && key > otherKey) {
1781
+ isSubset = true;
1782
+ break;
1783
+ }
1784
+ }
1785
+ if (!isSubset) {
1786
+ newFilters.push(f);
1787
+ addedKeys.push(key);
1788
+ }
1789
+ }
1790
+ // Rebuild the discovered map with deduped entries
1791
+ __xb_discovered_filters = {};
1792
+ for (var ni = 0; ni < newFilters.length; ni++) {
1793
+ __xb_discovered_filters[addedKeys[ni]] = newFilters[ni];
1794
+ }
1795
+
1796
+ // Push a discovered_filters action so the server picks it up.
1797
+ __xb_pushDiscovered();
1798
+ } catch(e) { /* scan failed, don't crash recorder */ }
1799
+ }
1800
+
1801
+ /** Push current discovered_filters snapshot to pending actions (debounced). */
1802
+ function __xb_pushDiscovered() {
1803
+ if (__xb_scan_scheduled) return;
1804
+ __xb_scan_scheduled = true;
1805
+ setTimeout(function() {
1806
+ __xb_scan_scheduled = false;
1807
+ var list = Object.keys(__xb_discovered_filters).map(function(k) {
1808
+ return __xb_discovered_filters[k];
1809
+ });
1810
+ window.__xb_pending_actions.push({
1811
+ type: 'discovered_filters',
1812
+ ts: Date.now(),
1813
+ url: location.href,
1814
+ title: document.title,
1815
+ discoveredFilters: list,
1816
+ });
1817
+ }, 200);
1818
+ }
1819
+
1820
+ /** Mark a trigger as user-interacted (called from click/hover handlers). */
1821
+ function __xb_markTriggerInteracted(target) {
1822
+ try {
1823
+ var sel = uniqueSelector(target);
1824
+ for (var k in __xb_discovered_filters) {
1825
+ var f = __xb_discovered_filters[k];
1826
+ for (var i = 0; i < f.triggers.length; i++) {
1827
+ if (f.triggers[i].selector === sel) {
1828
+ f.triggers[i].userInteracted = true;
1829
+ // Schedule a push so server gets the updated state
1830
+ __xb_pushDiscovered();
1831
+ return;
1832
+ }
1833
+ // Also accept ancestor match (target may be a child of the trigger)
1834
+ var triggerEl = document.querySelector(f.triggers[i].selector);
1835
+ if (triggerEl && triggerEl.contains(target)) {
1836
+ f.triggers[i].userInteracted = true;
1837
+ __xb_pushDiscovered();
1838
+ return;
1839
+ }
1840
+ }
1841
+ }
1842
+ } catch(e) {}
1843
+ }
1844
+
1845
+ /** Expose scan + capture for server-side triggers (post-navigation, flush-time). */
1846
+ window.__xb_scanFilters = __xb_scanFilters;
1847
+ window.__xb_capture_discovered_filters = function() {
1848
+ return Object.keys(__xb_discovered_filters).map(function(k) {
1849
+ return __xb_discovered_filters[k];
1850
+ });
1851
+ };
1852
+
1853
+ /** Find the nearest trigger element for a popup (looking up the DOM). */
1854
+ function findTriggerForPopup(popupEl) {
1855
+ try {
1856
+ // Walk up looking for known container types, then pick the trigger
1857
+ // nearest to the popup's top.
1858
+ var popupRect = popupEl.getBoundingClientRect();
1859
+ var ancestor = popupEl;
1860
+ for (var depth = 0; depth < 8 && ancestor; depth++) {
1861
+ ancestor = ancestor.parentElement;
1862
+ if (!ancestor) break;
1863
+ var triggersInAncestor = ancestor.querySelectorAll(
1864
+ '[role="tab"], [role="menuitem"], [role="option"], [role="button"], button, a, [class*="title"]'
1865
+ );
1866
+ // Pick the trigger whose x-center is closest to the popup's x-center
1867
+ // and is above the popup.
1868
+ var best = null, bestDist = Infinity;
1869
+ for (var i = 0; i < triggersInAncestor.length; i++) {
1870
+ var t = triggersInAncestor[i];
1871
+ if (!t.offsetParent) continue;
1872
+ var r = t.getBoundingClientRect();
1873
+ if (r.y + r.height > popupRect.y + 5) continue; // must be above popup
1874
+ var dist = Math.abs((r.x + r.width / 2) - (popupRect.x + popupRect.width / 2));
1875
+ if (dist < bestDist) {
1876
+ bestDist = dist;
1877
+ best = t;
1878
+ }
1879
+ }
1880
+ if (best) {
1881
+ // Mark the trigger as hasPopup in our discovered map
1882
+ var bestSel = uniqueSelector(best);
1883
+ for (var k in __xb_discovered_filters) {
1884
+ var f = __xb_discovered_filters[k];
1885
+ for (var j = 0; j < f.triggers.length; j++) {
1886
+ if (f.triggers[j].selector === bestSel) {
1887
+ f.triggers[j].hasPopup = true;
1888
+ }
1889
+ }
1890
+ }
1891
+ return {
1892
+ selector: bestSel,
1893
+ text: (best.textContent || '').trim().substring(0, 40),
1894
+ };
1895
+ }
1896
+ }
1897
+ } catch(e) {}
1898
+ return undefined;
1899
+ }
1900
+
1901
+ /** Extract popup items (reuses the same item-extraction logic as captureVisibleContext). */
1902
+ function extractPopupItems(popupEl) {
1903
+ var items = [];
1904
+ try {
1905
+ var children = popupEl.querySelectorAll(
1906
+ 'a,button,[role="menuitem"],[role="option"],[role="treeitem"],li,div[class*="item"],span[class*="item"]'
1907
+ );
1908
+ for (var k = 0; k < Math.min(children.length, 20); k++) {
1909
+ var child = children[k];
1910
+ var childText = (child.textContent || '').trim();
1911
+ if (!childText || childText.length > 60) continue;
1912
+ var info = { text: childText.substring(0, 60) };
1913
+ if (child.disabled) info.disabled = true;
1914
+ try { info.selector = uniqueSelector(child); } catch(e) {}
1915
+ items.push(info);
1916
+ }
1917
+ } catch(e) {}
1918
+ return items;
1919
+ }
1920
+
1921
+ /** Check whether an element is currently visible (has layout box). */
1922
+ function isVisibleEl(el) {
1923
+ try {
1924
+ var r = el.getBoundingClientRect();
1925
+ if (r.width === 0 || r.height === 0) return false;
1926
+ var cs = window.getComputedStyle(el);
1927
+ if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
1928
+ return true;
1929
+ } catch(e) { return false; }
1930
+ }
1931
+
1932
+ /** Push a 'popup_appear' sensing action. */
1933
+ function __xb_pushPopupAppear(popupEl, cause) {
1934
+ try {
1935
+ if (!popupEl || !isVisibleEl(popupEl)) return;
1936
+ var r = popupEl.getBoundingClientRect();
1937
+ // Heuristic: a real popup/menu should be relatively small (not a full
1938
+ // page banner) and have menu-like children. Page banners / hero sections
1939
+ // often match [class*="popup"] / [class*="modal"] but aren't real menus.
1940
+ var MAX_POPUP_AREA = 600 * 600; // 360,000 px\xB2
1941
+ if (r.width * r.height > MAX_POPUP_AREA) return;
1942
+ // Must have at least 1 child that looks like an item (link/button/li/div with class*="item")
1943
+ var itemChildren = popupEl.querySelectorAll(
1944
+ 'a, button, [role="menuitem"], [role="option"], li, [class*="item"]'
1945
+ );
1946
+ if (itemChildren.length === 0) return;
1947
+ // Dedup against recent popup_appear actions (same selector within 3s)
1948
+ var popupSel = uniqueSelector(popupEl);
1949
+ var now = Date.now();
1950
+ var recent = window.__xb_pending_actions.filter(function(a) {
1951
+ return a.type === 'popup_appear' && (now - a.ts) < 3000;
1952
+ });
1953
+ for (var i = 0; i < recent.length; i++) {
1954
+ if (recent[i].popupAppear && recent[i].popupAppear.popup.selector === popupSel) return;
1955
+ }
1956
+
1957
+ var triggerInfo = findTriggerForPopup(popupEl);
1958
+ window.__xb_pending_actions.push({
1959
+ type: 'popup_appear',
1960
+ ts: now,
1961
+ url: location.href,
1962
+ title: document.title,
1963
+ popupAppear: {
1964
+ trigger: triggerInfo,
1965
+ popup: {
1966
+ selector: popupSel,
1967
+ text: (popupEl.textContent || '').trim().substring(0, 100),
1968
+ rect: {
1969
+ x: Math.round(r.x), y: Math.round(r.y),
1970
+ w: Math.round(r.width), h: Math.round(r.height),
1971
+ },
1972
+ items: extractPopupItems(popupEl),
1973
+ },
1974
+ cause: cause || 'auto',
1975
+ userTriggered: cause === 'user-hover' || cause === 'user-click',
1976
+ },
1977
+ });
1978
+ // Also schedule a discovered_filters push (hasPopup flags may have updated)
1979
+ __xb_pushDiscovered();
1980
+ } catch(e) {}
1981
+ }
1982
+
1983
+ // Expose for the hover handler to call when it observes a popup (avoid double-record)
1984
+ window.__xb_pushPopupAppear = __xb_pushPopupAppear;
1985
+
1986
+ // \u2500\u2500 Layer 2: long-lived MutationObserver for popover visible transitions \u2500\u2500
1987
+ var __xb_long_observer = null;
1988
+ var __xb_observer_debounce = null;
1989
+ // Set of selectors we've already recorded as popups (avoid re-pushing)
1990
+ var __xb_seen_popups = {};
1991
+
1992
+ function __xb_startLongObserver() {
1993
+ if (!('MutationObserver' in window)) return;
1994
+ if (__xb_long_observer) __xb_long_observer.disconnect();
1995
+
1996
+ __xb_long_observer = new MutationObserver(function(mutations) {
1997
+ // Debounce \u2014 collect then process once
1998
+ if (__xb_observer_debounce) clearTimeout(__xb_observer_debounce);
1999
+ __xb_observer_debounce = setTimeout(function() {
2000
+ __xb_observer_debounce = null;
2001
+ // Scan all known popover containers; any newly visible one \u2192 push
2002
+ for (var i = 0; i < POPOVER_SELECTORS.length; i++) {
2003
+ var els;
2004
+ try { els = document.querySelectorAll(POPOVER_SELECTORS[i]); } catch(e) { continue; }
2005
+ for (var j = 0; j < els.length; j++) {
2006
+ var el = els[j];
2007
+ var sel = uniqueSelector(el);
2008
+ if (__xb_seen_popups[sel]) continue;
2009
+ if (!isVisibleEl(el)) continue;
2010
+ __xb_seen_popups[sel] = Date.now();
2011
+ __xb_pushPopupAppear(el, 'auto');
2012
+ }
2013
+ }
2014
+ }, 100);
2015
+ });
2016
+ try {
2017
+ __xb_long_observer.observe(document.body, {
2018
+ childList: true,
2019
+ subtree: true,
2020
+ attributes: true,
2021
+ attributeFilter: ['class', 'style', 'hidden'],
2022
+ });
2023
+ } catch(e) {}
2024
+ }
2025
+
2026
+ // Auto-cleanup seen_popups every 10s so transient popups can re-trigger
2027
+ setInterval(function() {
2028
+ var now = Date.now();
2029
+ for (var k in __xb_seen_popups) {
2030
+ if (now - __xb_seen_popups[k] > 10000) delete __xb_seen_popups[k];
2031
+ }
2032
+ }, 10000);
2033
+
2034
+ // \u2500\u2500 Layer 3: mousemove throttled diff (senses triggers near cursor) \u2500\u2500
2035
+ var __xb_last_scan_move = 0;
2036
+ var __xb_move_stop_timer = null;
2037
+ var __xb_last_pos = { x: 0, y: 0 };
2038
+
2039
+ document.addEventListener('mousemove', function(e) {
2040
+ __xb_last_pos = { x: e.clientX, y: e.clientY };
2041
+ var now = Date.now();
2042
+ if (now - __xb_last_scan_move < 300) return;
2043
+ __xb_last_scan_move = now;
2044
+
2045
+ // Reset the "stop" timer \u2014 when mouse stops moving for 800ms, we scan
2046
+ if (__xb_move_stop_timer) clearTimeout(__xb_move_stop_timer);
2047
+ __xb_move_stop_timer = setTimeout(function() {
2048
+ __xb_move_stop_timer = null;
2049
+ __xb_scanNearCursor(__xb_last_pos.x, __xb_last_pos.y, 'user-hover');
2050
+ }, 800);
2051
+ }, true);
2052
+
2053
+ /** Scan popups near the cursor; mark nearby triggers as explored. */
2054
+ function __xb_scanNearCursor(cx, cy, cause) {
2055
+ try {
2056
+ // 1. Check for any visible popover near cursor
2057
+ for (var i = 0; i < POPOVER_SELECTORS.length; i++) {
2058
+ var els;
2059
+ try { els = document.querySelectorAll(POPOVER_SELECTORS[i]); } catch(e) { continue; }
2060
+ for (var j = 0; j < els.length; j++) {
2061
+ var el = els[j];
2062
+ if (!isVisibleEl(el)) continue;
2063
+ var r = el.getBoundingClientRect();
2064
+ // Near = within 400px of cursor
2065
+ if (Math.abs((r.x + r.width / 2) - cx) > 400) continue;
2066
+ if (Math.abs((r.y + r.height / 2) - cy) > 400) continue;
2067
+ var sel = uniqueSelector(el);
2068
+ var now = Date.now();
2069
+ if (__xb_seen_popups[sel] && now - __xb_seen_popups[sel] < 2000) continue;
2070
+ __xb_seen_popups[sel] = now;
2071
+ __xb_pushPopupAppear(el, cause);
2072
+ }
2073
+ }
2074
+
2075
+ // 2. Mark nearby triggers as explored (mouse lingered without click)
2076
+ var near = document.elementsFromPoint(cx, cy);
2077
+ for (var k = 0; k < near.length && k < 5; k++) {
2078
+ var t = near[k];
2079
+ var tSel = uniqueSelector(t);
2080
+ for (var fk in __xb_discovered_filters) {
2081
+ var f = __xb_discovered_filters[fk];
2082
+ for (var ti = 0; ti < f.triggers.length; ti++) {
2083
+ if (f.triggers[ti].selector === tSel) {
2084
+ f.triggers[ti].explored = true;
2085
+ }
2086
+ }
2087
+ }
2088
+ }
2089
+ __xb_pushDiscovered();
2090
+ } catch(e) {}
2091
+ }
2092
+
2093
+ // \u2500\u2500 Trigger initial baseline scan (after DOM is ready) \u2500\u2500
2094
+ if (document.readyState === 'complete' || document.readyState === 'interactive') {
2095
+ setTimeout(__xb_scanFilters, 500);
2096
+ setTimeout(__xb_startLongObserver, 800);
2097
+ } else {
2098
+ window.addEventListener('DOMContentLoaded', function() {
2099
+ setTimeout(__xb_scanFilters, 500);
2100
+ setTimeout(__xb_startLongObserver, 800);
2101
+ });
2102
+ }
1407
2103
  })();
1408
2104
  `;
1409
2105
  var CHECKPOINT_OVERLAY_SCRIPT = `
@@ -1651,6 +2347,9 @@ var SessionRecorder = class _SessionRecorder {
1651
2347
  network = [];
1652
2348
  contextChanges = [];
1653
2349
  checkpoints = [];
2350
+ /** Proactive baseline scan of filter/sort/tab/menu regions. Keyed by
2351
+ * containerSelector so re-scans after navigation replace rather than dup. */
2352
+ discoveredFilters = /* @__PURE__ */ new Map();
1654
2353
  actionCounter = 0;
1655
2354
  networkCounter = 0;
1656
2355
  contextCounter = 0;
@@ -1671,6 +2370,9 @@ var SessionRecorder = class _SessionRecorder {
1671
2370
  _lastRequestTs = 0;
1672
2371
  _isRecording = false;
1673
2372
  streamMode = "clean";
2373
+ /** Snapshot throttle: prevents too many screenshots during rapid inputs.
2374
+ * Keyed by 'type|elementSelector', value = last snapshot timestamp. */
2375
+ snapshotThrottle = /* @__PURE__ */ new Map();
1674
2376
  /** Ambient action types: filtered out in clean mode */
1675
2377
  static AMBIENT_TYPES = /* @__PURE__ */ new Set([
1676
2378
  "hover",
@@ -1720,6 +2422,8 @@ var SessionRecorder = class _SessionRecorder {
1720
2422
  type: action.type,
1721
2423
  element: actionToPush.element
1722
2424
  });
2425
+ await this.captureAutoSnapshot(this.page, actionToPush, "after").catch(() => {
2426
+ });
1723
2427
  this.lastActionTs = ts;
1724
2428
  this.dedupMap.set(dedupFromAction, Date.now() + 2e3);
1725
2429
  this.dedupActionCount++;
@@ -1766,6 +2470,10 @@ var SessionRecorder = class _SessionRecorder {
1766
2470
  get recordingsDir() {
1767
2471
  return _SessionRecorder.getRecordingsDir(this.sessionName);
1768
2472
  }
2473
+ /** Directory for auto-captured snapshots (PNG screenshots, stored in .tmp for easy cleanup). */
2474
+ get snapshotsDir() {
2475
+ return join2(this.recordingsDir, ".tmp", "snapshots");
2476
+ }
1769
2477
  static getRecordingsDir(sessionName) {
1770
2478
  return join2(homedir2(), ".xbrowser", "sessions", sessionName, "recordings");
1771
2479
  }
@@ -1957,9 +2665,18 @@ var SessionRecorder = class _SessionRecorder {
1957
2665
  }
1958
2666
  }
1959
2667
  // ==================== Private ====================
2668
+ /** Whether the initial action-signal script injection succeeded. Check this
2669
+ * after start() returns — if false, user actions will NOT be recorded and
2670
+ * the caller should surface a clear error to the user instead of silently
2671
+ * pretending recording is working. */
2672
+ _injectionFailed = false;
2673
+ get injectionFailed() {
2674
+ return this._injectionFailed;
2675
+ }
1960
2676
  async injectActionScript(page) {
1961
2677
  let success = false;
1962
2678
  const maxAttempts = 3;
2679
+ let lastError = "";
1963
2680
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1964
2681
  try {
1965
2682
  await page.evaluate(getSelectorGeneratorScript());
@@ -1972,17 +2689,20 @@ var SessionRecorder = class _SessionRecorder {
1972
2689
  success = true;
1973
2690
  break;
1974
2691
  }
2692
+ lastError = "verification failed (script did not set __xb_action_signal flag)";
1975
2693
  console.error(`[recorder] Action signal injection verification failed (attempt ${attempt}/${maxAttempts})`);
1976
2694
  } catch (e) {
1977
- const msg = e instanceof Error ? e.message : String(e);
1978
- console.error(`[recorder] Action signal injection error (attempt ${attempt}/${maxAttempts}): ${msg}`);
2695
+ const err = e instanceof Error ? e : new Error(typeof e === "string" ? e : "unknown error");
2696
+ lastError = err.message;
2697
+ console.error(`[recorder] Action signal injection error (attempt ${attempt}/${maxAttempts}): ${err.message}`);
1979
2698
  }
1980
2699
  if (attempt < maxAttempts) {
1981
2700
  await new Promise((r) => setTimeout(r, 1e3));
1982
2701
  }
1983
2702
  }
1984
2703
  if (!success) {
1985
- console.error(`[recorder] WARNING: Action signal injection FAILED after ${maxAttempts} attempts. User actions will NOT be recorded. Check that the page supports Runtime.evaluate.`);
2704
+ this._injectionFailed = true;
2705
+ console.error(`[recorder] WARNING: Action signal injection FAILED after ${maxAttempts} attempts. User actions will NOT be recorded. Last error: ${lastError}`);
1986
2706
  }
1987
2707
  try {
1988
2708
  await page.evaluate(CHECKPOINT_OVERLAY_SCRIPT);
@@ -1990,6 +2710,16 @@ var SessionRecorder = class _SessionRecorder {
1990
2710
  const msg = e instanceof Error ? e.message : String(e);
1991
2711
  console.error(`[recorder] Checkpoint overlay injection failed (non-fatal): ${msg}`);
1992
2712
  }
2713
+ if (success) {
2714
+ try {
2715
+ await page.evaluate(`(function() {
2716
+ if (typeof window.__xb_scanFilters === 'function') {
2717
+ setTimeout(window.__xb_scanFilters, 100);
2718
+ }
2719
+ })()`);
2720
+ } catch {
2721
+ }
2722
+ }
1993
2723
  try {
1994
2724
  await page.evaluate(`
1995
2725
  (function() {
@@ -2141,9 +2871,9 @@ var SessionRecorder = class _SessionRecorder {
2141
2871
  return;
2142
2872
  }
2143
2873
  } catch (e) {
2144
- const msg = e instanceof Error ? e.message : String(e);
2874
+ const err = e instanceof Error ? e : new Error(typeof e === "string" ? e : "unknown error");
2145
2875
  if (attempt < 4) {
2146
- console.error(`[recorder] injectActionScript retry ${attempt + 1}/5 failed: ${msg}`);
2876
+ console.error(`[recorder] injectActionScript retry ${attempt + 1}/5 failed: ${err.message}`);
2147
2877
  }
2148
2878
  }
2149
2879
  await new Promise((r) => setTimeout(r, 1e3));
@@ -2165,8 +2895,8 @@ var SessionRecorder = class _SessionRecorder {
2165
2895
  try {
2166
2896
  await this.injectActionScript(page);
2167
2897
  } catch (e) {
2168
- const msg = e instanceof Error ? e.message : String(e);
2169
- console.error(`[recorder] injectActionScript after navigation failed: ${msg}`);
2898
+ const err = e instanceof Error ? e : new Error(typeof e === "string" ? e : "unknown error");
2899
+ console.error(`[recorder] injectActionScript after navigation failed: ${err.message}`);
2170
2900
  }
2171
2901
  }
2172
2902
  });
@@ -2287,6 +3017,14 @@ var SessionRecorder = class _SessionRecorder {
2287
3017
  try {
2288
3018
  pending = await page.evaluate(`(function() {
2289
3019
  var w = window;
3020
+ // Flush-time sweep: if there is a hover action still being enriched
3021
+ // by async samplers, force one last capture so its hoverContext lands
3022
+ // before the action is drained from the pending queue.
3023
+ try {
3024
+ if (typeof w.__xb_capture_hover_popups === 'function') {
3025
+ w.__xb_capture_hover_popups();
3026
+ }
3027
+ } catch(e) {}
2290
3028
  var actions = w.__xb_pending_actions || [];
2291
3029
  w.__xb_pending_actions = [];
2292
3030
 
@@ -2351,11 +3089,58 @@ var SessionRecorder = class _SessionRecorder {
2351
3089
  });
2352
3090
  }
2353
3091
  this.lastKnownUrl = currentUrl;
3092
+ try {
3093
+ page.evaluate(`(function() {
3094
+ if (typeof window.__xb_scanFilters === 'function') {
3095
+ setTimeout(window.__xb_scanFilters, 500);
3096
+ }
3097
+ })()`).catch(() => {
3098
+ });
3099
+ } catch {
3100
+ }
2354
3101
  }
2355
3102
  } catch {
2356
3103
  }
2357
3104
  for (const raw of pending) {
2358
3105
  if (raw.ts <= this.lastActionTs) continue;
3106
+ if (raw.type === "discovered_filters") {
3107
+ if (Array.isArray(raw.discoveredFilters)) {
3108
+ for (const f of raw.discoveredFilters) {
3109
+ if (f && f.containerSelector) {
3110
+ const existing = this.discoveredFilters.get(f.containerSelector);
3111
+ if (existing) {
3112
+ for (const newT of f.triggers) {
3113
+ const oldT = existing.triggers.find((t) => t.selector === newT.selector);
3114
+ if (oldT) {
3115
+ newT.userInteracted = newT.userInteracted || oldT.userInteracted;
3116
+ newT.explored = newT.explored || oldT.explored;
3117
+ newT.hasPopup = newT.hasPopup || oldT.hasPopup;
3118
+ }
3119
+ }
3120
+ }
3121
+ this.discoveredFilters.set(f.containerSelector, f);
3122
+ }
3123
+ }
3124
+ }
3125
+ continue;
3126
+ }
3127
+ if (raw.type === "popup_appear") {
3128
+ if (!raw.popupAppear?.userTriggered) continue;
3129
+ this.actionCounter++;
3130
+ const pushed2 = {
3131
+ id: this.actionCounter,
3132
+ type: "popup_appear",
3133
+ timestamp: raw.ts,
3134
+ url: raw.url || page.url(),
3135
+ pageTitle: raw.title || "",
3136
+ popupAppear: raw.popupAppear
3137
+ };
3138
+ this.actions.push(pushed2);
3139
+ await this.captureAutoSnapshot(page, pushed2, "after").catch(() => {
3140
+ });
3141
+ this.lastActionTs = raw.ts;
3142
+ continue;
3143
+ }
2359
3144
  const dedupFromSignal = this.dedupKey(raw.type, raw.element?.selector, raw.element?.tag, raw.value);
2360
3145
  if (dedupFromSignal) {
2361
3146
  const expires = this.dedupMap.get(dedupFromSignal);
@@ -2364,7 +3149,19 @@ var SessionRecorder = class _SessionRecorder {
2364
3149
  }
2365
3150
  }
2366
3151
  if (this.streamMode === "clean" && _SessionRecorder.AMBIENT_TYPES.has(raw.type)) {
2367
- continue;
3152
+ if (raw.type === "hover") {
3153
+ const hc = raw.hoverContext;
3154
+ if (!hc || hc.appeared.length === 0) continue;
3155
+ const hasSubstantialPopup = hc.appeared.some((p) => p.items && p.items.length > 0);
3156
+ if (!hasSubstantialPopup) continue;
3157
+ } else if (raw.type === "scroll") {
3158
+ const dx = raw.scrollX || 0;
3159
+ const dy = raw.scrollY || 0;
3160
+ const dist = Math.sqrt(dx * dx + dy * dy);
3161
+ if (dist < 50) continue;
3162
+ } else {
3163
+ continue;
3164
+ }
2368
3165
  }
2369
3166
  this.actionCounter++;
2370
3167
  let clickContext;
@@ -2385,10 +3182,17 @@ var SessionRecorder = class _SessionRecorder {
2385
3182
  scrollX: raw.scrollX,
2386
3183
  scrollY: raw.scrollY,
2387
3184
  clickContext,
3185
+ // Browser-side hover enrichment writes hoverContext onto the pending
3186
+ // action asynchronously (200/500/1000ms after mouseover). Flush may
3187
+ // run before the last sample lands — passthrough keeps whatever has
3188
+ // been collected so far.
3189
+ hoverContext: raw.hoverContext,
2388
3190
  files: raw.files
2389
3191
  });
2390
3192
  const pushed = this.actions[this.actions.length - 1];
2391
3193
  pushed.elementScreenshot = await this.captureElementScreenshot(page, raw);
3194
+ await this.captureAutoSnapshot(page, pushed, "after").catch(() => {
3195
+ });
2392
3196
  this.lastActionTs = raw.ts;
2393
3197
  const dedupKey = this.dedupKey(raw.type, raw.element?.selector, raw.element?.tag, raw.value);
2394
3198
  if (dedupKey) {
@@ -2452,6 +3256,85 @@ var SessionRecorder = class _SessionRecorder {
2452
3256
  return;
2453
3257
  }
2454
3258
  }
3259
+ /**
3260
+ * Action types that trigger auto-snapshot capture.
3261
+ * Hover is excluded — it's too noisy and rarely changes the page visually.
3262
+ * Popup_appear captures the popup state (after only).
3263
+ */
3264
+ static SNAPSHOT_TYPES = /* @__PURE__ */ new Set([
3265
+ "click",
3266
+ "dblclick",
3267
+ "navigation",
3268
+ "goto",
3269
+ "submit",
3270
+ "cdp-click",
3271
+ "cdp-fill",
3272
+ "input",
3273
+ "change",
3274
+ "filechooser",
3275
+ "popup_appear"
3276
+ ]);
3277
+ /**
3278
+ * Throttle key for snapshot capture to avoid too many during rapid inputs.
3279
+ * Returns true if this action should be throttled (skip snapshot).
3280
+ */
3281
+ isSnapshotThrottled(type, selector) {
3282
+ if (type === "input" || type === "change" || type === "cdp-fill") {
3283
+ const key = `${type}|${selector || ""}`;
3284
+ const last = this.snapshotThrottle.get(key);
3285
+ const now = Date.now();
3286
+ if (last && now - last < 2e3) return true;
3287
+ this.snapshotThrottle.set(key, now);
3288
+ }
3289
+ return false;
3290
+ }
3291
+ /**
3292
+ * Capture a viewport screenshot + compact accessibility tree for a key action.
3293
+ * - PNG stored in .tmp/snapshots/ (temporary, easy to clean up)
3294
+ * - aria tree stored inline as compact text on the action (searchable via grep)
3295
+ *
3296
+ * Non-blocking (errors silently caught). Throttled for rapid inputs.
3297
+ */
3298
+ async captureAutoSnapshot(page, action, suffix = "after") {
3299
+ if (!_SessionRecorder.SNAPSHOT_TYPES.has(action.type)) return;
3300
+ if (this.isSnapshotThrottled(action.type, action.element?.selector)) return;
3301
+ const snapDir = this.snapshotsDir;
3302
+ mkdirSync2(snapDir, { recursive: true });
3303
+ const stepId = String(action.id).padStart(3, "0");
3304
+ const snapshot = {};
3305
+ try {
3306
+ const pngPath = join2(snapDir, `step-${stepId}-${suffix}.png`);
3307
+ const buffer = await page.screenshot({ type: "png", timeout: 5e3 });
3308
+ writeFileSync2(pngPath, buffer);
3309
+ snapshot.png = `.tmp/snapshots/step-${stepId}-${suffix}.png`;
3310
+ } catch {
3311
+ }
3312
+ try {
3313
+ snapshot.aria = await page.evaluate(`
3314
+ (function() {
3315
+ try {
3316
+ var parts = [];
3317
+ var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, null, false);
3318
+ var node;
3319
+ while (node = walker.nextNode()) {
3320
+ var tag = node.tagName.toLowerCase();
3321
+ var role = node.getAttribute('role') || '';
3322
+ var text = (node.textContent || '').trim().substring(0, 60);
3323
+ var ariaLabel = node.getAttribute('aria-label') || '';
3324
+ if (role || tag === 'button' || tag === 'a' || tag === 'input' || tag === 'select' || tag === 'textarea') {
3325
+ parts.push((role || tag) + ': ' + (ariaLabel || text).substring(0, 40));
3326
+ }
3327
+ }
3328
+ return parts.slice(0, 100).join(' | ');
3329
+ } catch(e) { return ''; }
3330
+ })()
3331
+ `);
3332
+ } catch {
3333
+ }
3334
+ if (snapshot.png || snapshot.aria) {
3335
+ action.snapshots = snapshot;
3336
+ }
3337
+ }
2455
3338
  /**
2456
3339
  * After a click, wait 300ms then scan for popover/dropdown/menu elements
2457
3340
  * near the click position. This runs server-side to avoid race conditions
@@ -2651,12 +3534,18 @@ var SessionRecorder = class _SessionRecorder {
2651
3534
  JSON.stringify(data, null, 2),
2652
3535
  "utf-8"
2653
3536
  );
3537
+ const jsonlPath = join2(this.recordingsDir, "recording.jsonl");
3538
+ const lines = data.actions.map((a) => JSON.stringify(a)).join("\n") + "\n";
3539
+ writeFileSync2(jsonlPath, lines, "utf-8");
2654
3540
  } catch {
2655
3541
  }
2656
3542
  }
2657
3543
  writeFinalOutput(data, summary) {
2658
3544
  mkdirSync2(this.recordingsDir, { recursive: true });
2659
3545
  writeFileSync2(join2(this.recordingsDir, "recording.json"), JSON.stringify(data, null, 2), "utf-8");
3546
+ const jsonlPath = join2(this.recordingsDir, "recording.jsonl");
3547
+ const lines = data.actions.map((a) => JSON.stringify(a)).join("\n") + "\n";
3548
+ writeFileSync2(jsonlPath, lines, "utf-8");
2660
3549
  writeFileSync2(join2(this.recordingsDir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
2661
3550
  writeFileSync2(join2(this.recordingsDir, "summary.md"), this.buildMarkdownSummary(data, summary), "utf-8");
2662
3551
  }
@@ -2668,7 +3557,8 @@ var SessionRecorder = class _SessionRecorder {
2668
3557
  actions: [...this.actions],
2669
3558
  network: [...this.network],
2670
3559
  contextChanges: [...this.contextChanges],
2671
- checkpoints: [...this.checkpoints]
3560
+ checkpoints: [...this.checkpoints],
3561
+ discoveredFilters: [...this.discoveredFilters.values()]
2672
3562
  };
2673
3563
  }
2674
3564
  // ─── Summary builder with ref compression + input→network matching ──