arise-browser 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +247 -0
  3. package/deploy/neko/CONTEXT.md +37 -0
  4. package/deploy/neko/arise-browser.service +13 -0
  5. package/deploy/neko/neko.yaml +12 -0
  6. package/deploy/neko/openbox.xml +763 -0
  7. package/deploy/neko/policies.json +28 -0
  8. package/deploy/neko/pulseaudio.pa +16 -0
  9. package/deploy/neko/setup.sh +308 -0
  10. package/deploy/neko/xorg.conf +118 -0
  11. package/dist/bin/arise-browser.d.ts +26 -0
  12. package/dist/bin/arise-browser.d.ts.map +1 -0
  13. package/dist/bin/arise-browser.js +224 -0
  14. package/dist/bin/arise-browser.js.map +1 -0
  15. package/dist/src/browser/action-executor.d.ts +98 -0
  16. package/dist/src/browser/action-executor.d.ts.map +1 -0
  17. package/dist/src/browser/action-executor.js +2726 -0
  18. package/dist/src/browser/action-executor.js.map +1 -0
  19. package/dist/src/browser/behavior-recorder.d.ts +61 -0
  20. package/dist/src/browser/behavior-recorder.d.ts.map +1 -0
  21. package/dist/src/browser/behavior-recorder.js +442 -0
  22. package/dist/src/browser/behavior-recorder.js.map +1 -0
  23. package/dist/src/browser/browser-session.d.ts +202 -0
  24. package/dist/src/browser/browser-session.d.ts.map +1 -0
  25. package/dist/src/browser/browser-session.js +1647 -0
  26. package/dist/src/browser/browser-session.js.map +1 -0
  27. package/dist/src/browser/config.d.ts +43 -0
  28. package/dist/src/browser/config.d.ts.map +1 -0
  29. package/dist/src/browser/config.js +59 -0
  30. package/dist/src/browser/config.js.map +1 -0
  31. package/dist/src/browser/page-snapshot.d.ts +38 -0
  32. package/dist/src/browser/page-snapshot.d.ts.map +1 -0
  33. package/dist/src/browser/page-snapshot.js +241 -0
  34. package/dist/src/browser/page-snapshot.js.map +1 -0
  35. package/dist/src/browser/scripts/behavior_tracker.js +424 -0
  36. package/dist/src/browser/scripts/unified_analyzer.js +1576 -0
  37. package/dist/src/index.d.ts +15 -0
  38. package/dist/src/index.d.ts.map +1 -0
  39. package/dist/src/index.js +15 -0
  40. package/dist/src/index.js.map +1 -0
  41. package/dist/src/lock.d.ts +11 -0
  42. package/dist/src/lock.d.ts.map +1 -0
  43. package/dist/src/lock.js +47 -0
  44. package/dist/src/lock.js.map +1 -0
  45. package/dist/src/logger.d.ts +17 -0
  46. package/dist/src/logger.d.ts.map +1 -0
  47. package/dist/src/logger.js +29 -0
  48. package/dist/src/logger.js.map +1 -0
  49. package/dist/src/server/middleware/auth.d.ts +6 -0
  50. package/dist/src/server/middleware/auth.d.ts.map +1 -0
  51. package/dist/src/server/middleware/auth.js +24 -0
  52. package/dist/src/server/middleware/auth.js.map +1 -0
  53. package/dist/src/server/route-utils.d.ts +15 -0
  54. package/dist/src/server/route-utils.d.ts.map +1 -0
  55. package/dist/src/server/route-utils.js +33 -0
  56. package/dist/src/server/route-utils.js.map +1 -0
  57. package/dist/src/server/routes/action.d.ts +5 -0
  58. package/dist/src/server/routes/action.d.ts.map +1 -0
  59. package/dist/src/server/routes/action.js +69 -0
  60. package/dist/src/server/routes/action.js.map +1 -0
  61. package/dist/src/server/routes/actions.d.ts +3 -0
  62. package/dist/src/server/routes/actions.d.ts.map +1 -0
  63. package/dist/src/server/routes/actions.js +53 -0
  64. package/dist/src/server/routes/actions.js.map +1 -0
  65. package/dist/src/server/routes/cookies.d.ts +3 -0
  66. package/dist/src/server/routes/cookies.d.ts.map +1 -0
  67. package/dist/src/server/routes/cookies.js +27 -0
  68. package/dist/src/server/routes/cookies.js.map +1 -0
  69. package/dist/src/server/routes/download.d.ts +3 -0
  70. package/dist/src/server/routes/download.d.ts.map +1 -0
  71. package/dist/src/server/routes/download.js +35 -0
  72. package/dist/src/server/routes/download.js.map +1 -0
  73. package/dist/src/server/routes/evaluate.d.ts +3 -0
  74. package/dist/src/server/routes/evaluate.d.ts.map +1 -0
  75. package/dist/src/server/routes/evaluate.js +27 -0
  76. package/dist/src/server/routes/evaluate.js.map +1 -0
  77. package/dist/src/server/routes/health.d.ts +3 -0
  78. package/dist/src/server/routes/health.d.ts.map +1 -0
  79. package/dist/src/server/routes/health.js +11 -0
  80. package/dist/src/server/routes/health.js.map +1 -0
  81. package/dist/src/server/routes/navigate.d.ts +3 -0
  82. package/dist/src/server/routes/navigate.d.ts.map +1 -0
  83. package/dist/src/server/routes/navigate.js +36 -0
  84. package/dist/src/server/routes/navigate.js.map +1 -0
  85. package/dist/src/server/routes/page-model.d.ts +3 -0
  86. package/dist/src/server/routes/page-model.d.ts.map +1 -0
  87. package/dist/src/server/routes/page-model.js +22 -0
  88. package/dist/src/server/routes/page-model.js.map +1 -0
  89. package/dist/src/server/routes/pdf.d.ts +3 -0
  90. package/dist/src/server/routes/pdf.d.ts.map +1 -0
  91. package/dist/src/server/routes/pdf.js +20 -0
  92. package/dist/src/server/routes/pdf.js.map +1 -0
  93. package/dist/src/server/routes/recording.d.ts +5 -0
  94. package/dist/src/server/routes/recording.d.ts.map +1 -0
  95. package/dist/src/server/routes/recording.js +217 -0
  96. package/dist/src/server/routes/recording.js.map +1 -0
  97. package/dist/src/server/routes/screenshot.d.ts +3 -0
  98. package/dist/src/server/routes/screenshot.d.ts.map +1 -0
  99. package/dist/src/server/routes/screenshot.js +32 -0
  100. package/dist/src/server/routes/screenshot.js.map +1 -0
  101. package/dist/src/server/routes/snapshot.d.ts +3 -0
  102. package/dist/src/server/routes/snapshot.d.ts.map +1 -0
  103. package/dist/src/server/routes/snapshot.js +454 -0
  104. package/dist/src/server/routes/snapshot.js.map +1 -0
  105. package/dist/src/server/routes/tab-lock.d.ts +3 -0
  106. package/dist/src/server/routes/tab-lock.d.ts.map +1 -0
  107. package/dist/src/server/routes/tab-lock.js +30 -0
  108. package/dist/src/server/routes/tab-lock.js.map +1 -0
  109. package/dist/src/server/routes/tab.d.ts +3 -0
  110. package/dist/src/server/routes/tab.d.ts.map +1 -0
  111. package/dist/src/server/routes/tab.js +47 -0
  112. package/dist/src/server/routes/tab.js.map +1 -0
  113. package/dist/src/server/routes/tabs.d.ts +3 -0
  114. package/dist/src/server/routes/tabs.d.ts.map +1 -0
  115. package/dist/src/server/routes/tabs.js +13 -0
  116. package/dist/src/server/routes/tabs.js.map +1 -0
  117. package/dist/src/server/routes/text.d.ts +3 -0
  118. package/dist/src/server/routes/text.d.ts.map +1 -0
  119. package/dist/src/server/routes/text.js +20 -0
  120. package/dist/src/server/routes/text.js.map +1 -0
  121. package/dist/src/server/routes/upload.d.ts +3 -0
  122. package/dist/src/server/routes/upload.d.ts.map +1 -0
  123. package/dist/src/server/routes/upload.js +38 -0
  124. package/dist/src/server/routes/upload.js.map +1 -0
  125. package/dist/src/server/server.d.ts +7 -0
  126. package/dist/src/server/server.d.ts.map +1 -0
  127. package/dist/src/server/server.js +69 -0
  128. package/dist/src/server/server.js.map +1 -0
  129. package/dist/src/types/index.d.ts +125 -0
  130. package/dist/src/types/index.d.ts.map +1 -0
  131. package/dist/src/types/index.js +5 -0
  132. package/dist/src/types/index.js.map +1 -0
  133. package/dist/src/virtual-display/manager.d.ts +37 -0
  134. package/dist/src/virtual-display/manager.d.ts.map +1 -0
  135. package/dist/src/virtual-display/manager.js +229 -0
  136. package/dist/src/virtual-display/manager.js.map +1 -0
  137. package/dist/src/virtual-display/process-runner.d.ts +43 -0
  138. package/dist/src/virtual-display/process-runner.d.ts.map +1 -0
  139. package/dist/src/virtual-display/process-runner.js +174 -0
  140. package/dist/src/virtual-display/process-runner.js.map +1 -0
  141. package/dist/tsconfig.tsbuildinfo +1 -0
  142. package/package.json +57 -0
  143. package/plugin/openclaw.plugin.json +148 -0
  144. package/skill/arise-browser/SKILL.md +275 -0
  145. package/skill/arise-browser/TRUST.md +42 -0
  146. package/skill/arise-browser/references/api.md +198 -0
  147. package/src/browser/scripts/behavior_tracker.js +424 -0
  148. package/src/browser/scripts/unified_analyzer.js +1576 -0
@@ -0,0 +1,1576 @@
1
+ ((viewport_limit = false) => {
2
+ // Unified analyzer that combines visual and structural analysis
3
+ // Preserves complete snapshot.js logic while adding visual coordinate information
4
+ // Ported from CAMEL-AI/Eigent project
5
+
6
+ // Memory management constants and configuration
7
+ const MAX_REFS = 20000; // Maximum number of refs to keep in memory
8
+ const MAX_UNUSED_AGE_MS = 90000; // Remove refs unused for more than xx seconds
9
+ const CLEANUP_THRESHOLD = 0.8; // Start aggressive cleanup when 80% of max refs reached
10
+
11
+ // Persistent ref management across page analysis calls with memory leak prevention
12
+ const _hadExistingState = !!window.__camelRefElementMap;
13
+ const _existingMapSize = window.__camelRefElementMap ? window.__camelRefElementMap.size : 0;
14
+ let refCounter = window.__camelRefCounter || 1;
15
+ let elementRefMap = window.__camelElementRefMap || new WeakMap();
16
+ let refElementMap = window.__camelRefElementMap || new Map();
17
+ let elementSignatureMap = window.__camelElementSignatureMap || new Map();
18
+
19
+ // LRU tracking for ref access times
20
+ let refAccessTimes = window.__camelRefAccessTimes || new Map();
21
+ let lastNavigationUrl = window.__camelLastNavigationUrl || window.location.href;
22
+ window.__camelLastNavigationUrl = lastNavigationUrl;
23
+
24
+ console.log(`[CAMEL-REF-DEBUG] Init: hadExistingState=${_hadExistingState}, existingMapSize=${_existingMapSize}, refCounter=${refCounter}, ariaRefsInDOM=${document.querySelectorAll('[aria-ref]').length}`);
25
+
26
+ function generateRef() {
27
+ const ref = `e${refCounter++}`;
28
+ // Persist counter globally
29
+ window.__camelRefCounter = refCounter;
30
+ return ref;
31
+ }
32
+
33
+ // Clear all refs and reset memory state
34
+ function clearAllRefs() {
35
+ try {
36
+ // Clear all DOM aria-ref attributes
37
+ document.querySelectorAll('[aria-ref]').forEach(element => {
38
+ element.removeAttribute('aria-ref');
39
+ });
40
+
41
+ // Clear all maps and reset counters
42
+ // Note: WeakMap doesn't have .clear(), so we create a new one
43
+ elementRefMap = new WeakMap();
44
+ refElementMap.clear();
45
+ elementSignatureMap.clear();
46
+ refAccessTimes.clear();
47
+
48
+ // Reset refCounter to 1 for fresh start
49
+ refCounter = 1;
50
+
51
+ // Reset global state
52
+ window.__camelElementRefMap = elementRefMap;
53
+ window.__camelRefElementMap = refElementMap;
54
+ window.__camelElementSignatureMap = elementSignatureMap;
55
+ window.__camelRefAccessTimes = refAccessTimes;
56
+
57
+ // Clear cached analysis results
58
+ delete window.__camelLastAnalysisResult;
59
+ delete window.__camelLastAnalysisTime;
60
+ delete window.__camelLastAnalysisViewportLimit;
61
+ } catch (error) {
62
+ console.warn('CAMEL: Error clearing refs:', error);
63
+ }
64
+ }
65
+
66
+ window.__camelClearAllRefs = clearAllRefs;
67
+ lastNavigationUrl = window.location.href;
68
+ window.__camelLastNavigationUrl = lastNavigationUrl;
69
+
70
+ // Initialize navigation event listeners for automatic cleanup
71
+ if (!window.__camelNavigationListenersInitialized) {
72
+ window.__camelNavigationListenersInitialized = true;
73
+
74
+ const clearAndSyncNavigationState = function() {
75
+ try {
76
+ if (typeof window.__camelClearAllRefs === 'function') {
77
+ window.__camelClearAllRefs();
78
+ }
79
+ } finally {
80
+ window.__camelLastNavigationUrl = window.location.href;
81
+ }
82
+ };
83
+
84
+ // Listen for page navigation events
85
+ window.addEventListener('beforeunload', clearAndSyncNavigationState);
86
+ window.addEventListener('pagehide', clearAndSyncNavigationState);
87
+
88
+ // Listen for pushState/replaceState navigation (SPA navigation)
89
+ const originalPushState = history.pushState;
90
+ const originalReplaceState = history.replaceState;
91
+
92
+ history.pushState = function(...args) {
93
+ const result = originalPushState.apply(this, args);
94
+ clearAndSyncNavigationState();
95
+ return result;
96
+ };
97
+
98
+ history.replaceState = function(...args) {
99
+ const result = originalReplaceState.apply(this, args);
100
+ clearAndSyncNavigationState();
101
+ return result;
102
+ };
103
+
104
+ // Listen for popstate (back/forward navigation)
105
+ window.addEventListener('popstate', clearAndSyncNavigationState);
106
+
107
+ // Check for URL changes periodically (fallback for other navigation types)
108
+ setInterval(() => {
109
+ const trackedUrl = window.__camelLastNavigationUrl || window.location.href;
110
+ if (window.location.href !== trackedUrl) {
111
+ clearAndSyncNavigationState();
112
+ }
113
+ }, 1000);
114
+ }
115
+
116
+ // LRU eviction: Remove least recently used refs when limit exceeded
117
+ function evictLRURefs() {
118
+ const refsToEvict = refAccessTimes.size - MAX_REFS + Math.floor(MAX_REFS * 0.1); // Remove 10% extra for breathing room
119
+ if (refsToEvict <= 0) return 0;
120
+
121
+ // Sort refs by access time (oldest first)
122
+ const sortedRefs = Array.from(refAccessTimes.entries())
123
+ .sort((a, b) => a[1] - b[1])
124
+ .slice(0, refsToEvict);
125
+
126
+ let evictedCount = 0;
127
+ for (const [ref, _] of sortedRefs) {
128
+ const element = refElementMap.get(ref);
129
+ if (element) {
130
+ // Remove aria-ref attribute from DOM
131
+ try {
132
+ element.removeAttribute('aria-ref');
133
+ } catch (e) {
134
+ // Element might be detached from DOM
135
+ }
136
+ elementRefMap.delete(element);
137
+
138
+ // Remove from signature map
139
+ const signature = generateElementSignature(element);
140
+ if (signature && elementSignatureMap.get(signature) === ref) {
141
+ elementSignatureMap.delete(signature);
142
+ }
143
+ }
144
+
145
+ refElementMap.delete(ref);
146
+ refAccessTimes.delete(ref);
147
+ evictedCount++;
148
+ }
149
+
150
+ // Persist updated maps
151
+ window.__camelElementRefMap = elementRefMap;
152
+ window.__camelRefElementMap = refElementMap;
153
+ window.__camelElementSignatureMap = elementSignatureMap;
154
+ window.__camelRefAccessTimes = refAccessTimes;
155
+
156
+ return evictedCount;
157
+ }
158
+
159
+ // Update ref access time for LRU tracking
160
+ function updateRefAccessTime(ref) {
161
+ refAccessTimes.set(ref, Date.now());
162
+ window.__camelRefAccessTimes = refAccessTimes;
163
+ }
164
+
165
+ // Generate a unique signature for an element based on its characteristics
166
+ function generateElementSignature(element) {
167
+ if (!element || !element.tagName) return null;
168
+
169
+ const tagName = element.tagName.toLowerCase();
170
+ const textContent = (element.textContent || '').trim().substring(0, 50);
171
+ const className = element.className || '';
172
+ const id = element.id || '';
173
+ const href = element.href || '';
174
+ const src = element.src || '';
175
+ const value = element.value || '';
176
+ const type = element.type || '';
177
+ const placeholder = element.placeholder || '';
178
+
179
+ // Include position in DOM tree for uniqueness
180
+ let pathElements = [];
181
+ let current = element;
182
+ let depth = 0;
183
+ while (current && current.parentElement && depth < 5) {
184
+ const siblings = Array.from(current.parentElement.children);
185
+ const index = siblings.indexOf(current);
186
+ pathElements.unshift(`${current.tagName.toLowerCase()}[${index}]`);
187
+ current = current.parentElement;
188
+ depth++;
189
+ }
190
+ const domPath = pathElements.join('>');
191
+
192
+ return `${tagName}|${textContent}|${className}|${id}|${href}|${src}|${value}|${type}|${placeholder}|${domPath}`;
193
+ }
194
+
195
+ // Get or assign a persistent ref for an element
196
+ // Debug counters for ref assignment analysis
197
+ let _refDebug = { weakmapHit: 0, ariaRefHit: 0, signatureHit: 0, newRef: 0, evicted: 0 };
198
+
199
+ function getOrAssignRef(element) {
200
+ // Check if element already has a ref assigned
201
+ if (elementRefMap.has(element)) {
202
+ const existingRef = elementRefMap.get(element);
203
+ // Verify the ref is still valid
204
+ if (refElementMap.get(existingRef) === element) {
205
+ updateRefAccessTime(existingRef);
206
+ _refDebug.weakmapHit++;
207
+ return existingRef;
208
+ }
209
+ }
210
+
211
+ // Check if element has aria-ref attribute (from previous analysis)
212
+ const existingAriaRef = element.getAttribute('aria-ref');
213
+ if (existingAriaRef && refElementMap.get(existingAriaRef) === element) {
214
+ // Re-establish mappings
215
+ elementRefMap.set(element, existingAriaRef);
216
+ updateRefAccessTime(existingAriaRef);
217
+ _refDebug.ariaRefHit++;
218
+ return existingAriaRef;
219
+ }
220
+
221
+ // Try to find element by signature (in case DOM was modified)
222
+ const signature = generateElementSignature(element);
223
+ if (signature && elementSignatureMap.has(signature)) {
224
+ const existingRef = elementSignatureMap.get(signature);
225
+ // Verify the old element is no longer in DOM or has changed
226
+ const oldElement = refElementMap.get(existingRef);
227
+ if (!oldElement || !document.contains(oldElement) || generateElementSignature(oldElement) !== signature) {
228
+ // Reassign the ref to the new element
229
+ elementRefMap.set(element, existingRef);
230
+ refElementMap.set(existingRef, element);
231
+ elementSignatureMap.set(signature, existingRef);
232
+ element.setAttribute('aria-ref', existingRef);
233
+ updateRefAccessTime(existingRef);
234
+ _refDebug.signatureHit++;
235
+ return existingRef;
236
+ }
237
+ }
238
+
239
+ // Check if we need to evict refs before creating new ones
240
+ if (refElementMap.size >= MAX_REFS) {
241
+ const evictedCount = evictLRURefs();
242
+ _refDebug.evicted += evictedCount;
243
+ }
244
+
245
+ // Generate new ref for new element
246
+ const newRef = generateRef();
247
+ elementRefMap.set(element, newRef);
248
+ refElementMap.set(newRef, element);
249
+ if (signature) {
250
+ elementSignatureMap.set(signature, newRef);
251
+ }
252
+ element.setAttribute('aria-ref', newRef);
253
+ updateRefAccessTime(newRef);
254
+ _refDebug.newRef++;
255
+ return newRef;
256
+ }
257
+
258
+ // Enhanced cleanup function with aggressive stale ref removal
259
+ function cleanupStaleRefs() {
260
+ const staleRefs = [];
261
+ const currentTime = Date.now();
262
+ const isAggressiveCleanup = refElementMap.size > (MAX_REFS * CLEANUP_THRESHOLD);
263
+
264
+ // Check all mapped elements to see if they're still in DOM or too old
265
+ for (const [ref, element] of refElementMap.entries()) {
266
+ let shouldRemove = false;
267
+
268
+ // Standard checks: element not in DOM
269
+ if (!element || !document.contains(element)) {
270
+ shouldRemove = true;
271
+ }
272
+ // Aggressive cleanup: remove refs unused for too long
273
+ else if (isAggressiveCleanup) {
274
+ const lastAccess = refAccessTimes.get(ref) || 0;
275
+ const age = currentTime - lastAccess;
276
+ if (age > MAX_UNUSED_AGE_MS) {
277
+ shouldRemove = true;
278
+ }
279
+ }
280
+ // Additional checks for aggressive cleanup
281
+ else if (isAggressiveCleanup) {
282
+ // Remove refs for elements that are hidden or have no meaningful content
283
+ try {
284
+ const style = window.getComputedStyle(element);
285
+ const hasNoVisibleContent = !element.textContent?.trim() &&
286
+ !element.value?.trim() &&
287
+ !element.src &&
288
+ !element.href;
289
+
290
+ if ((style.display === 'none' || style.visibility === 'hidden') && hasNoVisibleContent) {
291
+ shouldRemove = true;
292
+ }
293
+ } catch (e) {
294
+ // If we can't get computed style, element might be detached
295
+ shouldRemove = true;
296
+ }
297
+ }
298
+
299
+ if (shouldRemove) {
300
+ staleRefs.push(ref);
301
+ }
302
+ }
303
+
304
+ // Remove stale mappings
305
+ for (const ref of staleRefs) {
306
+ const element = refElementMap.get(ref);
307
+ if (element) {
308
+ // Remove aria-ref attribute from DOM
309
+ try {
310
+ element.removeAttribute('aria-ref');
311
+ } catch (e) {
312
+ // Element might be detached from DOM
313
+ }
314
+ elementRefMap.delete(element);
315
+
316
+ // Remove from signature map
317
+ const signature = generateElementSignature(element);
318
+ if (signature && elementSignatureMap.get(signature) === ref) {
319
+ elementSignatureMap.delete(signature);
320
+ }
321
+ }
322
+ refElementMap.delete(ref);
323
+ refAccessTimes.delete(ref);
324
+ }
325
+
326
+ // Persist maps globally
327
+ window.__camelElementRefMap = elementRefMap;
328
+ window.__camelRefElementMap = refElementMap;
329
+ window.__camelElementSignatureMap = elementSignatureMap;
330
+ window.__camelRefAccessTimes = refAccessTimes;
331
+
332
+ return staleRefs.length;
333
+ }
334
+
335
+ // === Complete snapshot.js logic preservation ===
336
+
337
+ function isNativeFormControl(element) {
338
+ if (!element || element.nodeType !== Node.ELEMENT_NODE || !element.tagName) return false;
339
+ const tagName = element.tagName.toLowerCase();
340
+ return tagName === 'select' || tagName === 'input' || tagName === 'textarea' || tagName === 'button';
341
+ }
342
+
343
+ // Keep transparent native controls that are still actionable (common in overlay-based UI widgets).
344
+ function isTransparentButActionableControl(element, style) {
345
+ if (!isNativeFormControl(element)) return false;
346
+ if (!style) return false;
347
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
348
+ if (element.hidden || element.getAttribute('aria-hidden') === 'true') return false;
349
+ if (element.hasAttribute('disabled') || element.disabled) return false;
350
+
351
+ const opacity = Number.parseFloat(style.opacity || '1');
352
+ if (!Number.isFinite(opacity) || opacity !== 0) return false;
353
+
354
+ const rect = element.getBoundingClientRect();
355
+ return rect.width > 0 && rect.height > 0;
356
+ }
357
+
358
+ function isVisible(node) {
359
+ // Check if node is null or not a valid DOM node
360
+ if (!node || typeof node.nodeType === 'undefined') return false;
361
+ if (node.nodeType !== Node.ELEMENT_NODE) return true;
362
+
363
+ try {
364
+ const style = window.getComputedStyle(node);
365
+ if (style.display === 'none' || style.visibility === 'hidden')
366
+ return false;
367
+ const transparentActionable = isTransparentButActionableControl(node, style);
368
+ const opacity = Number.parseFloat(style.opacity || '1');
369
+ if (Number.isFinite(opacity) && opacity === 0 && !transparentActionable) return false;
370
+ // An element with `display: contents` is not rendered itself, but its children are.
371
+ if (style.display === 'contents')
372
+ return true;
373
+ const rect = node.getBoundingClientRect();
374
+ return rect.width > 0 && rect.height > 0;
375
+ } catch (e) {
376
+ // If there's an error getting computed style or bounding rect, assume element is not visible
377
+ return false;
378
+ }
379
+ }
380
+
381
+ // Optimized occlusion detection with fewer test points
382
+ function isOccluded(element) {
383
+ if (!element || element.nodeType !== Node.ELEMENT_NODE) return false;
384
+
385
+ try {
386
+ const rect = element.getBoundingClientRect();
387
+ if (rect.width === 0 || rect.height === 0) return true;
388
+
389
+ // Simplified: Use fewer test points for better performance
390
+ const testPoints = [
391
+ // Center point (most important)
392
+ { x: rect.left + rect.width * 0.5, y: rect.top + rect.height * 0.5, weight: 4 },
393
+ // Only test 4 strategic points instead of 9
394
+ { x: rect.left + rect.width * 0.25, y: rect.top + rect.height * 0.25, weight: 1 },
395
+ { x: rect.left + rect.width * 0.75, y: rect.top + rect.height * 0.25, weight: 1 },
396
+ { x: rect.left + rect.width * 0.25, y: rect.top + rect.height * 0.75, weight: 1 },
397
+ { x: rect.left + rect.width * 0.75, y: rect.top + rect.height * 0.75, weight: 1 }
398
+ ];
399
+
400
+ let totalWeight = 0;
401
+ let visibleWeight = 0;
402
+
403
+ for (const point of testPoints) {
404
+ // Skip points outside viewport
405
+ if (point.x < 0 || point.y < 0 ||
406
+ point.x >= window.innerWidth || point.y >= window.innerHeight) {
407
+ continue;
408
+ }
409
+
410
+ const hitElement = document.elementFromPoint(point.x, point.y);
411
+ totalWeight += point.weight;
412
+
413
+ // Simplified visibility check
414
+ if (hitElement && (hitElement === element || element.contains(hitElement) || hitElement.contains(element))) {
415
+ visibleWeight += point.weight;
416
+ }
417
+ }
418
+
419
+ // If no valid test points, assume not occluded
420
+ if (totalWeight === 0) return false;
421
+
422
+ // Element is occluded if less than 40% of weighted points are visible
423
+ return (visibleWeight / totalWeight) < 0.4;
424
+
425
+ } catch (e) {
426
+ return false;
427
+ }
428
+ }
429
+
430
+ function getRole(node) {
431
+ // Check if node is null or doesn't have required properties
432
+ if (!node || !node.tagName || !node.getAttribute) {
433
+ return 'generic';
434
+ }
435
+
436
+ const role = node.getAttribute('role');
437
+ if (role) return role;
438
+
439
+ const tagName = node.tagName.toLowerCase();
440
+
441
+ // Extended role mapping to better match Playwright
442
+ if (tagName === 'a') return 'link';
443
+ if (tagName === 'button') return 'button';
444
+ if (tagName === 'input') {
445
+ const type = node.getAttribute('type')?.toLowerCase();
446
+ if (['button', 'checkbox', 'radio', 'reset', 'submit'].includes(type)) return type;
447
+ return 'textbox';
448
+ }
449
+ if (['select', 'textarea'].includes(tagName)) return tagName;
450
+
451
+ // contenteditable elements act as textboxes (e.g. div[contenteditable="plaintext-only"])
452
+ const ce = node.getAttribute('contenteditable');
453
+ if (ce && ce !== 'false') return 'textbox';
454
+
455
+ if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName)) return 'heading';
456
+
457
+ // Additional roles for better Playwright compatibility
458
+ if (tagName === 'img') return 'img';
459
+ if (tagName === 'main') return 'main';
460
+ if (tagName === 'nav') return 'navigation';
461
+ if (tagName === 'ul' || tagName === 'ol') return 'list';
462
+ if (tagName === 'li') return 'listitem';
463
+ if (tagName === 'em') return 'emphasis';
464
+ if (tagName === 'form' && node.getAttribute('role') === 'search') return 'search';
465
+ if (tagName === 'section' || tagName === 'article') return 'region';
466
+ if (tagName === 'aside') return 'complementary';
467
+ if (tagName === 'header') return 'banner';
468
+ if (tagName === 'footer') return 'contentinfo';
469
+ if (tagName === 'fieldset') return 'group';
470
+
471
+ // Enhanced role mappings for table elements
472
+ if (tagName === 'table') return 'table';
473
+ if (tagName === 'tr') return 'row';
474
+ if (tagName === 'td' || tagName === 'th') return 'cell';
475
+
476
+ return 'generic';
477
+ }
478
+
479
+ // Playwright-inspired function to check if element receives pointer events
480
+ function receivesPointerEvents(element) {
481
+ if (!element || !element.nodeType || element.nodeType !== Node.ELEMENT_NODE) return false;
482
+
483
+ try {
484
+ let e = element;
485
+ while (e) {
486
+ const style = window.getComputedStyle(e);
487
+ if (!style) break;
488
+
489
+ const pointerEvents = style.pointerEvents;
490
+ if (pointerEvents === 'none') return false;
491
+ if (pointerEvents && pointerEvents !== 'auto') return true;
492
+
493
+ e = e.parentElement;
494
+ }
495
+ return true;
496
+ } catch (error) {
497
+ return false;
498
+ }
499
+ }
500
+
501
+ // Playwright-inspired function to check if element has pointer cursor
502
+ function hasPointerCursor(element) {
503
+ if (!element || !element.nodeType || element.nodeType !== Node.ELEMENT_NODE) return false;
504
+
505
+ try {
506
+ const style = window.getComputedStyle(element);
507
+ return style.cursor === 'pointer';
508
+ } catch (error) {
509
+ return false;
510
+ }
511
+ }
512
+
513
+ // Playwright-inspired function to get aria level
514
+ function getAriaLevel(element) {
515
+ if (!element || !element.tagName) return 0;
516
+
517
+ // Native HTML heading levels (H1=1, H2=2, etc.)
518
+ const tagName = element.tagName.toUpperCase();
519
+ const nativeLevel = { 'H1': 1, 'H2': 2, 'H3': 3, 'H4': 4, 'H5': 5, 'H6': 6 }[tagName];
520
+ if (nativeLevel) return nativeLevel;
521
+
522
+ // Check aria-level attribute for roles that support it
523
+ const role = getRole(element);
524
+ const kAriaLevelRoles = ['heading', 'listitem', 'row', 'treeitem'];
525
+ if (kAriaLevelRoles.includes(role)) {
526
+ const ariaLevel = element.getAttribute('aria-level');
527
+ if (ariaLevel !== null) {
528
+ const value = Number(ariaLevel);
529
+ if (Number.isInteger(value) && value >= 1) {
530
+ return value;
531
+ }
532
+ }
533
+ }
534
+
535
+ return 0;
536
+ }
537
+
538
+ // Structural containers — should NOT get name from descendant text.
539
+ // Per WAI-ARIA spec, these only get names from aria-label/aria-labelledby.
540
+ // NOTE: 'generic' is NOT here — handled separately via post-traversal cleanup
541
+ // in buildAriaTree (clear name only for generics that have child ariaNodes).
542
+ const CONTAINER_ROLES = new Set([
543
+ 'group', 'list', 'table', 'row', 'rowgroup',
544
+ 'region', 'main', 'navigation', 'complementary', 'banner', 'contentinfo',
545
+ 'search', 'form', 'grid', 'treegrid', 'menu', 'menubar', 'toolbar',
546
+ 'tablist', 'tree', 'directory', 'document', 'application',
547
+ ]);
548
+
549
+ function getAccessibleName(node) {
550
+ // Check if node is null or doesn't have required methods
551
+ if (!node || !node.hasAttribute || !node.getAttribute) return '';
552
+
553
+ // 1. aria-label always wins
554
+ if (node.hasAttribute('aria-label')) return node.getAttribute('aria-label') || '';
555
+
556
+ // 2. aria-labelledby
557
+ if (node.hasAttribute('aria-labelledby')) {
558
+ const id = node.getAttribute('aria-labelledby');
559
+ const labelEl = document.getElementById(id);
560
+ if (labelEl) return labelEl.textContent || '';
561
+ }
562
+
563
+ // 3. For container roles, stop here — do NOT collect descendant text.
564
+ // This prevents <section> from getting a giant name that swallows all children.
565
+ const role = getRole(node);
566
+ if (CONTAINER_ROLES.has(role)) return '';
567
+
568
+ // 4. For input-like elements, use placeholder / data-placeholder
569
+ if (node.hasAttribute('placeholder')) return node.getAttribute('placeholder') || '';
570
+ if (node.hasAttribute('data-placeholder')) return node.getAttribute('data-placeholder') || '';
571
+
572
+ // 5. For leaf/interactive elements, use visible text content
573
+ const rawText = getVisibleTextContent(node);
574
+ const text = rawText.replace(/\s+/g, ' ').trim();
575
+
576
+ // Ignore code-like text
577
+ if ((text.match(/[;:{}]/g)?.length || 0) > 2) return '';
578
+
579
+ if (text) return text;
580
+
581
+ // 6. Last resort for interactive elements: infer name from CSS class or title/tooltip.
582
+ // Many icon buttons have no a11y label but their class reveals intent
583
+ // (e.g., "smart-search-btn-search" → "search", "btn-voice" → "voice").
584
+ if (INTERACTIVE_TAGS.has(node.tagName?.toLowerCase?.())) {
585
+ // title attribute
586
+ if (node.hasAttribute('title')) return node.getAttribute('title') || '';
587
+ // Infer from class name: extract the most descriptive segment
588
+ const inferredName = inferNameFromClass(node);
589
+ if (inferredName) return inferredName;
590
+ // Check child icon elements for class-based hints
591
+ const iconChild = node.querySelector('i[class], span[class], svg[class]');
592
+ if (iconChild) {
593
+ const iconName = inferNameFromClass(iconChild);
594
+ if (iconName) return iconName;
595
+ }
596
+ }
597
+
598
+ return '';
599
+ }
600
+
601
+ const INTERACTIVE_TAGS = new Set(['button', 'a', 'input', 'select', 'summary']);
602
+
603
+ /**
604
+ * Infer a human-readable name from an element's CSS class.
605
+ * Last resort for icon buttons/links with no accessible name.
606
+ * Strips common noise prefixes and joins remaining segments.
607
+ * May produce obfuscated names on minified sites — that's acceptable
608
+ * since there's no other information available.
609
+ */
610
+ function inferNameFromClass(el) {
611
+ const cls = el.className;
612
+ if (!cls || typeof cls !== 'string') return '';
613
+
614
+ const NOISE_WORDS = new Set([
615
+ 'btn', 'button', 'icon', 'icons', 'ico', 'img', 'svg',
616
+ 'fa', 'fas', 'far', 'fab', 'material', 'glyphicon',
617
+ 'bi', 'ri', 'feather', 'lucide', 'storyicon',
618
+ 'hide', 'show', 'active', 'disabled', 'container',
619
+ 'wrapper', 'inner', 'outer',
620
+ ]);
621
+
622
+ const segments = cls.split(/\s+/)
623
+ .flatMap(c => c.split(/[-_]+/))
624
+ .map(s => s.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase().trim())
625
+ .flatMap(s => s.split(/\s+/))
626
+ .filter(s => s.length > 1 && !NOISE_WORDS.has(s) && !/^\d+$/.test(s));
627
+
628
+ if (segments.length === 0) return '';
629
+
630
+ const seen = new Set();
631
+ const unique = segments.filter(s => { if (seen.has(s)) return false; seen.add(s); return true; });
632
+
633
+ return unique.slice(0, 3).join(' ') || '';
634
+ }
635
+
636
+ const STRONG_SEMANTIC_ROLES = new Set([
637
+ 'button', 'link', 'checkbox', 'radio', 'switch', 'tab', 'option',
638
+ 'textbox', 'searchbox', 'combobox', 'select', 'spinbutton', 'slider',
639
+ 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'treeitem',
640
+ 'dialog', 'listbox'
641
+ ]);
642
+
643
+ // Structural landmark roles — preserve even without a name.
644
+ // These provide page layout structure (header, nav, footer, sections)
645
+ // that prevents the tree from becoming completely flat.
646
+ // Note: 'region' and 'form' are excluded — they're too common without names
647
+ // (every <section> and <form>) and would add noise without adding structure.
648
+ const STRUCTURAL_ROLES = new Set([
649
+ 'banner', 'navigation', 'main', 'contentinfo', 'complementary',
650
+ 'search',
651
+ ]);
652
+
653
+ const ACTIONABLE_TAGS = new Set(['button', 'a', 'input', 'select', 'textarea', 'summary']);
654
+
655
+ function getNodeSemanticPriority(node) {
656
+ if (!node || typeof node === 'string') return 0;
657
+
658
+ const role = (node.role || '').toLowerCase();
659
+ const tagName = node.element?.tagName?.toLowerCase?.() || '';
660
+
661
+ if (STRONG_SEMANTIC_ROLES.has(role) || ACTIONABLE_TAGS.has(tagName)) {
662
+ return 3;
663
+ }
664
+
665
+ if (role && role !== 'generic') {
666
+ return 2;
667
+ }
668
+
669
+ return 1;
670
+ }
671
+
672
+ function shouldPreserveSameNameChild(parent, child) {
673
+ if (!parent || !child || typeof child === 'string') return false;
674
+
675
+ const parentName = (parent.name || '').trim();
676
+ const childName = (child.name || '').trim();
677
+ if (!parentName || !childName || parentName !== childName) {
678
+ return false;
679
+ }
680
+
681
+ const parentPriority = getNodeSemanticPriority(parent);
682
+ const childPriority = getNodeSemanticPriority(child);
683
+ if (childPriority > parentPriority) {
684
+ return true;
685
+ }
686
+
687
+ const parentRole = (parent.role || '').toLowerCase();
688
+ const childRole = (child.role || '').toLowerCase();
689
+ if (parentRole === 'generic' && STRONG_SEMANTIC_ROLES.has(childRole)) {
690
+ return true;
691
+ }
692
+
693
+ return false;
694
+ }
695
+
696
+ const textCache = new Map();
697
+ function getVisibleTextContent(_node) {
698
+ // Check if node is null or doesn't have nodeType
699
+ if (!_node || typeof _node.nodeType === 'undefined') return '';
700
+
701
+ if (textCache.has(_node)) return textCache.get(_node);
702
+
703
+ if (_node.nodeType === Node.TEXT_NODE) {
704
+ // For a text node, its content is visible if its parent is.
705
+ // The isVisible check on the parent happens before this recursion.
706
+ return _node.nodeValue || '';
707
+ }
708
+
709
+ if (_node.nodeType !== Node.ELEMENT_NODE || !isVisible(_node) || ['SCRIPT', 'STYLE', 'NOSCRIPT', 'META', 'HEAD'].includes(_node.tagName)) {
710
+ return '';
711
+ }
712
+
713
+ let result = '';
714
+ for (const child of _node.childNodes) {
715
+ result += getVisibleTextContent(child);
716
+ }
717
+
718
+ // Caching the result for performance.
719
+ textCache.set(_node, result);
720
+ return result;
721
+ }
722
+
723
+ /**
724
+ * Phase 1: Build an in-memory representation of the accessibility tree.
725
+ * Complete preservation of snapshot.js buildAriaTree logic
726
+ */
727
+ function buildAriaTree(rootElement) {
728
+ const visited = new Set();
729
+
730
+ function toAriaNode(element) {
731
+ // Check if element is null or not a valid DOM element
732
+ if (!element || !element.tagName) return null;
733
+
734
+ // Only consider visible elements
735
+ if (!isVisible(element)) return null;
736
+
737
+ const role = getRole(element);
738
+ // 'presentation' and 'none' roles are ignored, but their children are processed.
739
+ if (['presentation', 'none'].includes(role)) return null;
740
+
741
+ const name = getAccessibleName(element);
742
+
743
+ // Get persistent ref for this element
744
+ const ref = getOrAssignRef(element);
745
+
746
+ // Create the node
747
+ const node = {
748
+ role,
749
+ name,
750
+ children: [],
751
+ element: element,
752
+ ref: ref,
753
+ };
754
+
755
+ // Add states for interactive elements, similar to Playwright
756
+ if (element.hasAttribute('disabled') || element.disabled) node.disabled = true;
757
+
758
+ // NEW: Check if element is occluded and mark with occluded tag
759
+ if (isOccluded(element)) {
760
+ node.occluded = true; // Mark as occluded but don't disable
761
+ }
762
+
763
+ // Handle aria-checked and native checked
764
+ const ariaChecked = element.getAttribute('aria-checked');
765
+ if (ariaChecked) {
766
+ node.checked = ariaChecked;
767
+ } else if (element.type === 'checkbox' || element.type === 'radio') {
768
+ node.checked = element.checked;
769
+ }
770
+
771
+ // Handle aria-expanded
772
+ const ariaExpanded = element.getAttribute('aria-expanded');
773
+ if (ariaExpanded) {
774
+ node.expanded = ariaExpanded === 'true';
775
+ }
776
+
777
+ // Handle aria-selected
778
+ const ariaSelected = element.getAttribute('aria-selected');
779
+ if (ariaSelected === 'true') {
780
+ node.selected = true;
781
+ }
782
+
783
+ // Add level support following Playwright's implementation
784
+ const level = getAriaLevel(element);
785
+ if (level > 0) node.level = level;
786
+
787
+
788
+
789
+ return node;
790
+ }
791
+
792
+ function traverse(element, parentNode) {
793
+ // Check if element is null or not a valid DOM element
794
+ if (!element || !element.tagName || visited.has(element)) return;
795
+ visited.add(element);
796
+
797
+ // FIX: Completely skip script and style tags and their children.
798
+ const tagName = element.tagName.toLowerCase();
799
+ if (['script', 'style', 'meta', 'noscript'].includes(tagName))
800
+ return;
801
+
802
+ // Check if element is explicitly hidden by CSS - if so, skip entirely including children.
803
+ // Exception: preserve transparent but actionable native controls (e.g. hidden select under overlay UI).
804
+ const style = window.getComputedStyle(element);
805
+ if (style.display === 'none' || style.visibility === 'hidden') {
806
+ return;
807
+ }
808
+ const transparentActionable = isTransparentButActionableControl(element, style);
809
+ const opacity = Number.parseFloat(style.opacity || '1');
810
+ if (Number.isFinite(opacity) && opacity === 0 && !transparentActionable) {
811
+ return;
812
+ }
813
+
814
+ const ariaNode = toAriaNode(element);
815
+ // If the element is not rendered or is presentational, its children
816
+ // are attached directly to the parent.
817
+ const newParent = ariaNode || parentNode;
818
+ if (ariaNode) parentNode.children.push(ariaNode);
819
+
820
+ for (const child of element.childNodes) {
821
+ if (child.nodeType === Node.ELEMENT_NODE) {
822
+ traverse(child, newParent);
823
+ } else if (child.nodeType === Node.TEXT_NODE) {
824
+ const text = (child.textContent || '').trim();
825
+ if (text) newParent.children.push(text);
826
+ }
827
+ }
828
+
829
+ // Also traverse into shadow DOM if it exists
830
+ if (element.shadowRoot) {
831
+ for (const child of element.shadowRoot.childNodes) {
832
+ if (child.nodeType === Node.ELEMENT_NODE) {
833
+ traverse(child, newParent);
834
+ } else if (child.nodeType === Node.TEXT_NODE) {
835
+ const text = (child.textContent || '').trim();
836
+ if (text) newParent.children.push(text);
837
+ }
838
+ }
839
+ }
840
+
841
+ // Post-traversal: clear name for container-like nodes that have child ariaNodes.
842
+ // When a node has child ariaNodes, its name (from getVisibleTextContent) is just
843
+ // concatenated descendant text which swallows child content. Clear it so children
844
+ // can express themselves. Only text-only leaf nodes keep their computed name.
845
+ // IMPORTANT: This must run BEFORE text dedup, otherwise the inflated name causes
846
+ // text dedup to incorrectly remove useful text children.
847
+ const POST_CLEAR_ROLES = new Set(['generic', 'listitem', 'cell']);
848
+ if (ariaNode && ariaNode.name && POST_CLEAR_ROLES.has(ariaNode.role)) {
849
+ const hasChildNodes = ariaNode.children.some(c => typeof c !== 'string');
850
+ if (hasChildNodes) {
851
+ ariaNode.name = '';
852
+ }
853
+ }
854
+
855
+ // Remove redundant text children that match the element's name
856
+ if (ariaNode && ariaNode.children.length > 0) {
857
+ ariaNode.children = ariaNode.children.filter(child => {
858
+ if (typeof child === 'string') {
859
+ const childText = child.trim();
860
+ const parentName = ariaNode.name.trim();
861
+
862
+ // Remove if text child exactly matches parent name
863
+ if (childText === parentName) {
864
+ return false;
865
+ }
866
+
867
+ // Also remove if the child text is completely contained in parent name
868
+ // and represents a significant portion (to avoid removing important partial text)
869
+ if (childText.length > 3 && parentName.includes(childText)) {
870
+ return false;
871
+ }
872
+
873
+ return true;
874
+ }
875
+ return true;
876
+ });
877
+
878
+ // If after filtering, we have only one text child that equals the name, remove it
879
+ if (ariaNode.children.length === 1 && typeof ariaNode.children[0] === 'string' && ariaNode.name === ariaNode.children[0]) {
880
+ ariaNode.children = [];
881
+ }
882
+ }
883
+ }
884
+
885
+ const root = { role: 'Root', name: '', children: [], element: rootElement };
886
+ traverse(rootElement, root);
887
+ return root;
888
+ }
889
+
890
+ /**
891
+ * Phase 2: Normalize the tree by removing redundant generic wrappers.
892
+ * Complete preservation of snapshot.js normalizeTree logic with cursor inheritance
893
+ */
894
+ function normalizeTree(node) {
895
+ if (typeof node === 'string') return [node];
896
+
897
+ const newChildren = [];
898
+ for (const child of node.children) {
899
+ newChildren.push(...normalizeTree(child));
900
+ }
901
+ node.children = newChildren;
902
+
903
+ // Remove child elements that have the same name as their parent
904
+ // and inherit cursor=pointer property if child had it
905
+ const filteredChildren = [];
906
+ for (const child of node.children) {
907
+ if (typeof child !== 'string' && child.name && node.name) {
908
+ const childName = child.name.trim();
909
+ const parentName = node.name.trim();
910
+ if (childName === parentName && !shouldPreserveSameNameChild(node, child)) {
911
+ // If child has same name as parent, merge its children into parent
912
+ filteredChildren.push(...(child.children || []));
913
+
914
+ // Inherit cursor=pointer from merged child
915
+ if (child.element && receivesPointerEvents(child.element) && hasPointerCursor(child.element)) {
916
+ node.inheritedCursor = true;
917
+ }
918
+
919
+ // Inherit href from merged child (e.g., <div> wrapping <a> with same name)
920
+ const childHref = (child.element && child.element.href) || child.inheritedHref;
921
+ if (childHref && !(node.element && node.element.href)) {
922
+ node.inheritedHref = childHref;
923
+ }
924
+
925
+ // Also inherit other properties if needed
926
+ if (child.disabled && !node.disabled) node.disabled = child.disabled;
927
+ if (child.selected && !node.selected) node.selected = child.selected;
928
+ } else {
929
+ filteredChildren.push(child);
930
+ }
931
+ } else {
932
+ filteredChildren.push(child);
933
+ }
934
+ }
935
+ node.children = filteredChildren;
936
+
937
+ // Also handle the case where we have only one child with same name
938
+ if (node.children.length === 1 && typeof node.children[0] !== 'string') {
939
+ const child = node.children[0];
940
+ if (
941
+ child.name &&
942
+ node.name &&
943
+ child.name.trim() === node.name.trim() &&
944
+ !shouldPreserveSameNameChild(node, child)
945
+ ) {
946
+ // Inherit cursor=pointer from the child being merged
947
+ if (child.element && receivesPointerEvents(child.element) && hasPointerCursor(child.element)) {
948
+ node.inheritedCursor = true;
949
+ }
950
+
951
+ // Inherit href from merged child
952
+ const childHref = (child.element && child.element.href) || child.inheritedHref;
953
+ if (childHref && !(node.element && node.element.href)) {
954
+ node.inheritedHref = childHref;
955
+ }
956
+
957
+ // Also inherit other properties
958
+ if (child.disabled && !node.disabled) node.disabled = child.disabled;
959
+ if (child.selected && !node.selected) node.selected = child.selected;
960
+
961
+ // Merge child's children into parent and remove the redundant child
962
+ node.children = child.children || [];
963
+ }
964
+ }
965
+
966
+ // A 'generic' role that just wraps a single other element is redundant.
967
+ // We lift its child up to replace it, simplifying the hierarchy.
968
+ const isRedundantWrapper = node.role === 'generic' && node.children.length === 1 && typeof node.children[0] !== 'string';
969
+ if (isRedundantWrapper) {
970
+ const child = node.children[0];
971
+
972
+ // Inherit href from the discarded generic wrapper to the promoted child
973
+ const nodeHref = (node.element && node.element.href) || node.inheritedHref;
974
+ if (nodeHref && !(child.element && child.element.href) && !child.inheritedHref) {
975
+ child.inheritedHref = nodeHref;
976
+ }
977
+ return node.children;
978
+ }
979
+
980
+ return [node];
981
+ }
982
+
983
+ /**
984
+ * Check if a subtree is entirely noise — no actionable content for an LLM.
985
+ * A node is noise if it's a generic with no name and either no children
986
+ * or all children are also noise. This catches Material Design icon containers,
987
+ * decorative wrappers, and similar structures.
988
+ */
989
+ function isNoiseSubtree(node) {
990
+ const role = (node.role || '').toLowerCase();
991
+ // Non-generic roles or nodes with names/refs are not noise
992
+ if (role !== 'generic' || (node.name && node.name.trim())) return false;
993
+ // Interactive elements are never noise
994
+ if (STRONG_SEMANTIC_ROLES.has(role)) return false;
995
+ // String children (text) that have content are not noise
996
+ for (const child of node.children) {
997
+ if (typeof child === 'string') {
998
+ if (child.trim()) return false;
999
+ } else {
1000
+ if (!isNoiseSubtree(child)) return false;
1001
+ }
1002
+ }
1003
+ return true;
1004
+ }
1005
+
1006
+ /**
1007
+ * Phase 3: Render the normalized tree into the final string format.
1008
+ * Complete preservation of snapshot.js renderTree logic with Playwright enhancements
1009
+ */
1010
+ function renderTree(node, indent = '', refToHref = {}, ancestorName = '') {
1011
+ const lines = [];
1012
+ let meaningfulProps = '';
1013
+ if (node.disabled) meaningfulProps += ' [disabled]';
1014
+ if (node.occluded) meaningfulProps += ' [occluded]';
1015
+ if (node.checked !== undefined) meaningfulProps += ` checked=${node.checked}`;
1016
+ if (node.expanded !== undefined) meaningfulProps += ` expanded=${node.expanded}`;
1017
+ if (node.selected) meaningfulProps += ' [selected]';
1018
+
1019
+ // Add level attribute following Playwright's format
1020
+ if (node.level !== undefined) meaningfulProps += ` [level=${node.level}]`;
1021
+
1022
+ // Resolve href: direct DOM property > inheritedHref from tree normalization > refToHref from DOM scan
1023
+ const nodeHref = (node.element && node.element.href) || node.inheritedHref || (node.ref && refToHref[node.ref]) || null;
1024
+ const ref = node.ref ? ` [ref=${node.ref}]` : '';
1025
+ const hrefSuffix = nodeHref ? ` -> ${nodeHref}` : '';
1026
+
1027
+ const name = (node.name || '').replace(/\s+/g, ' ').trim();
1028
+
1029
+ const role = (node.role || '').toLowerCase();
1030
+
1031
+ // Skip generic leaf nodes whose name is already covered by an ancestor.
1032
+ // This catches redundant wrappers that normalizeTree missed due to intermediate layers.
1033
+ // e.g., button "1" > div(name='') > p "1" — the p is redundant.
1034
+ if (role === 'generic' && name && !meaningfulProps && ancestorName
1035
+ && ancestorName.includes(name) && node.children.length === 0) {
1036
+ return lines;
1037
+ }
1038
+
1039
+ // Skip occluded generics with no name — they are pure noise
1040
+ // (background decorations, hidden overlays, Material icon containers, etc.)
1041
+ // Also skip if all descendants are equally useless (occluded/empty generics).
1042
+ if (role === 'generic' && !name && node.occluded) {
1043
+ if (node.children.length === 0 || isNoiseSubtree(node)) {
1044
+ return lines;
1045
+ }
1046
+ }
1047
+
1048
+ // Skip single-character decorative generics (visual separators like |, •, /, ·)
1049
+ // These waste tokens and provide no actionable information to the LLM.
1050
+ if (role === 'generic' && name.length === 1 && !meaningfulProps && node.children.length === 0) {
1051
+ return lines;
1052
+ }
1053
+
1054
+ // Skip elements with empty names and no meaningful props,
1055
+ // but never skip interactive or structural elements.
1056
+ // Interactive elements must remain addressable by ref.
1057
+ // Structural roles (banner, navigation, etc.) provide page structure
1058
+ // even without a name — without them the tree becomes flat.
1059
+ if (!name && !meaningfulProps && !STRONG_SEMANTIC_ROLES.has(role)
1060
+ && !STRUCTURAL_ROLES.has(role)) {
1061
+ // Transparent node: render children at current level.
1062
+ // Pass ancestorName so text dedup works across transparent wrappers.
1063
+ for (const child of node.children) {
1064
+ if (typeof child === 'string') {
1065
+ const childText = child.replace(/\s+/g, ' ').trim();
1066
+ // Apply same text dedup against ancestor name
1067
+ if (childText && !(ancestorName && ancestorName.includes(childText))) {
1068
+ lines.push(`${indent}- text "${childText}"`);
1069
+ }
1070
+ } else {
1071
+ lines.push(...renderTree(child, indent, refToHref, ancestorName));
1072
+ }
1073
+ }
1074
+ return lines;
1075
+ }
1076
+
1077
+ lines.push(`${indent}- ${node.role}${name ? ` "${name}"` : ''}${meaningfulProps}${ref}${hrefSuffix}`);
1078
+
1079
+ for (const child of node.children) {
1080
+ if (typeof child === 'string') {
1081
+ const childText = child.replace(/\s+/g, ' ').trim();
1082
+ // Skip text children that are redundant with parent name
1083
+ if (childText && !(name && name.includes(childText))) {
1084
+ lines.push(`${indent} - text "${childText}"`);
1085
+ }
1086
+ } else {
1087
+ lines.push(...renderTree(child, indent + ' ', refToHref, name));
1088
+ }
1089
+ }
1090
+ return lines;
1091
+ }
1092
+
1093
+ function processDocument(doc) {
1094
+ if (!doc.body) return [];
1095
+
1096
+ // Clear cache for each new document processing.
1097
+ textCache.clear();
1098
+ let tree = buildAriaTree(doc.body);
1099
+ [tree] = normalizeTree(tree);
1100
+
1101
+ const lines = renderTree(tree).slice(1); // Skip the root node line
1102
+
1103
+ const frames = doc.querySelectorAll('iframe');
1104
+ for (const frame of frames) {
1105
+ try {
1106
+ if (frame.contentDocument) {
1107
+ lines.push(...processDocument(frame.contentDocument));
1108
+ }
1109
+ } catch (e) {
1110
+ // Skip cross-origin iframes
1111
+ }
1112
+ }
1113
+ return lines;
1114
+ }
1115
+
1116
+ // === Visual analysis functions from page_script.js ===
1117
+
1118
+ // Check if element is within the current viewport
1119
+ function isInViewport(element) {
1120
+ if (!element || element.nodeType !== Node.ELEMENT_NODE) return false;
1121
+
1122
+ try {
1123
+ const rect = element.getBoundingClientRect();
1124
+ return (
1125
+ rect.top >= 0 &&
1126
+ rect.left >= 0 &&
1127
+ rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) &&
1128
+ rect.right <= (window.innerWidth || document.documentElement.clientWidth)
1129
+ );
1130
+ } catch (e) {
1131
+ return false;
1132
+ }
1133
+ }
1134
+
1135
+ // From page_script.js - check if element is topmost at coordinates
1136
+ function isTopmost(element, x, y) {
1137
+ let hit = document.elementFromPoint(x, y);
1138
+ if (hit === null) return true;
1139
+
1140
+ while (hit) {
1141
+ if (hit == element) return true;
1142
+ hit = hit.parentNode;
1143
+ }
1144
+ return false;
1145
+ }
1146
+
1147
+ // From page_script.js - get visual coordinates
1148
+ function getElementCoordinates(element) {
1149
+ let rects = element.getClientRects();
1150
+ let scale = window.devicePixelRatio || 1;
1151
+ let validRects = [];
1152
+
1153
+ for (const rect of rects) {
1154
+ let x = rect.left + rect.width / 2;
1155
+ let y = rect.top + rect.height / 2;
1156
+ if (isTopmost(element, x, y)) {
1157
+ validRects.push({
1158
+ x: rect.x * scale,
1159
+ y: rect.y * scale,
1160
+ width: rect.width * scale,
1161
+ height: rect.height * scale,
1162
+ top: rect.top * scale,
1163
+ left: rect.left * scale,
1164
+ right: rect.right * scale,
1165
+ bottom: rect.bottom * scale
1166
+ });
1167
+ }
1168
+ }
1169
+
1170
+ return validRects;
1171
+ }
1172
+
1173
+ const MONTH_LABEL_RE = /\b(?:January|February|March|April|May|June|July|August|September|October|November|December)\s+\d{4}\b/i;
1174
+ const INTERACTIVE_CONTEXT_ROLES = new Set([
1175
+ 'link',
1176
+ 'button',
1177
+ 'textbox',
1178
+ 'checkbox',
1179
+ 'radio',
1180
+ 'combobox',
1181
+ 'select',
1182
+ 'menuitem',
1183
+ 'tab',
1184
+ 'switch',
1185
+ 'slider',
1186
+ 'spinbutton',
1187
+ 'searchbox',
1188
+ 'option',
1189
+ 'menuitemcheckbox',
1190
+ 'menuitemradio',
1191
+ 'treeitem'
1192
+ ]);
1193
+
1194
+ function normalizeInlineText(value, maxLength = 160) {
1195
+ if (typeof value !== 'string') return '';
1196
+ return value.replace(/\s+/g, ' ').trim().slice(0, maxLength);
1197
+ }
1198
+
1199
+ function getLabelledByText(element) {
1200
+ const labelledBy = element.getAttribute('aria-labelledby');
1201
+ if (!labelledBy) return '';
1202
+
1203
+ const labels = labelledBy
1204
+ .split(/\s+/)
1205
+ .map(id => document.getElementById(id))
1206
+ .filter(Boolean)
1207
+ .map(node => normalizeInlineText(node.innerText || node.textContent || ''))
1208
+ .filter(Boolean);
1209
+
1210
+ return normalizeInlineText(labels.join(' '));
1211
+ }
1212
+
1213
+ function getElementAriaLabel(element) {
1214
+ return normalizeInlineText(
1215
+ element.getAttribute('aria-label')
1216
+ || getLabelledByText(element)
1217
+ || ''
1218
+ );
1219
+ }
1220
+
1221
+ function getElementHeadingText(element) {
1222
+ const heading = element.querySelector('h1, h2, h3, h4, h5, h6, [role="heading"]');
1223
+ if (!heading) return '';
1224
+ return normalizeInlineText(heading.innerText || heading.textContent || '');
1225
+ }
1226
+
1227
+ function getElementContextLabel(element) {
1228
+ const ariaLabel = getElementAriaLabel(element);
1229
+ if (ariaLabel) return ariaLabel;
1230
+
1231
+ const headingText = getElementHeadingText(element);
1232
+ if (headingText) return headingText;
1233
+
1234
+ const legend = element.querySelector('legend');
1235
+ if (legend) {
1236
+ return normalizeInlineText(legend.innerText || legend.textContent || '');
1237
+ }
1238
+
1239
+ return '';
1240
+ }
1241
+
1242
+ function extractMonthLabel(text) {
1243
+ const normalized = normalizeInlineText(text);
1244
+ const match = normalized.match(MONTH_LABEL_RE);
1245
+ return match ? match[0] : '';
1246
+ }
1247
+
1248
+ function isDateLikeText(text) {
1249
+ const normalized = normalizeInlineText(text);
1250
+ if (!normalized) return false;
1251
+ return (
1252
+ /^\d{1,2}(?:\b|[^\d])/.test(normalized)
1253
+ || /\b(?:sun|mon|tue|wed|thu|fri|sat)\b/i.test(normalized)
1254
+ || /\b(?:depart|departure|return|arrive|arrival)\b/i.test(normalized)
1255
+ );
1256
+ }
1257
+
1258
+ function getSemanticElementContext(element, nodeName, role) {
1259
+ let current = element;
1260
+ let depth = 0;
1261
+ let dialogLabel = '';
1262
+ let monthLabel = '';
1263
+ let hasGridAncestor = false;
1264
+ let hasDialogAncestor = false;
1265
+ const contextTrail = [];
1266
+
1267
+ while (current && depth < 8) {
1268
+ const roleAttr = (current.getAttribute('role') || '').toLowerCase();
1269
+ if (!hasGridAncestor && ['grid', 'gridcell', 'table', 'rowgroup'].includes(roleAttr)) {
1270
+ hasGridAncestor = true;
1271
+ }
1272
+ if (!hasDialogAncestor && (roleAttr === 'dialog' || current.getAttribute('aria-modal') === 'true')) {
1273
+ hasDialogAncestor = true;
1274
+ }
1275
+
1276
+ const contextLabel = getElementContextLabel(current);
1277
+ if (contextLabel) {
1278
+ if (!monthLabel) {
1279
+ monthLabel = extractMonthLabel(contextLabel);
1280
+ }
1281
+ if (!dialogLabel && (roleAttr === 'dialog' || current.getAttribute('aria-modal') === 'true')) {
1282
+ dialogLabel = contextLabel;
1283
+ }
1284
+ if (contextTrail[contextTrail.length - 1] !== contextLabel) {
1285
+ contextTrail.push(contextLabel);
1286
+ }
1287
+ }
1288
+
1289
+ current = current.parentElement;
1290
+ depth++;
1291
+ }
1292
+
1293
+ const ariaLabel = getElementAriaLabel(element);
1294
+ if (!monthLabel) {
1295
+ monthLabel = extractMonthLabel(ariaLabel);
1296
+ }
1297
+
1298
+ const targetText = normalizeInlineText(
1299
+ nodeName
1300
+ || ariaLabel
1301
+ || element.innerText
1302
+ || element.textContent
1303
+ || ''
1304
+ );
1305
+ const navLike = /^(previous|next)\b/i.test(targetText) || /^(previous|next)\b/i.test(ariaLabel);
1306
+ const doneLike = /^done\b/i.test(targetText) || /^done\b/i.test(ariaLabel);
1307
+ const dayLike = isDateLikeText(targetText) || isDateLikeText(ariaLabel);
1308
+
1309
+ let widget = '';
1310
+ const normalizedRole = (role || '').toLowerCase();
1311
+ if (
1312
+ (monthLabel || hasGridAncestor || hasDialogAncestor)
1313
+ && (dayLike || navLike || doneLike || normalizedRole === 'gridcell')
1314
+ ) {
1315
+ widget = 'calendar';
1316
+ } else if (hasDialogAncestor) {
1317
+ widget = 'dialog';
1318
+ }
1319
+
1320
+ return {
1321
+ ariaLabel,
1322
+ dialogLabel,
1323
+ monthLabel,
1324
+ widget,
1325
+ contextTrail: contextTrail.slice(0, 4),
1326
+ };
1327
+ }
1328
+
1329
+ // === Unified analysis function ===
1330
+
1331
+ function collectElementsFromTree(node, elementsMap, viewportLimitEnabled = false) {
1332
+ if (typeof node === 'string') return;
1333
+
1334
+ if (node.element && node.ref) {
1335
+ // If viewport_limit is enabled, only include elements that are in the viewport
1336
+ if (viewportLimitEnabled && !isInViewport(node.element)) {
1337
+ // Skip this element but still process its children
1338
+ if (node.children) {
1339
+ for (const child of node.children) {
1340
+ collectElementsFromTree(child, elementsMap, viewportLimitEnabled);
1341
+ }
1342
+ }
1343
+ return;
1344
+ }
1345
+
1346
+ // Get visual coordinates for this element
1347
+ const coordinates = getElementCoordinates(node.element);
1348
+ const interactiveLike = INTERACTIVE_CONTEXT_ROLES.has((node.role || '').toLowerCase());
1349
+ const inViewport = isInViewport(node.element);
1350
+ const semanticContext = interactiveLike
1351
+ ? getSemanticElementContext(node.element, node.name, node.role)
1352
+ : {
1353
+ ariaLabel: getElementAriaLabel(node.element),
1354
+ dialogLabel: '',
1355
+ monthLabel: '',
1356
+ widget: '',
1357
+ contextTrail: []
1358
+ };
1359
+ const occluded = interactiveLike && inViewport ? isOccluded(node.element) : false;
1360
+
1361
+ // Store comprehensive element information
1362
+ elementsMap[node.ref] = {
1363
+ // Structural information (preserved from snapshot.js)
1364
+ role: node.role,
1365
+ name: node.name,
1366
+ tagName: node.element.tagName.toLowerCase(),
1367
+ disabled: node.disabled,
1368
+ checked: node.checked,
1369
+ expanded: node.expanded,
1370
+ selected: node.selected,
1371
+ level: node.level,
1372
+
1373
+ // Visual information (from page_script.js)
1374
+ coordinates: coordinates,
1375
+ inViewport: inViewport,
1376
+ occluded: occluded,
1377
+
1378
+ // Additional metadata (inheritedHref preserves href from merged child nodes)
1379
+ href: node.element.href || node.inheritedHref || null,
1380
+ value: node.element.value || null,
1381
+ placeholder: node.element.placeholder || null,
1382
+ scrollable: node.element.scrollHeight > node.element.clientHeight,
1383
+ ariaLabel: semanticContext.ariaLabel || null,
1384
+ dialogLabel: semanticContext.dialogLabel || null,
1385
+ monthLabel: semanticContext.monthLabel || null,
1386
+ widget: semanticContext.widget || null,
1387
+ contextTrail: semanticContext.contextTrail,
1388
+
1389
+ // Playwright-inspired properties
1390
+ receivesPointerEvents: receivesPointerEvents(node.element),
1391
+ hasPointerCursor: hasPointerCursor(node.element)
1392
+ };
1393
+ }
1394
+
1395
+ // Recursively process children
1396
+ if (node.children) {
1397
+ for (const child of node.children) {
1398
+ collectElementsFromTree(child, elementsMap, viewportLimitEnabled);
1399
+ }
1400
+ }
1401
+ }
1402
+
1403
+ function analyzePageElements() {
1404
+ // Clean up stale refs before analysis
1405
+ const cleanedRefCount = cleanupStaleRefs();
1406
+
1407
+ // Performance optimization: Check if we can reuse recent analysis
1408
+ const currentTime = Date.now();
1409
+ const lastAnalysisTime = window.__camelLastAnalysisTime || 0;
1410
+ const timeSinceLastAnalysis = currentTime - lastAnalysisTime;
1411
+ const lastAnalysisViewportLimit = window.__camelLastAnalysisViewportLimit === true;
1412
+
1413
+ // If less than 1 second since last analysis and page hasn't changed significantly
1414
+ if (
1415
+ timeSinceLastAnalysis < 1000
1416
+ && window.__camelLastAnalysisResult
1417
+ && cleanedRefCount === 0
1418
+ && lastAnalysisViewportLimit === viewport_limit
1419
+ ) {
1420
+ const cachedResult = window.__camelLastAnalysisResult;
1421
+ // Update timestamp and memory info in cached result
1422
+ cachedResult.metadata.timestamp = new Date().toISOString();
1423
+ cachedResult.metadata.memoryInfo = {
1424
+ currentRefCount: refElementMap.size,
1425
+ maxRefs: MAX_REFS,
1426
+ memoryUtilization: (refElementMap.size / MAX_REFS * 100).toFixed(1) + '%',
1427
+ lruAccessTimesCount: refAccessTimes.size
1428
+ };
1429
+ cachedResult.metadata.cacheHit = true;
1430
+ return cachedResult;
1431
+ }
1432
+
1433
+ // Build tree once and reuse for both snapshot text and element collection
1434
+ textCache.clear();
1435
+
1436
+ let tree = buildAriaTree(document.body);
1437
+
1438
+ [tree] = normalizeTree(tree);
1439
+
1440
+ // Build refToHref map: scan DOM for <a href> elements whose href
1441
+ // was lost during tree normalization. Maps the nearest ancestor's
1442
+ // aria-ref to the anchor's href, so renderTree can display it inline.
1443
+ // Collect all refs that survived normalization (still in the rendered tree)
1444
+ const survivingRefs = new Set();
1445
+ (function collectRefs(n) {
1446
+ if (typeof n === 'string') return;
1447
+ if (n.ref) survivingRefs.add(n.ref);
1448
+ if (n.children) n.children.forEach(collectRefs);
1449
+ })(tree);
1450
+
1451
+ const refToHref = {};
1452
+ document.querySelectorAll('a[href]').forEach(anchor => {
1453
+ const href = anchor.href;
1454
+ if (!href) return;
1455
+ // If this <a> has aria-ref AND it survived normalization, skip (already rendered)
1456
+ const ownRef = anchor.getAttribute('aria-ref');
1457
+ if (ownRef && survivingRefs.has(ownRef)) return;
1458
+ // Walk up DOM to find nearest ancestor with aria-ref that survived
1459
+ let el = anchor.parentElement;
1460
+ while (el) {
1461
+ const ref = el.getAttribute('aria-ref');
1462
+ if (ref && survivingRefs.has(ref) && !refToHref[ref]) {
1463
+ refToHref[ref] = href;
1464
+ break;
1465
+ }
1466
+ el = el.parentElement;
1467
+ }
1468
+ });
1469
+
1470
+ // Generate snapshot text from the tree
1471
+ const lines = renderTree(tree, '', refToHref).slice(1); // Skip the root node line
1472
+
1473
+ // Handle iframes
1474
+ const frames = document.querySelectorAll('iframe');
1475
+ for (const frame of frames) {
1476
+ try {
1477
+ if (frame.contentDocument) {
1478
+ const frameLines = processDocument(frame.contentDocument);
1479
+ lines.push(...frameLines);
1480
+ }
1481
+ } catch (e) {
1482
+ // Skip cross-origin iframes
1483
+ }
1484
+ }
1485
+ const snapshotText = lines.join('\n');
1486
+
1487
+ // Collect element information from the same tree (no second buildAriaTree call)
1488
+ const elementsMap = {};
1489
+ collectElementsFromTree(tree, elementsMap, viewport_limit);
1490
+
1491
+ // Backfill href from DOM using the same refToHref map built earlier.
1492
+ for (const [ref, href] of Object.entries(refToHref)) {
1493
+ if (elementsMap[ref] && !elementsMap[ref].href) {
1494
+ elementsMap[ref].href = href;
1495
+ }
1496
+ }
1497
+
1498
+ // Verify uniqueness of aria-ref attributes (debugging aid)
1499
+ const ariaRefCounts = {};
1500
+ document.querySelectorAll('[aria-ref]').forEach(element => {
1501
+ const ref = element.getAttribute('aria-ref');
1502
+ ariaRefCounts[ref] = (ariaRefCounts[ref] || 0) + 1;
1503
+ });
1504
+
1505
+ // Log any duplicates for debugging
1506
+ const duplicateRefs = Object.entries(ariaRefCounts).filter(([ref, count]) => count > 1);
1507
+ if (duplicateRefs.length > 0) {
1508
+ console.warn('Duplicate aria-ref attributes found:', duplicateRefs);
1509
+ }
1510
+
1511
+ // Validate ref consistency
1512
+ const refValidationErrors = [];
1513
+ for (const [ref, elementInfo] of Object.entries(elementsMap)) {
1514
+ const mappedElement = refElementMap.get(ref);
1515
+ if (!mappedElement || !document.contains(mappedElement)) {
1516
+ refValidationErrors.push(`Ref ${ref} points to invalid or removed element`);
1517
+ }
1518
+ }
1519
+
1520
+ const result = {
1521
+ url: window.location.href,
1522
+ elements: elementsMap,
1523
+ snapshotText: snapshotText,
1524
+ metadata: {
1525
+ timestamp: new Date().toISOString(),
1526
+ elementCount: Object.keys(elementsMap).length,
1527
+ screenInfo: {
1528
+ width: window.innerWidth,
1529
+ height: window.innerHeight,
1530
+ devicePixelRatio: window.devicePixelRatio || 1
1531
+ },
1532
+ // Enhanced debugging information
1533
+ ariaRefCounts: ariaRefCounts,
1534
+ duplicateRefsFound: duplicateRefs.length > 0,
1535
+ staleRefsCleanedUp: cleanedRefCount,
1536
+ refValidationErrors: refValidationErrors,
1537
+ totalMappedRefs: refElementMap.size,
1538
+ refCounterValue: refCounter,
1539
+ // Memory management information
1540
+ memoryInfo: {
1541
+ currentRefCount: refElementMap.size,
1542
+ maxRefs: MAX_REFS,
1543
+ memoryUtilization: (refElementMap.size / MAX_REFS * 100).toFixed(1) + '%',
1544
+ lruAccessTimesCount: refAccessTimes.size,
1545
+ unusedAgeThreshold: MAX_UNUSED_AGE_MS + 'ms',
1546
+ cleanupThreshold: (CLEANUP_THRESHOLD * 100).toFixed(0) + '%',
1547
+ isAggressiveCleanup: refElementMap.size > (MAX_REFS * CLEANUP_THRESHOLD)
1548
+ },
1549
+ // Performance information
1550
+ cacheHit: false,
1551
+ analysisTime: Date.now() - currentTime,
1552
+ viewportLimitEnabled: viewport_limit,
1553
+ refDebug: _refDebug
1554
+ }
1555
+ };
1556
+
1557
+ // Log final ref state after analysis
1558
+ const _allRefs = document.querySelectorAll('[aria-ref]');
1559
+ const _sampleRefs = [];
1560
+ _allRefs.forEach((e, i) => { if (i < 3 || i === _allRefs.length - 1) _sampleRefs.push(e.getAttribute('aria-ref')); });
1561
+ console.log(`[CAMEL-REF-DEBUG] Done: refCounter=${refCounter}, refsInDOM=${_allRefs.length}, mapSize=${refElementMap.size}, samples=[${_sampleRefs.join(',')}], debug=${JSON.stringify(_refDebug)}`);
1562
+
1563
+ // Cache the result for potential reuse
1564
+ window.__camelLastAnalysisResult = result;
1565
+ window.__camelLastAnalysisTime = currentTime;
1566
+ window.__camelLastAnalysisViewportLimit = viewport_limit;
1567
+ lastNavigationUrl = window.location.href;
1568
+ window.__camelLastNavigationUrl = lastNavigationUrl;
1569
+ window.__camelClearAllRefs = clearAllRefs;
1570
+
1571
+ return result;
1572
+ }
1573
+
1574
+ // Execute analysis and return result
1575
+ return analyzePageElements();
1576
+ })