mcp-web-inspector 0.5.0 → 0.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.
Files changed (31) hide show
  1. package/README.md +6 -8
  2. package/dist/toolHandler.d.ts +4 -0
  3. package/dist/toolHandler.js +50 -25
  4. package/dist/tools/browser/base.d.ts +15 -9
  5. package/dist/tools/browser/base.js +130 -23
  6. package/dist/tools/browser/common/postAction.d.ts +7 -0
  7. package/dist/tools/browser/common/postAction.js +46 -0
  8. package/dist/tools/browser/console/__tests__/console.test.js +51 -0
  9. package/dist/tools/browser/console/get_console_logs.d.ts +4 -0
  10. package/dist/tools/browser/console/get_console_logs.js +17 -5
  11. package/dist/tools/browser/content/__tests__/screenshot.test.js +63 -45
  12. package/dist/tools/browser/content/get_html.js +2 -2
  13. package/dist/tools/browser/content/screenshot.js +58 -76
  14. package/dist/tools/browser/evaluation/evaluate.js +2 -2
  15. package/dist/tools/browser/inspection/compare_element_alignment.js +37 -13
  16. package/dist/tools/browser/inspection/inspect_dom.js +9 -5
  17. package/dist/tools/browser/inspection/query_selector.js +3 -1
  18. package/dist/tools/browser/interaction/__tests__/duplicateClickErrorFormatting.test.d.ts +1 -0
  19. package/dist/tools/browser/interaction/__tests__/duplicateClickErrorFormatting.test.js +97 -0
  20. package/dist/tools/browser/interaction/click.js +46 -2
  21. package/dist/tools/browser/navigation/__tests__/goNavigation.test.js +21 -33
  22. package/dist/tools/browser/navigation/history.d.ts +9 -0
  23. package/dist/tools/browser/navigation/history.js +86 -0
  24. package/dist/tools/browser/navigation/index.d.ts +1 -2
  25. package/dist/tools/browser/navigation/index.js +1 -2
  26. package/dist/tools/browser/navigation/navigate.js +40 -1
  27. package/dist/tools/browser/register.js +2 -4
  28. package/dist/tools/common/confirm_output.d.ts +26 -0
  29. package/dist/tools/common/confirm_output.js +77 -3
  30. package/dist/tools/common/types.js +26 -1
  31. package/package.json +12 -13
package/README.md CHANGED
@@ -595,7 +595,7 @@ COMPARE TWO ELEMENTS: Get comprehensive alignment and dimension comparison in on
595
595
  - selector2 (string, required): CSS selector, text selector, or testid shorthand for the second element (e.g., 'testid:chat-header', '#secondary-header')
596
596
 
597
597
  - Output Format:
598
- - Optional warnings when a selector matched multiple elements (using first).
598
+ - Optional warnings when a selector matched multiple elements (uses first visible; suggests adding unique data-testid).
599
599
  - Header: Alignment: <elem1> vs <elem2>
600
600
  - Two lines with each element's position and size: @ (x,y) w×h px
601
601
  - Edges block: Top/Left/Right/Bottom with ✓/✗ and diffs
@@ -911,11 +911,11 @@ Quick check if an element exists on the page. Ultra-lightweight alternative to q
911
911
 
912
912
  ### Navigation
913
913
 
914
- #### `go_back`
915
- Navigate back in browser history
914
+ #### `go_history`
915
+ Navigate browser history (back/forward). Returns: 'Navigated <direction> in browser history', a quick network-idle note if available, 'URL: <current>', and 'Title: <current>' when set. If console errors occur after the navigation, returns an error like 'Console error after history navigation: <message>' including Title when available.
916
916
 
917
- #### `go_forward`
918
- Navigate forward in browser history
917
+ - Parameters:
918
+ - direction (string, required): History direction to navigate
919
919
 
920
920
  #### `navigate`
921
921
  Navigate to a URL. Browser sessions (cookies, localStorage, sessionStorage) are automatically saved in ./.mcp-web-inspector/user-data directory and persist across restarts. To clear saved sessions, delete the directory.
@@ -1031,8 +1031,6 @@ Upload a file to an input[type='file'] element on the page
1031
1031
 
1032
1032
  ⚠️ Token cost: ~1,500 tokens to read. Structural tools: <100 tokens.
1033
1033
 
1034
- Admin control (optional): set env MCP_SCREENSHOT_GUARD=strict to block execution (prevents misuse by default). Unset to allow visuals for human review.
1035
-
1036
1034
  Screenshots saved to ./.mcp-web-inspector/screenshots. Example: { name: "login-page", fullPage: true } or { name: "submit-btn", selector: "testid:submit" }
1037
1035
 
1038
1036
  - Parameters:
@@ -1050,7 +1048,7 @@ Clears captured console logs and returns the number of entries cleared.
1050
1048
  Retrieve console logs with filtering and token‑efficient output. Defaults: since='last-interaction', limit=20, format='grouped'. Grouped output deduplicates identical lines and shows counts. Use format='raw' for chronological, ungrouped lines. Large outputs return a preview and a one-time token to fetch the full payload.
1051
1049
 
1052
1050
  - Parameters:
1053
- - type (string, optional): Type of logs to retrieve (all, error, warning, log, info, debug, exception)
1051
+ - type (string, optional): Type filter (all, error, warning, log, info, debug, exception). Note: 'error' also includes 'exception' entries for convenience.
1054
1052
  - search (string, optional): Text to search for in logs (handles text with square brackets)
1055
1053
  - limit (number, optional): Maximum entries to return (groups when grouped, lines when raw). Default: 20
1056
1054
  - since (string, optional): Filter logs since a specific event: 'last-call' (since last get_console_logs call), 'last-navigation' (since last page navigation), or 'last-interaction' (since last user interaction like click, fill, etc.). Default: 'last-interaction'
@@ -79,6 +79,10 @@ export declare function getConsoleLogs(): string[];
79
79
  * Get console logs captured after the last navigation
80
80
  */
81
81
  export declare function getConsoleLogsSinceLastNavigation(): string[];
82
+ /**
83
+ * Get console logs captured after the last interaction
84
+ */
85
+ export declare function getConsoleLogsSinceLastInteraction(): string[];
82
86
  /**
83
87
  * Get screenshots
84
88
  */
@@ -72,15 +72,30 @@ function getColorSchemeValue() {
72
72
  return colorSchemeOverride;
73
73
  }
74
74
  async function applyColorScheme(targetPage) {
75
- if (!targetPage) {
75
+ if (!targetPage)
76
76
  return;
77
- }
77
+ const scheme = getColorSchemeValue();
78
78
  try {
79
- const scheme = getColorSchemeValue();
80
- await targetPage.emulateMedia({ colorScheme: scheme ?? null });
79
+ // Some test environments or mocks may not implement emulateMedia
80
+ const anyPage = targetPage;
81
+ if (typeof anyPage.emulateMedia === 'function') {
82
+ await anyPage.emulateMedia({ colorScheme: scheme ?? null });
83
+ return;
84
+ }
85
+ // Fallback: if emulateMedia is unavailable, do a best-effort hint via CSS.
86
+ // This won't fully emulate prefers-color-scheme but avoids throwing in tests.
87
+ if (scheme) {
88
+ const css = scheme === 'dark' ? ':root{color-scheme: dark;}'
89
+ : scheme === 'light' ? ':root{color-scheme: light;}'
90
+ : ':root{color-scheme: light dark;}';
91
+ if (typeof anyPage.addStyleTag === 'function') {
92
+ await anyPage.addStyleTag({ content: css });
93
+ }
94
+ }
81
95
  }
82
96
  catch (error) {
83
- console.error("Failed to apply color scheme:", error);
97
+ // Swallow errors to keep color scheme application non-fatal
98
+ console.warn("Failed to apply color scheme (non-fatal):", error);
84
99
  }
85
100
  }
86
101
  export async function setColorSchemeOverride(scheme) {
@@ -258,13 +273,13 @@ async function getScreenSize() {
258
273
  await tempBrowser.close();
259
274
  // Validate the screen size values
260
275
  if (!screenSize || typeof screenSize.width !== 'number' || typeof screenSize.height !== 'number') {
261
- console.error('Invalid screen size detected, using defaults');
276
+ console.warn('Invalid screen size detected, using defaults');
262
277
  return { width: 1280, height: 720 };
263
278
  }
264
279
  return screenSize;
265
280
  }
266
281
  catch (error) {
267
- console.error('Failed to detect screen size, using defaults:', error);
282
+ console.warn('Failed to detect screen size, using defaults:', error);
268
283
  return { width: 1280, height: 720 };
269
284
  }
270
285
  }
@@ -279,15 +294,15 @@ export async function ensureBrowser(browserSettings) {
279
294
  const browserCheck = checkBrowsersInstalled();
280
295
  if (!browserCheck.installed) {
281
296
  // Try to install browsers automatically
282
- console.error('🎭 Playwright browsers not found. Installing automatically...');
283
- console.error('⏳ This will download ~1GB of browser binaries. Please wait...');
297
+ console.warn('🎭 Playwright browsers not found. Installing automatically...');
298
+ console.warn('⏳ This will download ~1GB of browser binaries. Please wait...');
284
299
  try {
285
300
  const { execSync } = await import('child_process');
286
301
  execSync('npx playwright install chromium firefox webkit', {
287
302
  stdio: 'inherit',
288
303
  encoding: 'utf8'
289
304
  });
290
- console.error('✅ Browsers installed successfully! Starting browser...');
305
+ console.log('✅ Browsers installed successfully! Starting browser...');
291
306
  // Note: browser variable is still undefined here, which is correct.
292
307
  // The code below (line 342) will launch the browser after installation.
293
308
  }
@@ -300,7 +315,7 @@ export async function ensureBrowser(browserSettings) {
300
315
  }
301
316
  // Check if browser exists but is disconnected
302
317
  if (browser && !browser.isConnected()) {
303
- console.error("Browser exists but is disconnected. Cleaning up...");
318
+ console.warn("Browser exists but is disconnected. Cleaning up...");
304
319
  try {
305
320
  await browser.close().catch(err => console.error("Error closing disconnected browser:", err));
306
321
  }
@@ -312,7 +327,7 @@ export async function ensureBrowser(browserSettings) {
312
327
  }
313
328
  // Check if device preset has changed (requires browser restart)
314
329
  if (browser && browserSettings?.device && browserSettings.device !== currentDevice) {
315
- console.error(`Device preset changed from ${currentDevice || 'none'} to ${browserSettings.device}. Restarting browser...`);
330
+ console.warn(`Device preset changed from ${currentDevice || 'none'} to ${browserSettings.device}. Restarting browser...`);
316
331
  try {
317
332
  await browser.close().catch(err => console.error("Error closing browser on device change:", err));
318
333
  }
@@ -331,7 +346,7 @@ export async function ensureBrowser(browserSettings) {
331
346
  const targetHeight = height ?? currentViewport?.height ?? 720;
332
347
  // Check if viewport size actually changed
333
348
  if (!currentViewport || currentViewport.width !== targetWidth || currentViewport.height !== targetHeight) {
334
- console.error(`Resizing viewport to ${targetWidth}x${targetHeight}`);
349
+ console.log(`Resizing viewport to ${targetWidth}x${targetHeight}`);
335
350
  await page.setViewportSize({ width: targetWidth, height: targetHeight });
336
351
  }
337
352
  }
@@ -356,18 +371,18 @@ export async function ensureBrowser(browserSettings) {
356
371
  // Check custom configs first, then Playwright's built-in devices
357
372
  deviceConfig = CUSTOM_DEVICE_CONFIGS[playwrightDeviceName] || devices[playwrightDeviceName];
358
373
  if (deviceConfig) {
359
- console.error(`Using device preset: ${device} (${playwrightDeviceName})`);
374
+ console.log(`Using device preset: ${device} (${playwrightDeviceName})`);
360
375
  currentDevice = device;
361
376
  }
362
377
  else {
363
- console.error(`Warning: Device preset ${playwrightDeviceName} not found`);
378
+ console.warn(`Warning: Device preset ${playwrightDeviceName} not found`);
364
379
  currentDevice = undefined;
365
380
  }
366
381
  }
367
382
  else {
368
383
  currentDevice = undefined;
369
384
  }
370
- console.error(`Launching new ${browserType} browser instance...`);
385
+ console.warn(`Launching new ${browserType} browser instance...`);
371
386
  // Use the appropriate browser engine
372
387
  let browserInstance;
373
388
  switch (browserType) {
@@ -397,7 +412,7 @@ export async function ensureBrowser(browserSettings) {
397
412
  viewportWidth = screenSize?.width ?? 1280;
398
413
  viewportHeight = screenSize?.height ?? 720;
399
414
  if (screenSize && screenSize.width > 0 && screenSize.height > 0) {
400
- console.error(`No viewport specified, using screen size: ${viewportWidth}x${viewportHeight}`);
415
+ console.log(`No viewport specified, using screen size: ${viewportWidth}x${viewportHeight}`);
401
416
  }
402
417
  }
403
418
  // Prepare context options
@@ -421,14 +436,14 @@ export async function ensureBrowser(browserSettings) {
421
436
  }
422
437
  // Use persistent context if session saving is enabled
423
438
  if (sessionConfig.saveSession) {
424
- console.error(`Launching ${browserType} with persistent context at ${sessionConfig.userDataDir}...`);
439
+ console.warn(`Launching ${browserType} with persistent context at ${sessionConfig.userDataDir}...`);
425
440
  const context = await browserInstance.launchPersistentContext(sessionConfig.userDataDir, contextOptions);
426
441
  // Get the browser instance from the context
427
442
  browser = context.browser();
428
443
  currentBrowserType = browserType;
429
444
  // Add cleanup logic when browser is disconnected
430
445
  browser.on('disconnected', () => {
431
- console.error("Browser disconnected event triggered");
446
+ console.warn("Browser disconnected event triggered");
432
447
  browser = undefined;
433
448
  page = undefined;
434
449
  });
@@ -444,7 +459,7 @@ export async function ensureBrowser(browserSettings) {
444
459
  currentBrowserType = browserType;
445
460
  // Add cleanup logic when browser is disconnected
446
461
  browser.on('disconnected', () => {
447
- console.error("Browser disconnected event triggered");
462
+ console.warn("Browser disconnected event triggered");
448
463
  browser = undefined;
449
464
  page = undefined;
450
465
  });
@@ -473,7 +488,7 @@ export async function ensureBrowser(browserSettings) {
473
488
  }
474
489
  // Verify page is still valid
475
490
  if (!page || page.isClosed()) {
476
- console.error("Page is closed or invalid. Creating new page...");
491
+ console.warn("Page is closed or invalid. Creating new page...");
477
492
  // Create a new page if the current one is invalid
478
493
  const context = browser.contexts()[0] || await browser.newContext();
479
494
  page = await context.newPage();
@@ -565,12 +580,12 @@ export async function ensureBrowser(browserSettings) {
565
580
  }
566
581
  // Use persistent context if session saving is enabled
567
582
  if (sessionConfig.saveSession) {
568
- console.error(`Launching ${browserType} with persistent context at ${sessionConfig.userDataDir} (retry)...`);
583
+ console.warn(`Launching ${browserType} with persistent context at ${sessionConfig.userDataDir} (retry)...`);
569
584
  const context = await browserInstance.launchPersistentContext(sessionConfig.userDataDir, retryContextOptions);
570
585
  browser = context.browser();
571
586
  currentBrowserType = browserType;
572
587
  browser.on('disconnected', () => {
573
- console.error("Browser disconnected event triggered (retry)");
588
+ console.warn("Browser disconnected event triggered (retry)");
574
589
  browser = undefined;
575
590
  page = undefined;
576
591
  });
@@ -584,7 +599,7 @@ export async function ensureBrowser(browserSettings) {
584
599
  });
585
600
  currentBrowserType = browserType;
586
601
  browser.on('disconnected', () => {
587
- console.error("Browser disconnected event triggered (retry)");
602
+ console.warn("Browser disconnected event triggered (retry)");
588
603
  browser = undefined;
589
604
  page = undefined;
590
605
  });
@@ -650,7 +665,7 @@ export async function handleToolCall(name, args, server) {
650
665
  const requiresBrowser = isBrowserTool(name);
651
666
  // Check if we have a disconnected browser that needs cleanup
652
667
  if (browser && !browser.isConnected() && requiresBrowser) {
653
- console.error("Detected disconnected browser before tool execution, cleaning up...");
668
+ console.warn("Detected disconnected browser before tool execution, cleaning up...");
654
669
  try {
655
670
  await browser.close().catch(() => { }); // Ignore errors
656
671
  }
@@ -739,6 +754,16 @@ export function getConsoleLogsSinceLastNavigation() {
739
754
  return [];
740
755
  return consoleLogsTool.getLogsSinceLastNavigation();
741
756
  }
757
+ /**
758
+ * Get console logs captured after the last interaction
759
+ */
760
+ export function getConsoleLogsSinceLastInteraction() {
761
+ const consoleLogsTool = getToolInstance("get_console_logs", null);
762
+ if (!consoleLogsTool)
763
+ return [];
764
+ // Expose a compact accessor mirroring navigation-based retrieval
765
+ return consoleLogsTool.getLogsSinceLastInteraction?.() ?? [];
766
+ }
742
767
  /**
743
768
  * Get screenshots
744
769
  */
@@ -12,14 +12,14 @@ export declare abstract class BrowserToolBase implements ToolHandler {
12
12
  */
13
13
  abstract execute(args: any, context: ToolContext): Promise<ToolResponse>;
14
14
  /**
15
- * Normalize selector shortcuts to full Playwright selectors and clean common escaping mistakes
16
- * - "testid:foo" → "[data-testid='foo']"
17
- * - "data-test:bar" → "[data-test='bar']"
18
- * - "data-cy:baz" → "[data-cy='baz']"
19
- * - Removes unnecessary backslash escapes from CSS selectors that LLMs often add
20
- * - ".dark\\:bg-gray-700" → ".dark:bg-gray-700"
21
- * - ".top-\\[36px\\]" ".top-[36px]"
22
- * - ".top-\\\\[36px\\\\]" ".top-[36px]" (double-escaped)
15
+ * Normalize selector shortcuts and fix common escaping mistakes safely.
16
+ * - "testid:foo" → "[data-testid=\"foo\"]"
17
+ * - "data-test:bar" → "[data-test=\"bar\"]"
18
+ * - "data-cy:baz" → "[data-cy=\"baz\"]"
19
+ * - Convert simple ID-only selectors with special chars to Playwright's id engine:
20
+ * "#radix-\:rc\:-content-123" → "id=radix-:rc:-content-123"
21
+ * - Remove unnecessary escapes for bracket characters only (\\[ and \\])
22
+ * DO NOT unescape colons globally — colons in class/ID names must stay escaped in CSS.
23
23
  * @param selector The selector string
24
24
  * @returns Normalized selector
25
25
  */
@@ -28,7 +28,7 @@ export declare abstract class BrowserToolBase implements ToolHandler {
28
28
  * Sanitize verbose Playwright selector engine messages by removing stack traces and
29
29
  * keeping only the essential syntax error information.
30
30
  */
31
- private sanitizeSelectorEngineMessage;
31
+ protected sanitizeSelectorEngineMessage(msg: string): string;
32
32
  /**
33
33
  * Ensures a page is available and returns it
34
34
  * @param context The tool context containing browser and page
@@ -121,4 +121,10 @@ export declare abstract class BrowserToolBase implements ToolHandler {
121
121
  * @param totalCount Total number of matches
122
122
  */
123
123
  protected buildNthSelectorHint(selector: string, totalCount: number): string;
124
+ /**
125
+ * Describe matched elements in a compact, copyable format for disambiguation errors.
126
+ * Shows: index, tag, trimmed text, nearest parent marker, and a suggested selector.
127
+ * Suggests testid:VALUE when present; otherwise falls back to id=VALUE or original >> nth=i.
128
+ */
129
+ protected describeMatchedElements(locator: any, originalSelector: string, count: number): Promise<string>;
124
130
  }
@@ -8,14 +8,14 @@ export class BrowserToolBase {
8
8
  this.server = server;
9
9
  }
10
10
  /**
11
- * Normalize selector shortcuts to full Playwright selectors and clean common escaping mistakes
12
- * - "testid:foo" → "[data-testid='foo']"
13
- * - "data-test:bar" → "[data-test='bar']"
14
- * - "data-cy:baz" → "[data-cy='baz']"
15
- * - Removes unnecessary backslash escapes from CSS selectors that LLMs often add
16
- * - ".dark\\:bg-gray-700" → ".dark:bg-gray-700"
17
- * - ".top-\\[36px\\]" ".top-[36px]"
18
- * - ".top-\\\\[36px\\\\]" ".top-[36px]" (double-escaped)
11
+ * Normalize selector shortcuts and fix common escaping mistakes safely.
12
+ * - "testid:foo" → "[data-testid=\"foo\"]"
13
+ * - "data-test:bar" → "[data-test=\"bar\"]"
14
+ * - "data-cy:baz" → "[data-cy=\"baz\"]"
15
+ * - Convert simple ID-only selectors with special chars to Playwright's id engine:
16
+ * "#radix-\:rc\:-content-123" → "id=radix-:rc:-content-123"
17
+ * - Remove unnecessary escapes for bracket characters only (\\[ and \\])
18
+ * DO NOT unescape colons globally — colons in class/ID names must stay escaped in CSS.
19
19
  * @param selector The selector string
20
20
  * @returns Normalized selector
21
21
  */
@@ -32,14 +32,33 @@ export class BrowserToolBase {
32
32
  return `[${attr}="${value}"]`;
33
33
  }
34
34
  }
35
- // Clean up common escaping mistakes that LLMs make in CSS selectors
36
- // These characters don't need to be escaped in many selector contexts: [ ] :
37
- let cleaned = selector;
38
- // Remove backslash escapes before brackets and colons
39
- // Handle both single escapes (\[) and double escapes (\\[)
40
- cleaned = cleaned.replace(/\\+\[/g, '[');
41
- cleaned = cleaned.replace(/\\+\]/g, ']');
42
- cleaned = cleaned.replace(/\\+:/g, ':');
35
+ const trimmed = selector.trim();
36
+ // Helper: unescape simple backslash-escapes used inside IDs (e.g., \:, \[, \])
37
+ const unescapeCssIdentifier = (s) => {
38
+ // Collapse multiple backslashes before a single char to the char itself
39
+ return s
40
+ .replace(/\\+:/g, ':')
41
+ .replace(/\\+\[/g, '[')
42
+ .replace(/\\+\]/g, ']');
43
+ };
44
+ // If this looks like a simple, standalone ID selector (no combinators or descendants),
45
+ // switch to Playwright's id engine. This avoids CSS escaping pitfalls with colons.
46
+ if (/^#[^\s>+~]+$/.test(trimmed)) {
47
+ const idToken = trimmed.slice(1);
48
+ // Only switch to id= engine if ID contains characters that commonly break CSS (#... with colons or escapes)
49
+ if (idToken.includes('\\') || idToken.includes(':') || idToken.includes('[') || idToken.includes(']')) {
50
+ const idValue = unescapeCssIdentifier(idToken);
51
+ return `id=${idValue}`;
52
+ }
53
+ // Otherwise, keep simple IDs as-is
54
+ return trimmed;
55
+ }
56
+ // For general CSS selectors, preserve required escapes for special chars.
57
+ // Collapse over-escaping (e.g., \\\\[ → \\[, but keep a single backslash before [ ] :)
58
+ let cleaned = trimmed;
59
+ cleaned = cleaned.replace(/\\{2,}(?=\[)/g, '\\');
60
+ cleaned = cleaned.replace(/\\{2,}(?=\])/g, '\\');
61
+ cleaned = cleaned.replace(/\\{2,}(?=:)/g, '\\');
43
62
  return cleaned;
44
63
  }
45
64
  /**
@@ -214,8 +233,8 @@ export class BrowserToolBase {
214
233
  // Check for multiple elements with errorOnMultiple flag
215
234
  if (options?.errorOnMultiple && count > 1) {
216
235
  const selector = options.originalSelector || 'selector';
217
- const nthHint = this.buildNthSelectorHint(selector, count).trimEnd();
218
- const warning = this.getDuplicateTestIdWarning(selector, count).trimEnd();
236
+ const nthHint = ''.trimEnd();
237
+ const warning = ''.trimEnd();
219
238
  let message = `Selector "${selector}" matched ${count} elements. Please use a more specific selector.`;
220
239
  if (nthHint) {
221
240
  message += `\n${nthHint}`;
@@ -223,7 +242,15 @@ export class BrowserToolBase {
223
242
  if (warning) {
224
243
  message += `\n${warning}`;
225
244
  }
226
- throw new Error(message);
245
+ {
246
+ const guidance = [
247
+ `1) Preferred: add a unique data-testid and select it directly (e.g., testid:submit).`,
248
+ `2) If you cannot change markup: append \`>> nth=<index>\` to target a specific match.`,
249
+ ];
250
+ const matchesDetails = await this.describeMatchedElements(locator, selector, count);
251
+ message += `\n${guidance.join('\n')}\n\nMatches:\n${matchesDetails}`;
252
+ throw new Error(message);
253
+ }
227
254
  }
228
255
  // Handle explicit element index (1-based)
229
256
  if (options?.elementIndex !== undefined) {
@@ -274,12 +301,19 @@ export class BrowserToolBase {
274
301
  * @returns Formatted string or empty if only one element
275
302
  */
276
303
  formatElementSelectionInfo(selector, elementIndex, totalCount, preferredVisible = true) {
304
+ const usesNth = selector.includes('>> nth=');
277
305
  if (totalCount <= 1) {
306
+ // Even when a single element is ultimately targeted, discourage nth usage
307
+ // because it is brittle across layout/content changes.
308
+ if (usesNth) {
309
+ return "💡 Tip: Selector uses '>> nth='. Prefer adding a unique data-testid for robust selection.";
310
+ }
278
311
  return '';
279
312
  }
280
313
  const duplicateWarning = this.getDuplicateTestIdWarning(selector, totalCount).trimEnd();
281
314
  const nthHint = this.buildNthSelectorHint(selector, totalCount).trimEnd();
282
- const extraHints = [duplicateWarning, nthHint].filter(Boolean).join('\n');
315
+ const avoidNth = usesNth ? "💡 Tip: Avoid relying on '>> nth='; add a unique data-testid instead." : '';
316
+ const extraHints = [duplicateWarning, nthHint, avoidNth].filter(Boolean).join('\n');
283
317
  const baseMessage = preferredVisible
284
318
  ? `⚠ Found ${totalCount} elements matching "${selector}", using element ${elementIndex + 1} (first visible)`
285
319
  : `⚠ Found ${totalCount} elements matching "${selector}", using element ${elementIndex + 1}`;
@@ -302,10 +336,14 @@ export class BrowserToolBase {
302
336
  selector.startsWith('data-cy:') ||
303
337
  selector.match(/^\[data-(testid|test|cy)=/);
304
338
  if (isTestIdSelector) {
305
- return `💡 Tip: Test IDs should be unique. Consider making this test ID unique to avoid ambiguity.\n\n`;
339
+ return (`💡 Tip: Test IDs should be unique. Consider making this test ID unique to avoid ambiguity.\n` +
340
+ ` Primary fix: assign a unique data-testid to the intended element.\n` +
341
+ ` Workaround: if you cannot change markup, you may use '>> nth=<index>' temporarily.\n\n`);
306
342
  }
307
343
  // Suggest testid for non-testid selectors
308
- return `💡 Tip: Consider adding a unique data-testid attribute for more reliable selection.\n\n`;
344
+ return (`💡 Tip: Consider adding a unique data-testid attribute for more reliable selection.\n` +
345
+ ` Primary fix: add data-testid and target it (e.g., testid:submit).\n` +
346
+ ` Workaround: use '>> nth=<index>' only when you can't add test IDs.\n\n`);
309
347
  }
310
348
  /**
311
349
  * Provide a hint for using >> nth= when multiple elements match a selector
@@ -321,6 +359,75 @@ export class BrowserToolBase {
321
359
  const firstExample = `${trimmed} >> nth=0`;
322
360
  const lastIndex = Math.max(totalCount - 1, 1);
323
361
  const lastExample = `${trimmed} >> nth=${lastIndex}`;
324
- return `💡 Hint: Append ">> nth=<index>" to target a specific match.\n Example: ${firstExample} (first match)\n Or: ${lastExample} (last match)`;
362
+ return (`Primary fix: add a unique data-testid to the intended element and select it directly.\n` +
363
+ `Workaround: Append ">> nth=<index>" to target a specific match when you cannot change markup.\n` +
364
+ ` Example: ${firstExample} (first match)\n` +
365
+ ` Or: ${lastExample} (last match)\n` +
366
+ `Note: nth selectors are brittle and may break with layout/content changes.\n` +
367
+ `Prefer unique data-testid attributes for long-term stability.`);
368
+ }
369
+ /**
370
+ * Describe matched elements in a compact, copyable format for disambiguation errors.
371
+ * Shows: index, tag, trimmed text, nearest parent marker, and a suggested selector.
372
+ * Suggests testid:VALUE when present; otherwise falls back to id=VALUE or original >> nth=i.
373
+ */
374
+ async describeMatchedElements(locator, originalSelector, count) {
375
+ const maxItems = Math.min(count, 5);
376
+ const lines = [];
377
+ for (let i = 0; i < maxItems; i++) {
378
+ const nth = locator.nth(i);
379
+ try {
380
+ const info = await nth.evaluate((el) => {
381
+ const tag = (el.tagName || '').toLowerCase();
382
+ let text = el.innerText || el.textContent || '';
383
+ text = (text || '').replace(/\s+/g, ' ').trim();
384
+ const testid = el.getAttribute?.('data-testid') || el.getAttribute?.('data-test') || el.getAttribute?.('data-cy') || null;
385
+ const id = el.id || null;
386
+ let parentLabel = null;
387
+ let p = el.parentElement;
388
+ while (p && !parentLabel) {
389
+ const ptid = p.getAttribute?.('data-testid');
390
+ const ptest = p.getAttribute?.('data-test');
391
+ const pcy = p.getAttribute?.('data-cy');
392
+ const pid = p.id || null;
393
+ if (ptid)
394
+ parentLabel = `[data-testid="${ptid}"]`;
395
+ else if (ptest)
396
+ parentLabel = `[data-test="${ptest}"]`;
397
+ else if (pcy)
398
+ parentLabel = `[data-cy="${pcy}"]`;
399
+ else if (pid)
400
+ parentLabel = `#${pid}`;
401
+ p = p.parentElement;
402
+ }
403
+ return { tag, text, testid, id, parentLabel };
404
+ });
405
+ const truncatedText = info.text && info.text.length > 80 ? `${info.text.slice(0, 77)}...` : info.text;
406
+ let selectorSuggestion = `${originalSelector} >> nth=${i}`;
407
+ let altSuggestion;
408
+ if (info?.testid) {
409
+ selectorSuggestion = `testid:${info.testid}`;
410
+ altSuggestion = `${originalSelector} >> nth=${i}`;
411
+ }
412
+ else if (info?.id) {
413
+ selectorSuggestion = `id=${info.id}`;
414
+ altSuggestion = `${originalSelector} >> nth=${i}`;
415
+ }
416
+ const parts = [
417
+ `[${i}] <${info.tag}>${truncatedText ? ` "${truncatedText}"` : ''}`,
418
+ info.parentLabel ? ` parent: ${info.parentLabel}` : undefined,
419
+ ` selector: ${selectorSuggestion}`,
420
+ altSuggestion ? ` alt: ${altSuggestion}` : undefined,
421
+ ].filter(Boolean);
422
+ lines.push(parts.join('\n'));
423
+ }
424
+ catch {
425
+ lines.push(`[${i}] (element)\n selector: ${originalSelector} >> nth=${i}`);
426
+ }
427
+ }
428
+ if (count > maxItems) {
429
+ lines.push(`… and ${count - maxItems} more matches (use >> nth=<index> to target).`);
430
+ }
431
+ return lines.join('\n');
325
432
  }
326
433
  }
@@ -0,0 +1,7 @@
1
+ import type { Page } from 'playwright';
2
+ export declare function gatherConsoleErrorsSince(since: 'navigation' | 'interaction'): Promise<string[]>;
3
+ export declare function quickNetworkIdleNote(page: Page): Promise<string>;
4
+ export declare function titleUrlChangeLines(page: Page, initial?: {
5
+ url?: string;
6
+ title?: string;
7
+ }): Promise<string[]>;
@@ -0,0 +1,46 @@
1
+ // Gather console error/exception logs since a baseline event
2
+ export async function gatherConsoleErrorsSince(since) {
3
+ const { getConsoleLogsSinceLastNavigation, getConsoleLogsSinceLastInteraction } = await import('../../../toolHandler.js');
4
+ const logs = since === 'navigation'
5
+ ? getConsoleLogsSinceLastNavigation()
6
+ : getConsoleLogsSinceLastInteraction();
7
+ return logs.filter(l => l.startsWith('[error]') || l.startsWith('[exception]'));
8
+ }
9
+ // Provide a compact, best-effort network idle note
10
+ export async function quickNetworkIdleNote(page) {
11
+ try {
12
+ const start = Date.now();
13
+ const anyPage = page;
14
+ const wait = anyPage?.waitForLoadState?.bind(page);
15
+ if (typeof wait === 'function') {
16
+ await wait('networkidle', { timeout: 500 });
17
+ const ms = Date.now() - start;
18
+ return `✓ Network idle after ${ms}ms, 0 pending requests`;
19
+ }
20
+ }
21
+ catch {
22
+ // fall through to no-activity note
23
+ }
24
+ return 'No new network activity detected (quick check)';
25
+ }
26
+ // Compute concise lines for title/URL when changed
27
+ export async function titleUrlChangeLines(page, initial = {}) {
28
+ const lines = [];
29
+ let newUrl = '';
30
+ let newTitle = '';
31
+ try {
32
+ newUrl = page.url();
33
+ }
34
+ catch { }
35
+ try {
36
+ newTitle = await page.title();
37
+ }
38
+ catch { }
39
+ if (initial.url && newUrl && initial.url !== newUrl) {
40
+ lines.push(`URL changed: ${newUrl}`);
41
+ }
42
+ if (initial.title && newTitle && initial.title !== newTitle) {
43
+ lines.push(`Title changed: ${newTitle}`);
44
+ }
45
+ return lines;
46
+ }
@@ -195,4 +195,55 @@ describe('GetConsoleLogsTool', () => {
195
195
  // (The truncation happens in toolHandler.ts before calling registerConsoleMessage)
196
196
  expect(logsText).toContain(longStackError);
197
197
  });
198
+ test('raw format preview should include confirm token and confirmation returns full payload', async () => {
199
+ // Generate enough long logs to exceed preview threshold
200
+ for (let i = 0; i < 40; i++) {
201
+ consoleLogsTool.registerConsoleMessage('log', `RAW-LONG-${i} ` + 'X'.repeat(160));
202
+ }
203
+ const previewResult = await consoleLogsTool.execute({ format: 'raw' }, mockContext);
204
+ expect(previewResult.isError).toBe(false);
205
+ const previewText = previewResult.content.map(c => c.text).join('\n');
206
+ // Should include confirm_output token reference
207
+ expect(previewText).toMatch(/confirm_output\(\{ token: \"([\w\d]+)\" \}\)/);
208
+ const token = (previewText.match(/confirm_output\(\{ token: \"([\w\d]+)\" \}\)/) || [])[1];
209
+ // Confirm to retrieve full payload
210
+ const { ConfirmOutputTool } = await import('../../../common/confirm_output.js');
211
+ const confirmTool = new ConfirmOutputTool({});
212
+ const full = await confirmTool.execute({ token }, mockContext);
213
+ expect(full.isError).toBe(false);
214
+ const fullText = full.content.map(c => c.text).join('\n');
215
+ // By default limit=20 → header should reflect 20 lines
216
+ expect(fullText).toContain('Retrieved 20 console log(s):');
217
+ // And include one of our long messages
218
+ expect(fullText).toContain('RAW-LONG-39');
219
+ });
220
+ test('grouped format preview should include confirm token and confirmation returns full payload', async () => {
221
+ // Generate 30 unique messages to form distinct groups and exceed threshold
222
+ for (let i = 0; i < 30; i++) {
223
+ consoleLogsTool.registerConsoleMessage('log', `GROUP-LONG-${i} ` + 'G'.repeat(160));
224
+ }
225
+ const previewResult = await consoleLogsTool.execute({}, mockContext); // grouped is default
226
+ expect(previewResult.isError).toBe(false);
227
+ const previewText = previewResult.content.map(c => c.text).join('\n');
228
+ expect(previewText).toMatch(/confirm_output\(\{ token: \"([\w\d]+)\" \}\)/);
229
+ const token = (previewText.match(/confirm_output\(\{ token: \"([\w\d]+)\" \}\)/) || [])[1];
230
+ const { ConfirmOutputTool } = await import('../../../common/confirm_output.js');
231
+ const confirmTool = new ConfirmOutputTool({});
232
+ const full = await confirmTool.execute({ token }, mockContext);
233
+ expect(full.isError).toBe(false);
234
+ const fullText = full.content.map(c => c.text).join('\n');
235
+ // Header reflects number of groups shown (limit default 20)
236
+ expect(fullText).toContain('Retrieved 20 console log(s):');
237
+ // Should include one of the first 20 grouped messages
238
+ expect(fullText).toContain('GROUP-LONG-0');
239
+ });
240
+ test('type: error should include exception entries', async () => {
241
+ consoleLogsTool.registerConsoleMessage('exception', 'Hook failed');
242
+ consoleLogsTool.registerConsoleMessage('error', 'Console error');
243
+ const result = await consoleLogsTool.execute({ type: 'error' }, mockContext);
244
+ expect(result.isError).toBe(false);
245
+ const text = result.content.map(c => c.text).join('\n');
246
+ expect(text).toContain('[exception] Hook failed');
247
+ expect(text).toContain('[error] Console error');
248
+ });
198
249
  });
@@ -36,6 +36,10 @@ export declare class GetConsoleLogsTool extends BrowserToolBase {
36
36
  * Return messages for logs captured after the last recorded navigation
37
37
  */
38
38
  getLogsSinceLastNavigation(): string[];
39
+ /**
40
+ * Return messages for logs captured after the last recorded interaction
41
+ */
42
+ getLogsSinceLastInteraction(): string[];
39
43
  }
40
44
  /**
41
45
  * Tool for clearing console logs (atomic operation)