chrometools-mcp 3.2.10 → 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,175 @@
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
+
52
+ ## [3.3.5] - 2026-01-30
53
+
54
+ ### Fixed
55
+ - **type/click: Intermittent timeout fix** — Added timeouts to internal operations
56
+ - `resolveSelector` now has 5s timeout (was unbounded)
57
+ - `getElementInfo` now has 5s timeout (was unbounded)
58
+ - `TextInputModel.setValue` operations have individual 5s timeouts
59
+ - Prevents indefinite hanging on unstable CDP sessions
60
+ - Error messages now show which specific operation timed out
61
+
62
+ ## [3.3.4] - 2026-01-30
63
+
64
+ ### Added
65
+ - **click: Form submit detection for non-SPA apps** — Detect classic form submissions with page reload
66
+ - Tracks URL before and after click to detect page navigation
67
+ - Reports "Page navigation detected (form submit)" when URL changes
68
+ - Solves issue where POST requests were lost during page reload
69
+ - Works for Django, Rails, PHP and other server-rendered apps
70
+ - Example output: `🔄 Page navigation detected (form submit): /form → /success`
71
+
72
+ ## [3.3.3] - 2026-01-30
73
+
74
+ ### Fixed
75
+ - **click: Post-click diagnostics timeout** — Fixed 30s timeout caused by network wait logic
76
+ - Now waits only for mutation requests (POST/PUT/PATCH) started within 200ms after click
77
+ - Ignores GET requests completely (were causing unnecessary waits)
78
+ - Hard 10s timeout limit enforced (never hangs indefinitely)
79
+ - Always returns success even if requests still pending after 10s
80
+ - Shows pending requests status instead of reporting timeout
81
+ - Changed output from "Form submission" to "Mutation requests"
82
+ - Example output:
83
+ ```
84
+ 📡 Mutation requests detected (2 POST/PUT/PATCH):
85
+ 1. ✓ POST /admin/tenant/.../change/ → 302 Found
86
+ 2. ⏳ POST /api/slow-endpoint/ → pending
87
+
88
+ ⏳ 1 request(s) still pending after 10000ms timeout
89
+ ```
90
+
91
+ ### Performance
92
+ - **click: Removed unnecessary delays** — Removed 100ms waits after scrollIntoView and retry clicks
93
+ - Click operations now complete instantly when no mutation requests detected
94
+ - Typical click time: <50ms (was 500-10000ms with old diagnostics)
95
+
96
+ ## [3.3.2] - 2026-01-30
97
+
98
+ ### Fixed
99
+ - **click/type: Viewport scrolling** — Always scrolls to element before interaction
100
+ - Prevents 30s timeout when elements are outside viewport
101
+ - Moves scrollIntoView BEFORE first click/type attempt (was in fallback)
102
+ - Fixes Django admin forms hanging after multiple navigations
103
+ - Pattern: scroll → wait 100ms → interact
104
+ - Applied to both click and type tools
105
+
106
+ ## [3.3.1] - 2026-01-30
107
+
108
+ ### Added
109
+ - **click: All network requests visibility** — Shows ALL requests started within 200ms after click
110
+ - AI agent now sees complete network activity picture after every click
111
+ - Shows: method, URL, and status code for each request
112
+ - Example output:
113
+ ```
114
+ 📡 Network requests started within 200ms after click (15 total):
115
+ 1. ✓ GET /api/auth/ws_token/ → 200 OK
116
+ 2. ✓ GET /static/css/app.css → 200 OK
117
+ 3. ✓ POST /admin/tenant/.../change/ → 302 Found
118
+ ```
119
+ - Helps AI understand what happened after click (form submission, navigation, polling, etc.)
120
+ - 200ms window captures both mutation requests and side-effect requests
121
+
122
+ ### Changed
123
+ - **Enhanced diagnostics output** — Restructured to show all requests first, then mutation tracking
124
+ - Section 1: All requests in 200ms window (complete visibility)
125
+ - Section 2: Form submission status (POST/PATCH/PUT tracking)
126
+ - Clear icons: ✓ (success), ✗ (error), ⏳ (pending)
127
+ - Agent can immediately see if any requests occurred or not
128
+
129
+ ## [3.3.0] - 2026-01-30
130
+
131
+ ### Added
132
+ - **click: skipNetworkWait parameter** — Skip network wait for forms with long-polling/WebSockets
133
+ - New parameter: `skipNetworkWait: boolean` (default: false)
134
+ - Use case: Pages with continuous long-polling to get instant response
135
+ - Example: `click({ selector: 'button[type="submit"]', skipNetworkWait: true })`
136
+ - **click: networkWaitTimeout parameter** — Custom network wait timeout
137
+ - New parameter: `networkWaitTimeout: number` (default: 10000ms)
138
+ - Configurable per-click timeout for network requests
139
+ - Example: `click({ selector: '.save-btn', networkWaitTimeout: 5000 })`
140
+ - **type: timeout parameter** — Explicit timeout for type operations
141
+ - New parameter: `timeout: number` (default: 30000ms)
142
+ - Prevents infinite hangs on input fields
143
+ - Example: `type({ selector: 'input[name="url"]', text: 'value', timeout: 10000 })`
144
+
145
+ ### Changed
146
+ - **Smart form submission tracking** — Only tracks POST/PATCH/PUT requests in 100ms window
147
+ - Filters mutation requests (POST/PATCH/PUT) that started within 100ms after click
148
+ - Waits ONLY for these mutation requests (up to 10s)
149
+ - Ignores all other requests (GET, polling, analytics, etc.)
150
+ - Shows form submission status: `✓ POST /admin/tenant/.../change/ → 302 Found`
151
+ - Example: Django form with 50 polling requests → only 1 POST tracked
152
+ - **Type operation timeout protection** — Wrapped in Promise.race with configurable timeout
153
+ - Prevents 120s hangs on problematic input fields
154
+ - Returns clear error message: "Type operation timed out after Xms"
155
+ - Django forms: type now fails fast instead of hanging
156
+
157
+ ### Fixed
158
+ - **Django form timeout issues** — Fixed 30s click timeout and 120s type timeout
159
+ - Root cause: Long-polling/WebSockets created noise in network tracking
160
+ - Solution: Track only POST/PATCH/PUT requests within 100ms detection window
161
+ - Type operations now have explicit timeout protection
162
+ - Example: Django Admin forms now show reliable form submission status
163
+
164
+ ## [3.2.11] - 2026-01-30
165
+
166
+ ### Fixed
167
+ - **Tailwind CSS selector escape** — Fixed `analyzePage` failing on pages with Tailwind CSS
168
+ - Tailwind classes with colons (e.g., `hover:text-gray-800`) broke `querySelectorAll`
169
+ - Added filtering for classes containing special characters (`:`, `/`, `[`, `]`)
170
+ - Applied `CSS.escape()` to class names when building CSS selectors
171
+ - Fixed in: APOM tree builder, Angular tools, hints generator, content script
172
+ - Example: Page with Tailwind → no more "not a valid selector" errors
173
+
5
174
  ## [3.2.10] - 2026-01-29
6
175
 
7
176
  ### Fixed
package/README.md CHANGED
@@ -494,9 +494,11 @@ Click an element with optional result screenshot. **PREFERRED**: Use APOM ID fro
494
494
  - `waitAfter` (optional): Wait time in ms (default: 1500)
495
495
  - `screenshot` (optional): Capture screenshot (default: false for performance) ⚡
496
496
  - `timeout` (optional): Max operation time in ms (default: 30000)
497
- - **Use case**: Buttons, links, form submissions
498
- - **Returns**: Confirmation text + optional screenshot
499
- - **Performance**: 2-10x faster without screenshot
497
+ - `skipNetworkWait` (optional): Skip waiting for network requests (default: false). **Use for pages with continuous long-polling to get instant response.**
498
+ - `networkWaitTimeout` (optional): Custom network wait timeout in ms (default: 10000). Only used if skipNetworkWait is false.
499
+ - **Use case**: Buttons, links, form submissions, Django admin forms
500
+ - **Returns**: Confirmation text + optional screenshot + network diagnostics
501
+ - **Performance**: 2-10x faster without screenshot, instant with skipNetworkWait
500
502
  - **Example**:
501
503
  ```javascript
502
504
  // PREFERRED: Using APOM ID
@@ -504,6 +506,12 @@ Click an element with optional result screenshot. **PREFERRED**: Use APOM ID fro
504
506
 
505
507
  // Alternative: Using CSS selector
506
508
  click({ selector: "button[type='submit']" })
509
+
510
+ // Django forms with WebSockets (prevents timeout)
511
+ click({ selector: ".submit-row input[type='submit']", skipNetworkWait: true })
512
+
513
+ // Custom network timeout for slow APIs
514
+ click({ id: "save_btn", networkWaitTimeout: 10000 })
507
515
  ```
508
516
 
509
517
  #### type
@@ -513,9 +521,10 @@ Type text into input fields with optional clearing and typing delay. **PREFERRED
513
521
  - `selector` (optional): CSS selector. Use when APOM ID is not available.
514
522
  - ⚠️ Either `id` OR `selector` required (mutually exclusive)
515
523
  - `text` (required): Text to type
516
- - `delay` (optional): Delay between keystrokes in ms
524
+ - `delay` (optional): Delay between keystrokes in ms (default: 30)
517
525
  - `clearFirst` (optional): Clear field first (default: true)
518
- - **Use case**: Filling forms, search boxes, text inputs
526
+ - `timeout` (optional): Max operation time in ms (default: 30000). **Prevents infinite hangs on Django forms.**
527
+ - **Use case**: Filling forms, search boxes, text inputs, Django admin forms
519
528
  - **Returns**: Confirmation text
520
529
  - **Example**:
521
530
  ```javascript
package/angular-tools.js CHANGED
@@ -29,10 +29,16 @@ export async function listAngularComponents(page, includeHidden = false) {
29
29
 
30
30
  const component = ng.getComponent(el);
31
31
  if (component && component.constructor && component.constructor.name !== 'Object') {
32
- // Get selector
32
+ // Get selector (filter out Tailwind classes with special characters)
33
33
  const tagName = el.tagName.toLowerCase();
34
- const id = el.id ? `#${el.id}` : '';
35
- const classes = el.className ? `.${el.className.split(' ').join('.')}` : '';
34
+ const id = el.id ? `#${CSS.escape(el.id)}` : '';
35
+ const stableClasses = el.className
36
+ ? el.className.split(' ')
37
+ .filter(c => c && !/[:\/\[\]]/.test(c))
38
+ .slice(0, 3)
39
+ .map(c => CSS.escape(c))
40
+ : [];
41
+ const classes = stableClasses.length > 0 ? `.${stableClasses.join('.')}` : '';
36
42
  const selector = id || `${tagName}${classes}` || tagName;
37
43
 
38
44
  // Get methods (public only by default)
@@ -12,8 +12,17 @@
12
12
  */
13
13
 
14
14
  import WebSocket from 'ws';
15
+ import { appendFileSync } from 'fs';
16
+ import { homedir } from 'os';
17
+ import { join } from 'path';
15
18
 
16
19
  const BRIDGE_PORT = 9223;
20
+ const LOG_FILE = join(homedir(), 'chrometools-bridge.log');
21
+
22
+ function logToFile(message) {
23
+ const timestamp = new Date().toISOString();
24
+ appendFileSync(LOG_FILE, `${timestamp} ${message}\n`);
25
+ }
17
26
  const RECONNECT_DELAY = 2000;
18
27
  const MAX_RECONNECT_ATTEMPTS = 5;
19
28
 
@@ -64,19 +73,24 @@ export function setActiveTabSyncHandler(handler) {
64
73
  */
65
74
  export async function connectToBridge() {
66
75
  return new Promise((resolve) => {
76
+ logToFile(`connectToBridge called, isConnected=${isConnected}, wsState=${ws?.readyState}`);
77
+
67
78
  if (isConnected && ws?.readyState === WebSocket.OPEN) {
68
79
  debugLog('Already connected to Bridge');
80
+ logToFile('Already connected, skipping');
69
81
  resolve(true);
70
82
  return;
71
83
  }
72
84
 
73
85
  debugLog(`Connecting to Bridge on port ${BRIDGE_PORT}...`);
86
+ logToFile(`Attempting connection to ws://127.0.0.1:${BRIDGE_PORT}`);
74
87
 
75
88
  try {
76
89
  ws = new WebSocket(`ws://127.0.0.1:${BRIDGE_PORT}`);
77
90
 
78
91
  const connectTimeout = setTimeout(() => {
79
- debugLog('Connection timeout');
92
+ console.error('[chrometools-mcp] Bridge connection timeout (5s)');
93
+ logToFile('TIMEOUT: Connection timeout after 5s');
80
94
  ws?.close();
81
95
  resolve(false);
82
96
  }, 5000);
@@ -86,7 +100,8 @@ export async function connectToBridge() {
86
100
  isConnected = true;
87
101
  reconnectAttempts = 0;
88
102
  debugLog('Connected to Bridge');
89
- console.error('[chrometools-mcp] Connected to Bridge Service');
103
+ console.error('[chrometools-mcp] Connected to Bridge Service (isConnected=true)');
104
+ logToFile('SUCCESS: WebSocket connected, isConnected=true');
90
105
  resolve(true);
91
106
  });
92
107
 
@@ -99,8 +114,9 @@ export async function connectToBridge() {
99
114
  }
100
115
  });
101
116
 
102
- ws.on('close', () => {
103
- debugLog('Disconnected from Bridge');
117
+ ws.on('close', (code, reason) => {
118
+ console.error(`[chrometools-mcp] Bridge disconnected (code=${code}, reason=${reason || 'none'})`);
119
+ logToFile(`CLOSE: WebSocket closed, code=${code}, reason=${reason || 'none'}`);
104
120
  isConnected = false;
105
121
  ws = null;
106
122
  scheduleReconnect();
@@ -108,12 +124,14 @@ export async function connectToBridge() {
108
124
 
109
125
  ws.on('error', (error) => {
110
126
  clearTimeout(connectTimeout);
111
- debugLog('Connection error:', error.message);
127
+ console.error(`[chrometools-mcp] Bridge connection error: ${error.message}`);
128
+ logToFile(`ERROR: WebSocket error: ${error.message}`);
112
129
  resolve(false);
113
130
  });
114
131
 
115
132
  } catch (error) {
116
- debugLog('Failed to create WebSocket:', error.message);
133
+ console.error(`[chrometools-mcp] Failed to create WebSocket: ${error.message}`);
134
+ logToFile(`EXCEPTION: Failed to create WebSocket: ${error.message}`);
117
135
  resolve(false);
118
136
  }
119
137
  });
@@ -125,7 +143,7 @@ export async function connectToBridge() {
125
143
  function scheduleReconnect() {
126
144
  if (reconnectTimer) return;
127
145
  if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
128
- debugLog(`Max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached`);
146
+ console.error(`[chrometools-mcp] Bridge: max reconnect attempts (${MAX_RECONNECT_ATTEMPTS}) reached, giving up`);
129
147
  return;
130
148
  }
131
149
 
@@ -360,6 +378,7 @@ export function isExtensionConnected() {
360
378
  * Check if connected to Bridge
361
379
  */
362
380
  export function isBridgeConnected() {
381
+ logToFile(`isBridgeConnected called, returning ${isConnected}`);
363
382
  return isConnected;
364
383
  }
365
384
 
@@ -470,3 +489,39 @@ export function getRecorderState() {
470
489
  export function getRecordings() {
471
490
  return bridgeState.recordings;
472
491
  }
492
+
493
+ /**
494
+ * Get network requests from Bridge (tracked via Extension webRequest API)
495
+ * These persist across page navigations unlike CDP-based tracking
496
+ */
497
+ export async function getNetworkRequestsFromBridge(options = {}) {
498
+ if (!isConnected) {
499
+ debugLog('Cannot get network requests: not connected to Bridge');
500
+ return [];
501
+ }
502
+
503
+ try {
504
+ const response = await sendCommand('get_network_requests', {}, options.timeout || 5000);
505
+ return response.payload?.requests || [];
506
+ } catch (error) {
507
+ debugLog('Failed to get network requests:', error.message);
508
+ return [];
509
+ }
510
+ }
511
+
512
+ /**
513
+ * Clear network requests in Bridge
514
+ */
515
+ export async function clearNetworkRequestsFromBridge() {
516
+ if (!isConnected) {
517
+ return false;
518
+ }
519
+
520
+ try {
521
+ await sendCommand('clear_network_requests', {}, 5000);
522
+ return true;
523
+ } catch (error) {
524
+ debugLog('Failed to clear network requests:', error.message);
525
+ return false;
526
+ }
527
+ }
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * - Launched by Chrome when Extension starts
9
9
  * - Maintains state (tabs, recordings, events)
10
- * - Accepts 0-8 WebSocket clients
10
+ * - Accepts up to 20 WebSocket clients (with dead connection cleanup)
11
11
  * - Clients get full state on connect + real-time updates
12
12
  */
13
13
 
@@ -24,7 +24,7 @@ const require = createRequire(import.meta.url);
24
24
  // ============================================
25
25
 
26
26
  const WS_PORT = 9223;
27
- const MAX_CLIENTS = 8;
27
+ const MAX_CLIENTS = 20; // Increased from 8 to handle multiple MCP restarts
28
28
  const HOST_NAME = 'com.chrometools.bridge';
29
29
 
30
30
  // ============================================
@@ -40,11 +40,13 @@ const state = {
40
40
  metadata: null,
41
41
  secrets: {}
42
42
  },
43
+ networkRequests: new Map(), // requestId -> request info (from Extension webRequest API)
43
44
  eventLog: [], // Ring buffer of recent events (max 1000)
44
45
  extensionConnected: false
45
46
  };
46
47
 
47
48
  const MAX_EVENT_LOG = 1000;
49
+ const MAX_NETWORK_REQUESTS = 500;
48
50
 
49
51
  // ============================================
50
52
  // WebSocket Server for Claude/MCP Clients
@@ -61,6 +63,14 @@ function startWebSocketServer() {
61
63
  });
62
64
 
63
65
  wss.on('connection', (ws) => {
66
+ // Clean up dead connections before checking limit
67
+ for (const client of clients) {
68
+ if (client.readyState !== 1) { // Not OPEN
69
+ clients.delete(client);
70
+ log(`Cleaned up dead client (readyState=${client.readyState})`);
71
+ }
72
+ }
73
+
64
74
  if (clients.size >= MAX_CLIENTS) {
65
75
  log(`Max clients (${MAX_CLIENTS}) reached, rejecting connection`);
66
76
  ws.close(1013, 'Max clients reached');
@@ -105,6 +115,7 @@ function getFullState() {
105
115
  tabs: Array.from(state.tabs.values()),
106
116
  recordings: state.recordings,
107
117
  recorderState: state.recorderState,
118
+ networkRequests: Array.from(state.networkRequests.values()),
108
119
  extensionConnected: state.extensionConnected,
109
120
  timestamp: Date.now()
110
121
  };
@@ -153,6 +164,25 @@ function handleClientMessage(ws, message) {
153
164
  });
154
165
  break;
155
166
 
167
+ case 'get_network_requests':
168
+ // Return network requests from state (collected via Extension webRequest API)
169
+ sendToClient(ws, {
170
+ type: 'network_requests',
171
+ payload: {
172
+ requests: Array.from(state.networkRequests.values())
173
+ },
174
+ requestId: message.requestId
175
+ });
176
+ break;
177
+
178
+ case 'clear_network_requests':
179
+ state.networkRequests.clear();
180
+ sendToClient(ws, {
181
+ type: 'network_requests_cleared',
182
+ requestId: message.requestId
183
+ });
184
+ break;
185
+
156
186
  // Commands to forward to extension
157
187
  case 'start_recording':
158
188
  case 'stop_recording':
@@ -295,6 +325,54 @@ function handleExtensionMessage(message) {
295
325
  broadcastToClients({ type: 'pong' });
296
326
  break;
297
327
 
328
+ // Network request events (from Extension webRequest API)
329
+ case 'network_request_started':
330
+ state.networkRequests.set(message.payload.requestId, message.payload);
331
+ // Clean old requests
332
+ if (state.networkRequests.size > MAX_NETWORK_REQUESTS) {
333
+ const entries = Array.from(state.networkRequests.entries());
334
+ const removeCount = entries.length - MAX_NETWORK_REQUESTS;
335
+ for (let i = 0; i < removeCount; i++) {
336
+ state.networkRequests.delete(entries[i][0]);
337
+ }
338
+ }
339
+ broadcastToClients({ type: 'network_request_started', payload: message.payload });
340
+ break;
341
+
342
+ case 'network_request_completed':
343
+ if (state.networkRequests.has(message.payload.requestId)) {
344
+ const req = state.networkRequests.get(message.payload.requestId);
345
+ req.status = message.payload.status;
346
+ req.statusText = message.payload.statusText;
347
+ req.completedAt = Date.now();
348
+ }
349
+ broadcastToClients({ type: 'network_request_completed', payload: message.payload });
350
+ break;
351
+
352
+ case 'network_request_failed':
353
+ if (state.networkRequests.has(message.payload.requestId)) {
354
+ const req = state.networkRequests.get(message.payload.requestId);
355
+ req.status = 'failed';
356
+ req.error = message.payload.error;
357
+ req.completedAt = Date.now();
358
+ }
359
+ broadcastToClients({ type: 'network_request_failed', payload: message.payload });
360
+ break;
361
+
362
+ case 'network_requests':
363
+ // Response from extension with all network requests
364
+ broadcastToClients({
365
+ type: 'network_requests',
366
+ payload: message.payload,
367
+ requestId: message.requestId
368
+ });
369
+ break;
370
+
371
+ case 'network_requests_cleared':
372
+ state.networkRequests.clear();
373
+ broadcastToClients({ type: 'network_requests_cleared' });
374
+ break;
375
+
298
376
  default:
299
377
  // Forward unknown messages to clients
300
378
  broadcastToClients(message);
@@ -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();