@xbrowser/cli 1.9.7 → 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-JXIJF4VL.js → browser-FFYPFTBV.js} +1 -1
  3. package/dist/{browser-ALPLJNH2.js → browser-RHWUAMTB.js} +2 -2
  4. package/dist/{browser-ZPQP6VW7.js → browser-YCO4GJMZ.js} +2 -2
  5. package/dist/{cdp-driver-MI7VCE44.js → cdp-driver-2T4P4ZE2.js} +103 -2
  6. package/dist/{cdp-driver-6EVYLHL2.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-CH5ZQV64.js → chunk-4452SPFI.js} +928 -18
  10. package/dist/{chunk-C4O73DQL.js → chunk-5UGR6MUK.js} +928 -18
  11. package/dist/{chunk-37SHZSUR.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-74S7UUI2.js → chunk-GUTBIYFW.js} +117 -17
  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-VJNMAWPZ.js → chunk-RRBXV7KE.js} +103 -2
  20. package/dist/{chunk-5JSOGINQ.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 +661 -70
  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 +172 -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-7GXI42FO.js → session-recorder-H3KEYU26.js} +1 -1
  37. package/dist/{session-recorder-RFOPX4SF.js → session-recorder-QRZMKFVL.js} +1 -1
  38. package/dist/{session-replayer-3HEIJBJ2.js → session-replayer-LJUC4TI7.js} +25 -8
  39. package/package.json +1 -1
@@ -34,6 +34,22 @@ function getSelectorGeneratorScript() {
34
34
  /^semi-[a-z]{4,}/
35
35
  ];
36
36
  var STABLE_ATTRS = ['data-testid', 'data-test-id', 'data-cy', 'data-qa', 'data-el'];
37
+ // Dynamic attrs known to change per page load (skip even if unique)
38
+ var UNSTABLE_ATTRS = [
39
+ 'data-spm-anchor-id', 'data-reactid', 'data-reactroot',
40
+ 'data-n-head', 'data-server-rendered',
41
+ ];
42
+ // Patterns catching other dynamic attrs by name shape
43
+ var UNSTABLE_ATTR_PATTERNS = [
44
+ /^data-spm/, /^data-track/, /^data-log/, /^data-pf/, /^data-key$/, /^data-rbd/,
45
+ ];
46
+ function isUnstableAttr(name) {
47
+ if (UNSTABLE_ATTRS.indexOf(name) !== -1) return true;
48
+ for (var i = 0; i < UNSTABLE_ATTR_PATTERNS.length; i++) {
49
+ if (UNSTABLE_ATTR_PATTERNS[i].test(name)) return true;
50
+ }
51
+ return false;
52
+ }
37
53
  var MAX_DEPTH = 5;
38
54
 
39
55
  function isStableClass(cls) {
@@ -81,6 +97,8 @@ function getSelectorGeneratorScript() {
81
97
  var a = el.attributes[ai];
82
98
  if (!a.name.startsWith('data-')) continue;
83
99
  if (STABLE_ATTRS.indexOf(a.name) !== -1) continue;
100
+ // Skip dynamic attrs (e.g. data-spm-anchor-id changes per page load)
101
+ if (isUnstableAttr(a.name)) continue;
84
102
  if (!a.value || a.value.length > 60 || a.value.indexOf('http') === 0) continue;
85
103
  var s = '[' + a.name + '="' + esc(a.value) + '"]';
86
104
  if (isUnique(root, s)) return { selector: s, strategy: 'data-attr', confidence: 'high' };
@@ -385,8 +403,11 @@ var ACTION_SIGNAL_SCRIPT = `
385
403
  selector = uniqueSelector(el);
386
404
  }
387
405
 
388
- // For low-confidence selectors (nth-of-type), generate a text-based fallback
389
- // when the element has short, unique text (e.g. menu items "\u5220\u9664", "\u786E\u8BA4")
406
+ // For low-confidence selectors (nth-of-type) OR selectors from dynamic
407
+ // attributes (which may not survive page reload), generate a text-based
408
+ // fallback when the element has short, unique text (e.g. menu items
409
+ // "\u5220\u9664", "\u786E\u8BA4", "\u6700\u65B0"). This lets replay fall back to text matching
410
+ // when the primary selector can't be found.
390
411
  var textFallback;
391
412
  var popupContext;
392
413
 
@@ -396,7 +417,18 @@ var ACTION_SIGNAL_SCRIPT = `
396
417
  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"]');
397
418
  } catch(e) {}
398
419
 
399
- if (confidence === 'low') {
420
+ // Determine if primary selector is "fragile" \u2014 won't survive page reload
421
+ // because it comes from a dynamic attribute (e.g. data-spm-anchor-id).
422
+ var selectorIsFragile = false;
423
+ if (selector && confidence === 'high' && strategy === 'data-attr') {
424
+ // Fragile if the selector references a known dynamic attribute
425
+ selectorIsFragile = selector.indexOf('[data-spm-anchor-id') !== -1
426
+ || selector.indexOf('[data-reactid') !== -1
427
+ || selector.indexOf('[data-track') !== -1
428
+ || selector.indexOf('[data-log') !== -1;
429
+ }
430
+
431
+ if (confidence === 'low' || selectorIsFragile) {
400
432
  var rawText = (el.textContent || '').trim();
401
433
  if (rawText && rawText.length >= 1 && rawText.length <= 30 && el.children.length === 0) {
402
434
  try {
@@ -638,6 +670,10 @@ var ACTION_SIGNAL_SCRIPT = `
638
670
  '.popover','.popup','.dropdown','.menu','.modal','.tooltip','.panel',
639
671
  '[class*="popover"]','[class*="popup"]','[class*="dropdown"]','[class*="menu"]','[class*="tooltip"]',
640
672
  '[class*="modal"]','[class*="panel"]','[class*="overlay"]','[class*="sheet"]',
673
+ // Modern SPA conventions: many libraries expose the open menu via class
674
+ // fragments like "items-container", "options", "list", "select-popup".
675
+ '[class*="items-container"]','[class*="options"]','[class*="select-popup"]',
676
+ '[class*="select-items"]','[class*="menu-items"]','[class*="list-box"]',
641
677
  '[data-popup]','[data-dropdown]','[data-menu]','[data-popover]',
642
678
  '.semi-dropdown','.semi-popover','.semi-modal','.semi-select-option',
643
679
  '.ant-dropdown','.ant-popover','.ant-modal','.ant-select-dropdown',
@@ -679,6 +715,11 @@ var ACTION_SIGNAL_SCRIPT = `
679
715
  if (child.getAttribute('aria-disabled') === 'true') childInfo.disabled = true;
680
716
  if (child.tagName) childInfo.tag = child.tagName.toLowerCase();
681
717
  if (child.href) childInfo.href = child.href.substring(0, 80);
718
+ // Attach a selector so replay / AI can target this item precisely.
719
+ try {
720
+ var childSel = uniqueSelector(child);
721
+ if (childSel) childInfo.selector = childSel;
722
+ } catch(e) {}
682
723
  items.push(childInfo);
683
724
  }
684
725
  result.appeared.push({
@@ -740,6 +781,9 @@ var ACTION_SIGNAL_SCRIPT = `
740
781
 
741
782
  pushAction('click', { element: describe(resolveMeaningful(e)), x: cx, y: cy });
742
783
 
784
+ // Mark this element as user-interacted in discovered_filters (proactive sensing)
785
+ try { __xb_markTriggerInteracted(e.target); } catch(_) {}
786
+
743
787
  // After 200ms, capture what changed
744
788
  setTimeout(function() {
745
789
  try {
@@ -878,12 +922,102 @@ var ACTION_SIGNAL_SCRIPT = `
878
922
  }, true);
879
923
 
880
924
  // \u2500\u2500 Hover (throttled to 800ms) \u2500\u2500
925
+ // After a hover we asynchronously sample for popups/dropdowns that appear
926
+ // (200/500/1000ms) and run a short-lived MutationObserver. Any popup that
927
+ // surfaces is recorded on the hover action as hoverContext, mirroring the
928
+ // clickContext flow used after clicks.
881
929
  var __xb_last_hover = 0;
930
+ var __xb_hover_observer = null; // current hover's MutationObserver
931
+ var __xb_hover_observer_timer = null; // auto-disconnect timer
932
+ var __xb_hover_action_ref = null; // ref to the hover action being enriched
933
+ var __xb_hover_seen_popups = {}; // dedup popup selectors for current hover
934
+ var __xb_hover_cx = 0, __xb_hover_cy = 0;
935
+
936
+ function __xb_merge_hover_context(ctx) {
937
+ if (!__xb_hover_action_ref || !ctx) return;
938
+ if (ctx.appeared.length === 0 && ctx.stateChanges.length === 0) return;
939
+ if (!__xb_hover_action_ref.hoverContext) {
940
+ __xb_hover_action_ref.hoverContext = { appeared: [], disappeared: [], stateChanges: [] };
941
+ }
942
+ var hc = __xb_hover_action_ref.hoverContext;
943
+ ctx.appeared.forEach(function(p) {
944
+ var key = p.selector || p.text;
945
+ if (!key || __xb_hover_seen_popups[key]) return;
946
+ __xb_hover_seen_popups[key] = true;
947
+ hc.appeared.push(p);
948
+ });
949
+ ctx.stateChanges.forEach(function(s) {
950
+ hc.stateChanges.push(s);
951
+ });
952
+ }
953
+
954
+ function __xb_capture_hover_popups() {
955
+ if (!__xb_hover_action_ref) return;
956
+ try {
957
+ var ctx = captureVisibleContext(__xb_hover_cx, __xb_hover_cy);
958
+ __xb_merge_hover_context(ctx);
959
+ } catch(e) {}
960
+ }
961
+ // Expose for flush-time sweep (server-side flushPendingActions calls this
962
+ // before draining the pending queue so any in-flight hover enrichment lands).
963
+ window.__xb_capture_hover_popups = __xb_capture_hover_popups;
964
+
965
+ function __xb_start_hover_observer() {
966
+ if (!('MutationObserver' in window)) return;
967
+ if (__xb_hover_observer) {
968
+ __xb_hover_observer.disconnect();
969
+ __xb_hover_observer = null;
970
+ }
971
+ if (__xb_hover_observer_timer) {
972
+ clearTimeout(__xb_hover_observer_timer);
973
+ __xb_hover_observer_timer = null;
974
+ }
975
+ __xb_hover_observer = new MutationObserver(function(mutations) {
976
+ if (!__xb_hover_action_ref) return;
977
+ for (var i = 0; i < mutations.length; i++) {
978
+ var added = mutations[i].addedNodes;
979
+ for (var j = 0; j < added.length; j++) {
980
+ var node = added[j];
981
+ if (node.nodeType !== 1) continue;
982
+ var matches = false;
983
+ // Is the added node itself a popover?
984
+ for (var k = 0; k < POPOVER_SELECTORS.length; k++) {
985
+ try { if (node.matches && node.matches(POPOVER_SELECTORS[k])) { matches = true; break; } } catch(e) {}
986
+ }
987
+ // Or does it contain a popover?
988
+ if (!matches && node.querySelector) {
989
+ for (var k2 = 0; k2 < POPOVER_SELECTORS.length; k2++) {
990
+ try { if (node.querySelector(POPOVER_SELECTORS[k2])) { matches = true; break; } } catch(e) {}
991
+ }
992
+ }
993
+ if (matches) {
994
+ __xb_capture_hover_popups();
995
+ break;
996
+ }
997
+ }
998
+ }
999
+ });
1000
+ try {
1001
+ __xb_hover_observer.observe(document.body, { childList: true, subtree: true });
1002
+ } catch(e) {}
1003
+ // Auto-disconnect after 1.2s \u2014 hover popups rarely appear later than this.
1004
+ __xb_hover_observer_timer = setTimeout(function() {
1005
+ if (__xb_hover_observer) {
1006
+ __xb_hover_observer.disconnect();
1007
+ __xb_hover_observer = null;
1008
+ }
1009
+ __xb_hover_observer_timer = null;
1010
+ }, 1200);
1011
+ }
1012
+
882
1013
  document.addEventListener('mouseover', function(e) {
883
1014
  if (Date.now() - __xb_last_hover < 800) return;
884
- __xb_last_hover = Date.now();
885
1015
  var target = resolveMeaningful(e);
886
- // Only record hover on interactive elements
1016
+ // Only record hover on interactive elements. Modern SPAs often implement
1017
+ // clickable items as <span>/<div> with onClick handlers and no semantic
1018
+ // role, so we also accept elements whose computed style suggests they are
1019
+ // meant to be interacted with (pointer cursor, or a class name that looks
1020
+ // like a UI control).
887
1021
  var tag = target.tagName && target.tagName.toLowerCase();
888
1022
  var isInteractive = tag === 'a' || tag === 'button' || tag === 'input'
889
1023
  || tag === 'select' || tag === 'textarea' || tag === 'summary'
@@ -893,12 +1027,69 @@ var ACTION_SIGNAL_SCRIPT = `
893
1027
  || target.getAttribute('role') === 'tab'
894
1028
  || target.getAttribute('role') === 'option'
895
1029
  || !!target.closest('[role="menu"], [role="menubar"], [role="tablist"], [role="listbox"], [role="tree"], nav, menu');
1030
+ // Heuristic fallback for span/div/li that look interactive (onClick,
1031
+ // pointer cursor, or class names hinting at a UI control like
1032
+ // "select", "trigger", "tab", "sort", "menu", "item").
1033
+ if (!isInteractive && (tag === 'span' || tag === 'div' || tag === 'li')) {
1034
+ try {
1035
+ if (target.onclick) isInteractive = true;
1036
+ } catch(e2) {}
1037
+ if (!isInteractive) {
1038
+ try {
1039
+ var cs = window.getComputedStyle(target);
1040
+ if (cs.cursor === 'pointer') isInteractive = true;
1041
+ } catch(e3) {}
1042
+ }
1043
+ if (!isInteractive) {
1044
+ try {
1045
+ var cls = (target.className && typeof target.className === 'string')
1046
+ ? target.className.toLowerCase() : '';
1047
+ if (/(?:^|[-_])(select|trigger|tab|sort|menu|item|dropdown|popover|title|toggle|action|btn)(?:[-_]|$)/.test(cls)) {
1048
+ // But only if an ancestor looks like a toolbar/menu/sort bar \u2014
1049
+ // otherwise this would match almost every list item on the web.
1050
+ if (target.closest('[class*="select"], [class*="sort"], [class*="tab"], [class*="menu"], [class*="toolbar"], [class*="filter"], [class*="dropdown"]')) {
1051
+ isInteractive = true;
1052
+ }
1053
+ }
1054
+ } catch(e4) {}
1055
+ }
1056
+ }
896
1057
  if (isInteractive) {
1058
+ // Reset state for the new hover target
1059
+ if (__xb_hover_observer) {
1060
+ __xb_hover_observer.disconnect();
1061
+ __xb_hover_observer = null;
1062
+ }
1063
+ if (__xb_hover_observer_timer) {
1064
+ clearTimeout(__xb_hover_observer_timer);
1065
+ __xb_hover_observer_timer = null;
1066
+ }
1067
+ __xb_hover_seen_popups = {};
1068
+
1069
+ var cx = e.clientX, cy = e.clientY;
1070
+ __xb_hover_cx = cx;
1071
+ __xb_hover_cy = cy;
1072
+
897
1073
  pushAction('hover', {
898
1074
  element: describe(target),
899
- x: e.clientX,
900
- y: e.clientY,
1075
+ x: cx,
1076
+ y: cy,
901
1077
  });
1078
+ // Mark this element as user-interacted in discovered_filters
1079
+ try { __xb_markTriggerInteracted(target); } catch(_) {}
1080
+ __xb_last_hover = Date.now();
1081
+
1082
+ // Grab a reference to the just-pushed action so we can enrich it async.
1083
+ var pending = window.__xb_pending_actions;
1084
+ __xb_hover_action_ref = pending.length > 0 ? pending[pending.length - 1] : null;
1085
+
1086
+ // 1. Async sampling: 200/500/1000ms covers CSS transitions + JS render delays.
1087
+ setTimeout(__xb_capture_hover_popups, 200);
1088
+ setTimeout(__xb_capture_hover_popups, 500);
1089
+ setTimeout(__xb_capture_hover_popups, 1000);
1090
+
1091
+ // 2. Short-lived MutationObserver for popups that appear on their own schedule.
1092
+ __xb_start_hover_observer();
902
1093
  }
903
1094
  }, true);
904
1095
 
@@ -1007,6 +1198,511 @@ var ACTION_SIGNAL_SCRIPT = `
1007
1198
  visibility: { state: document.hidden ? 'hidden' : 'visible' },
1008
1199
  });
1009
1200
  }, true);
1201
+
1202
+ // \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
1203
+ // PROACTIVE SENSING \u2014 discover interactive regions without user action.
1204
+ //
1205
+ // Three layers:
1206
+ // 1. __xb_scanFilters() \u2014 baseline scan, called on start/navigation
1207
+ // 2. Long-lived MutationObserver \u2014 watches popover visible transitions
1208
+ // 3. mousemove throttled diff \u2014 senses triggers near the cursor
1209
+ //
1210
+ // Results:
1211
+ // - 'discovered_filters' action \u2192 merged into RecordingData.discoveredFilters
1212
+ // - 'popup_appear' action \u2192 added to actions stream (with cause tag)
1213
+ // \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
1214
+
1215
+ var FILTER_CONTAINER_SELECTORS = [
1216
+ '[role="tablist"]', '[role="menu"]', '[role="menubar"]', '[role="toolbar"]',
1217
+ '[role="navigation"]', '[role="listbox"]',
1218
+ '[class*="filter"]', '[class*="sort"]', '[class*="toolbar"]',
1219
+ '[class*="select-bar"]', '[class*="tabs"]', '[class*="nav-bar"]',
1220
+ '[class*="sidebar"]', '[class*="menu-bar"]', '[class*="select-container"]',
1221
+ 'fieldset',
1222
+ ];
1223
+
1224
+ var TRIGGER_SELECTORS = [
1225
+ '[role="tab"]', '[role="menuitem"]', '[role="menuitemcheckbox"]',
1226
+ '[role="menuitemradio"]', '[role="option"]', '[role="button"]',
1227
+ '[role="link"]', '[role="switch"]', '[role="checkbox"]',
1228
+ 'button', 'a', 'select',
1229
+ // Modern SPA span/div triggers (cursor:pointer is a strong signal)
1230
+ 'span[class*="title"]', 'div[class*="title"]',
1231
+ 'div[class*="trigger"]', 'span[class*="trigger"]',
1232
+ 'div[class*="item"]', 'li',
1233
+ ];
1234
+
1235
+ /** Categorize a container/trigger by class name + role heuristics. */
1236
+ function categorizeElement(el) {
1237
+ try {
1238
+ var cls = ((el.className && typeof el.className === 'string') ? el.className : '').toLowerCase();
1239
+ var role = (el.getAttribute && el.getAttribute('role')) || '';
1240
+ if (role === 'tab' || /(^|[-_])tab(s|list)?([-_]|$)/.test(cls)) return 'tab';
1241
+ if (role === 'menuitem' || role === 'menu' || /menu/.test(cls)) return 'menu';
1242
+ if (/sort/.test(cls)) return 'sort';
1243
+ if (/filter/.test(cls)) return 'filter';
1244
+ if (role === 'navigation' || /nav([-_]|$)/.test(cls)) return 'navigation';
1245
+ // Common compound patterns
1246
+ if (/search-filter|search-select|filter-up|filter-select|filter-checkbox/.test(cls)) return 'filter';
1247
+ if (/sort-bar|sort-container|sort-trigger/.test(cls)) return 'sort';
1248
+ if (/checkbox-group|radio-group/.test(cls)) return 'filter';
1249
+ if (/sidebar/.test(cls)) return 'navigation';
1250
+ } catch(e) {}
1251
+ return 'unknown';
1252
+ }
1253
+
1254
+ /** Build a DiscoveredTrigger from an element. */
1255
+ function buildTrigger(el) {
1256
+ var text = (el.textContent || '').trim();
1257
+ var trigger = {
1258
+ selector: uniqueSelector(el),
1259
+ text: text.substring(0, 40),
1260
+ role: el.getAttribute ? (el.getAttribute('role') || undefined) : undefined,
1261
+ category: categorizeElement(el),
1262
+ hasPopup: false,
1263
+ userInteracted: false,
1264
+ explored: false,
1265
+ };
1266
+ return trigger;
1267
+ }
1268
+
1269
+ /** All DiscoveredFilters found so far. Keyed by containerSelector for dedup. */
1270
+ var __xb_discovered_filters = {};
1271
+ /** True if a 'discovered_filters' push is pending (debounced). */
1272
+ var __xb_scan_scheduled = false;
1273
+ /** Track which DOM nodes we've already scanned (by identity, not selector). */
1274
+ var __xb_scanned_nodes = (typeof WeakSet !== 'undefined') ? new WeakSet() : null;
1275
+
1276
+ /** Scan the page for filter/sort/tab/menu containers + their triggers.
1277
+ * Dedup by DOM node identity (WeakSet) so the same element reached via
1278
+ * different selectors is recorded once. Nested containers are skipped \u2014
1279
+ * the outermost container wins. */
1280
+ function __xb_scanFilters() {
1281
+ try {
1282
+ // First pass: collect all candidate container nodes (dedup by identity)
1283
+ var candidates = [];
1284
+ var seenNodes = new Set(); // fallback if WeakSet unavailable
1285
+ for (var ci = 0; ci < FILTER_CONTAINER_SELECTORS.length; ci++) {
1286
+ var containers;
1287
+ try { containers = document.querySelectorAll(FILTER_CONTAINER_SELECTORS[ci]); } catch(e) { continue; }
1288
+ for (var cj = 0; cj < containers.length; cj++) {
1289
+ var c = containers[cj];
1290
+ // Dedup by identity
1291
+ if (__xb_scanned_nodes) {
1292
+ if (__xb_scanned_nodes.has(c)) continue;
1293
+ } else {
1294
+ if (seenNodes.has(c)) continue;
1295
+ seenNodes.add(c);
1296
+ }
1297
+ // Skip non-visible containers
1298
+ if (!c.offsetParent && c.tagName.toLowerCase() !== 'body') continue;
1299
+ // Skip if an ancestor is already a candidate (avoid nested dup)
1300
+ var isNested = false;
1301
+ for (var ck = 0; ck < candidates.length; ck++) {
1302
+ if (candidates[ck].contains(c)) { isNested = true; break; }
1303
+ }
1304
+ if (isNested) continue;
1305
+ // If this candidate contains existing ones, remove them
1306
+ candidates = candidates.filter(function(p) { return !c.contains(p); });
1307
+ candidates.push(c);
1308
+ }
1309
+ }
1310
+
1311
+ // Second pass: for each candidate, mark as scanned + extract triggers
1312
+ for (var ck2 = 0; ck2 < candidates.length; ck2++) {
1313
+ var c2 = candidates[ck2];
1314
+ if (__xb_scanned_nodes) __xb_scanned_nodes.add(c2);
1315
+
1316
+ var containerSel = uniqueSelector(c2);
1317
+ // If we already have this in discovered map (by selector), skip
1318
+ if (__xb_discovered_filters[containerSel]) continue;
1319
+
1320
+ // Extract triggers: prefer leaf-ish elements with short text
1321
+ var triggers = [];
1322
+ var seenTrigSels = {};
1323
+ // Use a single querySelectorAll with comma-joined selectors for speed
1324
+ var allCandidates = c2.querySelectorAll(TRIGGER_SELECTORS.join(', '));
1325
+ for (var ti = 0; ti < allCandidates.length; ti++) {
1326
+ var el = allCandidates[ti];
1327
+ if (!el.offsetParent) continue;
1328
+ // Skip elements whose nearest ancestor in the container is also a
1329
+ // candidate (would create nested triggers).
1330
+ var skip = false;
1331
+ var p = el.parentElement;
1332
+ while (p && p !== c2) {
1333
+ if (seenTrigSels[uniqueSelector(p)]) { skip = true; break; }
1334
+ p = p.parentElement;
1335
+ }
1336
+ if (skip) continue;
1337
+ var txt = (el.textContent || '').trim();
1338
+ if (!txt || txt.length === 0 || txt.length > 40) continue;
1339
+ // Skip if this looks like a full item card (long combined text)
1340
+ if (el.children.length > 3) continue;
1341
+ var sel = uniqueSelector(el);
1342
+ if (seenTrigSels[sel]) continue;
1343
+ seenTrigSels[sel] = true;
1344
+ triggers.push(buildTrigger(el));
1345
+ if (triggers.length >= 30) break;
1346
+ }
1347
+ if (triggers.length === 0) continue;
1348
+
1349
+ __xb_discovered_filters[containerSel] = {
1350
+ containerSelector: containerSel,
1351
+ category: categorizeElement(c2),
1352
+ containerText: (c2.textContent || '').trim().substring(0, 60),
1353
+ triggers: triggers,
1354
+ };
1355
+ }
1356
+ // Third pass: dedup containers whose trigger set is a subset of another.
1357
+ // This handles the case where two overlapping containers (e.g. outer
1358
+ // ".filter-up-container" and inner ".filter-select-container") share
1359
+ // triggers \u2014 keep the larger one, drop the redundant subset.
1360
+ var newFilters = [];
1361
+ var addedKeys = [];
1362
+ for (var key in __xb_discovered_filters) {
1363
+ var f = __xb_discovered_filters[key];
1364
+ var isSubset = false;
1365
+ for (var otherKey in __xb_discovered_filters) {
1366
+ if (otherKey === key) continue;
1367
+ var other = __xb_discovered_filters[otherKey];
1368
+ if (f.triggers.length > other.triggers.length) continue;
1369
+ // Check if every trigger in f is also in other (by selector)
1370
+ var allInOther = true;
1371
+ for (var ti2 = 0; ti2 < f.triggers.length; ti2++) {
1372
+ var found = false;
1373
+ for (var to2 = 0; to2 < other.triggers.length; to2++) {
1374
+ if (other.triggers[to2].selector === f.triggers[ti2].selector) { found = true; break; }
1375
+ }
1376
+ if (!found) { allInOther = false; break; }
1377
+ }
1378
+ if (allInOther && f.triggers.length < other.triggers.length) {
1379
+ isSubset = true;
1380
+ break;
1381
+ }
1382
+ // Same size + same triggers \u2014 keep the one with a more "container-y" selector
1383
+ if (allInOther && f.triggers.length === other.triggers.length && key > otherKey) {
1384
+ isSubset = true;
1385
+ break;
1386
+ }
1387
+ }
1388
+ if (!isSubset) {
1389
+ newFilters.push(f);
1390
+ addedKeys.push(key);
1391
+ }
1392
+ }
1393
+ // Rebuild the discovered map with deduped entries
1394
+ __xb_discovered_filters = {};
1395
+ for (var ni = 0; ni < newFilters.length; ni++) {
1396
+ __xb_discovered_filters[addedKeys[ni]] = newFilters[ni];
1397
+ }
1398
+
1399
+ // Push a discovered_filters action so the server picks it up.
1400
+ __xb_pushDiscovered();
1401
+ } catch(e) { /* scan failed, don't crash recorder */ }
1402
+ }
1403
+
1404
+ /** Push current discovered_filters snapshot to pending actions (debounced). */
1405
+ function __xb_pushDiscovered() {
1406
+ if (__xb_scan_scheduled) return;
1407
+ __xb_scan_scheduled = true;
1408
+ setTimeout(function() {
1409
+ __xb_scan_scheduled = false;
1410
+ var list = Object.keys(__xb_discovered_filters).map(function(k) {
1411
+ return __xb_discovered_filters[k];
1412
+ });
1413
+ window.__xb_pending_actions.push({
1414
+ type: 'discovered_filters',
1415
+ ts: Date.now(),
1416
+ url: location.href,
1417
+ title: document.title,
1418
+ discoveredFilters: list,
1419
+ });
1420
+ }, 200);
1421
+ }
1422
+
1423
+ /** Mark a trigger as user-interacted (called from click/hover handlers). */
1424
+ function __xb_markTriggerInteracted(target) {
1425
+ try {
1426
+ var sel = uniqueSelector(target);
1427
+ for (var k in __xb_discovered_filters) {
1428
+ var f = __xb_discovered_filters[k];
1429
+ for (var i = 0; i < f.triggers.length; i++) {
1430
+ if (f.triggers[i].selector === sel) {
1431
+ f.triggers[i].userInteracted = true;
1432
+ // Schedule a push so server gets the updated state
1433
+ __xb_pushDiscovered();
1434
+ return;
1435
+ }
1436
+ // Also accept ancestor match (target may be a child of the trigger)
1437
+ var triggerEl = document.querySelector(f.triggers[i].selector);
1438
+ if (triggerEl && triggerEl.contains(target)) {
1439
+ f.triggers[i].userInteracted = true;
1440
+ __xb_pushDiscovered();
1441
+ return;
1442
+ }
1443
+ }
1444
+ }
1445
+ } catch(e) {}
1446
+ }
1447
+
1448
+ /** Expose scan + capture for server-side triggers (post-navigation, flush-time). */
1449
+ window.__xb_scanFilters = __xb_scanFilters;
1450
+ window.__xb_capture_discovered_filters = function() {
1451
+ return Object.keys(__xb_discovered_filters).map(function(k) {
1452
+ return __xb_discovered_filters[k];
1453
+ });
1454
+ };
1455
+
1456
+ /** Find the nearest trigger element for a popup (looking up the DOM). */
1457
+ function findTriggerForPopup(popupEl) {
1458
+ try {
1459
+ // Walk up looking for known container types, then pick the trigger
1460
+ // nearest to the popup's top.
1461
+ var popupRect = popupEl.getBoundingClientRect();
1462
+ var ancestor = popupEl;
1463
+ for (var depth = 0; depth < 8 && ancestor; depth++) {
1464
+ ancestor = ancestor.parentElement;
1465
+ if (!ancestor) break;
1466
+ var triggersInAncestor = ancestor.querySelectorAll(
1467
+ '[role="tab"], [role="menuitem"], [role="option"], [role="button"], button, a, [class*="title"]'
1468
+ );
1469
+ // Pick the trigger whose x-center is closest to the popup's x-center
1470
+ // and is above the popup.
1471
+ var best = null, bestDist = Infinity;
1472
+ for (var i = 0; i < triggersInAncestor.length; i++) {
1473
+ var t = triggersInAncestor[i];
1474
+ if (!t.offsetParent) continue;
1475
+ var r = t.getBoundingClientRect();
1476
+ if (r.y + r.height > popupRect.y + 5) continue; // must be above popup
1477
+ var dist = Math.abs((r.x + r.width / 2) - (popupRect.x + popupRect.width / 2));
1478
+ if (dist < bestDist) {
1479
+ bestDist = dist;
1480
+ best = t;
1481
+ }
1482
+ }
1483
+ if (best) {
1484
+ // Mark the trigger as hasPopup in our discovered map
1485
+ var bestSel = uniqueSelector(best);
1486
+ for (var k in __xb_discovered_filters) {
1487
+ var f = __xb_discovered_filters[k];
1488
+ for (var j = 0; j < f.triggers.length; j++) {
1489
+ if (f.triggers[j].selector === bestSel) {
1490
+ f.triggers[j].hasPopup = true;
1491
+ }
1492
+ }
1493
+ }
1494
+ return {
1495
+ selector: bestSel,
1496
+ text: (best.textContent || '').trim().substring(0, 40),
1497
+ };
1498
+ }
1499
+ }
1500
+ } catch(e) {}
1501
+ return undefined;
1502
+ }
1503
+
1504
+ /** Extract popup items (reuses the same item-extraction logic as captureVisibleContext). */
1505
+ function extractPopupItems(popupEl) {
1506
+ var items = [];
1507
+ try {
1508
+ var children = popupEl.querySelectorAll(
1509
+ 'a,button,[role="menuitem"],[role="option"],[role="treeitem"],li,div[class*="item"],span[class*="item"]'
1510
+ );
1511
+ for (var k = 0; k < Math.min(children.length, 20); k++) {
1512
+ var child = children[k];
1513
+ var childText = (child.textContent || '').trim();
1514
+ if (!childText || childText.length > 60) continue;
1515
+ var info = { text: childText.substring(0, 60) };
1516
+ if (child.disabled) info.disabled = true;
1517
+ try { info.selector = uniqueSelector(child); } catch(e) {}
1518
+ items.push(info);
1519
+ }
1520
+ } catch(e) {}
1521
+ return items;
1522
+ }
1523
+
1524
+ /** Check whether an element is currently visible (has layout box). */
1525
+ function isVisibleEl(el) {
1526
+ try {
1527
+ var r = el.getBoundingClientRect();
1528
+ if (r.width === 0 || r.height === 0) return false;
1529
+ var cs = window.getComputedStyle(el);
1530
+ if (cs.display === 'none' || cs.visibility === 'hidden' || cs.opacity === '0') return false;
1531
+ return true;
1532
+ } catch(e) { return false; }
1533
+ }
1534
+
1535
+ /** Push a 'popup_appear' sensing action. */
1536
+ function __xb_pushPopupAppear(popupEl, cause) {
1537
+ try {
1538
+ if (!popupEl || !isVisibleEl(popupEl)) return;
1539
+ var r = popupEl.getBoundingClientRect();
1540
+ // Heuristic: a real popup/menu should be relatively small (not a full
1541
+ // page banner) and have menu-like children. Page banners / hero sections
1542
+ // often match [class*="popup"] / [class*="modal"] but aren't real menus.
1543
+ var MAX_POPUP_AREA = 600 * 600; // 360,000 px\xB2
1544
+ if (r.width * r.height > MAX_POPUP_AREA) return;
1545
+ // Must have at least 1 child that looks like an item (link/button/li/div with class*="item")
1546
+ var itemChildren = popupEl.querySelectorAll(
1547
+ 'a, button, [role="menuitem"], [role="option"], li, [class*="item"]'
1548
+ );
1549
+ if (itemChildren.length === 0) return;
1550
+ // Dedup against recent popup_appear actions (same selector within 3s)
1551
+ var popupSel = uniqueSelector(popupEl);
1552
+ var now = Date.now();
1553
+ var recent = window.__xb_pending_actions.filter(function(a) {
1554
+ return a.type === 'popup_appear' && (now - a.ts) < 3000;
1555
+ });
1556
+ for (var i = 0; i < recent.length; i++) {
1557
+ if (recent[i].popupAppear && recent[i].popupAppear.popup.selector === popupSel) return;
1558
+ }
1559
+
1560
+ var triggerInfo = findTriggerForPopup(popupEl);
1561
+ window.__xb_pending_actions.push({
1562
+ type: 'popup_appear',
1563
+ ts: now,
1564
+ url: location.href,
1565
+ title: document.title,
1566
+ popupAppear: {
1567
+ trigger: triggerInfo,
1568
+ popup: {
1569
+ selector: popupSel,
1570
+ text: (popupEl.textContent || '').trim().substring(0, 100),
1571
+ rect: {
1572
+ x: Math.round(r.x), y: Math.round(r.y),
1573
+ w: Math.round(r.width), h: Math.round(r.height),
1574
+ },
1575
+ items: extractPopupItems(popupEl),
1576
+ },
1577
+ cause: cause || 'auto',
1578
+ userTriggered: cause === 'user-hover' || cause === 'user-click',
1579
+ },
1580
+ });
1581
+ // Also schedule a discovered_filters push (hasPopup flags may have updated)
1582
+ __xb_pushDiscovered();
1583
+ } catch(e) {}
1584
+ }
1585
+
1586
+ // Expose for the hover handler to call when it observes a popup (avoid double-record)
1587
+ window.__xb_pushPopupAppear = __xb_pushPopupAppear;
1588
+
1589
+ // \u2500\u2500 Layer 2: long-lived MutationObserver for popover visible transitions \u2500\u2500
1590
+ var __xb_long_observer = null;
1591
+ var __xb_observer_debounce = null;
1592
+ // Set of selectors we've already recorded as popups (avoid re-pushing)
1593
+ var __xb_seen_popups = {};
1594
+
1595
+ function __xb_startLongObserver() {
1596
+ if (!('MutationObserver' in window)) return;
1597
+ if (__xb_long_observer) __xb_long_observer.disconnect();
1598
+
1599
+ __xb_long_observer = new MutationObserver(function(mutations) {
1600
+ // Debounce \u2014 collect then process once
1601
+ if (__xb_observer_debounce) clearTimeout(__xb_observer_debounce);
1602
+ __xb_observer_debounce = setTimeout(function() {
1603
+ __xb_observer_debounce = null;
1604
+ // Scan all known popover containers; any newly visible one \u2192 push
1605
+ for (var i = 0; i < POPOVER_SELECTORS.length; i++) {
1606
+ var els;
1607
+ try { els = document.querySelectorAll(POPOVER_SELECTORS[i]); } catch(e) { continue; }
1608
+ for (var j = 0; j < els.length; j++) {
1609
+ var el = els[j];
1610
+ var sel = uniqueSelector(el);
1611
+ if (__xb_seen_popups[sel]) continue;
1612
+ if (!isVisibleEl(el)) continue;
1613
+ __xb_seen_popups[sel] = Date.now();
1614
+ __xb_pushPopupAppear(el, 'auto');
1615
+ }
1616
+ }
1617
+ }, 100);
1618
+ });
1619
+ try {
1620
+ __xb_long_observer.observe(document.body, {
1621
+ childList: true,
1622
+ subtree: true,
1623
+ attributes: true,
1624
+ attributeFilter: ['class', 'style', 'hidden'],
1625
+ });
1626
+ } catch(e) {}
1627
+ }
1628
+
1629
+ // Auto-cleanup seen_popups every 10s so transient popups can re-trigger
1630
+ setInterval(function() {
1631
+ var now = Date.now();
1632
+ for (var k in __xb_seen_popups) {
1633
+ if (now - __xb_seen_popups[k] > 10000) delete __xb_seen_popups[k];
1634
+ }
1635
+ }, 10000);
1636
+
1637
+ // \u2500\u2500 Layer 3: mousemove throttled diff (senses triggers near cursor) \u2500\u2500
1638
+ var __xb_last_scan_move = 0;
1639
+ var __xb_move_stop_timer = null;
1640
+ var __xb_last_pos = { x: 0, y: 0 };
1641
+
1642
+ document.addEventListener('mousemove', function(e) {
1643
+ __xb_last_pos = { x: e.clientX, y: e.clientY };
1644
+ var now = Date.now();
1645
+ if (now - __xb_last_scan_move < 300) return;
1646
+ __xb_last_scan_move = now;
1647
+
1648
+ // Reset the "stop" timer \u2014 when mouse stops moving for 800ms, we scan
1649
+ if (__xb_move_stop_timer) clearTimeout(__xb_move_stop_timer);
1650
+ __xb_move_stop_timer = setTimeout(function() {
1651
+ __xb_move_stop_timer = null;
1652
+ __xb_scanNearCursor(__xb_last_pos.x, __xb_last_pos.y, 'user-hover');
1653
+ }, 800);
1654
+ }, true);
1655
+
1656
+ /** Scan popups near the cursor; mark nearby triggers as explored. */
1657
+ function __xb_scanNearCursor(cx, cy, cause) {
1658
+ try {
1659
+ // 1. Check for any visible popover near cursor
1660
+ for (var i = 0; i < POPOVER_SELECTORS.length; i++) {
1661
+ var els;
1662
+ try { els = document.querySelectorAll(POPOVER_SELECTORS[i]); } catch(e) { continue; }
1663
+ for (var j = 0; j < els.length; j++) {
1664
+ var el = els[j];
1665
+ if (!isVisibleEl(el)) continue;
1666
+ var r = el.getBoundingClientRect();
1667
+ // Near = within 400px of cursor
1668
+ if (Math.abs((r.x + r.width / 2) - cx) > 400) continue;
1669
+ if (Math.abs((r.y + r.height / 2) - cy) > 400) continue;
1670
+ var sel = uniqueSelector(el);
1671
+ var now = Date.now();
1672
+ if (__xb_seen_popups[sel] && now - __xb_seen_popups[sel] < 2000) continue;
1673
+ __xb_seen_popups[sel] = now;
1674
+ __xb_pushPopupAppear(el, cause);
1675
+ }
1676
+ }
1677
+
1678
+ // 2. Mark nearby triggers as explored (mouse lingered without click)
1679
+ var near = document.elementsFromPoint(cx, cy);
1680
+ for (var k = 0; k < near.length && k < 5; k++) {
1681
+ var t = near[k];
1682
+ var tSel = uniqueSelector(t);
1683
+ for (var fk in __xb_discovered_filters) {
1684
+ var f = __xb_discovered_filters[fk];
1685
+ for (var ti = 0; ti < f.triggers.length; ti++) {
1686
+ if (f.triggers[ti].selector === tSel) {
1687
+ f.triggers[ti].explored = true;
1688
+ }
1689
+ }
1690
+ }
1691
+ }
1692
+ __xb_pushDiscovered();
1693
+ } catch(e) {}
1694
+ }
1695
+
1696
+ // \u2500\u2500 Trigger initial baseline scan (after DOM is ready) \u2500\u2500
1697
+ if (document.readyState === 'complete' || document.readyState === 'interactive') {
1698
+ setTimeout(__xb_scanFilters, 500);
1699
+ setTimeout(__xb_startLongObserver, 800);
1700
+ } else {
1701
+ window.addEventListener('DOMContentLoaded', function() {
1702
+ setTimeout(__xb_scanFilters, 500);
1703
+ setTimeout(__xb_startLongObserver, 800);
1704
+ });
1705
+ }
1010
1706
  })();
1011
1707
  `;
1012
1708
  var CHECKPOINT_OVERLAY_SCRIPT = `
@@ -1254,6 +1950,9 @@ var SessionRecorder = class _SessionRecorder {
1254
1950
  network = [];
1255
1951
  contextChanges = [];
1256
1952
  checkpoints = [];
1953
+ /** Proactive baseline scan of filter/sort/tab/menu regions. Keyed by
1954
+ * containerSelector so re-scans after navigation replace rather than dup. */
1955
+ discoveredFilters = /* @__PURE__ */ new Map();
1257
1956
  actionCounter = 0;
1258
1957
  networkCounter = 0;
1259
1958
  contextCounter = 0;
@@ -1274,6 +1973,9 @@ var SessionRecorder = class _SessionRecorder {
1274
1973
  _lastRequestTs = 0;
1275
1974
  _isRecording = false;
1276
1975
  streamMode = "clean";
1976
+ /** Snapshot throttle: prevents too many screenshots during rapid inputs.
1977
+ * Keyed by 'type|elementSelector', value = last snapshot timestamp. */
1978
+ snapshotThrottle = /* @__PURE__ */ new Map();
1277
1979
  /** Ambient action types: filtered out in clean mode */
1278
1980
  static AMBIENT_TYPES = /* @__PURE__ */ new Set([
1279
1981
  "hover",
@@ -1323,6 +2025,8 @@ var SessionRecorder = class _SessionRecorder {
1323
2025
  type: action.type,
1324
2026
  element: actionToPush.element
1325
2027
  });
2028
+ await this.captureAutoSnapshot(this.page, actionToPush, "after").catch(() => {
2029
+ });
1326
2030
  this.lastActionTs = ts;
1327
2031
  this.dedupMap.set(dedupFromAction, Date.now() + 2e3);
1328
2032
  this.dedupActionCount++;
@@ -1369,6 +2073,10 @@ var SessionRecorder = class _SessionRecorder {
1369
2073
  get recordingsDir() {
1370
2074
  return _SessionRecorder.getRecordingsDir(this.sessionName);
1371
2075
  }
2076
+ /** Directory for auto-captured snapshots (PNG screenshots, stored in .tmp for easy cleanup). */
2077
+ get snapshotsDir() {
2078
+ return join(this.recordingsDir, ".tmp", "snapshots");
2079
+ }
1372
2080
  static getRecordingsDir(sessionName) {
1373
2081
  return join(homedir(), ".xbrowser", "sessions", sessionName, "recordings");
1374
2082
  }
@@ -1560,9 +2268,18 @@ var SessionRecorder = class _SessionRecorder {
1560
2268
  }
1561
2269
  }
1562
2270
  // ==================== Private ====================
2271
+ /** Whether the initial action-signal script injection succeeded. Check this
2272
+ * after start() returns — if false, user actions will NOT be recorded and
2273
+ * the caller should surface a clear error to the user instead of silently
2274
+ * pretending recording is working. */
2275
+ _injectionFailed = false;
2276
+ get injectionFailed() {
2277
+ return this._injectionFailed;
2278
+ }
1563
2279
  async injectActionScript(page) {
1564
2280
  let success = false;
1565
2281
  const maxAttempts = 3;
2282
+ let lastError = "";
1566
2283
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1567
2284
  try {
1568
2285
  await page.evaluate(getSelectorGeneratorScript());
@@ -1575,17 +2292,20 @@ var SessionRecorder = class _SessionRecorder {
1575
2292
  success = true;
1576
2293
  break;
1577
2294
  }
2295
+ lastError = "verification failed (script did not set __xb_action_signal flag)";
1578
2296
  console.error(`[recorder] Action signal injection verification failed (attempt ${attempt}/${maxAttempts})`);
1579
2297
  } catch (e) {
1580
- const msg = e instanceof Error ? e.message : String(e);
1581
- console.error(`[recorder] Action signal injection error (attempt ${attempt}/${maxAttempts}): ${msg}`);
2298
+ const err = e instanceof Error ? e : new Error(typeof e === "string" ? e : "unknown error");
2299
+ lastError = err.message;
2300
+ console.error(`[recorder] Action signal injection error (attempt ${attempt}/${maxAttempts}): ${err.message}`);
1582
2301
  }
1583
2302
  if (attempt < maxAttempts) {
1584
2303
  await new Promise((r) => setTimeout(r, 1e3));
1585
2304
  }
1586
2305
  }
1587
2306
  if (!success) {
1588
- console.error(`[recorder] WARNING: Action signal injection FAILED after ${maxAttempts} attempts. User actions will NOT be recorded. Check that the page supports Runtime.evaluate.`);
2307
+ this._injectionFailed = true;
2308
+ console.error(`[recorder] WARNING: Action signal injection FAILED after ${maxAttempts} attempts. User actions will NOT be recorded. Last error: ${lastError}`);
1589
2309
  }
1590
2310
  try {
1591
2311
  await page.evaluate(CHECKPOINT_OVERLAY_SCRIPT);
@@ -1593,6 +2313,16 @@ var SessionRecorder = class _SessionRecorder {
1593
2313
  const msg = e instanceof Error ? e.message : String(e);
1594
2314
  console.error(`[recorder] Checkpoint overlay injection failed (non-fatal): ${msg}`);
1595
2315
  }
2316
+ if (success) {
2317
+ try {
2318
+ await page.evaluate(`(function() {
2319
+ if (typeof window.__xb_scanFilters === 'function') {
2320
+ setTimeout(window.__xb_scanFilters, 100);
2321
+ }
2322
+ })()`);
2323
+ } catch {
2324
+ }
2325
+ }
1596
2326
  try {
1597
2327
  await page.evaluate(`
1598
2328
  (function() {
@@ -1744,9 +2474,9 @@ var SessionRecorder = class _SessionRecorder {
1744
2474
  return;
1745
2475
  }
1746
2476
  } catch (e) {
1747
- const msg = e instanceof Error ? e.message : String(e);
2477
+ const err = e instanceof Error ? e : new Error(typeof e === "string" ? e : "unknown error");
1748
2478
  if (attempt < 4) {
1749
- console.error(`[recorder] injectActionScript retry ${attempt + 1}/5 failed: ${msg}`);
2479
+ console.error(`[recorder] injectActionScript retry ${attempt + 1}/5 failed: ${err.message}`);
1750
2480
  }
1751
2481
  }
1752
2482
  await new Promise((r) => setTimeout(r, 1e3));
@@ -1768,8 +2498,8 @@ var SessionRecorder = class _SessionRecorder {
1768
2498
  try {
1769
2499
  await this.injectActionScript(page);
1770
2500
  } catch (e) {
1771
- const msg = e instanceof Error ? e.message : String(e);
1772
- console.error(`[recorder] injectActionScript after navigation failed: ${msg}`);
2501
+ const err = e instanceof Error ? e : new Error(typeof e === "string" ? e : "unknown error");
2502
+ console.error(`[recorder] injectActionScript after navigation failed: ${err.message}`);
1773
2503
  }
1774
2504
  }
1775
2505
  });
@@ -1890,6 +2620,14 @@ var SessionRecorder = class _SessionRecorder {
1890
2620
  try {
1891
2621
  pending = await page.evaluate(`(function() {
1892
2622
  var w = window;
2623
+ // Flush-time sweep: if there is a hover action still being enriched
2624
+ // by async samplers, force one last capture so its hoverContext lands
2625
+ // before the action is drained from the pending queue.
2626
+ try {
2627
+ if (typeof w.__xb_capture_hover_popups === 'function') {
2628
+ w.__xb_capture_hover_popups();
2629
+ }
2630
+ } catch(e) {}
1893
2631
  var actions = w.__xb_pending_actions || [];
1894
2632
  w.__xb_pending_actions = [];
1895
2633
 
@@ -1954,11 +2692,58 @@ var SessionRecorder = class _SessionRecorder {
1954
2692
  });
1955
2693
  }
1956
2694
  this.lastKnownUrl = currentUrl;
2695
+ try {
2696
+ page.evaluate(`(function() {
2697
+ if (typeof window.__xb_scanFilters === 'function') {
2698
+ setTimeout(window.__xb_scanFilters, 500);
2699
+ }
2700
+ })()`).catch(() => {
2701
+ });
2702
+ } catch {
2703
+ }
1957
2704
  }
1958
2705
  } catch {
1959
2706
  }
1960
2707
  for (const raw of pending) {
1961
2708
  if (raw.ts <= this.lastActionTs) continue;
2709
+ if (raw.type === "discovered_filters") {
2710
+ if (Array.isArray(raw.discoveredFilters)) {
2711
+ for (const f of raw.discoveredFilters) {
2712
+ if (f && f.containerSelector) {
2713
+ const existing = this.discoveredFilters.get(f.containerSelector);
2714
+ if (existing) {
2715
+ for (const newT of f.triggers) {
2716
+ const oldT = existing.triggers.find((t) => t.selector === newT.selector);
2717
+ if (oldT) {
2718
+ newT.userInteracted = newT.userInteracted || oldT.userInteracted;
2719
+ newT.explored = newT.explored || oldT.explored;
2720
+ newT.hasPopup = newT.hasPopup || oldT.hasPopup;
2721
+ }
2722
+ }
2723
+ }
2724
+ this.discoveredFilters.set(f.containerSelector, f);
2725
+ }
2726
+ }
2727
+ }
2728
+ continue;
2729
+ }
2730
+ if (raw.type === "popup_appear") {
2731
+ if (!raw.popupAppear?.userTriggered) continue;
2732
+ this.actionCounter++;
2733
+ const pushed2 = {
2734
+ id: this.actionCounter,
2735
+ type: "popup_appear",
2736
+ timestamp: raw.ts,
2737
+ url: raw.url || page.url(),
2738
+ pageTitle: raw.title || "",
2739
+ popupAppear: raw.popupAppear
2740
+ };
2741
+ this.actions.push(pushed2);
2742
+ await this.captureAutoSnapshot(page, pushed2, "after").catch(() => {
2743
+ });
2744
+ this.lastActionTs = raw.ts;
2745
+ continue;
2746
+ }
1962
2747
  const dedupFromSignal = this.dedupKey(raw.type, raw.element?.selector, raw.element?.tag, raw.value);
1963
2748
  if (dedupFromSignal) {
1964
2749
  const expires = this.dedupMap.get(dedupFromSignal);
@@ -1967,7 +2752,19 @@ var SessionRecorder = class _SessionRecorder {
1967
2752
  }
1968
2753
  }
1969
2754
  if (this.streamMode === "clean" && _SessionRecorder.AMBIENT_TYPES.has(raw.type)) {
1970
- continue;
2755
+ if (raw.type === "hover") {
2756
+ const hc = raw.hoverContext;
2757
+ if (!hc || hc.appeared.length === 0) continue;
2758
+ const hasSubstantialPopup = hc.appeared.some((p) => p.items && p.items.length > 0);
2759
+ if (!hasSubstantialPopup) continue;
2760
+ } else if (raw.type === "scroll") {
2761
+ const dx = raw.scrollX || 0;
2762
+ const dy = raw.scrollY || 0;
2763
+ const dist = Math.sqrt(dx * dx + dy * dy);
2764
+ if (dist < 50) continue;
2765
+ } else {
2766
+ continue;
2767
+ }
1971
2768
  }
1972
2769
  this.actionCounter++;
1973
2770
  let clickContext;
@@ -1988,10 +2785,17 @@ var SessionRecorder = class _SessionRecorder {
1988
2785
  scrollX: raw.scrollX,
1989
2786
  scrollY: raw.scrollY,
1990
2787
  clickContext,
2788
+ // Browser-side hover enrichment writes hoverContext onto the pending
2789
+ // action asynchronously (200/500/1000ms after mouseover). Flush may
2790
+ // run before the last sample lands — passthrough keeps whatever has
2791
+ // been collected so far.
2792
+ hoverContext: raw.hoverContext,
1991
2793
  files: raw.files
1992
2794
  });
1993
2795
  const pushed = this.actions[this.actions.length - 1];
1994
2796
  pushed.elementScreenshot = await this.captureElementScreenshot(page, raw);
2797
+ await this.captureAutoSnapshot(page, pushed, "after").catch(() => {
2798
+ });
1995
2799
  this.lastActionTs = raw.ts;
1996
2800
  const dedupKey = this.dedupKey(raw.type, raw.element?.selector, raw.element?.tag, raw.value);
1997
2801
  if (dedupKey) {
@@ -2020,21 +2824,120 @@ var SessionRecorder = class _SessionRecorder {
2020
2824
  * selector or the screenshot fails (non-critical — never blocks recording).
2021
2825
  *
2022
2826
  * Only captures for: click, input, change, dblclick, and their CDP counterparts.
2827
+ *
2828
+ * Retry strategy (3 attempts, 3s each):
2829
+ * 1. element screenshot via locator
2830
+ * 2. retry element screenshot (element may have been animating/detached)
2831
+ * 3. fallback to viewport screenshot (so we always get *something*)
2023
2832
  */
2024
2833
  async captureElementScreenshot(page, action) {
2025
2834
  const keyTypes = ["click", "input", "change", "dblclick", "cdp-click", "cdp-fill", "filechooser"];
2026
2835
  if (!keyTypes.includes(action.type)) return;
2027
2836
  if (!action.element?.selector) return;
2837
+ const selector = action.element.selector;
2838
+ try {
2839
+ const buffer = await page.locator(selector).first().screenshot({
2840
+ type: "png",
2841
+ timeout: 3e3
2842
+ });
2843
+ return buffer.toString("base64");
2844
+ } catch {
2845
+ }
2028
2846
  try {
2029
- const buffer = await page.locator(action.element.selector).first().screenshot({
2847
+ await page.waitForSelector(selector, { state: "visible", timeout: 2e3 });
2848
+ const buffer = await page.locator(selector).first().screenshot({
2030
2849
  type: "png",
2031
- timeout: 2e3
2850
+ timeout: 3e3
2032
2851
  });
2033
2852
  return buffer.toString("base64");
2853
+ } catch {
2854
+ }
2855
+ try {
2856
+ const buffer = await page.screenshot({ type: "png", timeout: 3e3 });
2857
+ return buffer.toString("base64");
2034
2858
  } catch {
2035
2859
  return;
2036
2860
  }
2037
2861
  }
2862
+ /**
2863
+ * Action types that trigger auto-snapshot capture.
2864
+ * Hover is excluded — it's too noisy and rarely changes the page visually.
2865
+ * Popup_appear captures the popup state (after only).
2866
+ */
2867
+ static SNAPSHOT_TYPES = /* @__PURE__ */ new Set([
2868
+ "click",
2869
+ "dblclick",
2870
+ "navigation",
2871
+ "goto",
2872
+ "submit",
2873
+ "cdp-click",
2874
+ "cdp-fill",
2875
+ "input",
2876
+ "change",
2877
+ "filechooser",
2878
+ "popup_appear"
2879
+ ]);
2880
+ /**
2881
+ * Throttle key for snapshot capture to avoid too many during rapid inputs.
2882
+ * Returns true if this action should be throttled (skip snapshot).
2883
+ */
2884
+ isSnapshotThrottled(type, selector) {
2885
+ if (type === "input" || type === "change" || type === "cdp-fill") {
2886
+ const key = `${type}|${selector || ""}`;
2887
+ const last = this.snapshotThrottle.get(key);
2888
+ const now = Date.now();
2889
+ if (last && now - last < 2e3) return true;
2890
+ this.snapshotThrottle.set(key, now);
2891
+ }
2892
+ return false;
2893
+ }
2894
+ /**
2895
+ * Capture a viewport screenshot + compact accessibility tree for a key action.
2896
+ * - PNG stored in .tmp/snapshots/ (temporary, easy to clean up)
2897
+ * - aria tree stored inline as compact text on the action (searchable via grep)
2898
+ *
2899
+ * Non-blocking (errors silently caught). Throttled for rapid inputs.
2900
+ */
2901
+ async captureAutoSnapshot(page, action, suffix = "after") {
2902
+ if (!_SessionRecorder.SNAPSHOT_TYPES.has(action.type)) return;
2903
+ if (this.isSnapshotThrottled(action.type, action.element?.selector)) return;
2904
+ const snapDir = this.snapshotsDir;
2905
+ mkdirSync(snapDir, { recursive: true });
2906
+ const stepId = String(action.id).padStart(3, "0");
2907
+ const snapshot = {};
2908
+ try {
2909
+ const pngPath = join(snapDir, `step-${stepId}-${suffix}.png`);
2910
+ const buffer = await page.screenshot({ type: "png", timeout: 5e3 });
2911
+ writeFileSync(pngPath, buffer);
2912
+ snapshot.png = `.tmp/snapshots/step-${stepId}-${suffix}.png`;
2913
+ } catch {
2914
+ }
2915
+ try {
2916
+ snapshot.aria = await page.evaluate(`
2917
+ (function() {
2918
+ try {
2919
+ var parts = [];
2920
+ var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_ELEMENT, null, false);
2921
+ var node;
2922
+ while (node = walker.nextNode()) {
2923
+ var tag = node.tagName.toLowerCase();
2924
+ var role = node.getAttribute('role') || '';
2925
+ var text = (node.textContent || '').trim().substring(0, 60);
2926
+ var ariaLabel = node.getAttribute('aria-label') || '';
2927
+ if (role || tag === 'button' || tag === 'a' || tag === 'input' || tag === 'select' || tag === 'textarea') {
2928
+ parts.push((role || tag) + ': ' + (ariaLabel || text).substring(0, 40));
2929
+ }
2930
+ }
2931
+ return parts.slice(0, 100).join(' | ');
2932
+ } catch(e) { return ''; }
2933
+ })()
2934
+ `);
2935
+ } catch {
2936
+ }
2937
+ if (snapshot.png || snapshot.aria) {
2938
+ action.snapshots = snapshot;
2939
+ }
2940
+ }
2038
2941
  /**
2039
2942
  * After a click, wait 300ms then scan for popover/dropdown/menu elements
2040
2943
  * near the click position. This runs server-side to avoid race conditions
@@ -2234,12 +3137,18 @@ var SessionRecorder = class _SessionRecorder {
2234
3137
  JSON.stringify(data, null, 2),
2235
3138
  "utf-8"
2236
3139
  );
3140
+ const jsonlPath = join(this.recordingsDir, "recording.jsonl");
3141
+ const lines = data.actions.map((a) => JSON.stringify(a)).join("\n") + "\n";
3142
+ writeFileSync(jsonlPath, lines, "utf-8");
2237
3143
  } catch {
2238
3144
  }
2239
3145
  }
2240
3146
  writeFinalOutput(data, summary) {
2241
3147
  mkdirSync(this.recordingsDir, { recursive: true });
2242
3148
  writeFileSync(join(this.recordingsDir, "recording.json"), JSON.stringify(data, null, 2), "utf-8");
3149
+ const jsonlPath = join(this.recordingsDir, "recording.jsonl");
3150
+ const lines = data.actions.map((a) => JSON.stringify(a)).join("\n") + "\n";
3151
+ writeFileSync(jsonlPath, lines, "utf-8");
2243
3152
  writeFileSync(join(this.recordingsDir, "summary.json"), JSON.stringify(summary, null, 2), "utf-8");
2244
3153
  writeFileSync(join(this.recordingsDir, "summary.md"), this.buildMarkdownSummary(data, summary), "utf-8");
2245
3154
  }
@@ -2251,7 +3160,8 @@ var SessionRecorder = class _SessionRecorder {
2251
3160
  actions: [...this.actions],
2252
3161
  network: [...this.network],
2253
3162
  contextChanges: [...this.contextChanges],
2254
- checkpoints: [...this.checkpoints]
3163
+ checkpoints: [...this.checkpoints],
3164
+ discoveredFilters: [...this.discoveredFilters.values()]
2255
3165
  };
2256
3166
  }
2257
3167
  // ─── Summary builder with ref compression + input→network matching ──