camel-ai 0.2.71a7__py3-none-any.whl → 0.2.71a9__py3-none-any.whl

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.

Potentially problematic release.


This version of camel-ai might be problematic. Click here for more details.

@@ -2,9 +2,302 @@
2
2
  // Unified analyzer that combines visual and structural analysis
3
3
  // Preserves complete snapshot.js logic while adding visual coordinate information
4
4
 
5
- let refCounter = 1;
5
+ // Memory management constants and configuration
6
+ const MAX_REFS = 2000; // Maximum number of refs to keep in memory
7
+ const MAX_UNUSED_AGE_MS = 90000; // Remove refs unused for more than xx seconds
8
+ const CLEANUP_THRESHOLD = 0.8; // Start aggressive cleanup when 80% of max refs reached
9
+
10
+ // Persistent ref management across page analysis calls with memory leak prevention
11
+ let refCounter = window.__camelRefCounter || 1;
12
+ let elementRefMap = window.__camelElementRefMap || new WeakMap();
13
+ let refElementMap = window.__camelRefElementMap || new Map();
14
+ let elementSignatureMap = window.__camelElementSignatureMap || new Map();
15
+
16
+ // LRU tracking for ref access times
17
+ let refAccessTimes = window.__camelRefAccessTimes || new Map();
18
+ let lastNavigationUrl = window.__camelLastNavigationUrl || window.location.href;
19
+
20
+ // Initialize navigation event listeners for automatic cleanup
21
+ if (!window.__camelNavigationListenersInitialized) {
22
+ window.__camelNavigationListenersInitialized = true;
23
+
24
+ // Listen for page navigation events
25
+ window.addEventListener('beforeunload', clearAllRefs);
26
+ window.addEventListener('pagehide', clearAllRefs);
27
+
28
+ // Listen for pushState/replaceState navigation (SPA navigation)
29
+ const originalPushState = history.pushState;
30
+ const originalReplaceState = history.replaceState;
31
+
32
+ history.pushState = function(...args) {
33
+ clearAllRefs();
34
+ return originalPushState.apply(this, args);
35
+ };
36
+
37
+ history.replaceState = function(...args) {
38
+ clearAllRefs();
39
+ return originalReplaceState.apply(this, args);
40
+ };
41
+
42
+ // Listen for popstate (back/forward navigation)
43
+ window.addEventListener('popstate', clearAllRefs);
44
+
45
+ // Check for URL changes periodically (fallback for other navigation types)
46
+ setInterval(() => {
47
+ if (window.location.href !== lastNavigationUrl) {
48
+ clearAllRefs();
49
+ lastNavigationUrl = window.location.href;
50
+ window.__camelLastNavigationUrl = lastNavigationUrl;
51
+ }
52
+ }, 1000);
53
+ }
54
+
6
55
  function generateRef() {
7
- return `e${refCounter++}`;
56
+ const ref = `e${refCounter++}`;
57
+ // Persist counter globally
58
+ window.__camelRefCounter = refCounter;
59
+ return ref;
60
+ }
61
+
62
+ // Clear all refs and reset memory state
63
+ function clearAllRefs() {
64
+ try {
65
+ // Clear all DOM aria-ref attributes
66
+ document.querySelectorAll('[aria-ref]').forEach(element => {
67
+ element.removeAttribute('aria-ref');
68
+ });
69
+
70
+ // Clear all maps and reset counters
71
+ elementRefMap.clear();
72
+ refElementMap.clear();
73
+ elementSignatureMap.clear();
74
+ refAccessTimes.clear();
75
+
76
+ // Reset global state
77
+ window.__camelElementRefMap = elementRefMap;
78
+ window.__camelRefElementMap = refElementMap;
79
+ window.__camelElementSignatureMap = elementSignatureMap;
80
+ window.__camelRefAccessTimes = refAccessTimes;
81
+
82
+ // Clear cached analysis results
83
+ delete window.__camelLastAnalysisResult;
84
+ delete window.__camelLastAnalysisTime;
85
+
86
+ console.log('CAMEL: Cleared all refs due to navigation');
87
+ } catch (error) {
88
+ console.warn('CAMEL: Error clearing refs:', error);
89
+ }
90
+ }
91
+
92
+ // LRU eviction: Remove least recently used refs when limit exceeded
93
+ function evictLRURefs() {
94
+ const refsToEvict = refAccessTimes.size - MAX_REFS + Math.floor(MAX_REFS * 0.1); // Remove 10% extra for breathing room
95
+ if (refsToEvict <= 0) return 0;
96
+
97
+ // Sort refs by access time (oldest first)
98
+ const sortedRefs = Array.from(refAccessTimes.entries())
99
+ .sort((a, b) => a[1] - b[1])
100
+ .slice(0, refsToEvict);
101
+
102
+ let evictedCount = 0;
103
+ for (const [ref, _] of sortedRefs) {
104
+ const element = refElementMap.get(ref);
105
+ if (element) {
106
+ // Remove aria-ref attribute from DOM
107
+ try {
108
+ element.removeAttribute('aria-ref');
109
+ } catch (e) {
110
+ // Element might be detached from DOM
111
+ }
112
+ elementRefMap.delete(element);
113
+
114
+ // Remove from signature map
115
+ const signature = generateElementSignature(element);
116
+ if (signature && elementSignatureMap.get(signature) === ref) {
117
+ elementSignatureMap.delete(signature);
118
+ }
119
+ }
120
+
121
+ refElementMap.delete(ref);
122
+ refAccessTimes.delete(ref);
123
+ evictedCount++;
124
+ }
125
+
126
+ // Persist updated maps
127
+ window.__camelElementRefMap = elementRefMap;
128
+ window.__camelRefElementMap = refElementMap;
129
+ window.__camelElementSignatureMap = elementSignatureMap;
130
+ window.__camelRefAccessTimes = refAccessTimes;
131
+
132
+ return evictedCount;
133
+ }
134
+
135
+ // Update ref access time for LRU tracking
136
+ function updateRefAccessTime(ref) {
137
+ refAccessTimes.set(ref, Date.now());
138
+ window.__camelRefAccessTimes = refAccessTimes;
139
+ }
140
+
141
+ // Generate a unique signature for an element based on its characteristics
142
+ function generateElementSignature(element) {
143
+ if (!element || !element.tagName) return null;
144
+
145
+ const tagName = element.tagName.toLowerCase();
146
+ const textContent = (element.textContent || '').trim().substring(0, 50);
147
+ const className = element.className || '';
148
+ const id = element.id || '';
149
+ const href = element.href || '';
150
+ const src = element.src || '';
151
+ const value = element.value || '';
152
+ const type = element.type || '';
153
+ const placeholder = element.placeholder || '';
154
+
155
+ // Include position in DOM tree for uniqueness
156
+ let pathElements = [];
157
+ let current = element;
158
+ let depth = 0;
159
+ while (current && current.parentElement && depth < 5) {
160
+ const siblings = Array.from(current.parentElement.children);
161
+ const index = siblings.indexOf(current);
162
+ pathElements.unshift(`${current.tagName.toLowerCase()}[${index}]`);
163
+ current = current.parentElement;
164
+ depth++;
165
+ }
166
+ const domPath = pathElements.join('>');
167
+
168
+ return `${tagName}|${textContent}|${className}|${id}|${href}|${src}|${value}|${type}|${placeholder}|${domPath}`;
169
+ }
170
+
171
+ // Get or assign a persistent ref for an element
172
+ function getOrAssignRef(element) {
173
+ // Check if element already has a ref assigned
174
+ if (elementRefMap.has(element)) {
175
+ const existingRef = elementRefMap.get(element);
176
+ // Verify the ref is still valid
177
+ if (refElementMap.get(existingRef) === element) {
178
+ updateRefAccessTime(existingRef);
179
+ return existingRef;
180
+ }
181
+ }
182
+
183
+ // Check if element has aria-ref attribute (from previous analysis)
184
+ const existingAriaRef = element.getAttribute('aria-ref');
185
+ if (existingAriaRef && refElementMap.get(existingAriaRef) === element) {
186
+ // Re-establish mappings
187
+ elementRefMap.set(element, existingAriaRef);
188
+ updateRefAccessTime(existingAriaRef);
189
+ return existingAriaRef;
190
+ }
191
+
192
+ // Try to find element by signature (in case DOM was modified)
193
+ const signature = generateElementSignature(element);
194
+ if (signature && elementSignatureMap.has(signature)) {
195
+ const existingRef = elementSignatureMap.get(signature);
196
+ // Verify the old element is no longer in DOM or has changed
197
+ const oldElement = refElementMap.get(existingRef);
198
+ if (!oldElement || !document.contains(oldElement) || generateElementSignature(oldElement) !== signature) {
199
+ // Reassign the ref to the new element
200
+ elementRefMap.set(element, existingRef);
201
+ refElementMap.set(existingRef, element);
202
+ elementSignatureMap.set(signature, existingRef);
203
+ element.setAttribute('aria-ref', existingRef);
204
+ updateRefAccessTime(existingRef);
205
+ return existingRef;
206
+ }
207
+ }
208
+
209
+ // Check if we need to evict refs before creating new ones
210
+ if (refElementMap.size >= MAX_REFS) {
211
+ evictLRURefs();
212
+ }
213
+
214
+ // Generate new ref for new element
215
+ const newRef = generateRef();
216
+ elementRefMap.set(element, newRef);
217
+ refElementMap.set(newRef, element);
218
+ if (signature) {
219
+ elementSignatureMap.set(signature, newRef);
220
+ }
221
+ element.setAttribute('aria-ref', newRef);
222
+ updateRefAccessTime(newRef);
223
+ return newRef;
224
+ }
225
+
226
+ // Enhanced cleanup function with aggressive stale ref removal
227
+ function cleanupStaleRefs() {
228
+ const staleRefs = [];
229
+ const currentTime = Date.now();
230
+ const isAggressiveCleanup = refElementMap.size > (MAX_REFS * CLEANUP_THRESHOLD);
231
+
232
+ // Check all mapped elements to see if they're still in DOM or too old
233
+ for (const [ref, element] of refElementMap.entries()) {
234
+ let shouldRemove = false;
235
+
236
+ // Standard checks: element not in DOM
237
+ if (!element || !document.contains(element)) {
238
+ shouldRemove = true;
239
+ }
240
+ // Aggressive cleanup: remove refs unused for too long
241
+ else if (isAggressiveCleanup) {
242
+ const lastAccess = refAccessTimes.get(ref) || 0;
243
+ const age = currentTime - lastAccess;
244
+ if (age > MAX_UNUSED_AGE_MS) {
245
+ shouldRemove = true;
246
+ }
247
+ }
248
+ // Additional checks for aggressive cleanup
249
+ else if (isAggressiveCleanup) {
250
+ // Remove refs for elements that are hidden or have no meaningful content
251
+ try {
252
+ const style = window.getComputedStyle(element);
253
+ const hasNoVisibleContent = !element.textContent?.trim() &&
254
+ !element.value?.trim() &&
255
+ !element.src &&
256
+ !element.href;
257
+
258
+ if ((style.display === 'none' || style.visibility === 'hidden') && hasNoVisibleContent) {
259
+ shouldRemove = true;
260
+ }
261
+ } catch (e) {
262
+ // If we can't get computed style, element might be detached
263
+ shouldRemove = true;
264
+ }
265
+ }
266
+
267
+ if (shouldRemove) {
268
+ staleRefs.push(ref);
269
+ }
270
+ }
271
+
272
+ // Remove stale mappings
273
+ for (const ref of staleRefs) {
274
+ const element = refElementMap.get(ref);
275
+ if (element) {
276
+ // Remove aria-ref attribute from DOM
277
+ try {
278
+ element.removeAttribute('aria-ref');
279
+ } catch (e) {
280
+ // Element might be detached from DOM
281
+ }
282
+ elementRefMap.delete(element);
283
+
284
+ // Remove from signature map
285
+ const signature = generateElementSignature(element);
286
+ if (signature && elementSignatureMap.get(signature) === ref) {
287
+ elementSignatureMap.delete(signature);
288
+ }
289
+ }
290
+ refElementMap.delete(ref);
291
+ refAccessTimes.delete(ref);
292
+ }
293
+
294
+ // Persist maps globally
295
+ window.__camelElementRefMap = elementRefMap;
296
+ window.__camelRefElementMap = refElementMap;
297
+ window.__camelElementSignatureMap = elementSignatureMap;
298
+ window.__camelRefAccessTimes = refAccessTimes;
299
+
300
+ return staleRefs.length;
8
301
  }
9
302
 
10
303
  // === Complete snapshot.js logic preservation ===
@@ -15,7 +308,7 @@
15
308
  if (node.nodeType !== Node.ELEMENT_NODE) return true;
16
309
 
17
310
  try {
18
- const style = window.getComputedStyle(node);
311
+ const style = window.getComputedStyle(node);
19
312
  if (style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0')
20
313
  return false;
21
314
  // An element with `display: contents` is not rendered itself, but its children are.
@@ -29,6 +322,55 @@
29
322
  }
30
323
  }
31
324
 
325
+ // Optimized occlusion detection with fewer test points
326
+ function isOccluded(element) {
327
+ if (!element || element.nodeType !== Node.ELEMENT_NODE) return false;
328
+
329
+ try {
330
+ const rect = element.getBoundingClientRect();
331
+ if (rect.width === 0 || rect.height === 0) return true;
332
+
333
+ // Simplified: Use fewer test points for better performance
334
+ const testPoints = [
335
+ // Center point (most important)
336
+ { x: rect.left + rect.width * 0.5, y: rect.top + rect.height * 0.5, weight: 4 },
337
+ // Only test 4 strategic points instead of 9
338
+ { x: rect.left + rect.width * 0.25, y: rect.top + rect.height * 0.25, weight: 1 },
339
+ { x: rect.left + rect.width * 0.75, y: rect.top + rect.height * 0.25, weight: 1 },
340
+ { x: rect.left + rect.width * 0.25, y: rect.top + rect.height * 0.75, weight: 1 },
341
+ { x: rect.left + rect.width * 0.75, y: rect.top + rect.height * 0.75, weight: 1 }
342
+ ];
343
+
344
+ let totalWeight = 0;
345
+ let visibleWeight = 0;
346
+
347
+ for (const point of testPoints) {
348
+ // Skip points outside viewport
349
+ if (point.x < 0 || point.y < 0 ||
350
+ point.x >= window.innerWidth || point.y >= window.innerHeight) {
351
+ continue;
352
+ }
353
+
354
+ const hitElement = document.elementFromPoint(point.x, point.y);
355
+ totalWeight += point.weight;
356
+
357
+ // Simplified visibility check
358
+ if (hitElement && (hitElement === element || element.contains(hitElement) || hitElement.contains(element))) {
359
+ visibleWeight += point.weight;
360
+ }
361
+ }
362
+
363
+ // If no valid test points, assume not occluded
364
+ if (totalWeight === 0) return false;
365
+
366
+ // Element is occluded if less than 40% of weighted points are visible
367
+ return (visibleWeight / totalWeight) < 0.4;
368
+
369
+ } catch (e) {
370
+ return false;
371
+ }
372
+ }
373
+
32
374
  function getRole(node) {
33
375
  // Check if node is null or doesn't have required properties
34
376
  if (!node || !node.tagName || !node.getAttribute) {
@@ -39,6 +381,8 @@
39
381
  if (role) return role;
40
382
 
41
383
  const tagName = node.tagName.toLowerCase();
384
+
385
+ // Extended role mapping to better match Playwright
42
386
  if (tagName === 'a') return 'link';
43
387
  if (tagName === 'button') return 'button';
44
388
  if (tagName === 'input') {
@@ -48,9 +392,83 @@
48
392
  }
49
393
  if (['select', 'textarea'].includes(tagName)) return tagName;
50
394
  if (['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(tagName)) return 'heading';
395
+
396
+ // Additional roles for better Playwright compatibility
397
+ if (tagName === 'img') return 'img';
398
+ if (tagName === 'main') return 'main';
399
+ if (tagName === 'nav') return 'navigation';
400
+ if (tagName === 'ul' || tagName === 'ol') return 'list';
401
+ if (tagName === 'li') return 'listitem';
402
+ if (tagName === 'em') return 'emphasis';
403
+ if (tagName === 'form' && node.getAttribute('role') === 'search') return 'search';
404
+ if (tagName === 'section' || tagName === 'article') return 'region';
405
+ if (tagName === 'aside') return 'complementary';
406
+ if (tagName === 'header') return 'banner';
407
+ if (tagName === 'footer') return 'contentinfo';
408
+ if (tagName === 'fieldset') return 'group';
409
+
51
410
  return 'generic';
52
411
  }
53
412
 
413
+ // Playwright-inspired function to check if element receives pointer events
414
+ function receivesPointerEvents(element) {
415
+ if (!element || !element.nodeType || element.nodeType !== Node.ELEMENT_NODE) return false;
416
+
417
+ try {
418
+ let e = element;
419
+ while (e) {
420
+ const style = window.getComputedStyle(e);
421
+ if (!style) break;
422
+
423
+ const pointerEvents = style.pointerEvents;
424
+ if (pointerEvents === 'none') return false;
425
+ if (pointerEvents && pointerEvents !== 'auto') return true;
426
+
427
+ e = e.parentElement;
428
+ }
429
+ return true;
430
+ } catch (error) {
431
+ return false;
432
+ }
433
+ }
434
+
435
+ // Playwright-inspired function to check if element has pointer cursor
436
+ function hasPointerCursor(element) {
437
+ if (!element || !element.nodeType || element.nodeType !== Node.ELEMENT_NODE) return false;
438
+
439
+ try {
440
+ const style = window.getComputedStyle(element);
441
+ return style.cursor === 'pointer';
442
+ } catch (error) {
443
+ return false;
444
+ }
445
+ }
446
+
447
+ // Playwright-inspired function to get aria level
448
+ function getAriaLevel(element) {
449
+ if (!element || !element.tagName) return 0;
450
+
451
+ // Native HTML heading levels (H1=1, H2=2, etc.)
452
+ const tagName = element.tagName.toUpperCase();
453
+ const nativeLevel = { 'H1': 1, 'H2': 2, 'H3': 3, 'H4': 4, 'H5': 5, 'H6': 6 }[tagName];
454
+ if (nativeLevel) return nativeLevel;
455
+
456
+ // Check aria-level attribute for roles that support it
457
+ const role = getRole(element);
458
+ const kAriaLevelRoles = ['heading', 'listitem', 'row', 'treeitem'];
459
+ if (kAriaLevelRoles.includes(role)) {
460
+ const ariaLevel = element.getAttribute('aria-level');
461
+ if (ariaLevel !== null) {
462
+ const value = Number(ariaLevel);
463
+ if (Number.isInteger(value) && value >= 1) {
464
+ return value;
465
+ }
466
+ }
467
+ }
468
+
469
+ return 0;
470
+ }
471
+
54
472
  function getAccessibleName(node) {
55
473
  // Check if node is null or doesn't have required methods
56
474
  if (!node || !node.hasAttribute || !node.getAttribute) return '';
@@ -67,7 +485,7 @@
67
485
  // Add a heuristic to ignore code-like text that might be in the DOM
68
486
  if ((text.match(/[;:{}]/g)?.length || 0) > 2) return '';
69
487
  return text;
70
- }
488
+ }
71
489
 
72
490
  const textCache = new Map();
73
491
  function getVisibleTextContent(_node) {
@@ -116,22 +534,49 @@
116
534
 
117
535
  const name = getAccessibleName(element);
118
536
 
537
+ // Get persistent ref for this element
538
+ const ref = getOrAssignRef(element);
539
+
119
540
  // Create the node
120
541
  const node = {
121
542
  role,
122
543
  name,
123
544
  children: [],
124
545
  element: element,
125
- ref: generateRef(),
546
+ ref: ref,
126
547
  };
127
548
 
128
549
  // Add states for interactive elements, similar to Playwright
129
- if (element.hasAttribute('disabled')) node.disabled = true;
130
- if (element.hasAttribute('aria-checked')) node.checked = element.getAttribute('aria-checked');
131
- if (element.hasAttribute('aria-expanded')) node.expanded = element.getAttribute('aria-expanded');
550
+ if (element.hasAttribute('disabled') || element.disabled) node.disabled = true;
551
+
552
+ // NEW: Check if element is occluded and mark with occluded tag
553
+ if (isOccluded(element)) {
554
+ node.occluded = true; // Mark as occluded but don't disable
555
+ }
556
+
557
+ // Handle aria-checked and native checked
558
+ const ariaChecked = element.getAttribute('aria-checked');
559
+ if (ariaChecked) {
560
+ node.checked = ariaChecked;
561
+ } else if (element.type === 'checkbox' || element.type === 'radio') {
562
+ node.checked = element.checked;
563
+ }
564
+
565
+ // Handle aria-expanded
566
+ const ariaExpanded = element.getAttribute('aria-expanded');
567
+ if (ariaExpanded) {
568
+ node.expanded = ariaExpanded === 'true';
569
+ }
132
570
 
133
- // Tag element with a ref for later lookup
134
- element.setAttribute('aria-ref', node.ref);
571
+ // Handle aria-selected
572
+ const ariaSelected = element.getAttribute('aria-selected');
573
+ if (ariaSelected === 'true') {
574
+ node.selected = true;
575
+ }
576
+
577
+ // Add level support following Playwright's implementation
578
+ const level = getAriaLevel(element);
579
+ if (level > 0) node.level = level;
135
580
 
136
581
  return node;
137
582
  }
@@ -217,7 +662,7 @@
217
662
 
218
663
  /**
219
664
  * Phase 2: Normalize the tree by removing redundant generic wrappers.
220
- * Complete preservation of snapshot.js normalizeTree logic
665
+ * Complete preservation of snapshot.js normalizeTree logic with cursor inheritance
221
666
  */
222
667
  function normalizeTree(node) {
223
668
  if (typeof node === 'string') return [node];
@@ -229,6 +674,7 @@
229
674
  node.children = newChildren;
230
675
 
231
676
  // Remove child elements that have the same name as their parent
677
+ // and inherit cursor=pointer property if child had it
232
678
  const filteredChildren = [];
233
679
  for (const child of node.children) {
234
680
  if (typeof child !== 'string' && child.name && node.name) {
@@ -237,6 +683,15 @@
237
683
  if (childName === parentName) {
238
684
  // If child has same name as parent, merge its children into parent
239
685
  filteredChildren.push(...(child.children || []));
686
+
687
+ // Inherit cursor=pointer from merged child
688
+ if (child.element && receivesPointerEvents(child.element) && hasPointerCursor(child.element)) {
689
+ node.inheritedCursor = true;
690
+ }
691
+
692
+ // Also inherit other properties if needed
693
+ if (child.disabled && !node.disabled) node.disabled = child.disabled;
694
+ if (child.selected && !node.selected) node.selected = child.selected;
240
695
  } else {
241
696
  filteredChildren.push(child);
242
697
  }
@@ -250,6 +705,15 @@
250
705
  if (node.children.length === 1 && typeof node.children[0] !== 'string') {
251
706
  const child = node.children[0];
252
707
  if (child.name && node.name && child.name.trim() === node.name.trim()) {
708
+ // Inherit cursor=pointer from the child being merged
709
+ if (child.element && receivesPointerEvents(child.element) && hasPointerCursor(child.element)) {
710
+ node.inheritedCursor = true;
711
+ }
712
+
713
+ // Also inherit other properties
714
+ if (child.disabled && !node.disabled) node.disabled = child.disabled;
715
+ if (child.selected && !node.selected) node.selected = child.selected;
716
+
253
717
  // Merge child's children into parent and remove the redundant child
254
718
  node.children = child.children || [];
255
719
  }
@@ -266,19 +730,36 @@
266
730
 
267
731
  /**
268
732
  * Phase 3: Render the normalized tree into the final string format.
269
- * Complete preservation of snapshot.js renderTree logic
733
+ * Complete preservation of snapshot.js renderTree logic with Playwright enhancements
270
734
  */
271
735
  function renderTree(node, indent = '') {
272
736
  const lines = [];
273
737
  let meaningfulProps = '';
274
738
  if (node.disabled) meaningfulProps += ' disabled';
739
+ if (node.occluded) meaningfulProps += ' occluded';
275
740
  if (node.checked !== undefined) meaningfulProps += ` checked=${node.checked}`;
276
741
  if (node.expanded !== undefined) meaningfulProps += ` expanded=${node.expanded}`;
742
+ if (node.selected) meaningfulProps += ' selected';
743
+
744
+ // Add level attribute following Playwright's format
745
+ if (node.level !== undefined) meaningfulProps += ` [level=${node.level}]`;
277
746
 
278
747
  const ref = node.ref ? ` [ref=${node.ref}]` : '';
748
+
749
+ // Add cursor=pointer detection following Playwright's implementation
750
+ // Check both direct cursor and inherited cursor from merged children
751
+ let cursor = '';
752
+ const hasDirectCursor = node.element && receivesPointerEvents(node.element) && hasPointerCursor(node.element);
753
+ const hasInheritedCursor = node.inheritedCursor;
754
+
755
+ // Only add cursor=pointer if element is not occluded
756
+ if ((hasDirectCursor || hasInheritedCursor) && !node.occluded) {
757
+ cursor = ' [cursor=pointer]';
758
+ }
759
+
279
760
  const name = (node.name || '').replace(/\s+/g, ' ').trim();
280
761
 
281
- // Skip elements with empty names and no meaningful props (ref is not considered meaningful)
762
+ // Skip elements with empty names and no meaningful props (ref and cursor are not considered meaningful for this check)
282
763
  if (!name && !meaningfulProps) {
283
764
  // If element has no name and no meaningful props, render its children directly at current level
284
765
  for (const child of node.children) {
@@ -294,7 +775,7 @@
294
775
  return lines;
295
776
  }
296
777
 
297
- lines.push(`${indent}- ${node.role}${name ? ` "${name}"` : ''}${meaningfulProps}${ref}`);
778
+ lines.push(`${indent}- ${node.role}${name ? ` "${name}"` : ''}${meaningfulProps}${ref}${cursor}`);
298
779
 
299
780
  for (const child of node.children) {
300
781
  if (typeof child === 'string') {
@@ -390,6 +871,7 @@
390
871
  disabled: node.disabled,
391
872
  checked: node.checked,
392
873
  expanded: node.expanded,
874
+ level: node.level,
393
875
 
394
876
  // Visual information (from page_script.js)
395
877
  coordinates: coordinates,
@@ -398,7 +880,11 @@
398
880
  href: node.element.href || null,
399
881
  value: node.element.value || null,
400
882
  placeholder: node.element.placeholder || null,
401
- scrollable: node.element.scrollHeight > node.element.clientHeight
883
+ scrollable: node.element.scrollHeight > node.element.clientHeight,
884
+
885
+ // Playwright-inspired properties
886
+ receivesPointerEvents: receivesPointerEvents(node.element),
887
+ hasPointerCursor: hasPointerCursor(node.element)
402
888
  };
403
889
  }
404
890
 
@@ -411,19 +897,64 @@
411
897
  }
412
898
 
413
899
  function analyzePageElements() {
900
+ // Clean up stale refs before analysis
901
+ const cleanedRefCount = cleanupStaleRefs();
902
+
903
+ // Performance optimization: Check if we can reuse recent analysis
904
+ const currentTime = Date.now();
905
+ const lastAnalysisTime = window.__camelLastAnalysisTime || 0;
906
+ const timeSinceLastAnalysis = currentTime - lastAnalysisTime;
907
+
908
+ // If less than 1 second since last analysis and page hasn't changed significantly
909
+ if (timeSinceLastAnalysis < 1000 && window.__camelLastAnalysisResult && cleanedRefCount === 0) {
910
+ const cachedResult = window.__camelLastAnalysisResult;
911
+ // Update timestamp and memory info in cached result
912
+ cachedResult.metadata.timestamp = new Date().toISOString();
913
+ cachedResult.metadata.memoryInfo = {
914
+ currentRefCount: refElementMap.size,
915
+ maxRefs: MAX_REFS,
916
+ memoryUtilization: (refElementMap.size / MAX_REFS * 100).toFixed(1) + '%',
917
+ lruAccessTimesCount: refAccessTimes.size
918
+ };
919
+ cachedResult.metadata.cacheHit = true;
920
+ return cachedResult;
921
+ }
922
+
414
923
  // Generate the complete structured snapshot using original snapshot.js logic
415
924
  const outputLines = processDocument(document);
416
925
  const snapshotText = outputLines.join('\n');
417
926
 
418
927
  // Build the tree again to collect element information with visual data
419
928
  textCache.clear();
420
- refCounter = 1; // Reset counter to match snapshot generation
929
+ // Note: Don't reset refCounter anymore - use persistent counters
421
930
  let tree = buildAriaTree(document.body);
422
931
  [tree] = normalizeTree(tree);
423
932
 
424
933
  const elementsMap = {};
425
934
  collectElementsFromTree(tree, elementsMap);
426
935
 
936
+ // Verify uniqueness of aria-ref attributes (debugging aid)
937
+ const ariaRefCounts = {};
938
+ document.querySelectorAll('[aria-ref]').forEach(element => {
939
+ const ref = element.getAttribute('aria-ref');
940
+ ariaRefCounts[ref] = (ariaRefCounts[ref] || 0) + 1;
941
+ });
942
+
943
+ // Log any duplicates for debugging
944
+ const duplicateRefs = Object.entries(ariaRefCounts).filter(([ref, count]) => count > 1);
945
+ if (duplicateRefs.length > 0) {
946
+ console.warn('Duplicate aria-ref attributes found:', duplicateRefs);
947
+ }
948
+
949
+ // Validate ref consistency
950
+ const refValidationErrors = [];
951
+ for (const [ref, elementInfo] of Object.entries(elementsMap)) {
952
+ const mappedElement = refElementMap.get(ref);
953
+ if (!mappedElement || !document.contains(mappedElement)) {
954
+ refValidationErrors.push(`Ref ${ref} points to invalid or removed element`);
955
+ }
956
+ }
957
+
427
958
  const result = {
428
959
  url: window.location.href,
429
960
  elements: elementsMap,
@@ -435,10 +966,34 @@
435
966
  width: window.innerWidth,
436
967
  height: window.innerHeight,
437
968
  devicePixelRatio: window.devicePixelRatio || 1
438
- }
969
+ },
970
+ // Enhanced debugging information
971
+ ariaRefCounts: ariaRefCounts,
972
+ duplicateRefsFound: duplicateRefs.length > 0,
973
+ staleRefsCleanedUp: cleanedRefCount,
974
+ refValidationErrors: refValidationErrors,
975
+ totalMappedRefs: refElementMap.size,
976
+ refCounterValue: refCounter,
977
+ // Memory management information
978
+ memoryInfo: {
979
+ currentRefCount: refElementMap.size,
980
+ maxRefs: MAX_REFS,
981
+ memoryUtilization: (refElementMap.size / MAX_REFS * 100).toFixed(1) + '%',
982
+ lruAccessTimesCount: refAccessTimes.size,
983
+ unusedAgeThreshold: MAX_UNUSED_AGE_MS + 'ms',
984
+ cleanupThreshold: (CLEANUP_THRESHOLD * 100).toFixed(0) + '%',
985
+ isAggressiveCleanup: refElementMap.size > (MAX_REFS * CLEANUP_THRESHOLD)
986
+ },
987
+ // Performance information
988
+ cacheHit: false,
989
+ analysisTime: Date.now() - currentTime
439
990
  }
440
991
  };
441
992
 
993
+ // Cache the result for potential reuse
994
+ window.__camelLastAnalysisResult = result;
995
+ window.__camelLastAnalysisTime = currentTime;
996
+
442
997
  return result;
443
998
  }
444
999