mcp-web-inspector 0.2.1 → 0.4.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 (32) hide show
  1. package/dist/toolHandler.d.ts +3 -0
  2. package/dist/toolHandler.js +30 -0
  3. package/dist/tools/browser/base.d.ts +5 -2
  4. package/dist/tools/browser/base.js +45 -4
  5. package/dist/tools/browser/content/__tests__/visiblePage.test.js +107 -3
  6. package/dist/tools/browser/content/get_html.d.ts +1 -0
  7. package/dist/tools/browser/content/get_html.js +46 -1
  8. package/dist/tools/browser/content/get_text.js +1 -0
  9. package/dist/tools/browser/content/screenshot.js +5 -1
  10. package/dist/tools/browser/evaluation/evaluate.js +10 -0
  11. package/dist/tools/browser/inspection/__tests__/inspectAncestors.test.js +112 -1
  12. package/dist/tools/browser/inspection/__tests__/inspectDom.test.js +102 -0
  13. package/dist/tools/browser/inspection/check_visibility.js +1 -0
  14. package/dist/tools/browser/inspection/get_computed_styles.js +1 -0
  15. package/dist/tools/browser/inspection/inspect_ancestors.js +107 -21
  16. package/dist/tools/browser/inspection/inspect_dom.js +44 -1
  17. package/dist/tools/browser/inspection/measure_element.js +1 -0
  18. package/dist/tools/browser/interaction/__tests__/interaction.test.js +11 -0
  19. package/dist/tools/browser/lifecycle/index.d.ts +1 -0
  20. package/dist/tools/browser/lifecycle/index.js +1 -0
  21. package/dist/tools/browser/lifecycle/set_color_scheme.d.ts +9 -0
  22. package/dist/tools/browser/lifecycle/set_color_scheme.js +52 -0
  23. package/dist/tools/browser/navigation/__tests__/scroll.test.d.ts +1 -0
  24. package/dist/tools/browser/navigation/__tests__/scroll.test.js +385 -0
  25. package/dist/tools/browser/navigation/index.d.ts +2 -0
  26. package/dist/tools/browser/navigation/index.js +2 -0
  27. package/dist/tools/browser/navigation/scroll_by.d.ts +9 -0
  28. package/dist/tools/browser/navigation/scroll_by.js +380 -0
  29. package/dist/tools/browser/navigation/scroll_to_element.d.ts +9 -0
  30. package/dist/tools/browser/navigation/scroll_to_element.js +92 -0
  31. package/dist/tools/browser/register.js +8 -1
  32. package/package.json +1 -1
@@ -19,6 +19,7 @@ export interface NetworkRequest {
19
19
  body: string | null;
20
20
  };
21
21
  }
22
+ type ColorSchemeOverride = 'light' | 'dark' | 'no-preference';
22
23
  /**
23
24
  * Sets the session configuration
24
25
  */
@@ -49,6 +50,8 @@ export declare function clearNetworkLog(): void;
49
50
  * @param newPage The Page object to set as the global page
50
51
  */
51
52
  export declare function setGlobalPage(newPage: Page): Promise<void>;
53
+ export declare function setColorSchemeOverride(scheme: ColorSchemeOverride | null): Promise<void>;
54
+ export declare function getColorSchemeOverride(): ColorSchemeOverride | null;
52
55
  interface BrowserSettings {
53
56
  viewport?: {
54
57
  width?: number;
@@ -12,6 +12,7 @@ let sessionConfig = {
12
12
  screenshotsDir: './.mcp-web-inspector/screenshots',
13
13
  headlessDefault: false,
14
14
  };
15
+ let colorSchemeOverride = null;
15
16
  /**
16
17
  * Sets the session configuration
17
18
  */
@@ -61,9 +62,34 @@ export async function setGlobalPage(newPage) {
61
62
  // Register console message handlers and network listeners for the new page
62
63
  await registerConsoleMessage(page);
63
64
  await registerNetworkListeners(page);
65
+ await applyColorScheme(page);
64
66
  page.bringToFront(); // Bring the new tab to the front
65
67
  console.log("Global page has been updated with listeners registered.");
66
68
  }
69
+ function getColorSchemeValue() {
70
+ return colorSchemeOverride;
71
+ }
72
+ async function applyColorScheme(targetPage) {
73
+ if (!targetPage) {
74
+ return;
75
+ }
76
+ try {
77
+ const scheme = getColorSchemeValue();
78
+ await targetPage.emulateMedia({ colorScheme: scheme ?? null });
79
+ }
80
+ catch (error) {
81
+ console.error("Failed to apply color scheme:", error);
82
+ }
83
+ }
84
+ export async function setColorSchemeOverride(scheme) {
85
+ colorSchemeOverride = scheme;
86
+ if (page && !page.isClosed()) {
87
+ await applyColorScheme(page);
88
+ }
89
+ }
90
+ export function getColorSchemeOverride() {
91
+ return colorSchemeOverride;
92
+ }
67
93
  /**
68
94
  * Device preset mapping to Playwright device descriptors
69
95
  */
@@ -425,6 +451,7 @@ export async function ensureBrowser(browserSettings) {
425
451
  // Register console message handler and network listeners
426
452
  await registerConsoleMessage(page);
427
453
  await registerNetworkListeners(page);
454
+ await applyColorScheme(page);
428
455
  }
429
456
  // Verify page is still valid
430
457
  if (!page || page.isClosed()) {
@@ -435,7 +462,9 @@ export async function ensureBrowser(browserSettings) {
435
462
  // Re-register console message handler and network listeners
436
463
  await registerConsoleMessage(page);
437
464
  await registerNetworkListeners(page);
465
+ await applyColorScheme(page);
438
466
  }
467
+ await applyColorScheme(page);
439
468
  return page;
440
469
  }
441
470
  catch (error) {
@@ -552,6 +581,7 @@ export async function ensureBrowser(browserSettings) {
552
581
  }
553
582
  await registerConsoleMessage(page);
554
583
  await registerNetworkListeners(page);
584
+ await applyColorScheme(page);
555
585
  return page;
556
586
  }
557
587
  }
@@ -12,11 +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
15
+ * Normalize selector shortcuts to full Playwright selectors and clean common escaping mistakes
16
16
  * - "testid:foo" → "[data-testid='foo']"
17
17
  * - "data-test:bar" → "[data-test='bar']"
18
18
  * - "data-cy:baz" → "[data-cy='baz']"
19
- * - Everything else pass through
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)
20
23
  * @param selector The selector string
21
24
  * @returns Normalized selector
22
25
  */
@@ -8,11 +8,14 @@ export class BrowserToolBase {
8
8
  this.server = server;
9
9
  }
10
10
  /**
11
- * Normalize selector shortcuts to full Playwright selectors
11
+ * Normalize selector shortcuts to full Playwright selectors and clean common escaping mistakes
12
12
  * - "testid:foo" → "[data-testid='foo']"
13
13
  * - "data-test:bar" → "[data-test='bar']"
14
14
  * - "data-cy:baz" → "[data-cy='baz']"
15
- * - Everything else pass through
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)
16
19
  * @param selector The selector string
17
20
  * @returns Normalized selector
18
21
  */
@@ -22,13 +25,22 @@ export class BrowserToolBase {
22
25
  'data-test:': 'data-test',
23
26
  'data-cy:': 'data-cy',
24
27
  };
28
+ // Handle testid shortcuts first
25
29
  for (const [prefix, attr] of Object.entries(prefixMap)) {
26
30
  if (selector.startsWith(prefix)) {
27
31
  const value = selector.slice(prefix.length);
28
32
  return `[${attr}="${value}"]`;
29
33
  }
30
34
  }
31
- return selector; // CSS, text=, etc. pass through
35
+ // Clean up common escaping mistakes that LLMs make in CSS selectors
36
+ // These characters don't need to be escaped in CSS selectors: [ ] :
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, ':');
43
+ return cleaned;
32
44
  }
33
45
  /**
34
46
  * Ensures a page is available and returns it
@@ -140,7 +152,36 @@ export class BrowserToolBase {
140
152
  * @returns Object with { element: Locator, elementIndex: number, totalCount: number }
141
153
  */
142
154
  async selectPreferredLocator(locator, options) {
143
- const count = await locator.count();
155
+ let count;
156
+ try {
157
+ count = await locator.count();
158
+ }
159
+ catch (error) {
160
+ // Catch selector syntax errors and provide helpful guidance
161
+ const errorMsg = error.message;
162
+ const selector = options?.originalSelector || 'selector';
163
+ if (errorMsg.includes('Unexpected token') ||
164
+ errorMsg.includes('Invalid selector') ||
165
+ errorMsg.includes('SyntaxError') ||
166
+ errorMsg.includes('selector')) {
167
+ throw new Error(`Invalid CSS selector: "${selector}"\n\n` +
168
+ `Selector syntax error: ${errorMsg}\n\n` +
169
+ `💡 Tips:\n` +
170
+ ` • CSS selectors don't need backslash escapes for [ ] or :\n` +
171
+ ` • Use .class-name or #id without escaping\n` +
172
+ ` • For data attributes, use [data-attr="value"]\n` +
173
+ ` • For testid shortcuts, use testid:name\n\n` +
174
+ `Examples:\n` +
175
+ ` ✓ .dark:bg-gray-700\n` +
176
+ ` ✓ .top-[36px]\n` +
177
+ ` ✓ testid:submit-button\n` +
178
+ ` ✓ #login-form\n` +
179
+ ` ✗ .dark\\:bg-gray-700 (unnecessary escape)\n` +
180
+ ` ✗ .top-\\[36px\\] (unnecessary escape)`);
181
+ }
182
+ // Re-throw other errors as-is
183
+ throw error;
184
+ }
144
185
  if (count === 0) {
145
186
  throw new Error('No elements found');
146
187
  }
@@ -300,10 +300,11 @@ describe('GetHtmlTool', () => {
300
300
  expect(result.isError).toBe(false);
301
301
  expect(result.content[0].text).toContain('HTML content');
302
302
  });
303
- test('should respect maxLength parameter', async () => {
303
+ test('should respect maxLength parameter for small HTML', async () => {
304
304
  const args = { maxLength: 50 };
305
- const longHtml = '<div>' + 'A'.repeat(100) + '</div>';
306
- mockEvaluate.mockImplementationOnce(() => Promise.resolve(longHtml));
305
+ // Small HTML that won't trigger preview threshold
306
+ const smallHtml = '<div>' + 'A'.repeat(100) + '</div>';
307
+ mockEvaluate.mockImplementationOnce(() => Promise.resolve(smallHtml));
307
308
  const result = await visibleHtmlTool.execute(args, mockContext);
308
309
  expect(result.isError).toBe(false);
309
310
  expect(result.content[0].text).toContain('Output truncated due to size limits');
@@ -378,4 +379,107 @@ describe('GetHtmlTool', () => {
378
379
  expect(result.content[0].text).toContain('Failed to get visible HTML content');
379
380
  expect(result.content[0].text).toContain('Content retrieval failed');
380
381
  });
382
+ test('should return preview with token for large HTML (≥2000 chars)', async () => {
383
+ const args = {};
384
+ // Large HTML that exceeds preview threshold
385
+ const largeHtml = '<div>' + 'X'.repeat(3000) + '</div>';
386
+ mockEvaluate.mockImplementationOnce(() => Promise.resolve(largeHtml));
387
+ const result = await visibleHtmlTool.execute(args, mockContext);
388
+ expect(result.isError).toBe(false);
389
+ expect(result.content[0].text).toContain('HTML size:');
390
+ expect(result.content[0].text).toContain('exceeds 2000 char threshold');
391
+ expect(result.content[0].text).toContain('Preview (first 500 chars)');
392
+ expect(result.content[0].text).toContain('⚠️ Full HTML not returned to save tokens');
393
+ expect(result.content[0].text).toContain('💡 RECOMMENDED: Use token-efficient alternatives');
394
+ expect(result.content[0].text).toContain('inspect_dom()');
395
+ expect(result.content[0].text).toMatch(/confirmToken: "[\w\d]{8}"/);
396
+ });
397
+ test('should return full HTML with valid confirmToken', async () => {
398
+ const args = {};
399
+ // Large HTML
400
+ const largeHtml = '<div>' + 'Y'.repeat(3000) + '</div>';
401
+ mockEvaluate.mockImplementation(() => Promise.resolve(largeHtml));
402
+ // First call - get preview and token
403
+ const previewResult = await visibleHtmlTool.execute(args, mockContext);
404
+ expect(previewResult.isError).toBe(false);
405
+ // Extract token from preview
406
+ const tokenMatch = previewResult.content[0].text.match(/confirmToken: "([\w\d]{8})"/);
407
+ expect(tokenMatch).toBeTruthy();
408
+ const token = tokenMatch[1];
409
+ // Second call - with token
410
+ const fullResult = await visibleHtmlTool.execute({ confirmToken: token }, mockContext);
411
+ expect(fullResult.isError).toBe(false);
412
+ expect(fullResult.content[0].text).toContain(largeHtml);
413
+ expect(fullResult.content[0].text).not.toContain('Preview (first 500 chars)');
414
+ expect(fullResult.content[0].text).toContain('💡 TIP: If you need structured inspection');
415
+ });
416
+ test('should generate new token for invalid confirmToken', async () => {
417
+ const args = { confirmToken: 'invalid123' };
418
+ // Large HTML
419
+ const largeHtml = '<div>' + 'Z'.repeat(3000) + '</div>';
420
+ mockEvaluate.mockImplementationOnce(() => Promise.resolve(largeHtml));
421
+ const result = await visibleHtmlTool.execute(args, mockContext);
422
+ expect(result.isError).toBe(false);
423
+ expect(result.content[0].text).toContain('HTML size:');
424
+ expect(result.content[0].text).toContain('Preview (first 500 chars)');
425
+ expect(result.content[0].text).toMatch(/confirmToken: "[\w\d]{8}"/);
426
+ // Should not return full HTML
427
+ expect(result.content[0].text).not.toContain('Z'.repeat(3000));
428
+ });
429
+ test('should return small HTML directly without token requirement', async () => {
430
+ const args = {};
431
+ // Small HTML below threshold
432
+ const smallHtml = '<div>Small content with 1500 chars: ' + 'A'.repeat(1400) + '</div>';
433
+ mockEvaluate.mockImplementationOnce(() => Promise.resolve(smallHtml));
434
+ const result = await visibleHtmlTool.execute(args, mockContext);
435
+ expect(result.isError).toBe(false);
436
+ expect(result.content[0].text).toContain(smallHtml);
437
+ expect(result.content[0].text).not.toContain('confirmToken:');
438
+ expect(result.content[0].text).not.toContain('Preview (first 500 chars)');
439
+ expect(result.content[0].text).toContain('💡 TIP: If you need structured inspection');
440
+ });
441
+ test('should consume token only once (one-time use)', async () => {
442
+ const args = {};
443
+ // Large HTML
444
+ const largeHtml = '<div>' + 'M'.repeat(3000) + '</div>';
445
+ mockEvaluate.mockImplementation(() => Promise.resolve(largeHtml));
446
+ // First call - get token
447
+ const previewResult = await visibleHtmlTool.execute(args, mockContext);
448
+ const tokenMatch = previewResult.content[0].text.match(/confirmToken: "([\w\d]{8})"/);
449
+ const token = tokenMatch[1];
450
+ // Second call - use token (should work)
451
+ const fullResult = await visibleHtmlTool.execute({ confirmToken: token }, mockContext);
452
+ expect(fullResult.isError).toBe(false);
453
+ expect(fullResult.content[0].text).toContain(largeHtml);
454
+ // Third call - try to reuse same token (should fail, get new preview)
455
+ const retryResult = await visibleHtmlTool.execute({ confirmToken: token }, mockContext);
456
+ expect(retryResult.isError).toBe(false);
457
+ expect(retryResult.content[0].text).toContain('Preview (first 500 chars)');
458
+ expect(retryResult.content[0].text).toMatch(/confirmToken: "[\w\d]{8}"/);
459
+ // Should have different token
460
+ const newTokenMatch = retryResult.content[0].text.match(/confirmToken: "([\w\d]{8})"/);
461
+ expect(newTokenMatch[1]).not.toBe(token);
462
+ });
463
+ test('should handle large HTML with selector and confirmToken', async () => {
464
+ const largeHtml = '<section>' + 'S'.repeat(3000) + '</section>';
465
+ const args = { selector: 'testid:large-section' };
466
+ mockLocator.mockImplementation(() => ({}));
467
+ const mockElement = {
468
+ evaluate: jest.fn(async () => largeHtml),
469
+ };
470
+ const selectSpy = jest
471
+ .spyOn(visibleHtmlTool, 'selectPreferredLocator')
472
+ .mockResolvedValue({ element: mockElement, elementIndex: 0, totalCount: 1 });
473
+ mockEvaluate.mockImplementation(() => Promise.resolve(largeHtml));
474
+ // First call - get preview
475
+ const previewResult = await visibleHtmlTool.execute(args, mockContext);
476
+ expect(previewResult.content[0].text).toContain('(from "testid:large-section")');
477
+ const tokenMatch = previewResult.content[0].text.match(/confirmToken: "([\w\d]{8})"/);
478
+ const token = tokenMatch[1];
479
+ // Second call - with token
480
+ const fullResult = await visibleHtmlTool.execute({ selector: 'testid:large-section', confirmToken: token }, mockContext);
481
+ expect(fullResult.isError).toBe(false);
482
+ expect(fullResult.content[0].text).toContain(largeHtml);
483
+ selectSpy.mockRestore();
484
+ });
381
485
  });
@@ -4,6 +4,7 @@ import { ToolContext, ToolResponse, ToolMetadata, SessionConfig } from '../../co
4
4
  * Tool for getting HTML from the page
5
5
  */
6
6
  export declare class GetHtmlTool extends BrowserToolBase {
7
+ private confirmTokens;
7
8
  static getMetadata(sessionConfig?: SessionConfig): ToolMetadata;
8
9
  execute(args: any, context: ToolContext): Promise<ToolResponse>;
9
10
  }
@@ -4,10 +4,14 @@ import { createSuccessResponse, createErrorResponse, } from '../../common/types.
4
4
  * Tool for getting HTML from the page
5
5
  */
6
6
  export class GetHtmlTool extends BrowserToolBase {
7
+ constructor() {
8
+ super(...arguments);
9
+ this.confirmTokens = new Map(); // Stores tokens for two-step confirmation
10
+ }
7
11
  static getMetadata(sessionConfig) {
8
12
  return {
9
13
  name: "get_html",
10
- description: "⚠️ RARELY NEEDED: Get raw HTML markup from the page (no rendering, just source code). Most tasks need structured inspection instead. ONLY use get_html for: (1) checking specific HTML attributes or element nesting, (2) analyzing markup structure, (3) debugging SSR/HTML issues. For structured tasks, use: inspect_dom() to understand page structure with positions, query_selector() to find and inspect elements, get_computed_styles() for CSS values. Returns HTML up to 20000 chars (truncated if longer), scripts removed by default for security/size. Supports testid shortcuts.",
14
+ description: "⚠️ RARELY NEEDED: Get raw HTML markup from the page (no rendering, just source code). Most tasks need structured inspection instead. ONLY use get_html for: (1) checking specific HTML attributes or element nesting, (2) analyzing markup structure, (3) debugging SSR/HTML issues. For structured tasks, use: inspect_dom() to understand page structure with positions, query_selector() to find and inspect elements, get_computed_styles() for CSS values. Auto-returns HTML if <2000 chars (small elements), shows preview with token-based confirmation if larger. Scripts removed by default for security/size. Supports testid shortcuts.",
11
15
  inputSchema: {
12
16
  type: "object",
13
17
  properties: {
@@ -26,6 +30,10 @@ export class GetHtmlTool extends BrowserToolBase {
26
30
  maxLength: {
27
31
  type: "number",
28
32
  description: "Maximum number of characters to return (default: 20000)"
33
+ },
34
+ confirmToken: {
35
+ type: "string",
36
+ description: "Confirmation token from preview response (required to retrieve large HTML). Get this token by calling without confirmToken first - the preview will include the token to use."
29
37
  }
30
38
  },
31
39
  required: [],
@@ -37,6 +45,8 @@ export class GetHtmlTool extends BrowserToolBase {
37
45
  ? Math.floor(args.maxLength)
38
46
  : 20000;
39
47
  const clean = args.clean ?? false;
48
+ const confirmToken = args.confirmToken;
49
+ const PREVIEW_THRESHOLD = 2000;
40
50
  if (!context.page) {
41
51
  return createErrorResponse('Page is not available');
42
52
  }
@@ -58,6 +68,7 @@ export class GetHtmlTool extends BrowserToolBase {
58
68
  const locator = page.locator(normalizedSelector);
59
69
  const { element, elementIndex, totalCount } = await this.selectPreferredLocator(locator, {
60
70
  elementIndex: args.elementIndex,
71
+ originalSelector: args.selector,
61
72
  });
62
73
  selectionWarning = this.formatElementSelectionInfo(args.selector, elementIndex, totalCount, true);
63
74
  rawHtml = await element.evaluate((target) => {
@@ -99,6 +110,40 @@ export class GetHtmlTool extends BrowserToolBase {
99
110
  const safeMaxLength = requestedMaxLength > 0 ? requestedMaxLength : 20000;
100
111
  const processedHtml = sanitizedHtml ?? '';
101
112
  const originalLength = processedHtml.length;
113
+ // Generate key for this HTML request
114
+ const tokenKey = `${args.selector || 'page'}:${originalLength}`;
115
+ // Check if HTML is too large
116
+ if (originalLength >= PREVIEW_THRESHOLD) {
117
+ // Verify if confirmToken matches
118
+ const expectedToken = this.confirmTokens.get(tokenKey);
119
+ if (confirmToken && expectedToken && confirmToken === expectedToken) {
120
+ // Valid token - delete it (one-time use) and proceed to return HTML
121
+ this.confirmTokens.delete(tokenKey);
122
+ }
123
+ else {
124
+ // No token or invalid token - show preview and generate new token
125
+ const newToken = Math.random().toString(36).substring(2, 10);
126
+ this.confirmTokens.set(tokenKey, newToken);
127
+ lines.push(`HTML size: ${originalLength.toLocaleString()} characters (exceeds ${PREVIEW_THRESHOLD} char threshold)`);
128
+ lines.push('');
129
+ lines.push('Preview (first 500 chars):');
130
+ lines.push(processedHtml.slice(0, 500));
131
+ if (originalLength > 500) {
132
+ lines.push('...');
133
+ }
134
+ lines.push('');
135
+ lines.push('⚠️ Full HTML not returned to save tokens (~' + Math.round(originalLength / 3) + ' tokens)');
136
+ lines.push('');
137
+ lines.push('💡 RECOMMENDED: Use token-efficient alternatives:');
138
+ lines.push(' • inspect_dom() - structured view with positions and layout');
139
+ lines.push(' • query_selector_all() - find specific elements');
140
+ lines.push(' • get_computed_styles() - CSS values for debugging');
141
+ lines.push('');
142
+ lines.push(`To get full HTML anyway, call again with: confirmToken: "${newToken}"`);
143
+ return createSuccessResponse(lines.join('\n'));
144
+ }
145
+ }
146
+ // Return full HTML (either small or explicitly requested)
102
147
  let displayHtml = processedHtml;
103
148
  const truncated = displayHtml.length > safeMaxLength;
104
149
  if (truncated) {
@@ -53,6 +53,7 @@ export class GetTextTool extends BrowserToolBase {
53
53
  const locator = page.locator(normalizedSelector);
54
54
  const { element, elementIndex, totalCount } = await this.selectPreferredLocator(locator, {
55
55
  elementIndex: args.elementIndex,
56
+ originalSelector: args.selector,
56
57
  });
57
58
  selectionWarning = this.formatElementSelectionInfo(args.selector, elementIndex, totalCount, true);
58
59
  textContent = await element.evaluate((target) => {
@@ -82,9 +82,13 @@ export class ScreenshotTool extends BrowserToolBase {
82
82
  });
83
83
  messages.push(`Screenshot also stored in memory with name: '${args.name || 'screenshot'}'`);
84
84
  }
85
+ // Add token cost warning
86
+ messages.push('');
87
+ messages.push('📸 Open the file in your IDE to view the screenshot');
88
+ messages.push('⚠️ Reading the image file consumes ~1,500 tokens - only use Read tool if visual analysis is essential');
85
89
  // Add actionable guidance based on screenshot context
86
90
  messages.push('');
87
- messages.push('💡 To debug layout issues in this screenshot:');
91
+ messages.push('💡 To debug layout issues without reading the screenshot:');
88
92
  if (args.selector) {
89
93
  messages.push(` inspect_ancestors({ selector: "${args.selector}" })`);
90
94
  messages.push(' → See parent constraints (width, margins, overflow, borders)');
@@ -79,6 +79,16 @@ export class EvaluateTool extends BrowserToolBase {
79
79
  ' Returns: Alignment status (left/right/top/bottom/center), pixel gaps\n' +
80
80
  ' Perfect for: Checking if elements are aligned or overlapping');
81
81
  }
82
+ // Pattern: Scrolling operations
83
+ if (scriptLower.match(/scrollto|scrollby|scrollintoview|scrolltop|scrollleft|window\.scroll|pageyoffset|scrolly/)) {
84
+ suggestions.push('📜 Scrolling - Use specialized scroll tools\n' +
85
+ ' • scroll_to_element({ selector: "...", position: "start|center|end" })\n' +
86
+ ' → Scrolls element into view (handles containers automatically)\n' +
87
+ ' • scroll_by({ selector: "html", pixels: 500 })\n' +
88
+ ' → Precise pixel scrolling for testing sticky headers, infinite scroll\n' +
89
+ ' Why: Playwright auto-scrolls before interactions, but these tools help with\n' +
90
+ ' testing scroll behavior, lazy-loading, and scroll-triggered content');
91
+ }
82
92
  return suggestions;
83
93
  }
84
94
  async execute(args, context) {
@@ -208,7 +208,7 @@ describe('InspectAncestorsTool', () => {
208
208
  expect(result.isError).toBeFalsy();
209
209
  const text = result.content[0].text;
210
210
  // Should show both overflow-x and overflow-y when different
211
- expect(text).toMatch(/overflow-x.*overflow-y|overflow.*hidden.*auto/);
211
+ expect(text).toMatch(/overflow-[xy].*overflow-[xy]/);
212
212
  });
213
213
  it('should show flexbox layout context', async () => {
214
214
  await page.setContent(`
@@ -351,4 +351,115 @@ describe('InspectAncestorsTool', () => {
351
351
  expect(text).not.toContain('Test IDs should be unique');
352
352
  expect(text).toContain('Found 2 elements');
353
353
  });
354
+ it('should detect vertically scrollable containers with overflow amount', async () => {
355
+ await page.setContent(`
356
+ <html>
357
+ <body style="margin: 0; padding: 0;">
358
+ <div style="overflow-y: auto; height: 200px; width: 400px;">
359
+ <div style="height: 500px;">
360
+ <p data-testid="scrollable-content">Content that extends beyond container</p>
361
+ </div>
362
+ </div>
363
+ </body>
364
+ </html>
365
+ `);
366
+ const result = await tool.execute({ selector: 'testid:scrollable-content' }, { page, browser });
367
+ expect(result.isError).toBeFalsy();
368
+ const text = result.content[0].text;
369
+ // Should show overflow with scrollable indicator (uniform overflow shows as "overflow:")
370
+ expect(text).toMatch(/overflow(-y)?:/);
371
+ expect(text).toContain('↕️');
372
+ expect(text).toContain('scrollable');
373
+ // Should show scrollable container diagnostic
374
+ expect(text).toContain('SCROLLABLE CONTAINER');
375
+ expect(text).toContain('vertically');
376
+ });
377
+ it('should detect horizontally scrollable containers with overflow amount', async () => {
378
+ await page.setContent(`
379
+ <html>
380
+ <body style="margin: 0; padding: 0;">
381
+ <div style="overflow-x: scroll; width: 300px;">
382
+ <div style="width: 800px;">
383
+ <span data-testid="wide-content">Wide content</span>
384
+ </div>
385
+ </div>
386
+ </body>
387
+ </html>
388
+ `);
389
+ const result = await tool.execute({ selector: 'testid:wide-content' }, { page, browser });
390
+ expect(result.isError).toBeFalsy();
391
+ const text = result.content[0].text;
392
+ // Should show overflow-x with scrollable indicator
393
+ expect(text).toContain('overflow-x:');
394
+ expect(text).toContain('↔️');
395
+ expect(text).toContain('scrollable');
396
+ // Should show scrollable container diagnostic
397
+ expect(text).toContain('SCROLLABLE CONTAINER');
398
+ expect(text).toContain('horizontally');
399
+ });
400
+ it('should detect both vertically and horizontally scrollable containers', async () => {
401
+ await page.setContent(`
402
+ <html>
403
+ <body style="margin: 0; padding: 0;">
404
+ <div style="overflow: auto; height: 200px; width: 300px;">
405
+ <div style="height: 500px; width: 800px;">
406
+ <div data-testid="scroll-both">Content scrolls both ways</div>
407
+ </div>
408
+ </div>
409
+ </body>
410
+ </html>
411
+ `);
412
+ const result = await tool.execute({ selector: 'testid:scroll-both' }, { page, browser });
413
+ expect(result.isError).toBeFalsy();
414
+ const text = result.content[0].text;
415
+ // Should show overflow with both indicators
416
+ expect(text).toContain('overflow:');
417
+ expect(text).toContain('scrollable');
418
+ // Should show scrollable container diagnostic
419
+ expect(text).toContain('SCROLLABLE CONTAINER');
420
+ expect(text).toContain('vertically & horizontally');
421
+ });
422
+ it('should show clipped content when overflow:hidden with actual overflow', async () => {
423
+ await page.setContent(`
424
+ <html>
425
+ <body style="margin: 0; padding: 0;">
426
+ <div style="overflow: hidden; height: 150px;">
427
+ <div style="height: 400px;">
428
+ <p data-testid="clipped">This content is clipped</p>
429
+ </div>
430
+ </div>
431
+ </body>
432
+ </html>
433
+ `);
434
+ const result = await tool.execute({ selector: 'testid:clipped' }, { page, browser });
435
+ expect(result.isError).toBeFalsy();
436
+ const text = result.content[0].text;
437
+ // Should show overflow:hidden with clipped amount
438
+ expect(text).toContain('overflow:');
439
+ expect(text).toContain('🔒');
440
+ expect(text).toContain('hidden');
441
+ expect(text).toContain('clipped');
442
+ // Should show both clipping point and (still show it even though content is scrollable)
443
+ expect(text).toContain('CLIPPING POINT');
444
+ });
445
+ it('should not show overflow info when no CSS overflow is set and no actual overflow exists', async () => {
446
+ await page.setContent(`
447
+ <html>
448
+ <body style="margin: 0; padding: 0;">
449
+ <div style="height: 300px; width: 400px;">
450
+ <div style="height: 100px; width: 200px;">
451
+ <p data-testid="no-overflow">Normal content</p>
452
+ </div>
453
+ </div>
454
+ </body>
455
+ </html>
456
+ `);
457
+ const result = await tool.execute({ selector: 'testid:no-overflow' }, { page, browser });
458
+ expect(result.isError).toBeFalsy();
459
+ const text = result.content[0].text;
460
+ // Should NOT show overflow info when there's no overflow
461
+ // (overflow: visible is the default and is not shown)
462
+ const hasOverflowInfo = text.includes('overflow:') || text.includes('overflow-x:') || text.includes('overflow-y:');
463
+ expect(hasOverflowInfo).toBeFalsy();
464
+ });
354
465
  });