chrometools-mcp 3.5.5 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -37,9 +37,16 @@ function initializeModelRegistry() {
37
37
  *
38
38
  * @param {boolean} interactiveOnly - Only include interactive elements and their parents
39
39
  * @param {boolean} viewportOnly - Only include elements visible in current viewport
40
+ * @param {Object} portalOpts - Portal scan options: { include: boolean, selectors: string[] }
41
+ * When include=true, force-includes contents of React Portal containers
42
+ * (e.g. #menu-popup-root, #tooltip-root) that live outside main React root.
40
43
  * @returns {Object} APOM tree structure
41
44
  */
42
- function buildAPOMTree(interactiveOnly = true, viewportOnly = false) {
45
+ function buildAPOMTree(interactiveOnly = true, viewportOnly = false, portalOpts = undefined) {
46
+ const portalInclude = portalOpts ? portalOpts.include !== false : true;
47
+ const portalSelectors = (portalOpts && Array.isArray(portalOpts.selectors) && portalOpts.selectors.length > 0)
48
+ ? portalOpts.selectors
49
+ : ['#modal-root', '#menu-popup-root', '#tooltip-root', '#popover-root', '[data-portal]'];
43
50
  const pageId = `page_${btoa(window.location.href).replace(/[^a-zA-Z0-9]/g, '').substring(0, 20)}_${Date.now()}`;
44
51
 
45
52
  // Initialize model registry (Strategy Pattern setup)
@@ -116,6 +123,54 @@ function buildAPOMTree(interactiveOnly = true, viewportOnly = false) {
116
123
  forceMarkModalTree(el);
117
124
  }
118
125
  });
126
+
127
+ // Generic portal containers (menus, tooltips, popovers) — opt-in by selector list.
128
+ // Unlike framework modals, these are app-defined wrappers (e.g. #menu-popup-root from
129
+ // client/index.html). When non-empty, force-include their subtree.
130
+ if (portalInclude && portalSelectors.length > 0) {
131
+ try {
132
+ document.querySelectorAll(portalSelectors.join(',')).forEach(container => {
133
+ if (!container.children || container.children.length === 0) return;
134
+ for (const child of container.children) {
135
+ if (!modalElements.has(child)) {
136
+ forceMarkModalTree(child);
137
+ }
138
+ }
139
+ });
140
+ } catch (e) {
141
+ // Invalid selector in portalSelectors — skip silently, do not break analyzePage
142
+ }
143
+ }
144
+
145
+ // In-tree popups (Popper/Tippy/FloatingUI/custom contextMenu pattern). Some libs
146
+ // render the popup inside a 0-height inline wrapper and then absolute-position it
147
+ // out of the wrapper's box. Default isVisible() drops the wrapper (height: 0) and
148
+ // every popup descendant is lost. Detect: a 0×0 wrapper whose subtree contains a
149
+ // positioned (absolute/fixed) child with real bounds — force-mark that child.
150
+ if (portalInclude) {
151
+ function findPositionedPopup(el, maxDepth) {
152
+ if (maxDepth <= 0) return null;
153
+ for (const child of el.children) {
154
+ if (modalElements.has(child)) continue;
155
+ const cs = window.getComputedStyle(child);
156
+ if ((cs.position === 'absolute' || cs.position === 'fixed') &&
157
+ child.offsetWidth > 0 && child.offsetHeight > 0) {
158
+ return child;
159
+ }
160
+ const deeper = findPositionedPopup(child, maxDepth - 1);
161
+ if (deeper) return deeper;
162
+ }
163
+ return null;
164
+ }
165
+
166
+ const allEls = document.body.querySelectorAll('*');
167
+ for (const el of allEls) {
168
+ // Only zero-sized wrappers with children are candidates — cheap filter
169
+ if ((el.offsetHeight !== 0 && el.offsetWidth !== 0) || el.children.length === 0) continue;
170
+ const popup = findPositionedPopup(el, 3);
171
+ if (popup) forceMarkModalTree(popup);
172
+ }
173
+ }
119
174
  }
120
175
 
121
176
  // Build tree from body
@@ -37,6 +37,10 @@ export const toolDefinitions = [
37
37
  waitAfter: { type: "number", description: "Wait ms (default: 1500)" },
38
38
  screenshot: { type: "boolean", description: "Screenshot (default: false)" },
39
39
  timeout: { type: "number", description: "Max wait ms (default: 30000)" },
40
+ waitForSelector: { type: "string", description: "CSS selector to wait for after click (atomic click+wait). Use for dropdowns/popups that render into portals." },
41
+ waitTimeoutMs: { type: "number", description: "Timeout for waitForSelector in ms (default: 2000)." },
42
+ waitForRouteChange: { type: "boolean", description: "SPA route wait: after click, wait for location.pathname+search to change vs before; reports 'routeChanged:true/false'. Does not fail click on timeout. For URL-less view changes use waitForSelector." },
43
+ autoAnalyzeAfter: { type: "boolean", description: "After click, diff APOM and append '+N appeared: id:\"text\"' delta to result. New ids are pre-registered for follow-up clicks. Use for dropdowns/menus opening with new options." },
40
44
  },
41
45
  },
42
46
  },
@@ -92,18 +96,18 @@ export const toolDefinitions = [
92
96
  },
93
97
  {
94
98
  name: "screenshot",
95
- description: "Capture element image (5-10k tokens). Use analyzePage for form data/validation (8-10k tokens).",
99
+ description: "Capture element image (5-10k tokens), or full viewport when no id/selector is given. Use analyzePage for form data/validation (8-10k tokens).",
96
100
  inputSchema: {
97
101
  type: "object",
98
102
  properties: {
99
- selector: { type: "string", description: "CSS selector" },
100
- padding: { type: "number", description: "Padding px (default: 0)" },
103
+ id: { type: "string", description: "APOM element ID. Mutually exclusive with selector. Omit both for viewport screenshot." },
104
+ selector: { type: "string", description: "CSS selector. Mutually exclusive with id. Omit both for viewport screenshot." },
105
+ padding: { type: "number", description: "Padding px (default: 0). Ignored for viewport." },
101
106
  maxWidth: { type: "number", description: "Max width px (default: 1024, null=original)" },
102
107
  maxHeight: { type: "number", description: "Max height px (default: 8000, null=original)" },
103
108
  quality: { type: "number", minimum: 1, maximum: 100, description: "JPEG quality (default: 40)" },
104
109
  format: { type: "string", enum: ["png", "jpeg", "auto"], description: "Format (default: jpeg)" },
105
110
  },
106
- required: ["selector"],
107
111
  },
108
112
  },
109
113
  {
@@ -364,6 +368,25 @@ Examples:
364
368
  required: ["url"],
365
369
  },
366
370
  },
371
+ {
372
+ name: "listFrames",
373
+ description: "List all frames (main + iframes) on the current page with url/name/isMain, plus the currently active frame. Use to discover cross-origin iframes (e.g. app.example.com), then switchFrame into one.",
374
+ inputSchema: {
375
+ type: "object",
376
+ properties: {},
377
+ },
378
+ },
379
+ {
380
+ name: "switchFrame",
381
+ description: "Set the active frame so click/type/hover/analyzePage/find/executeScript/waitForElement run INSIDE it — required to automate cross-origin iframes (resolved via CDP, bypassing Same-Origin Policy). Call with no args to reset to the main frame. Auto-resets on navigateTo.",
382
+ inputSchema: {
383
+ type: "object",
384
+ properties: {
385
+ frameUrl: { type: "string", description: "Substring matched against each frame's URL (e.g. 'app.example.com'). Mutually exclusive with frameSelector." },
386
+ frameSelector: { type: "string", description: "CSS selector of the <iframe> element; its content frame becomes active. Mutually exclusive with frameUrl." },
387
+ },
388
+ },
389
+ },
367
390
  {
368
391
  name: "getFigmaFrame",
369
392
  description: "Export Figma frame as PNG. Requires API token and file/node IDs.",
@@ -503,6 +526,7 @@ Examples:
503
526
  properties: {
504
527
  description: { type: "string", description: "Natural language description" },
505
528
  maxResults: { type: "number", minimum: 1, maximum: 20, description: "Max candidates (default: 5)" },
529
+ minConfidence: { type: "number", minimum: 0, maximum: 1, description: "Confidence threshold (default: 0.6) for auto-executing `action`. Below it (or too close to runner-up), action is skipped and candidates returned with 'actionSkipped'. Prevents auto-clicking the wrong control." },
506
530
  action: {
507
531
  type: "object",
508
532
  properties: {
@@ -532,6 +556,8 @@ Examples:
532
556
  groupBy: { type: "string", description: "Group elements: 'type' or 'flat' (default: 'type')", enum: ["type", "flat"] },
533
557
  viewportOnly: { type: "boolean", description: "Only analyze elements in current viewport (default: false). Reduces output for long pages." },
534
558
  diff: { type: "boolean", description: "Return only changes since last analysis: {added, removed, changed} (default: false)." },
559
+ includePortals: { type: "boolean", description: "Include React Portal contents — menus, tooltips, popovers outside main root (default: true). Without this, dropdown items are invisible." },
560
+ portalSelectors: { type: "array", items: { type: "string" }, description: "Custom portal root CSS selectors. Default: ['#modal-root', '#menu-popup-root', '#tooltip-root', '#popover-root', '[data-portal]']." },
535
561
  },
536
562
  },
537
563
  },
@@ -7,7 +7,7 @@
7
7
  export const toolGroups = {
8
8
  core: ['ping', 'openBrowser', 'executeScript', 'navigateTo'],
9
9
 
10
- interaction: ['click', 'type', 'scrollTo', 'waitForElement', 'hover', 'selectOption', 'selectFromGroup', 'drag', 'scrollHorizontal'],
10
+ interaction: ['click', 'type', 'scrollTo', 'waitForElement', 'hover', 'selectOption', 'selectFromGroup', 'drag', 'scrollHorizontal', 'switchFrame', 'listFrames'],
11
11
 
12
12
  inspection: ['getElement', 'getComputedCss', 'getBoxModel', 'screenshot', 'saveScreenshot'],
13
13
 
@@ -23,6 +23,10 @@ export const ClickSchema = z.object({
23
23
  timeout: z.number().optional().describe("Maximum time to wait for operation in ms (default: 30000)"),
24
24
  skipNetworkWait: z.boolean().optional().describe("Skip waiting for network requests (default: false). Use for forms with long-polling/WebSockets to avoid timeouts."),
25
25
  networkWaitTimeout: z.number().optional().describe("Maximum time to wait for network requests in ms (default: 3000). Only used if skipNetworkWait is false."),
26
+ waitForSelector: z.string().optional().describe("CSS selector to wait for after click — atomic click+wait. Useful for dropdowns/popups in portals (e.g. '#menu-popup-root > div') that otherwise race against the next MCP call."),
27
+ waitTimeoutMs: z.number().optional().describe("Timeout for waitForSelector in ms (default: 2000)."),
28
+ waitForRouteChange: z.boolean().optional().describe("For SPAs (React Router etc.): after click, wait for location.pathname+search to change relative to before. Surfaces 'routeChanged:true/false' so success means the view actually navigated, not just that the click was delivered. Does not fail the click on timeout. For content that renders without a URL change, prefer waitForSelector."),
29
+ autoAnalyzeAfter: z.boolean().optional().describe("After click, automatically diff APOM state and append a delta to the result: '+N appeared: id1:\"text\", id2:\"text\"'. New ids are re-registered so callers can use them directly in the next click/type call without an extra analyzePage. Use for dropdowns and menus that reveal new options on click."),
26
30
  }).refine(data => (data.id && !data.selector) || (!data.id && data.selector), {
27
31
  message: "Either 'id' or 'selector' must be provided, but not both"
28
32
  });
@@ -110,15 +114,15 @@ export const SetStylesSchema = z.object({
110
114
 
111
115
  // Screenshot tools
112
116
  export const ScreenshotSchema = z.object({
113
- id: z.string().optional().describe("APOM element ID from analyzePage (e.g., 'div_20'). Mutually exclusive with selector."),
114
- selector: z.string().optional().describe("CSS selector for element to screenshot. Mutually exclusive with id."),
115
- padding: z.number().optional().describe("Padding around element in pixels (default: 0)"),
117
+ id: z.string().optional().describe("APOM element ID from analyzePage (e.g., 'div_20'). Mutually exclusive with selector. If neither id nor selector is provided, captures full viewport."),
118
+ selector: z.string().optional().describe("CSS selector for element to screenshot. Mutually exclusive with id. If neither id nor selector is provided, captures full viewport."),
119
+ padding: z.number().optional().describe("Padding around element in pixels (default: 0). Ignored for viewport screenshot."),
116
120
  maxWidth: z.number().nullable().optional().describe("Maximum width in pixels, auto-scales if larger (default: 1024, set to null for original size)"),
117
121
  maxHeight: z.number().nullable().optional().describe("Maximum height in pixels, auto-scales if larger (default: 8000 for API limit, set to null for original size)"),
118
122
  quality: z.number().min(1).max(100).optional().describe("JPEG quality 1-100 (default: 40)"),
119
123
  format: z.enum(['png', 'jpeg', 'auto']).optional().describe("Image format (default: 'jpeg')"),
120
- }).refine(data => (data.id && !data.selector) || (!data.id && data.selector), {
121
- message: "Either 'id' or 'selector' must be provided, but not both"
124
+ }).refine(data => !(data.id && data.selector), {
125
+ message: "Provide only one of 'id' or 'selector' (or neither for a viewport screenshot)"
122
126
  });
123
127
 
124
128
  export const SaveScreenshotSchema = z.object({
@@ -153,6 +157,13 @@ export const NavigateToSchema = z.object({
153
157
  .describe("Wait until event (default: networkidle2)"),
154
158
  });
155
159
 
160
+ export const ListFramesSchema = z.object({});
161
+
162
+ export const SwitchFrameSchema = z.object({
163
+ frameUrl: z.string().optional().describe("Substring matched against each frame's URL (e.g. 'app.example.com'). Selects the first matching frame, including cross-origin iframes (resolved via CDP, so Same-Origin Policy does not block access). Mutually exclusive with frameSelector."),
164
+ frameSelector: z.string().optional().describe("CSS selector of the <iframe> element in the current document; its content frame becomes active. Mutually exclusive with frameUrl."),
165
+ }).describe("Set the active frame for subsequent click/type/hover/analyzePage/find/executeScript/waitForElement. Call with NO arguments to reset back to the main frame. Active frame is also reset automatically on navigateTo. Use listFrames() to discover available frames.");
166
+
156
167
  export const SetViewportSchema = z.object({
157
168
  width: z.number().min(320).max(4000).describe("Viewport width in pixels (320-4000)"),
158
169
  height: z.number().min(200).max(3000).describe("Viewport height in pixels (200-3000)"),
@@ -269,6 +280,7 @@ export const ConvertFigmaToCodeSchema = z.object({
269
280
  export const SmartFindElementSchema = z.object({
270
281
  description: z.string().describe("Natural language description of element to find (e.g., 'login button', 'email field')"),
271
282
  maxResults: z.number().min(1).max(20).optional().describe("Maximum number of candidates to return (default: 5)"),
283
+ minConfidence: z.number().min(0).max(1).optional().describe("Confidence threshold (0-1, default: 0.6) for auto-executing `action`. If the best match scores below this, OR is too close to the runner-up, the action is SKIPPED and candidates are returned with an 'actionSkipped' reason — prevents auto-clicking the wrong control (e.g. a primary form submit when you asked for a menu item). Lower it to act on weaker matches."),
272
284
  action: z.object({
273
285
  type: z.enum(['click', 'type', 'scrollTo', 'screenshot', 'hover', 'setStyles']).describe("Action to perform on the best match"),
274
286
  text: z.string().optional().describe("Text to type (required for 'type' action)"),
@@ -289,6 +301,8 @@ export const AnalyzePageSchema = z.object({
289
301
  groupBy: z.enum(['type', 'flat']).optional().describe("Group elements by type or return flat structure (default: 'type')"),
290
302
  viewportOnly: z.boolean().optional().describe("Only analyze elements visible in current viewport (default: false). Reduces output for long pages."),
291
303
  diff: z.boolean().optional().describe("Return only changes since last analysis: {added, removed, changed} (default: false). Useful after clicks to see what changed."),
304
+ includePortals: z.boolean().optional().describe("Include contents of React Portal containers that live outside main React root (default: true). Covers menus, tooltips, popovers rendered via portals — without this, dropdown contents are invisible to analyzePage."),
305
+ portalSelectors: z.array(z.string()).optional().describe("CSS selectors of portal root containers to scan (default: ['#modal-root', '#menu-popup-root', '#tooltip-root', '#popover-root', '[data-portal]']). Provide custom list when the app uses different portal element ids."),
292
306
  });
293
307
 
294
308
  export const GetElementDetailsSchema = z.object({
@@ -17,6 +17,8 @@ import { processScreenshot } from '../screenshot-processor.js';
17
17
  * @param {boolean} options.screenshot - Whether to capture screenshot after click
18
18
  * @param {boolean} options.skipNetworkWait - Skip waiting for network requests
19
19
  * @param {number} options.networkWaitTimeout - Network wait timeout in ms
20
+ * @param {string} options.waitForSelector - CSS selector to wait for after click (e.g., dropdown menu opening)
21
+ * @param {number} options.waitTimeoutMs - Timeout for waitForSelector in ms (default: 2000)
20
22
  * @returns {Promise<Object>} Result with content array
21
23
  */
22
24
  export async function executeClickAction(page, element, options = {}) {
@@ -24,7 +26,10 @@ export async function executeClickAction(page, element, options = {}) {
24
26
  identifier = 'element',
25
27
  screenshot = false,
26
28
  skipNetworkWait = false,
27
- networkWaitTimeout = 3000
29
+ networkWaitTimeout = 3000,
30
+ waitForSelector = null,
31
+ waitTimeoutMs = 2000,
32
+ waitForRouteChange = false
28
33
  } = options;
29
34
 
30
35
  // Capture timestamp and URL BEFORE click for diagnostics
@@ -127,6 +132,52 @@ export async function executeClickAction(page, element, options = {}) {
127
132
 
128
133
  await clickWithTimeout();
129
134
 
135
+ // Atomic click + wait: if caller asked to wait for a selector to appear after click,
136
+ // do it BEFORE diagnostics. Useful for dropdowns/popups that render into a portal —
137
+ // without atomic wait, the next MCP call may race against the popup closing.
138
+ let waitResult = null;
139
+ if (waitForSelector) {
140
+ const waitStart = Date.now();
141
+ try {
142
+ await page.waitForSelector(waitForSelector, { timeout: waitTimeoutMs, visible: true });
143
+ waitResult = { appeared: true, selector: waitForSelector, appearedInMs: Date.now() - waitStart };
144
+ } catch (e) {
145
+ waitResult = {
146
+ appeared: false,
147
+ selector: waitForSelector,
148
+ timedOutAfterMs: Date.now() - waitStart,
149
+ timeoutMs: waitTimeoutMs
150
+ };
151
+ }
152
+ }
153
+
154
+ // SPA route-change wait (P1-5): SPAs navigate via history.pushState, so page.url()
155
+ // diagnostics below may not register a "navigation". Wait for location.pathname+search
156
+ // to actually change relative to before. Best-effort — a timeout never fails the click.
157
+ let routeChangeResult = null;
158
+ if (waitForRouteChange) {
159
+ let pathBefore;
160
+ try {
161
+ pathBefore = await page.evaluate(() => location.pathname + location.search);
162
+ } catch (e) {
163
+ pathBefore = null;
164
+ }
165
+ if (pathBefore !== null) {
166
+ const routeStart = Date.now();
167
+ try {
168
+ await page.waitForFunction(
169
+ (before) => (location.pathname + location.search) !== before,
170
+ { timeout: waitTimeoutMs },
171
+ pathBefore
172
+ );
173
+ const pathAfter = await page.evaluate(() => location.pathname + location.search);
174
+ routeChangeResult = { routeChanged: true, from: pathBefore, to: pathAfter, changedInMs: Date.now() - routeStart };
175
+ } catch (e) {
176
+ routeChangeResult = { routeChanged: false, from: pathBefore, timeoutMs: waitTimeoutMs };
177
+ }
178
+ }
179
+ }
180
+
130
181
  // Check if element was detached from DOM during click (Angular *ngFor + Zone.js pattern)
131
182
  let elementDetached = false;
132
183
  try {
@@ -207,6 +258,24 @@ export async function executeClickAction(page, element, options = {}) {
207
258
  hintsText += `\nSuggested next: ${hints.suggestedNext.join('; ')}`;
208
259
  }
209
260
 
261
+ // Surface SPA route-change outcome to the caller
262
+ if (routeChangeResult) {
263
+ if (routeChangeResult.routeChanged) {
264
+ hintsText += `\nRoute changed: "${routeChangeResult.from}" → "${routeChangeResult.to}" (${routeChangeResult.changedInMs}ms)`;
265
+ } else {
266
+ hintsText += `\n⚠️ ROUTE_UNCHANGED: location did not change within ${routeChangeResult.timeoutMs}ms after click (still "${routeChangeResult.from}"). View may render without a URL change — use waitForSelector instead.`;
267
+ }
268
+ }
269
+
270
+ // Surface waitForSelector outcome to the caller (success or timeout)
271
+ if (waitResult) {
272
+ if (waitResult.appeared) {
273
+ hintsText += `\nWait: "${waitResult.selector}" appeared in ${waitResult.appearedInMs}ms`;
274
+ } else {
275
+ hintsText += `\n⚠️ WAIT_TIMEOUT: "${waitResult.selector}" did not appear within ${waitResult.timeoutMs}ms after click`;
276
+ }
277
+ }
278
+
210
279
  // 4. Add diagnostics to output
211
280
  const diagnosticsText = formatDiagnosticsForAI(diagnostics);
212
281
 
@@ -20,13 +20,16 @@ async function takeActionScreenshot(page, clip) {
20
20
  };
21
21
  }
22
22
 
23
- // Helper function to execute actions on elements
24
- export async function executeElementAction(page, selector, action) {
23
+ // Helper function to execute actions on elements.
24
+ // `frame` (P0-1) is the DOM context used for element resolution/evaluation —
25
+ // pass the active iframe's Frame to act inside it. Defaults to `page` (main frame).
26
+ // Page-level operations (keyboard, screenshot) always use `page`.
27
+ export async function executeElementAction(page, selector, action, frame = page) {
25
28
  if (!action || !action.type) {
26
29
  return null;
27
30
  }
28
31
 
29
- const element = await page.$(selector);
32
+ const element = await frame.$(selector);
30
33
  if (!element) {
31
34
  throw new Error(`Element not found for action: ${selector}`);
32
35
  }
@@ -83,7 +86,7 @@ export async function executeElementAction(page, selector, action) {
83
86
  case 'scrollTo':
84
87
  await element.scrollIntoView({ behavior: 'auto' });
85
88
  await new Promise(resolve => setTimeout(resolve, action.waitAfter || 300));
86
- const position = await page.evaluate(() => ({
89
+ const position = await frame.evaluate(() => ({
87
90
  x: window.scrollX,
88
91
  y: window.scrollY
89
92
  }));
@@ -128,7 +131,7 @@ export async function executeElementAction(page, selector, action) {
128
131
  for (const style of action.styles) {
129
132
  stylesObject[style.name] = style.value;
130
133
  }
131
- await page.evaluate((sel, styles) => {
134
+ await frame.evaluate((sel, styles) => {
132
135
  const el = document.querySelector(sel);
133
136
  if (el) {
134
137
  Object.entries(styles).forEach(([key, value]) => {
@@ -30,6 +30,10 @@ export const isWindows = process.platform === 'win32' || isWSL;
30
30
  * @returns {string} - Path to Chrome executable
31
31
  */
32
32
  export function getChromePath() {
33
+ // Explicit override wins on every platform
34
+ if (process.env.CHROMETOOLS_CHROME_PATH) {
35
+ return process.env.CHROMETOOLS_CHROME_PATH;
36
+ }
33
37
  if (process.platform === 'win32') {
34
38
  // Native Windows
35
39
  return 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe';
@@ -57,6 +61,17 @@ export function getTempDir() {
57
61
  }
58
62
 
59
63
  /**
60
- * Chrome remote debugging port
64
+ * Get the Chrome user-data-dir used when launching a new instance.
65
+ * Override with CHROMETOOLS_USER_DATA_DIR to point at a real/cloned profile
66
+ * that already holds the user's login session/cookies.
67
+ * @returns {string} - Path to Chrome user data directory
68
+ */
69
+ export function getUserDataDir() {
70
+ return process.env.CHROMETOOLS_USER_DATA_DIR || `${getTempDir()}/chrome-mcp-profile`;
71
+ }
72
+
73
+ /**
74
+ * Chrome remote debugging port.
75
+ * Override with CHROMETOOLS_DEBUG_PORT (defaults to 9222).
61
76
  */
62
- export const CHROME_DEBUG_PORT = 9222;
77
+ export const CHROME_DEBUG_PORT = parseInt(process.env.CHROMETOOLS_DEBUG_PORT, 10) || 9222;
@@ -298,21 +298,38 @@ export function formatDiagnosticsForAI(diagnostics) {
298
298
  const netActivity = diagnostics.networkActivity;
299
299
  const trackedRequests = netActivity.trackedRequests || [];
300
300
 
301
- // Show requests detected within 200ms after action
302
- if (trackedRequests.length > 0) {
303
- output += `\n\n📡 Network requests (${trackedRequests.length}):`;
304
- trackedRequests.forEach((req, idx) => {
301
+ // Show requests detected within 200ms after action.
302
+ // P1-6: filter to data requests (XHR/Fetch) and drop static assets — a single
303
+ // SPA navigation pulls dozens of JS chunks/CSS/fonts that drown the signal.
304
+ // Cap the printed list and summarize the rest.
305
+ const STATIC_TYPES = new Set(['Script', 'Stylesheet', 'Font', 'Image', 'Media', 'Manifest', 'Other']);
306
+ const MAX_SHOWN = 12;
307
+ const dataRequests = trackedRequests.filter(req => {
308
+ if (!req.type) return true; // no type info → keep (bridge/legacy)
309
+ return !STATIC_TYPES.has(req.type);
310
+ });
311
+ const staticDropped = trackedRequests.length - dataRequests.length;
312
+
313
+ if (dataRequests.length > 0) {
314
+ const shown = dataRequests.slice(0, MAX_SHOWN);
315
+ output += `\n\n📡 Network requests (XHR/Fetch: ${dataRequests.length}${staticDropped > 0 ? `, ${staticDropped} static asset(s) hidden` : ''}):`;
316
+ shown.forEach((req, idx) => {
305
317
  const statusIcon = req.status === 'pending' ? '⏳' :
306
318
  (req.status === 'completed' || (typeof req.status === 'number' && req.status < 400) ? '✓' : '✗');
307
319
  const statusText = req.statusText || req.status || 'pending';
308
320
  output += `\n ${idx + 1}. ${statusIcon} ${req.method} ${req.url}`;
309
321
  output += `\n → Status: ${statusText}`;
310
322
  });
323
+ if (dataRequests.length > MAX_SHOWN) {
324
+ output += `\n … ${dataRequests.length - MAX_SHOWN} more`;
325
+ }
311
326
 
312
327
  // Show if some requests are still pending after timeout
313
328
  if (netActivity.stillPending > 0) {
314
329
  output += `\n\n⏳ ${netActivity.stillPending} request(s) still pending after ${Math.round(netActivity.waitedMs)}ms timeout`;
315
330
  }
331
+ } else if (staticDropped > 0) {
332
+ output += `\n\n📡 No XHR/Fetch requests detected within 200ms (${staticDropped} static asset(s) hidden)`;
316
333
  } else {
317
334
  output += '\n\n📡 No network requests detected within 200ms';
318
335
  }