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.
- package/README.md +6 -8
- package/dist/toolHandler.d.ts +4 -0
- package/dist/toolHandler.js +50 -25
- package/dist/tools/browser/base.d.ts +15 -9
- package/dist/tools/browser/base.js +130 -23
- package/dist/tools/browser/common/postAction.d.ts +7 -0
- package/dist/tools/browser/common/postAction.js +46 -0
- package/dist/tools/browser/console/__tests__/console.test.js +51 -0
- package/dist/tools/browser/console/get_console_logs.d.ts +4 -0
- package/dist/tools/browser/console/get_console_logs.js +17 -5
- package/dist/tools/browser/content/__tests__/screenshot.test.js +63 -45
- package/dist/tools/browser/content/get_html.js +2 -2
- package/dist/tools/browser/content/screenshot.js +58 -76
- package/dist/tools/browser/evaluation/evaluate.js +2 -2
- package/dist/tools/browser/inspection/compare_element_alignment.js +37 -13
- package/dist/tools/browser/inspection/inspect_dom.js +9 -5
- package/dist/tools/browser/inspection/query_selector.js +3 -1
- package/dist/tools/browser/interaction/__tests__/duplicateClickErrorFormatting.test.d.ts +1 -0
- package/dist/tools/browser/interaction/__tests__/duplicateClickErrorFormatting.test.js +97 -0
- package/dist/tools/browser/interaction/click.js +46 -2
- package/dist/tools/browser/navigation/__tests__/goNavigation.test.js +21 -33
- package/dist/tools/browser/navigation/history.d.ts +9 -0
- package/dist/tools/browser/navigation/history.js +86 -0
- package/dist/tools/browser/navigation/index.d.ts +1 -2
- package/dist/tools/browser/navigation/index.js +1 -2
- package/dist/tools/browser/navigation/navigate.js +40 -1
- package/dist/tools/browser/register.js +2 -4
- package/dist/tools/common/confirm_output.d.ts +26 -0
- package/dist/tools/common/confirm_output.js +77 -3
- package/dist/tools/common/types.js +26 -1
- 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 (
|
|
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
|
-
#### `
|
|
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
|
-
|
|
918
|
-
|
|
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
|
|
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'
|
package/dist/toolHandler.d.ts
CHANGED
|
@@ -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
|
*/
|
package/dist/toolHandler.js
CHANGED
|
@@ -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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
283
|
-
console.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
374
|
+
console.log(`Using device preset: ${device} (${playwrightDeviceName})`);
|
|
360
375
|
currentDevice = device;
|
|
361
376
|
}
|
|
362
377
|
else {
|
|
363
|
-
console.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
16
|
-
* - "testid:foo" → "[data-testid
|
|
17
|
-
* - "data-test:bar" → "[data-test
|
|
18
|
-
* - "data-cy:baz" → "[data-cy
|
|
19
|
-
* -
|
|
20
|
-
*
|
|
21
|
-
* -
|
|
22
|
-
*
|
|
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
|
-
|
|
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
|
|
12
|
-
* - "testid:foo" → "[data-testid
|
|
13
|
-
* - "data-test:bar" → "[data-test
|
|
14
|
-
* - "data-cy:baz" → "[data-cy
|
|
15
|
-
* -
|
|
16
|
-
*
|
|
17
|
-
* -
|
|
18
|
-
*
|
|
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
|
-
|
|
36
|
-
//
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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 =
|
|
218
|
-
const warning =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|