chrometools-mcp 3.1.7 → 3.2.6

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.
@@ -43,6 +43,11 @@ function buildAPOMTree(interactiveOnly = true) {
43
43
  // Build tree from body
44
44
  result.tree = buildNode(document.body, null, 0, []);
45
45
 
46
+ // Prune empty branches (containers without interactive elements)
47
+ if (interactiveOnly && result.tree) {
48
+ result.tree = pruneEmptyBranches(result.tree);
49
+ }
50
+
46
51
  // Collect radio and checkbox groups for easier agent access
47
52
  result.groups = collectInputGroups(result.tree);
48
53
 
@@ -102,29 +107,109 @@ function buildAPOMTree(interactiveOnly = true) {
102
107
  }
103
108
 
104
109
  /**
105
- * Mark interactive elements and their ancestors
110
+ * Prune empty branches (containers without interactive elements)
111
+ * Bottom-up traversal: remove container branches that don't end with interactive leaves
106
112
  */
107
- function markInteractiveElements(root) {
108
- const interactiveTags = new Set([
109
- 'A', 'BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'LABEL', 'FORM'
110
- ]);
113
+ function pruneEmptyBranches(node) {
114
+ if (!node) return null;
115
+
116
+ // Handle compact format containers
117
+ if (typeof node === 'object' && !node.id && !node.tag) {
118
+ // Compact format: { "tag_id": [children] }
119
+ const keys = Object.keys(node);
120
+ if (keys.length === 1 && Array.isArray(node[keys[0]])) {
121
+ const key = keys[0];
122
+ const prunedChildren = node[key]
123
+ .map(child => pruneEmptyBranches(child))
124
+ .filter(child => child !== null);
125
+
126
+ // If no children left after pruning, remove this container
127
+ if (prunedChildren.length === 0) {
128
+ return null;
129
+ }
130
+
131
+ return { [key]: prunedChildren };
132
+ }
133
+ }
134
+
135
+ // Handle regular format (interactive elements or full-mode containers)
136
+ if (node.children && Array.isArray(node.children)) {
137
+ // Prune children recursively
138
+ node.children = node.children
139
+ .map(child => pruneEmptyBranches(child))
140
+ .filter(child => child !== null);
141
+ }
142
+
143
+ // If this is a container (no type = not interactive) with no children, remove it
144
+ if (!node.type && node.children && node.children.length === 0) {
145
+ return null;
146
+ }
111
147
 
112
- const interactiveRoles = new Set([
113
- 'button', 'link', 'textbox', 'checkbox', 'radio', 'combobox', 'listbox',
114
- 'menuitem', 'tab', 'switch', 'slider', 'searchbox'
115
- ]);
148
+ return node;
149
+ }
150
+
151
+ /**
152
+ * Check if cursor:pointer is explicitly set (not inherited)
153
+ */
154
+ function hasCursorPointerExplicit(element) {
155
+ const computedStyle = window.getComputedStyle(element);
156
+ if (computedStyle.cursor !== 'pointer') {
157
+ return false;
158
+ }
159
+
160
+ // Check if cursor is set via inline style
161
+ if (element.style.cursor === 'pointer') {
162
+ return true;
163
+ }
164
+
165
+ // Check if cursor is set via CSS class or direct CSS rule (not inherited)
166
+ // If parent also has cursor:pointer computed, then it's likely inherited
167
+ const parent = element.parentElement;
168
+ if (parent) {
169
+ const parentStyle = window.getComputedStyle(parent);
170
+ if (parentStyle.cursor === 'pointer') {
171
+ // Parent has cursor:pointer, so this is inherited
172
+ return false;
173
+ }
174
+ }
175
+
176
+ // Element has cursor:pointer but parent doesn't - it's explicitly set
177
+ return true;
178
+ }
116
179
 
117
- // Find all interactive elements
180
+ /**
181
+ * Mark interactive elements and their ancestors
182
+ * NOTE: This function is defined before checkInteractivity,
183
+ * so we need to inline the checks or move function definitions
184
+ */
185
+ function markInteractiveElements(root) {
186
+ // Find all interactive elements using the same logic as checkInteractivity
118
187
  const elements = root.querySelectorAll('*');
119
188
  const interactiveList = [];
120
189
 
121
190
  elements.forEach(el => {
122
- const isInteractive =
123
- interactiveTags.has(el.tagName) ||
124
- interactiveRoles.has(el.getAttribute('role')) ||
191
+ // Use inline checks (same logic as checkInteractivity)
192
+ const tag = el.tagName.toLowerCase();
193
+ const role = el.getAttribute('role');
194
+
195
+ const isInteractive = (
196
+ // Native HTML interactive elements
197
+ ['a', 'button', 'input', 'select', 'textarea', 'label', 'form'].includes(tag) ||
198
+ // Interactive ARIA roles
199
+ (role && ['button', 'link', 'checkbox', 'radio', 'tab', 'menuitem', 'option', 'switch', 'textbox'].includes(role)) ||
200
+ // onclick attribute
125
201
  el.hasAttribute('onclick') ||
126
- el.hasAttribute('tabindex') && el.getAttribute('tabindex') !== '-1' ||
127
- (el.tagName === 'DIV' && el.getAttribute('contenteditable') === 'true');
202
+ // onclick property
203
+ (el.onclick !== null && el.onclick !== undefined) ||
204
+ // cursor: pointer (only if explicitly set, not inherited)
205
+ hasCursorPointerExplicit(el) ||
206
+ // tabindex (except -1)
207
+ (el.hasAttribute('tabindex') && el.getAttribute('tabindex') !== '-1') ||
208
+ // contenteditable
209
+ el.getAttribute('contenteditable') === 'true'
210
+ // Note: We skip event listener check here for performance
211
+ // as querySelectorAll can return thousands of elements
212
+ );
128
213
 
129
214
  if (isInteractive && isVisible(el)) {
130
215
  interactiveList.push(el);
@@ -146,13 +231,26 @@ function buildAPOMTree(interactiveOnly = true) {
146
231
 
147
232
  /**
148
233
  * Check if element is visible
234
+ * More reliable check that works with position:fixed elements (Angular Material, etc.)
149
235
  */
150
236
  function isVisible(el) {
151
- if (!el.offsetParent && el !== document.body) return false;
237
+ // Check dimensions first (works for fixed position elements)
238
+ if (el.offsetWidth === 0 || el.offsetHeight === 0) return false;
239
+
240
+ // Check computed styles
152
241
  const style = window.getComputedStyle(el);
153
- return style.display !== 'none' &&
154
- style.visibility !== 'hidden' &&
155
- style.opacity !== '0';
242
+ if (style.display === 'none' ||
243
+ style.visibility === 'hidden' ||
244
+ style.opacity === '0') {
245
+ return false;
246
+ }
247
+
248
+ // For body element, always consider visible if dimensions > 0
249
+ if (el === document.body) return true;
250
+
251
+ // Additional check: element should be in viewport or have offsetParent
252
+ // This handles elements inside position:fixed containers (Angular Material)
253
+ return el.offsetParent !== null || style.position === 'fixed' || style.position === 'sticky';
156
254
  }
157
255
 
158
256
  /**
@@ -169,10 +267,17 @@ function buildAPOMTree(interactiveOnly = true) {
169
267
  return null;
170
268
  }
171
269
 
172
- // Generate unique ID
270
+ // Generate unique ID and CSS selector
173
271
  const id = generateElementId(element);
272
+ const selector = generateSelector(element);
174
273
  elementIds.set(element, id);
175
274
 
275
+ // Register element in selector resolver (internal only)
276
+ if (typeof window !== 'undefined' && typeof window.registerElement === 'function') {
277
+ const tag = element.tagName.toLowerCase();
278
+ window.registerElement(id, selector, { tag, depth });
279
+ }
280
+
176
281
  const currentPath = [...path, id];
177
282
 
178
283
  // Get positioning info
@@ -184,26 +289,47 @@ function buildAPOMTree(interactiveOnly = true) {
184
289
  // Build node - minimize non-interactive parents
185
290
  const isInteractive = elementType.isInteractive;
186
291
 
187
- const node = {
188
- id,
189
- tag: element.tagName.toLowerCase(),
190
- selector: generateSelector(element),
191
- position,
192
- children: []
193
- };
292
+ // Build node structure based on mode
293
+ let node;
194
294
 
195
- // Add full info only for interactive elements
196
295
  if (isInteractive) {
197
- node.type = elementType.type;
198
- node.bounds = getBounds(element);
296
+ // Interactive elements: full structure without selector (unless includeAll)
297
+ node = {
298
+ id,
299
+ tag: element.tagName.toLowerCase(),
300
+ position,
301
+ type: elementType.type,
302
+ children: []
303
+ };
304
+
305
+ // Add selector only in includeAll mode
306
+ if (!interactiveOnly) {
307
+ node.selector = selector;
308
+ }
199
309
 
200
- // Add metadata based on element type
310
+ // Add metadata for interactive elements
201
311
  if (elementType.metadata) {
202
312
  node.metadata = elementType.metadata;
203
313
  }
204
314
  } else {
205
- // For containers (parents), keep it minimal
206
- node.type = elementType.type;
315
+ // Containers: compact format "tag_id": [children] when interactiveOnly
316
+ // or full format when includeAll
317
+ if (interactiveOnly) {
318
+ // Compact format - will be converted after processing children
319
+ node = {
320
+ _compact: true,
321
+ _key: `${element.tagName.toLowerCase()}_${id}`,
322
+ children: []
323
+ };
324
+ } else {
325
+ // Full format with selector
326
+ node = {
327
+ id,
328
+ tag: element.tagName.toLowerCase(),
329
+ selector,
330
+ children: []
331
+ };
332
+ }
207
333
  }
208
334
 
209
335
  // Update metadata counters
@@ -231,6 +357,14 @@ function buildAPOMTree(interactiveOnly = true) {
231
357
  }
232
358
  }
233
359
 
360
+ // Convert compact containers to final format
361
+ if (node._compact) {
362
+ // Return compact format: { "tag_id": [children] }
363
+ const compactNode = {};
364
+ compactNode[node._key] = node.children;
365
+ return compactNode;
366
+ }
367
+
234
368
  return node;
235
369
  }
236
370
 
@@ -291,6 +425,87 @@ function buildAPOMTree(interactiveOnly = true) {
291
425
  };
292
426
  }
293
427
 
428
+ /**
429
+ * Check if element has click event listeners
430
+ */
431
+ function hasClickListener(element) {
432
+ try {
433
+ // Check for getEventListeners (available in Chrome DevTools context)
434
+ if (typeof getEventListeners === 'function') {
435
+ const listeners = getEventListeners(element);
436
+ return listeners && listeners.click && listeners.click.length > 0;
437
+ }
438
+
439
+ // Fallback: check for common event listener markers
440
+ // Note: This is not 100% reliable but catches common cases
441
+ return element._events?.click ||
442
+ element.__listeners?.click ||
443
+ element.__eventListeners?.click;
444
+ } catch (e) {
445
+ return false;
446
+ }
447
+ }
448
+
449
+ /**
450
+ * Check if element is interactive based on various signals
451
+ */
452
+ function checkInteractivity(element) {
453
+ const tag = element.tagName.toLowerCase();
454
+ const role = element.getAttribute('role');
455
+
456
+ // 1. Standard interactive HTML elements
457
+ const interactiveTags = ['a', 'button', 'input', 'select', 'textarea'];
458
+ if (interactiveTags.includes(tag)) {
459
+ return { isInteractive: true, reason: 'native-html' };
460
+ }
461
+
462
+ // 2. Interactive ARIA roles
463
+ const interactiveRoles = [
464
+ 'button', 'link', 'checkbox', 'radio', 'tab',
465
+ 'menuitem', 'option', 'switch', 'textbox'
466
+ ];
467
+ if (role && interactiveRoles.includes(role)) {
468
+ return { isInteractive: true, reason: 'aria-role' };
469
+ }
470
+
471
+ // 3. Elements with onclick attribute
472
+ if (element.hasAttribute('onclick')) {
473
+ return { isInteractive: true, reason: 'onclick-attr' };
474
+ }
475
+
476
+ // 4. Elements with onclick property set via JavaScript
477
+ if (element.onclick !== null && element.onclick !== undefined) {
478
+ return { isInteractive: true, reason: 'onclick-prop' };
479
+ }
480
+
481
+ // 5. Elements with cursor: pointer (only if explicitly set, not inherited)
482
+ if (hasCursorPointerExplicit(element)) {
483
+ return { isInteractive: true, reason: 'cursor-pointer' };
484
+ }
485
+
486
+ // 6. Elements with click event listeners
487
+ if (hasClickListener(element)) {
488
+ return { isInteractive: true, reason: 'event-listener' };
489
+ }
490
+
491
+ // 7. Elements with tabindex (except -1)
492
+ const tabindex = element.getAttribute('tabindex');
493
+ if (tabindex !== null && tabindex !== '-1') {
494
+ return { isInteractive: true, reason: 'tabindex' };
495
+ }
496
+
497
+ // 8. Contenteditable elements
498
+ if (element.getAttribute('contenteditable') === 'true') {
499
+ return { isInteractive: true, reason: 'contenteditable' };
500
+ }
501
+
502
+ return { isInteractive: false, reason: null };
503
+ }
504
+
505
+ /**
506
+ * Detect framework-specific attributes on element
507
+ * Returns framework info or null
508
+ */
294
509
  /**
295
510
  * Determine element type and metadata
296
511
  */
@@ -301,14 +516,16 @@ function buildAPOMTree(interactiveOnly = true) {
301
516
 
302
517
  // Form
303
518
  if (tag === 'form') {
519
+ const metadata = {
520
+ method: element.method?.toUpperCase() || 'GET',
521
+ action: element.action || '',
522
+ name: element.name || null
523
+ };
524
+
304
525
  return {
305
526
  type: 'form',
306
527
  isInteractive: true,
307
- metadata: {
308
- method: element.method?.toUpperCase() || 'GET',
309
- action: element.action || '',
310
- name: element.name || null
311
- }
528
+ metadata
312
529
  };
313
530
  }
314
531
 
@@ -450,30 +667,58 @@ function buildAPOMTree(interactiveOnly = true) {
450
667
 
451
668
  // Container with semantic role
452
669
  if (role) {
670
+ const interactivityCheck = checkInteractivity(element);
453
671
  return {
454
672
  type: role,
455
- isInteractive: false,
673
+ isInteractive: interactivityCheck.isInteractive,
456
674
  metadata: {
457
- ariaLabel: element.getAttribute('aria-label') || null
675
+ ariaLabel: element.getAttribute('aria-label') || null,
676
+ interactivityReason: interactivityCheck.reason || undefined
458
677
  }
459
678
  };
460
679
  }
461
680
 
462
- // Generic container
681
+ // Generic container - check for JavaScript interactivity
682
+ const interactivityCheck = checkInteractivity(element);
463
683
  return {
464
684
  type: 'container',
465
- isInteractive: false,
466
- metadata: null
685
+ isInteractive: interactivityCheck.isInteractive,
686
+ metadata: interactivityCheck.isInteractive ? {
687
+ text: element.textContent?.trim().substring(0, 100) || '',
688
+ interactivityReason: interactivityCheck.reason
689
+ } : null
467
690
  };
468
691
  }
469
692
 
470
693
  /**
471
694
  * Generate unique CSS selector
695
+ * Excludes framework-specific dynamic attributes (React, Vue, Angular)
472
696
  */
473
697
  function generateSelector(element) {
474
- // Use ID if available and unique
475
- if (element.id && document.querySelectorAll(`#${element.id}`).length === 1) {
476
- return `#${element.id}`;
698
+ // Use ID if available, valid (not starting with digit), and unique
699
+ // CSS selectors don't support IDs starting with digits (e.g., #301178 is invalid)
700
+ if (element.id && !/^[0-9]/.test(element.id)) {
701
+ try {
702
+ const selector = `#${CSS.escape(element.id)}`;
703
+ if (document.querySelectorAll(selector).length === 1) {
704
+ return selector;
705
+ }
706
+ } catch (e) {
707
+ // Invalid selector, continue to other strategies
708
+ }
709
+ }
710
+
711
+ // Try to find stable class name (excluding framework-specific dynamic classes)
712
+ const stableClass = getStableClassName(element);
713
+ if (stableClass) {
714
+ const classSelector = `.${stableClass}`;
715
+ // Verify it's unique within parent context
716
+ if (element.parentElement) {
717
+ const matches = element.parentElement.querySelectorAll(classSelector);
718
+ if (matches.length === 1 && matches[0] === element) {
719
+ return classSelector;
720
+ }
721
+ }
477
722
  }
478
723
 
479
724
  // Build path from parent
@@ -483,6 +728,12 @@ function buildAPOMTree(interactiveOnly = true) {
483
728
  while (current && current !== document.body) {
484
729
  let selector = current.tagName.toLowerCase();
485
730
 
731
+ // Add stable class if available
732
+ const stableClass = getStableClassName(current);
733
+ if (stableClass) {
734
+ selector += `.${stableClass}`;
735
+ }
736
+
486
737
  // Add nth-of-type if needed
487
738
  if (current.parentElement) {
488
739
  const siblings = Array.from(current.parentElement.children).filter(
@@ -500,6 +751,39 @@ function buildAPOMTree(interactiveOnly = true) {
500
751
 
501
752
  return path.join(' > ');
502
753
  }
754
+
755
+ /**
756
+ * Get stable class name excluding framework-specific dynamic classes
757
+ * Returns first stable class or null
758
+ */
759
+ function getStableClassName(element) {
760
+ if (!element.className || typeof element.className !== 'string') {
761
+ return null;
762
+ }
763
+
764
+ const classes = element.className.split(/\s+/).filter(c => c);
765
+
766
+ // Filter out framework-specific classes
767
+ const stableClasses = classes.filter(className => {
768
+ // React: CSS Modules, Styled Components, Emotion
769
+ if (/^[a-zA-Z0-9_-]+-[a-zA-Z0-9_-]{5,}$/.test(className)) return false;
770
+ if (/^css-[a-z0-9]+(-[a-z0-9]+)?$/i.test(className)) return false;
771
+ if (/^sc-[a-z0-9]+-[a-z0-9]+$/i.test(className)) return false;
772
+
773
+ // Vue: scoped styles
774
+ if (/^data-v-[a-f0-9]{8}$/i.test(className)) return false;
775
+
776
+ // Angular: component styles (no classes starting with _ng)
777
+ if (/^_ng/.test(className)) return false;
778
+
779
+ // Generic hash patterns
780
+ if (/^[a-z0-9]{32,}$/i.test(className)) return false;
781
+
782
+ return true;
783
+ });
784
+
785
+ return stableClasses.length > 0 ? stableClasses[0] : null;
786
+ }
503
787
  }
504
788
 
505
789
  // Export for use in both Node.js and browser context
File without changes