mcp-web-inspector 0.5.3 → 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.
package/README.md CHANGED
@@ -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.
@@ -1048,7 +1048,7 @@ Clears captured console logs and returns the number of entries cleared.
1048
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.
1049
1049
 
1050
1050
  - Parameters:
1051
- - 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.
1052
1052
  - search (string, optional): Text to search for in logs (handles text with square brackets)
1053
1053
  - limit (number, optional): Maximum entries to return (groups when grouped, lines when raw). Default: 20
1054
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
  */
@@ -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
  }
@@ -233,8 +233,8 @@ export class BrowserToolBase {
233
233
  // Check for multiple elements with errorOnMultiple flag
234
234
  if (options?.errorOnMultiple && count > 1) {
235
235
  const selector = options.originalSelector || 'selector';
236
- const nthHint = this.buildNthSelectorHint(selector, count).trimEnd();
237
- const warning = this.getDuplicateTestIdWarning(selector, count).trimEnd();
236
+ const nthHint = ''.trimEnd();
237
+ const warning = ''.trimEnd();
238
238
  let message = `Selector "${selector}" matched ${count} elements. Please use a more specific selector.`;
239
239
  if (nthHint) {
240
240
  message += `\n${nthHint}`;
@@ -242,7 +242,15 @@ export class BrowserToolBase {
242
242
  if (warning) {
243
243
  message += `\n${warning}`;
244
244
  }
245
- 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
+ }
246
254
  }
247
255
  // Handle explicit element index (1-based)
248
256
  if (options?.elementIndex !== undefined) {
@@ -358,4 +366,68 @@ export class BrowserToolBase {
358
366
  `Note: nth selectors are brittle and may break with layout/content changes.\n` +
359
367
  `Prefer unique data-testid attributes for long-term stability.`);
360
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');
432
+ }
361
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
+ }
@@ -237,4 +237,13 @@ describe('GetConsoleLogsTool', () => {
237
237
  // Should include one of the first 20 grouped messages
238
238
  expect(fullText).toContain('GROUP-LONG-0');
239
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
+ });
240
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)
@@ -23,7 +23,7 @@ export class GetConsoleLogsTool extends BrowserToolBase {
23
23
  properties: {
24
24
  type: {
25
25
  type: "string",
26
- description: "Type of logs to retrieve (all, error, warning, log, info, debug, exception)",
26
+ description: "Type filter (all, error, warning, log, info, debug, exception). Note: 'error' also includes 'exception' entries for convenience.",
27
27
  enum: ["all", "error", "warning", "log", "info", "debug", "exception"]
28
28
  },
29
29
  search: {
@@ -100,7 +100,10 @@ export class GetConsoleLogsTool extends BrowserToolBase {
100
100
  this.lastCallTimestamp = Date.now();
101
101
  // Filter by type if specified
102
102
  if (args.type && args.type !== 'all') {
103
- logs = logs.filter(log => log.message.startsWith(`[${args.type}]`));
103
+ const wanted = args.type;
104
+ // Treat 'error' as including both console errors and exceptions captured via pageerror/unhandledrejection
105
+ const prefixes = wanted === 'error' ? ['[error]', '[exception]'] : [`[${wanted}]`];
106
+ logs = logs.filter(log => prefixes.some(p => log.message.startsWith(p)));
104
107
  }
105
108
  // Filter by search text if specified
106
109
  if (args.search) {
@@ -193,6 +196,15 @@ export class GetConsoleLogsTool extends BrowserToolBase {
193
196
  .filter(log => log.timestamp > since)
194
197
  .map(log => log.message);
195
198
  }
199
+ /**
200
+ * Return messages for logs captured after the last recorded interaction
201
+ */
202
+ getLogsSinceLastInteraction() {
203
+ const since = this.lastInteractionTimestamp;
204
+ return this.consoleLogs
205
+ .filter(log => log.timestamp > since)
206
+ .map(log => log.message);
207
+ }
196
208
  }
197
209
  // Track latest instance for sibling tool access (module-level singleton pattern)
198
210
  GetConsoleLogsTool.latestInstance = null;
@@ -0,0 +1,97 @@
1
+ import { describe, expect, test, beforeEach, jest } from '@jest/globals';
2
+ import { ClickTool } from '../click.js';
3
+ function makeFakeLocator() {
4
+ const elements = [
5
+ {
6
+ tagName: 'BUTTON',
7
+ innerText: 'Cancel',
8
+ textContent: 'Cancel',
9
+ id: '',
10
+ attrs: { 'data-testid': 'modal-cancel' },
11
+ parent: { tagName: 'DIV', id: 'modal-footer', attrs: {}, parent: null },
12
+ },
13
+ {
14
+ tagName: 'BUTTON',
15
+ innerText: 'Cancel',
16
+ textContent: 'Cancel',
17
+ id: 'toolbar-cancel',
18
+ attrs: {},
19
+ parent: { tagName: 'DIV', id: 'toolbar', attrs: {}, parent: null },
20
+ },
21
+ {
22
+ tagName: 'BUTTON',
23
+ innerText: 'Cancel third',
24
+ textContent: 'Cancel third',
25
+ id: '',
26
+ attrs: {},
27
+ parent: { tagName: 'DIV', id: '', attrs: { 'data-test': 'footer' }, parent: null },
28
+ },
29
+ ];
30
+ const wrap = (el) => ({
31
+ evaluate: (fn) => {
32
+ const toDomEl = (src) => ({
33
+ tagName: src.tagName,
34
+ innerText: src.innerText,
35
+ textContent: src.textContent,
36
+ id: src.id,
37
+ getAttribute: (name) => src.attrs[name] ?? null,
38
+ parentElement: src.parent
39
+ ? {
40
+ tagName: src.parent.tagName,
41
+ id: src.parent.id,
42
+ getAttribute: (name) => src.parent.attrs[name] ?? null,
43
+ parentElement: src.parent.parent,
44
+ }
45
+ : null,
46
+ });
47
+ return fn(toDomEl(el));
48
+ },
49
+ });
50
+ return {
51
+ count: async () => elements.length,
52
+ nth: (i) => wrap(elements[i]),
53
+ };
54
+ }
55
+ describe('ClickTool duplicate selection error formatting (integration)', () => {
56
+ let clickTool;
57
+ let mockPage;
58
+ let mockBrowser;
59
+ let mockContext;
60
+ const mockIsClosed = jest.fn().mockReturnValue(false);
61
+ const mockIsConnected = jest.fn().mockReturnValue(true);
62
+ const mockPageLocator = jest.fn();
63
+ beforeEach(() => {
64
+ jest.clearAllMocks();
65
+ clickTool = new ClickTool({});
66
+ mockPage = {
67
+ locator: mockPageLocator,
68
+ isClosed: mockIsClosed,
69
+ };
70
+ mockBrowser = {
71
+ isConnected: mockIsConnected,
72
+ };
73
+ mockContext = {
74
+ page: mockPage,
75
+ browser: mockBrowser,
76
+ server: {},
77
+ };
78
+ });
79
+ test('returns concise guidance and match options when multiple elements match', async () => {
80
+ const fakeLocator = makeFakeLocator();
81
+ mockPageLocator.mockReturnValue(fakeLocator);
82
+ const res = await clickTool.execute({ selector: 'text=Cancel' }, mockContext);
83
+ expect(res.isError).toBe(true);
84
+ const text = res.content.map(c => c.text).join('\n');
85
+ // Prefixed by "Operation failed: ..."
86
+ expect(text).toContain('matched 3 elements');
87
+ expect(text).toContain('1) Preferred: add a unique data-testid');
88
+ expect(text).toContain('2) If you cannot change markup');
89
+ expect(text).toContain('Matches:');
90
+ expect(text).toContain('selector: testid:modal-cancel');
91
+ expect(text).toContain('selector: id=toolbar-cancel');
92
+ expect(text).toContain('selector: text=Cancel >> nth=2');
93
+ // Ensure old verbose hints are not duplicated here
94
+ expect(text).not.toContain('Primary fix: add a unique data-testid');
95
+ expect(text).not.toContain('Workaround: Append');
96
+ });
97
+ });
@@ -1,5 +1,6 @@
1
1
  import { BrowserToolBase } from '../base.js';
2
- import { createSuccessResponse } from '../../common/types.js';
2
+ import { createSuccessResponse, createErrorResponse } from '../../common/types.js';
3
+ import { gatherConsoleErrorsSince, quickNetworkIdleNote, titleUrlChangeLines } from '../common/postAction.js';
3
4
  /**
4
5
  * Tool for clicking elements on the page
5
6
  */
@@ -27,8 +28,51 @@ export class ClickTool extends BrowserToolBase {
27
28
  errorOnMultiple: true,
28
29
  originalSelector: args.selector,
29
30
  });
31
+ // Capture initial state for change detection
32
+ let initialUrl = '';
33
+ let initialTitle = '';
34
+ try {
35
+ initialUrl = page.url();
36
+ }
37
+ catch { }
38
+ try {
39
+ initialTitle = await page.title();
40
+ }
41
+ catch { }
30
42
  await element.click();
31
- return createSuccessResponse(`Clicked element: ${args.selector}`);
43
+ const lines = [`Clicked element: ${args.selector}`];
44
+ // First, a quick network-idle hint to allow errors to flush
45
+ try {
46
+ const note = await quickNetworkIdleNote(page);
47
+ if (note)
48
+ lines.push(note);
49
+ }
50
+ catch { }
51
+ // Then, surface console errors triggered by the interaction
52
+ try {
53
+ const errs = await gatherConsoleErrorsSince('interaction');
54
+ if (errs.length > 0) {
55
+ let titleInfo = '';
56
+ try {
57
+ const t = await page.title();
58
+ if (t)
59
+ titleInfo = `\nTitle: ${t}`;
60
+ }
61
+ catch { }
62
+ return createErrorResponse(`Console error after click: ${errs[0]}${titleInfo}`);
63
+ }
64
+ }
65
+ catch {
66
+ // ignore log retrieval errors
67
+ }
68
+ // Title / URL changes
69
+ try {
70
+ const changeLines = await titleUrlChangeLines(page, { url: initialUrl, title: initialTitle });
71
+ if (changeLines.length > 0)
72
+ lines.push(...changeLines);
73
+ }
74
+ catch { }
75
+ return createSuccessResponse(lines);
32
76
  });
33
77
  }
34
78
  }
@@ -1,5 +1,4 @@
1
- import { GoBackTool } from '../go_back.js';
2
- import { GoForwardTool } from '../go_forward.js';
1
+ import { GoHistoryTool } from '../history.js';
3
2
  import { jest } from '@jest/globals';
4
3
  // Mock page functions
5
4
  const mockGoBack = jest.fn().mockImplementation(() => Promise.resolve());
@@ -26,63 +25,52 @@ const mockContext = {
26
25
  browser: mockBrowser,
27
26
  server: mockServer
28
27
  };
29
- describe('Browser Navigation History Tools', () => {
30
- let goBackTool;
31
- let goForwardTool;
28
+ describe('Browser Navigation History Tool', () => {
29
+ let historyTool;
32
30
  beforeEach(() => {
33
31
  jest.clearAllMocks();
34
- goBackTool = new GoBackTool(mockServer);
35
- goForwardTool = new GoForwardTool(mockServer);
32
+ historyTool = new GoHistoryTool(mockServer);
36
33
  // Reset browser and page mocks
37
34
  mockIsConnected.mockReturnValue(true);
38
35
  mockIsClosed.mockReturnValue(false);
39
36
  });
40
- describe('GoBackTool', () => {
37
+ describe('HistoryTool', () => {
41
38
  test('should navigate back in browser history', async () => {
42
- const args = {};
43
- const result = await goBackTool.execute(args, mockContext);
39
+ const args = { direction: 'back' };
40
+ const result = await historyTool.execute(args, mockContext);
44
41
  expect(mockGoBack).toHaveBeenCalled();
45
42
  expect(result.isError).toBe(false);
46
43
  expect(result.content[0].text).toContain('Navigated back');
47
44
  });
45
+ test('should navigate forward in browser history', async () => {
46
+ const args = { direction: 'forward' };
47
+ const result = await historyTool.execute(args, mockContext);
48
+ expect(mockGoForward).toHaveBeenCalled();
49
+ expect(result.isError).toBe(false);
50
+ expect(result.content[0].text).toContain('Navigated forward');
51
+ });
48
52
  test('should handle navigation back errors', async () => {
49
- const args = {};
53
+ const args = { direction: 'back' };
50
54
  // Mock a navigation error
51
55
  mockGoBack.mockImplementationOnce(() => Promise.reject(new Error('Navigation back failed')));
52
- const result = await goBackTool.execute(args, mockContext);
56
+ const result = await historyTool.execute(args, mockContext);
53
57
  expect(mockGoBack).toHaveBeenCalled();
54
58
  expect(result.isError).toBe(true);
55
59
  expect(result.content[0].text).toContain('Operation failed');
56
60
  });
57
- test('should handle missing page', async () => {
58
- const args = {};
59
- const result = await goBackTool.execute(args, { server: mockServer });
60
- expect(mockGoBack).not.toHaveBeenCalled();
61
- expect(result.isError).toBe(true);
62
- expect(result.content[0].text).toContain('Browser page not initialized');
63
- });
64
- });
65
- describe('GoForwardTool', () => {
66
- test('should navigate forward in browser history', async () => {
67
- const args = {};
68
- const result = await goForwardTool.execute(args, mockContext);
69
- expect(mockGoForward).toHaveBeenCalled();
70
- expect(result.isError).toBe(false);
71
- expect(result.content[0].text).toContain('Navigated forward');
72
- });
73
61
  test('should handle navigation forward errors', async () => {
74
- const args = {};
62
+ const args = { direction: 'forward' };
75
63
  // Mock a navigation error
76
64
  mockGoForward.mockImplementationOnce(() => Promise.reject(new Error('Navigation forward failed')));
77
- const result = await goForwardTool.execute(args, mockContext);
65
+ const result = await historyTool.execute(args, mockContext);
78
66
  expect(mockGoForward).toHaveBeenCalled();
79
67
  expect(result.isError).toBe(true);
80
68
  expect(result.content[0].text).toContain('Operation failed');
81
69
  });
82
70
  test('should handle missing page', async () => {
83
- const args = {};
84
- const result = await goForwardTool.execute(args, { server: mockServer });
85
- expect(mockGoForward).not.toHaveBeenCalled();
71
+ const args = { direction: 'back' };
72
+ const result = await historyTool.execute(args, { server: mockServer });
73
+ expect(mockGoBack).not.toHaveBeenCalled();
86
74
  expect(result.isError).toBe(true);
87
75
  expect(result.content[0].text).toContain('Browser page not initialized');
88
76
  });
@@ -0,0 +1,9 @@
1
+ import { BrowserToolBase } from '../base.js';
2
+ import { ToolContext, ToolResponse, ToolMetadata, SessionConfig } from '../../common/types.js';
3
+ /**
4
+ * Tool for navigating browser history (back/forward)
5
+ */
6
+ export declare class GoHistoryTool extends BrowserToolBase {
7
+ static getMetadata(sessionConfig?: SessionConfig): ToolMetadata;
8
+ execute(args: any, context: ToolContext): Promise<ToolResponse>;
9
+ }
@@ -0,0 +1,86 @@
1
+ import { BrowserToolBase } from '../base.js';
2
+ import { createSuccessResponse, createErrorResponse } from '../../common/types.js';
3
+ import { gatherConsoleErrorsSince, quickNetworkIdleNote } from '../common/postAction.js';
4
+ /**
5
+ * Tool for navigating browser history (back/forward)
6
+ */
7
+ export class GoHistoryTool extends BrowserToolBase {
8
+ static getMetadata(sessionConfig) {
9
+ return {
10
+ name: 'go_history',
11
+ description: "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.",
12
+ inputSchema: {
13
+ type: 'object',
14
+ properties: {
15
+ direction: {
16
+ type: 'string',
17
+ description: "History direction to navigate",
18
+ enum: ['back', 'forward']
19
+ },
20
+ },
21
+ required: ['direction'],
22
+ },
23
+ };
24
+ }
25
+ async execute(args, context) {
26
+ this.recordNavigation();
27
+ return this.safeExecute(context, async (page) => {
28
+ const dir = args.direction === 'forward' ? 'forward' : 'back';
29
+ // Capture initial state
30
+ let initialUrl = '';
31
+ let initialTitle = '';
32
+ try {
33
+ initialUrl = page.url();
34
+ }
35
+ catch { }
36
+ try {
37
+ initialTitle = await page.title();
38
+ }
39
+ catch { }
40
+ // Perform history navigation
41
+ if (dir === 'back') {
42
+ await page.goBack();
43
+ }
44
+ else {
45
+ await page.goForward();
46
+ }
47
+ const verb = dir === 'back' ? 'back' : 'forward';
48
+ const lines = [`Navigated ${verb} in browser history`];
49
+ // Allow network to settle briefly first (best-effort)
50
+ try {
51
+ const note = await quickNetworkIdleNote(page);
52
+ if (note)
53
+ lines.push(note);
54
+ }
55
+ catch { }
56
+ // After the brief wait, surface console errors since navigation
57
+ try {
58
+ const errs = await gatherConsoleErrorsSince('navigation');
59
+ if (errs.length > 0) {
60
+ let titleInfo = '';
61
+ try {
62
+ const t = await page.title();
63
+ if (t)
64
+ titleInfo = `\nTitle: ${t}`;
65
+ }
66
+ catch { }
67
+ return createErrorResponse(`Console error after history navigation: ${errs[0]}${titleInfo}`);
68
+ }
69
+ }
70
+ catch { }
71
+ // Report new URL and Title explicitly
72
+ try {
73
+ const newUrl = page.url();
74
+ lines.push(`URL: ${newUrl}`);
75
+ }
76
+ catch { }
77
+ try {
78
+ const newTitle = await page.title();
79
+ if (newTitle)
80
+ lines.push(`Title: ${newTitle}`);
81
+ }
82
+ catch { }
83
+ return createSuccessResponse(lines);
84
+ });
85
+ }
86
+ }
@@ -1,5 +1,4 @@
1
1
  export { NavigateTool } from './navigate.js';
2
- export { GoBackTool } from './go_back.js';
3
- export { GoForwardTool } from './go_forward.js';
2
+ export { GoHistoryTool } from './history.js';
4
3
  export { ScrollToElementTool } from './scroll_to_element.js';
5
4
  export { ScrollByTool } from './scroll_by.js';
@@ -1,5 +1,4 @@
1
1
  export { NavigateTool } from './navigate.js';
2
- export { GoBackTool } from './go_back.js';
3
- export { GoForwardTool } from './go_forward.js';
2
+ export { GoHistoryTool } from './history.js';
4
3
  export { ScrollToElementTool } from './scroll_to_element.js';
5
4
  export { ScrollByTool } from './scroll_by.js';
@@ -1,5 +1,6 @@
1
1
  import { BrowserToolBase } from '../base.js';
2
2
  import { createSuccessResponse, createErrorResponse } from '../../common/types.js';
3
+ import { gatherConsoleErrorsSince, quickNetworkIdleNote } from '../common/postAction.js';
3
4
  async function resetState() {
4
5
  const { resetBrowserState } = await import('../../../toolHandler.js');
5
6
  resetBrowserState();
@@ -105,7 +106,45 @@ export class NavigateTool extends BrowserToolBase {
105
106
  catch {
106
107
  // Best-effort detection; ignore and proceed
107
108
  }
108
- return createSuccessResponse(`Navigated to ${args.url}`);
109
+ // First, perform a quick network-idle check to allow any errors to flush
110
+ // before we examine console errors. Keep it best-effort with small timeout.
111
+ const messages = [`Navigated to ${args.url}`];
112
+ try {
113
+ const note = await quickNetworkIdleNote(page);
114
+ if (note)
115
+ messages.push(note);
116
+ }
117
+ catch {
118
+ // Ignore failures in the quick check
119
+ }
120
+ // After waiting briefly, surface hard page errors if any
121
+ try {
122
+ const errs = await gatherConsoleErrorsSince('navigation');
123
+ if (errs.length > 0) {
124
+ // Include page title (best-effort) to aid debugging
125
+ let titleInfo = '';
126
+ try {
127
+ const t = await page.title();
128
+ if (t)
129
+ titleInfo = `\nTitle: ${t}`;
130
+ }
131
+ catch { }
132
+ return createErrorResponse(`Console error after navigation: ${errs[0]}${titleInfo}`);
133
+ }
134
+ }
135
+ catch {
136
+ // If log retrieval fails, continue normally
137
+ }
138
+ // Add page title to help the agent orient itself
139
+ try {
140
+ const title = await page.title();
141
+ if (title)
142
+ messages.push(`Title: ${title}`);
143
+ }
144
+ catch {
145
+ // Ignore title failures
146
+ }
147
+ return createSuccessResponse(messages);
109
148
  }
110
149
  catch (error) {
111
150
  const errorMessage = error.message;
@@ -1,7 +1,6 @@
1
1
  // Navigation
2
2
  import { NavigateTool } from './navigation/navigate.js';
3
- import { GoBackTool } from './navigation/go_back.js';
4
- import { GoForwardTool } from './navigation/go_forward.js';
3
+ import { GoHistoryTool } from './navigation/history.js';
5
4
  import { ScrollToElementTool } from './navigation/scroll_to_element.js';
6
5
  import { ScrollByTool } from './navigation/scroll_by.js';
7
6
  // Lifecycle
@@ -43,8 +42,7 @@ import { WaitForNetworkIdleTool } from './waiting/wait_for_network_idle.js';
43
42
  export const BROWSER_TOOL_CLASSES = [
44
43
  // Navigation (5)
45
44
  NavigateTool,
46
- GoBackTool,
47
- GoForwardTool,
45
+ GoHistoryTool,
48
46
  ScrollToElementTool,
49
47
  ScrollByTool,
50
48
  // Lifecycle (2)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-web-inspector",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "Web Inspector MCP: Give LLMs visual superpowers to see, debug, and test any web page.",
5
5
  "license": "MIT",
6
6
  "author": "Anton",
@@ -28,14 +28,13 @@
28
28
  "url": "https://github.com/antonzherdev/mcp-web-inspector.git"
29
29
  },
30
30
  "dependencies": {
31
- "@modelcontextprotocol/sdk": "1.11.1",
32
- "@playwright/browser-chromium": "1.53.1",
33
- "@playwright/browser-firefox": "1.53.1",
34
- "@playwright/browser-webkit": "1.53.1",
35
- "@playwright/test": "^1.53.1",
36
- "mcp-evals": "^1.0.18",
37
- "playwright": "1.53.1",
38
- "uuid": "11.1.0"
31
+ "@modelcontextprotocol/sdk": "1.21.0",
32
+ "@playwright/browser-chromium": "1.56.1",
33
+ "@playwright/browser-firefox": "1.56.1",
34
+ "@playwright/browser-webkit": "1.56.1",
35
+ "@playwright/test": "^1.56.1",
36
+ "playwright": "1.56.1",
37
+ "uuid": "13.0.0"
39
38
  },
40
39
  "keywords": [
41
40
  "mcp",
@@ -51,11 +50,11 @@
51
50
  ],
52
51
  "devDependencies": {
53
52
  "@types/jest": "^29.5.14",
54
- "@types/node": "^20.10.5",
53
+ "@types/node": "^24.10.0",
55
54
  "jest": "^29.7.0",
56
55
  "jest-playwright-preset": "4.0.0",
57
- "shx": "^0.3.4",
58
- "ts-jest": "^29.2.6",
59
- "typescript": "^5.8.3"
56
+ "shx": "^0.4.0",
57
+ "ts-jest": "^29.4.5",
58
+ "typescript": "^5.9.3"
60
59
  }
61
60
  }