chrometools-mcp 3.3.6 → 3.3.8

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,53 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [3.3.8] - 2026-02-03
6
+
7
+ ### Added
8
+ - **analyzePage: addEventListener tracking for Angular/React/Vue** — Monkey-patch detection
9
+ - Injects tracker via `evaluateOnNewDocument` before page load
10
+ - Catches `addEventListener('click', ...)` calls from any framework
11
+ - Solves Angular detection (compiled `(click)` bindings now visible)
12
+ - WeakMap storage prevents memory leaks
13
+ - `hasExplicitClickBinding()` now checks `window.__hasClickListener()`
14
+
15
+ - **analyzePage: viewportOnly flag** — Filter to visible elements only
16
+ - Reduces output by 30-56% on large pages
17
+ - Useful for data-heavy pages with content below fold
18
+
19
+ - **analyzePage: diff mode** — Show only changes since last analysis
20
+ - Returns `{added, removed, changed}` structure
21
+ - ~80-90% size reduction for incremental updates
22
+
23
+ - **analyzePage: clickTarget legend** — Clarified in tool description
24
+ - Format: `"tag:id"` (e.g., `"div:container_19"`)
25
+ - No clickTarget = element handles its own click
26
+
27
+ ## [3.3.7] - 2026-02-03
28
+
29
+ ### Performance
30
+ - **analyzePage: 26% output size reduction** — Optimized JSON structure
31
+ - Removed `position` for static elements (default, no need to include)
32
+ - Removed `zIndex: "auto"` (default value)
33
+ - Removed `isStacking`, `hasBackdrop`, `isFullscreen` from position object
34
+ - Removed empty `children: []` arrays
35
+ - Filtered out `null`, `undefined`, empty string, and `false` values from metadata
36
+ - Google Search benchmark: ~38 KB → ~28 KB
37
+
38
+ ### Added
39
+ - **CLAUDE.md: analyzePage benchmark requirement** — Mandatory performance check
40
+ - Test URL: `https://www.google.com/search?q=puppeteer+mcp+server`
41
+ - Baseline: ~28 KB, threshold: < 40 KB
42
+ - Must run after any changes to analyzePage tool
43
+
44
+ ## [3.3.6] - 2026-02-03
45
+
46
+ ### Added
47
+ - **Chrome Extension: POST request tracking** — Track POST requests via webRequest API
48
+ - Extension now captures POST/PUT/PATCH requests that Puppeteer may miss
49
+ - Shows in "Browser-level requests (via Extension)" section
50
+ - Useful for SPA apps with complex async request patterns
51
+
5
52
  ## [3.3.5] - 2026-01-30
6
53
 
7
54
  ### Fixed
@@ -7,6 +7,59 @@
7
7
  import { getBrowser } from './browser-manager.js';
8
8
  // Note: injectRecorder removed - now using Chrome Extension for recording
9
9
 
10
+ /**
11
+ * Click listener tracking script - injected via evaluateOnNewDocument
12
+ * Monkey-patches addEventListener to track which elements have click listeners
13
+ * This enables detection of click handlers added by frameworks like Angular
14
+ */
15
+ const CLICK_LISTENER_TRACKER_SCRIPT = `
16
+ (function() {
17
+ // Only inject once
18
+ if (window.__clickListenerTracker) return;
19
+ window.__clickListenerTracker = true;
20
+
21
+ // WeakMap to track elements with click listeners (prevents memory leaks)
22
+ const clickListeners = new WeakMap();
23
+
24
+ // Store original methods
25
+ const originalAddEventListener = EventTarget.prototype.addEventListener;
26
+ const originalRemoveEventListener = EventTarget.prototype.removeEventListener;
27
+
28
+ // Patch addEventListener
29
+ EventTarget.prototype.addEventListener = function(type, listener, options) {
30
+ if (type === 'click' && this instanceof Element) {
31
+ // Track this element has click listener
32
+ const count = clickListeners.get(this) || 0;
33
+ clickListeners.set(this, count + 1);
34
+ }
35
+ return originalAddEventListener.call(this, type, listener, options);
36
+ };
37
+
38
+ // Patch removeEventListener for accuracy
39
+ EventTarget.prototype.removeEventListener = function(type, listener, options) {
40
+ if (type === 'click' && this instanceof Element) {
41
+ const count = clickListeners.get(this) || 0;
42
+ if (count > 0) {
43
+ clickListeners.set(this, count - 1);
44
+ }
45
+ }
46
+ return originalRemoveEventListener.call(this, type, listener, options);
47
+ };
48
+
49
+ // Global function to check if element has click listener
50
+ window.__hasClickListener = function(element) {
51
+ if (!element) return false;
52
+ const count = clickListeners.get(element);
53
+ return count && count > 0;
54
+ };
55
+
56
+ // Also expose for debugging
57
+ window.__getClickListenerCount = function(element) {
58
+ return clickListeners.get(element) || 0;
59
+ };
60
+ })();
61
+ `;
62
+
10
63
  /**
11
64
  * Debug log helper (only logs to stderr when DEBUG=1)
12
65
  * @param {...any} args - Arguments to log
@@ -159,6 +212,20 @@ export async function setupNetworkMonitoring(page) {
159
212
 
160
213
  // Note: setupRecorderAutoReinjection removed - Chrome Extension handles recording now
161
214
 
215
+ /**
216
+ * Inject click listener tracker into page
217
+ * Must be called BEFORE page.goto() to catch all listeners
218
+ * @param {Page} page - Puppeteer page instance
219
+ */
220
+ async function injectClickListenerTracker(page) {
221
+ try {
222
+ await page.evaluateOnNewDocument(CLICK_LISTENER_TRACKER_SCRIPT);
223
+ debugLog('Click listener tracker injected');
224
+ } catch (error) {
225
+ debugLog('Failed to inject click listener tracker:', error.message);
226
+ }
227
+ }
228
+
162
229
  /**
163
230
  * Get or create page for URL
164
231
  * @param {string} url - URL to navigate to
@@ -180,6 +247,9 @@ export async function getOrCreatePage(url) {
180
247
  // Create new page
181
248
  const page = await browser.newPage();
182
249
 
250
+ // Inject click listener tracker BEFORE navigation
251
+ await injectClickListenerTracker(page);
252
+
183
253
  // Set up console log capture
184
254
  const client = await page.target().createCDPSession();
185
255
  await client.send('Runtime.enable');
@@ -277,6 +347,19 @@ export function setLastPage(page) {
277
347
  * @returns {Promise<void>}
278
348
  */
279
349
  export async function setupNewPage(page, source = 'manual') {
350
+ // Inject click listener tracker
351
+ // For new tabs, we need both:
352
+ // 1. evaluateOnNewDocument for future navigations
353
+ // 2. evaluate for already-loaded content (won't catch existing listeners, but will catch new ones)
354
+ try {
355
+ await page.evaluateOnNewDocument(CLICK_LISTENER_TRACKER_SCRIPT);
356
+ // Also inject immediately for current page (in case content already loaded)
357
+ await page.evaluate(CLICK_LISTENER_TRACKER_SCRIPT);
358
+ debugLog(`Click listener tracker injected for ${source} page`);
359
+ } catch (error) {
360
+ debugLog('Failed to inject click listener tracker:', error.message);
361
+ }
362
+
280
363
  // Set up console log capture
281
364
  try {
282
365
  const client = await page.target().createCDPSession();
package/index.js CHANGED
@@ -2212,20 +2212,25 @@ Start coding now.`;
2212
2212
  };
2213
2213
  }
2214
2214
 
2215
+ // Store previous analysis for diff calculation
2216
+ if (!global.previousApomAnalysis) {
2217
+ global.previousApomAnalysis = new Map(); // pageUrl -> analysis data
2218
+ }
2219
+
2215
2220
  if (name === "analyzePage") {
2216
2221
  const validatedArgs = schemas.AnalyzePageSchema.parse(args);
2217
2222
  const page = await getLastOpenPage();
2218
2223
  const pageUrl = page.url();
2219
2224
 
2220
2225
  // APOM Tree format (default) - v2 with tree structure and positioning
2221
- const apomResult = await page.evaluate((apomTreeConverterCode, selectorResolverCode, shouldRegister, includeAll) => {
2226
+ const apomResult = await page.evaluate((apomTreeConverterCode, selectorResolverCode, shouldRegister, includeAll, viewportOnly) => {
2222
2227
  // Inject utilities
2223
2228
  eval(apomTreeConverterCode);
2224
2229
  eval(selectorResolverCode);
2225
2230
 
2226
2231
  // Build APOM tree
2227
2232
  // interactiveOnly = !includeAll (if includeAll is true, we want ALL elements)
2228
- const apomData = buildAPOMTree(!includeAll);
2233
+ const apomData = buildAPOMTree(!includeAll, viewportOnly);
2229
2234
 
2230
2235
  // Register elements in selector resolver if requested
2231
2236
  if (shouldRegister) {
@@ -2258,7 +2263,43 @@ Start coding now.`;
2258
2263
  }
2259
2264
 
2260
2265
  return apomData;
2261
- }, apomTreeConverter, selectorResolver, validatedArgs.registerElements !== false, validatedArgs.includeAll || false);
2266
+ }, apomTreeConverter, selectorResolver, validatedArgs.registerElements !== false, validatedArgs.includeAll || false, validatedArgs.viewportOnly || false);
2267
+
2268
+ // Handle diff mode
2269
+ if (validatedArgs.diff) {
2270
+ const previousAnalysis = global.previousApomAnalysis.get(pageUrl);
2271
+
2272
+ if (previousAnalysis) {
2273
+ // Calculate diff
2274
+ const diff = calculateApomDiff(previousAnalysis, apomResult);
2275
+
2276
+ // Store current analysis for next diff
2277
+ global.previousApomAnalysis.set(pageUrl, apomResult);
2278
+
2279
+ return {
2280
+ content: [{
2281
+ type: 'text',
2282
+ text: JSON.stringify({
2283
+ mode: 'diff',
2284
+ pageId: apomResult.pageId,
2285
+ url: apomResult.url,
2286
+ timestamp: apomResult.timestamp,
2287
+ previousTimestamp: previousAnalysis.timestamp,
2288
+ diff,
2289
+ metadata: apomResult.metadata,
2290
+ alerts: apomResult.alerts
2291
+ })
2292
+ }]
2293
+ };
2294
+ } else {
2295
+ // No previous analysis, return full result with note
2296
+ global.previousApomAnalysis.set(pageUrl, apomResult);
2297
+ apomResult._note = 'First analysis for this page, no diff available';
2298
+ }
2299
+ } else {
2300
+ // Store for future diff
2301
+ global.previousApomAnalysis.set(pageUrl, apomResult);
2302
+ }
2262
2303
 
2263
2304
  return {
2264
2305
  content: [{
@@ -2268,6 +2309,82 @@ Start coding now.`;
2268
2309
  };
2269
2310
  }
2270
2311
 
2312
+ /**
2313
+ * Calculate diff between two APOM analyses
2314
+ */
2315
+ function calculateApomDiff(previous, current) {
2316
+ const previousElements = flattenApomTree(previous.tree);
2317
+ const currentElements = flattenApomTree(current.tree);
2318
+
2319
+ const previousIds = new Set(previousElements.map(e => e.id));
2320
+ const currentIds = new Set(currentElements.map(e => e.id));
2321
+
2322
+ const added = currentElements.filter(e => !previousIds.has(e.id));
2323
+ const removed = previousElements.filter(e => !currentIds.has(e.id));
2324
+
2325
+ // Find changed elements (same ID but different content)
2326
+ const changed = [];
2327
+ for (const curr of currentElements) {
2328
+ if (previousIds.has(curr.id)) {
2329
+ const prev = previousElements.find(e => e.id === curr.id);
2330
+ if (prev && JSON.stringify(prev.metadata) !== JSON.stringify(curr.metadata)) {
2331
+ changed.push({
2332
+ id: curr.id,
2333
+ type: curr.type,
2334
+ before: prev.metadata,
2335
+ after: curr.metadata
2336
+ });
2337
+ }
2338
+ }
2339
+ }
2340
+
2341
+ return {
2342
+ added: added.length > 0 ? added : undefined,
2343
+ removed: removed.length > 0 ? removed : undefined,
2344
+ changed: changed.length > 0 ? changed : undefined,
2345
+ summary: {
2346
+ addedCount: added.length,
2347
+ removedCount: removed.length,
2348
+ changedCount: changed.length
2349
+ }
2350
+ };
2351
+ }
2352
+
2353
+ /**
2354
+ * Flatten APOM tree to array of elements
2355
+ */
2356
+ function flattenApomTree(node, result = []) {
2357
+ if (!node) return result;
2358
+
2359
+ // Handle compact format: { "tag_id": [children] }
2360
+ if (typeof node === 'object' && !node.id && !node.tag) {
2361
+ const keys = Object.keys(node);
2362
+ for (const key of keys) {
2363
+ if (Array.isArray(node[key])) {
2364
+ node[key].forEach(child => flattenApomTree(child, result));
2365
+ }
2366
+ }
2367
+ return result;
2368
+ }
2369
+
2370
+ // Interactive element with id
2371
+ if (node.id) {
2372
+ result.push({
2373
+ id: node.id,
2374
+ tag: node.tag,
2375
+ type: node.type,
2376
+ metadata: node.metadata
2377
+ });
2378
+ }
2379
+
2380
+ // Process children
2381
+ if (node.children) {
2382
+ node.children.forEach(child => flattenApomTree(child, result));
2383
+ }
2384
+
2385
+ return result;
2386
+ }
2387
+
2271
2388
  if (name === "getElementDetails") {
2272
2389
  const validatedArgs = schemas.GetElementDetailsSchema.parse(args);
2273
2390
  const page = await getLastOpenPage();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chrometools-mcp",
3
- "version": "3.3.6",
3
+ "version": "3.3.8",
4
4
  "description": "MCP (Model Context Protocol) server for Chrome automation using Puppeteer. Persistent browser sessions, UI framework detection (MUI, Ant Design, etc.), Page Object support, visual testing, Figma comparison. Works seamlessly in WSL, Linux, macOS, and Windows.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -10,9 +10,10 @@
10
10
  * Runs in browser context via page.evaluate()
11
11
  *
12
12
  * @param {boolean} interactiveOnly - Only include interactive elements and their parents
13
+ * @param {boolean} viewportOnly - Only include elements visible in current viewport
13
14
  * @returns {Object} APOM tree structure
14
15
  */
15
- function buildAPOMTree(interactiveOnly = true) {
16
+ function buildAPOMTree(interactiveOnly = true, viewportOnly = false) {
16
17
  const pageId = `page_${btoa(window.location.href).replace(/[^a-zA-Z0-9]/g, '').substring(0, 20)}_${Date.now()}`;
17
18
 
18
19
  const result = {
@@ -26,7 +27,14 @@ function buildAPOMTree(interactiveOnly = true) {
26
27
  interactiveCount: 0,
27
28
  formCount: 0,
28
29
  modalCount: 0,
29
- maxDepth: 0
30
+ maxDepth: 0,
31
+ viewportOnly: viewportOnly || undefined,
32
+ viewport: viewportOnly ? {
33
+ width: window.innerWidth || document.documentElement.clientWidth,
34
+ height: window.innerHeight || document.documentElement.clientHeight,
35
+ scrollX: window.scrollX || window.pageXOffset,
36
+ scrollY: window.scrollY || window.pageYOffset
37
+ } : undefined
30
38
  }
31
39
  };
32
40
 
@@ -51,8 +59,122 @@ function buildAPOMTree(interactiveOnly = true) {
51
59
  // Collect radio and checkbox groups for easier agent access
52
60
  result.groups = collectInputGroups(result.tree);
53
61
 
62
+ // Detect page alerts, warnings, errors, and restrictions
63
+ result.alerts = detectPageAlerts();
64
+
54
65
  return result;
55
66
 
67
+ /**
68
+ * Detect page alerts, warnings, errors, toasts, and restrictions
69
+ * Uses heuristics to find notification elements
70
+ */
71
+ function detectPageAlerts() {
72
+ const alerts = [];
73
+ const seenTexts = new Set();
74
+ const MIN_TEXT_LENGTH = 10; // Ignore very short texts like "19", "OK"
75
+
76
+ // Helper to add alert avoiding duplicates and substrings
77
+ function addAlert(type, text, source) {
78
+ text = text?.trim();
79
+ if (!text || text.length < MIN_TEXT_LENGTH || text.length > 300) return;
80
+
81
+ // Normalize text
82
+ const normalizedText = text.substring(0, 200);
83
+
84
+ // Skip if already seen or is substring of existing
85
+ if (seenTexts.has(normalizedText)) return;
86
+ for (const seen of seenTexts) {
87
+ if (seen.includes(normalizedText) || normalizedText.includes(seen)) return;
88
+ }
89
+
90
+ seenTexts.add(normalizedText);
91
+ alerts.push({ type, text: normalizedText, source });
92
+ }
93
+
94
+ // 1. Elements with role="alert" or aria-live (highest priority)
95
+ document.querySelectorAll('[role="alert"], [aria-live="assertive"]').forEach(el => {
96
+ const text = el.textContent?.trim();
97
+ addAlert('alert', text, 'aria');
98
+ });
99
+
100
+ // 2. Classes containing restriction keywords (high priority - affects functionality)
101
+ const restrictionSelector = '[class*="deactivat"], [class*="disabled"], [class*="blocked"], [class*="restrict"], [class*="suspend"], [class*="inactive"]';
102
+ document.querySelectorAll(restrictionSelector).forEach(el => {
103
+ if (!isVisibleForAlert(el)) return;
104
+ const text = el.textContent?.trim();
105
+ addAlert('restriction', text, 'class');
106
+ });
107
+
108
+ // 3. Error classes
109
+ document.querySelectorAll('[class*="error"], [class*="danger"], [class*="invalid"]').forEach(el => {
110
+ if (!isVisibleForAlert(el)) return;
111
+ const text = el.textContent?.trim();
112
+ addAlert('error', text, 'class');
113
+ });
114
+
115
+ // 4. Warning classes
116
+ document.querySelectorAll('[class*="warning"], [class*="warn"], [class*="caution"]').forEach(el => {
117
+ if (!isVisibleForAlert(el)) return;
118
+ const text = el.textContent?.trim();
119
+ addAlert('warning', text, 'class');
120
+ });
121
+
122
+ // 5. Toast/Snackbar notifications (usually important messages)
123
+ document.querySelectorAll('[class*="toast"], [class*="snackbar"], [class*="alert-banner"]').forEach(el => {
124
+ if (!isVisibleForAlert(el)) return;
125
+ const text = el.textContent?.trim();
126
+ addAlert('notification', text, 'toast');
127
+ });
128
+
129
+ // 6. SVG icons with warning colors - only for restriction/deactivated contexts
130
+ const warningColors = ['#FF3B30', '#FAB32F', '#FFCC00', '#FF9500', '#F44336'];
131
+ document.querySelectorAll('svg').forEach(svg => {
132
+ const svgHtml = svg.outerHTML.toLowerCase();
133
+ const hasWarningColor = warningColors.some(c => svgHtml.includes(c.toLowerCase()));
134
+ if (!hasWarningColor) return;
135
+
136
+ // Only consider if parent has restriction-related class or text
137
+ let parent = svg.parentElement;
138
+ let attempts = 0;
139
+ while (parent && attempts < 3) {
140
+ const className = parent.className?.toLowerCase() || '';
141
+ const text = parent.textContent?.trim() || '';
142
+
143
+ // Check if context suggests restriction/warning
144
+ const isRestrictionContext =
145
+ /deactivat|disabled|blocked|restrict|suspend|приостановлен|заблокирован/.test(className + ' ' + text.toLowerCase());
146
+
147
+ if (isRestrictionContext && text.length >= MIN_TEXT_LENGTH) {
148
+ addAlert('restriction', text, 'icon-color');
149
+ break;
150
+ }
151
+ parent = parent.parentElement;
152
+ attempts++;
153
+ }
154
+ });
155
+
156
+ // 7. Elements with semantic key/name attributes
157
+ document.querySelectorAll('[key*="error"], [key*="warning"], [key*="deactivat"], [key*="block"]').forEach(el => {
158
+ const parent = el.closest('div, span, p');
159
+ if (parent) {
160
+ const text = parent.textContent?.trim();
161
+ addAlert('warning', text, 'semantic-attr');
162
+ }
163
+ });
164
+
165
+ // Return only if there are meaningful alerts
166
+ return alerts.length > 0 ? alerts : undefined;
167
+ }
168
+
169
+ /**
170
+ * Check if element is visible (for alert detection)
171
+ */
172
+ function isVisibleForAlert(el) {
173
+ if (el.offsetWidth === 0 || el.offsetHeight === 0) return false;
174
+ const style = window.getComputedStyle(el);
175
+ return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0';
176
+ }
177
+
56
178
  /**
57
179
  * Collect radio and checkbox groups from the tree
58
180
  */
@@ -148,6 +270,32 @@ function buildAPOMTree(interactiveOnly = true) {
148
270
  return node;
149
271
  }
150
272
 
273
+ /**
274
+ * Filter out null, undefined, and empty string values from object
275
+ * to reduce JSON output size
276
+ */
277
+ function filterNullValues(obj) {
278
+ if (!obj || typeof obj !== 'object') return obj;
279
+
280
+ const result = {};
281
+ for (const [key, value] of Object.entries(obj)) {
282
+ // Skip null, undefined, and empty strings
283
+ if (value === null || value === undefined || value === '') continue;
284
+ // Skip false for boolean fields (keep true)
285
+ if (value === false) continue;
286
+ // Recursively filter nested objects (but not arrays)
287
+ if (typeof value === 'object' && !Array.isArray(value)) {
288
+ const filtered = filterNullValues(value);
289
+ if (Object.keys(filtered).length > 0) {
290
+ result[key] = filtered;
291
+ }
292
+ } else {
293
+ result[key] = value;
294
+ }
295
+ }
296
+ return result;
297
+ }
298
+
151
299
  /**
152
300
  * Check if cursor:pointer is explicitly set (not inherited)
153
301
  */
@@ -229,6 +377,23 @@ function buildAPOMTree(interactiveOnly = true) {
229
377
  interactiveElements.add(document.body);
230
378
  }
231
379
 
380
+ /**
381
+ * Check if element is in viewport
382
+ */
383
+ function isInViewport(el) {
384
+ const rect = el.getBoundingClientRect();
385
+ const viewportHeight = window.innerHeight || document.documentElement.clientHeight;
386
+ const viewportWidth = window.innerWidth || document.documentElement.clientWidth;
387
+
388
+ // Element is in viewport if any part of it is visible
389
+ return (
390
+ rect.bottom > 0 &&
391
+ rect.right > 0 &&
392
+ rect.top < viewportHeight &&
393
+ rect.left < viewportWidth
394
+ );
395
+ }
396
+
232
397
  /**
233
398
  * Check if element is visible
234
399
  * More reliable check that works with position:fixed elements (Angular Material, etc.)
@@ -248,6 +413,11 @@ function buildAPOMTree(interactiveOnly = true) {
248
413
  // For body element, always consider visible if dimensions > 0
249
414
  if (el === document.body) return true;
250
415
 
416
+ // Check viewport if viewportOnly mode is enabled
417
+ if (viewportOnly && !isInViewport(el)) {
418
+ return false;
419
+ }
420
+
251
421
  // Additional check: element should be in viewport or have offsetParent
252
422
  // This handles elements inside position:fixed containers (Angular Material)
253
423
  return el.offsetParent !== null || style.position === 'fixed' || style.position === 'sticky';
@@ -297,19 +467,23 @@ function buildAPOMTree(interactiveOnly = true) {
297
467
  node = {
298
468
  id,
299
469
  tag: element.tagName.toLowerCase(),
300
- position,
301
470
  type: elementType.type,
302
471
  children: []
303
472
  };
304
473
 
474
+ // Only include position if not static (position is null for static)
475
+ if (position) {
476
+ node.position = position;
477
+ }
478
+
305
479
  // Add selector only in includeAll mode
306
480
  if (!interactiveOnly) {
307
481
  node.selector = selector;
308
482
  }
309
483
 
310
- // Add metadata for interactive elements
484
+ // Add metadata for interactive elements, filtering out null/undefined values
311
485
  if (elementType.metadata) {
312
- node.metadata = elementType.metadata;
486
+ node.metadata = filterNullValues(elementType.metadata);
313
487
  }
314
488
  } else {
315
489
  // Containers: compact format "tag_id": [children] when interactiveOnly
@@ -340,8 +514,8 @@ function buildAPOMTree(interactiveOnly = true) {
340
514
  if (elementType.type === 'form') {
341
515
  result.metadata.formCount++;
342
516
  }
343
- if (position.type === 'fixed' || position.type === 'absolute') {
344
- if (position.zIndex >= 100) {
517
+ if (position && (position.type === 'fixed' || position.type === 'absolute')) {
518
+ if (position.zIndex && position.zIndex >= 100) {
345
519
  result.metadata.modalCount++;
346
520
  }
347
521
  }
@@ -365,6 +539,11 @@ function buildAPOMTree(interactiveOnly = true) {
365
539
  return compactNode;
366
540
  }
367
541
 
542
+ // Remove empty children array to reduce output size
543
+ if (node.children && node.children.length === 0) {
544
+ delete node.children;
545
+ }
546
+
368
547
  return node;
369
548
  }
370
549
 
@@ -379,37 +558,30 @@ function buildAPOMTree(interactiveOnly = true) {
379
558
 
380
559
  /**
381
560
  * Get positioning information
561
+ * Returns null for static position (default) to reduce output size
382
562
  */
383
563
  function getPositionInfo(element) {
384
564
  const style = window.getComputedStyle(element);
385
565
  const position = style.position;
386
- const zIndex = style.zIndex === 'auto' ? 'auto' : parseInt(style.zIndex, 10);
387
566
 
388
- // Check if creates stacking context
389
- const isStacking =
390
- position === 'fixed' ||
391
- position === 'sticky' ||
392
- (position === 'absolute' && zIndex !== 'auto') ||
393
- (position === 'relative' && zIndex !== 'auto') ||
394
- parseFloat(style.opacity) < 1 ||
395
- style.transform !== 'none' ||
396
- style.filter !== 'none' ||
397
- style.perspective !== 'none' ||
398
- style.clipPath !== 'none' ||
399
- style.mask !== 'none' ||
400
- style.mixBlendMode !== 'normal' ||
401
- style.isolation === 'isolate';
567
+ // Skip static position (default) - no need to include
568
+ if (position === 'static') {
569
+ return null;
570
+ }
402
571
 
403
- return {
404
- type: position,
405
- zIndex: zIndex,
406
- isStacking: isStacking,
407
- // Additional positioning properties for modals/overlays detection
408
- hasBackdrop: style.backgroundColor !== 'rgba(0, 0, 0, 0)' &&
409
- (position === 'fixed' || position === 'absolute'),
410
- isFullscreen: element.offsetWidth >= window.innerWidth * 0.9 &&
411
- element.offsetHeight >= window.innerHeight * 0.9
572
+ const zIndex = style.zIndex === 'auto' ? 'auto' : parseInt(style.zIndex, 10);
573
+
574
+ // Only include non-default values
575
+ const result = {
576
+ type: position
412
577
  };
578
+
579
+ // Only include zIndex if it's not 'auto'
580
+ if (zIndex !== 'auto') {
581
+ result.zIndex = zIndex;
582
+ }
583
+
584
+ return result;
413
585
  }
414
586
 
415
587
  /**
@@ -446,6 +618,83 @@ function buildAPOMTree(interactiveOnly = true) {
446
618
  }
447
619
  }
448
620
 
621
+ /**
622
+ * Check if tag is a custom element (Web Component / Framework component)
623
+ */
624
+ function isCustomElement(tag) {
625
+ // Custom elements must contain a hyphen
626
+ return tag.includes('-');
627
+ }
628
+
629
+ /**
630
+ * Check if element has explicit click binding (framework attributes or addEventListener)
631
+ * Uses monkey-patched addEventListener tracker for framework detection (Angular, React, Vue)
632
+ */
633
+ function hasExplicitClickBinding(element) {
634
+ // Check for framework-specific click bindings (attributes)
635
+ const attrs = element.attributes;
636
+ for (let i = 0; i < attrs.length; i++) {
637
+ const name = attrs[i].name.toLowerCase();
638
+ // Angular: (click), Angular.js: ng-click
639
+ // Vue: @click, v-on:click
640
+ // React: onClick (but this is a property, not attribute)
641
+ if (name === '(click)' || name === 'ng-click' ||
642
+ name === '@click' || name === 'v-on:click' ||
643
+ name.startsWith('on') && name.includes('click')) {
644
+ return true;
645
+ }
646
+ }
647
+ // Check onclick property
648
+ if (element.onclick) return true;
649
+ // Check onclick attribute
650
+ if (element.hasAttribute('onclick')) return true;
651
+
652
+ // Check for click listeners added via addEventListener (framework detection)
653
+ // This is injected by page-manager.js via evaluateOnNewDocument
654
+ if (typeof window.__hasClickListener === 'function' && window.__hasClickListener(element)) {
655
+ return true;
656
+ }
657
+
658
+ return false;
659
+ }
660
+
661
+ /**
662
+ * Find click handler for interactive elements (bubbling search)
663
+ * Searches UP from element to find ancestor with explicit click binding
664
+ * Returns "tag:id" format like "div:container_19" or null if not found
665
+ */
666
+ function findClickHandler(element) {
667
+ const tag = element.tagName.toLowerCase();
668
+
669
+ // Check self first - native interactive elements handle their own clicks
670
+ if (hasExplicitClickBinding(element) ||
671
+ ['button', 'a', 'input', 'select'].includes(tag) ||
672
+ element.getAttribute('role') === 'button') {
673
+ return null; // element handles its own click, no need for clickTarget
674
+ }
675
+
676
+ // Search UP (like event bubbling) - find ancestor with explicit click handler
677
+ let parent = element.parentElement;
678
+ let depth = 0;
679
+ const maxDepth = 5; // limit to prevent hanging
680
+
681
+ while (parent && depth < maxDepth) {
682
+ if (hasExplicitClickBinding(parent)) {
683
+ // Found real click handler
684
+ const parentId = elementIds.get(parent);
685
+ if (parentId) {
686
+ const parentTag = parent.tagName.toLowerCase();
687
+ return `${parentTag}:${parentId}`;
688
+ }
689
+ }
690
+ parent = parent.parentElement;
691
+ depth++;
692
+ }
693
+
694
+ // No click handler found - return null (no fallback!)
695
+ return null;
696
+ }
697
+
449
698
  /**
450
699
  * Check if element is interactive based on various signals
451
700
  */
@@ -680,12 +929,20 @@ function buildAPOMTree(interactiveOnly = true) {
680
929
 
681
930
  // Generic container - check for JavaScript interactivity
682
931
  const interactivityCheck = checkInteractivity(element);
932
+
933
+ // For ALL interactive elements, search for click handler (bubbling)
934
+ // Returns "tag:id" format or null if not found
935
+ const clickTarget = interactivityCheck.isInteractive
936
+ ? findClickHandler(element)
937
+ : null;
938
+
683
939
  return {
684
940
  type: 'container',
685
941
  isInteractive: interactivityCheck.isInteractive,
686
942
  metadata: interactivityCheck.isInteractive ? {
687
943
  text: element.textContent?.trim().substring(0, 100) || '',
688
- interactivityReason: interactivityCheck.reason
944
+ interactivityReason: interactivityCheck.reason,
945
+ clickTarget: clickTarget || undefined
689
946
  } : null
690
947
  };
691
948
  }
@@ -477,7 +477,7 @@ export const toolDefinitions = [
477
477
  },
478
478
  {
479
479
  name: "analyzePage",
480
- description: "PRIMARY tool for reading page state. Returns APOM tree: {tree, metadata, groups}. Compact format (default): containers as \"tag_id\":[children] keys, interactive elements as {id, tag, type, position, metadata} without selectors. Use element IDs (e.g., button_45, input_20) with click/type tools. Selectors registered internally for resolution. Use refresh:true after clicks. Efficient: 8-10k tokens vs screenshot 15-25k.",
480
+ description: "PRIMARY tool for reading page state. Returns APOM tree: {tree, metadata, groups}. Compact format (default): containers as \"tag_id\":[children] keys, interactive elements as {id, tag, type, position, metadata} without selectors. Use element IDs (e.g., button_45, input_20) with click/type tools. Selectors registered internally for resolution. Use refresh:true after clicks. Efficient: 8-10k tokens vs screenshot 15-25k. Legend: clickTarget format is \"tag:id\" (e.g., \"kp-chats-list-item:container_58\") - use the id part for clicking. No clickTarget = element handles its own click.",
481
481
  inputSchema: {
482
482
  type: "object",
483
483
  properties: {
@@ -486,6 +486,8 @@ export const toolDefinitions = [
486
486
  useLegacyFormat: { type: "boolean", description: "Return legacy format instead of APOM (default: false - APOM is now default)" },
487
487
  registerElements: { type: "boolean", description: "Auto-register elements in selector resolver (default: true)" },
488
488
  groupBy: { type: "string", description: "Group elements: 'type' or 'flat' (default: 'type')", enum: ["type", "flat"] },
489
+ viewportOnly: { type: "boolean", description: "Only analyze elements in current viewport (default: false). Reduces output for long pages." },
490
+ diff: { type: "boolean", description: "Return only changes since last analysis: {added, removed, changed} (default: false)." },
489
491
  },
490
492
  },
491
493
  },
@@ -263,6 +263,8 @@ export const AnalyzePageSchema = z.object({
263
263
  useLegacyFormat: z.boolean().optional().describe("Return legacy format instead of APOM (default: false - APOM is now the default format)"),
264
264
  registerElements: z.boolean().optional().describe("Automatically register elements in selector resolver (default: true)"),
265
265
  groupBy: z.enum(['type', 'flat']).optional().describe("Group elements by type or return flat structure (default: 'type')"),
266
+ viewportOnly: z.boolean().optional().describe("Only analyze elements visible in current viewport (default: false). Reduces output for long pages."),
267
+ diff: z.boolean().optional().describe("Return only changes since last analysis: {added, removed, changed} (default: false). Useful after clicks to see what changed."),
266
268
  });
267
269
 
268
270
  export const GetElementDetailsSchema = z.object({