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.
- package/dist/toolHandler.d.ts +3 -0
- package/dist/toolHandler.js +30 -0
- package/dist/tools/browser/base.d.ts +5 -2
- package/dist/tools/browser/base.js +45 -4
- package/dist/tools/browser/content/__tests__/visiblePage.test.js +107 -3
- package/dist/tools/browser/content/get_html.d.ts +1 -0
- package/dist/tools/browser/content/get_html.js +46 -1
- package/dist/tools/browser/content/get_text.js +1 -0
- package/dist/tools/browser/content/screenshot.js +5 -1
- package/dist/tools/browser/evaluation/evaluate.js +10 -0
- package/dist/tools/browser/inspection/__tests__/inspectAncestors.test.js +112 -1
- package/dist/tools/browser/inspection/__tests__/inspectDom.test.js +102 -0
- package/dist/tools/browser/inspection/check_visibility.js +1 -0
- package/dist/tools/browser/inspection/get_computed_styles.js +1 -0
- package/dist/tools/browser/inspection/inspect_ancestors.js +107 -21
- package/dist/tools/browser/inspection/inspect_dom.js +44 -1
- package/dist/tools/browser/inspection/measure_element.js +1 -0
- package/dist/tools/browser/interaction/__tests__/interaction.test.js +11 -0
- package/dist/tools/browser/lifecycle/index.d.ts +1 -0
- package/dist/tools/browser/lifecycle/index.js +1 -0
- package/dist/tools/browser/lifecycle/set_color_scheme.d.ts +9 -0
- package/dist/tools/browser/lifecycle/set_color_scheme.js +52 -0
- package/dist/tools/browser/navigation/__tests__/scroll.test.d.ts +1 -0
- package/dist/tools/browser/navigation/__tests__/scroll.test.js +385 -0
- package/dist/tools/browser/navigation/index.d.ts +2 -0
- package/dist/tools/browser/navigation/index.js +2 -0
- package/dist/tools/browser/navigation/scroll_by.d.ts +9 -0
- package/dist/tools/browser/navigation/scroll_by.js +380 -0
- package/dist/tools/browser/navigation/scroll_to_element.d.ts +9 -0
- package/dist/tools/browser/navigation/scroll_to_element.js +92 -0
- package/dist/tools/browser/register.js +8 -1
- package/package.json +1 -1
package/dist/toolHandler.d.ts
CHANGED
|
@@ -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;
|
package/dist/toolHandler.js
CHANGED
|
@@ -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
|
-
* -
|
|
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
|
-
* -
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
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.
|
|
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
|
|
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-
|
|
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
|
});
|