/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import assert from 'node:assert'; import {readFile, rm} from 'node:fs/promises'; import {tmpdir} from 'node:os'; import {join} from 'node:path'; import {describe, it} from 'node:test'; import sinon from 'sinon'; import type {ParsedArguments} from '../src/bin/chrome-devtools-mcp-cli-options.js'; import type {McpContext} from '../src/McpContext.js'; import type {McpResponse} from '../src/McpResponse.js'; import {replaceHtmlElementsWithUids} from '../src/McpResponse.js'; import type { Extension, JSONSchema7Definition, } from '../src/third_party/index.js'; import { closePage, listPages, navigatePage, newPage, selectPage, } from '../src/tools/pages.js'; import type {InsightName} from '../src/trace-processing/parse.js'; import { parseRawTraceBuffer, traceResultIsSuccess, } from '../src/trace-processing/parse.js'; import {serverHooks} from './server.js'; import {loadTraceAsBuffer} from './trace-processing/fixtures/load.js'; import { getImageContent, getMockAggregatedIssue, getMockRequest, getMockResponse, getTextContent, html, stabilizeResponseOutput, stabilizeStructuredContent, withMcpContext, } from './utils.js'; describe('McpResponse', () => { it('list pages', async t => { await withMcpContext(async (response, context) => { response.setIncludePages(true); const {content, structuredContent} = await response.handle( 'test', context, ); assert.equal(content[0].type, 'text'); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('allows response text lines to be added', async t => { await withMcpContext(async (response, context) => { response.appendResponseLine('Testing 1'); response.appendResponseLine('Testing 2'); const {content, structuredContent} = await response.handle( 'test', context, ); assert.equal(content[0].type, 'text'); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('does not include anything in response if snapshot is null', async t => { await withMcpContext(async (response, context) => { const page = context.getSelectedMcpPage().pptrPage; page.accessibility.snapshot = async () => null; const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('returns correctly formatted snapshot for a simple tree', async t => { await withMcpContext(async (response, context) => { const page = context.getSelectedMcpPage().pptrPage; await page.setContent( html` `, ); await page.focus('button'); response.includeSnapshot(); const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('returns values for textboxes', async t => { await withMcpContext(async (response, context) => { const page = context.getSelectedMcpPage().pptrPage; await page.setContent( html``, ); await page.focus('input'); response.includeSnapshot(); const {content, structuredContent} = await response.handle( 'test', context, ); assert.equal(content[0].type, 'text'); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('returns verbose snapshot and structured content', async t => { await withMcpContext(async (response, context) => { const page = context.getSelectedMcpPage().pptrPage; await page.setContent(html``); response.includeSnapshot({ verbose: true, }); const {content, structuredContent} = await response.handle( 'test', context, ); assert.equal(content[0].type, 'text'); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot(JSON.stringify(structuredContent, null, 2)); }); }); it('saves snapshot to file and returns structured content', async t => { const filePath = join(tmpdir(), 'test-snapshot.txt'); try { await withMcpContext(async (response, context) => { const page = context.getSelectedMcpPage().pptrPage; await page.setContent(html``); response.includeSnapshot({ verbose: true, filePath, }); const {content, structuredContent} = await response.handle( 'test', context, ); assert.equal(content[0].type, 'text'); t.assert.snapshot(stabilizeResponseOutput(getTextContent(content[0]))); t.assert.snapshot( JSON.stringify( stabilizeStructuredContent(structuredContent), null, 2, ), ); }); const content = await readFile(filePath, 'utf-8'); t.assert.snapshot(stabilizeResponseOutput(content)); } finally { await rm(filePath, {force: true}); } }); it('preserves mapping ids across multiple snapshots', async () => { await withMcpContext(async (response, context) => { const page = context.getSelectedMcpPage().pptrPage; await page.setContent(html`
Span 1
`); response.includeSnapshot(); // First snapshot const res1 = await response.handle('test', context); const text1 = getTextContent(res1.content[0]); const btn1IdMatch = text1.match(/uid=(\S+) .*Button 1/); const span1IdMatch = text1.match(/uid=(\S+) .*Span 1/); assert.ok(btn1IdMatch, 'Button 1 ID not found in first snapshot'); assert.ok(span1IdMatch, 'Span 1 ID not found in first snapshot'); const btn1Id = btn1IdMatch[1]; const span1Id = span1IdMatch[1]; // Modify page: add a new element before the others to potentially shift indices if not stable await page.evaluate(() => { const newBtn = document.createElement('button'); newBtn.textContent = 'Button 2'; document.body.prepend(newBtn); }); // Second snapshot const res2 = await response.handle('test', context); const text2 = getTextContent(res2.content[0]); const btn1IdMatch2 = text2.match(/uid=(\S+) .*Button 1/); const span1IdMatch2 = text2.match(/uid=(\S+) .*Span 1/); const btn2IdMatch = text2.match(/uid=(\S+) .*Button 2/); assert.ok(btn1IdMatch2, 'Button 1 ID not found in second snapshot'); assert.ok(span1IdMatch2, 'Span 1 ID not found in second snapshot'); assert.ok(btn2IdMatch, 'Button 2 ID not found in second snapshot'); assert.strictEqual( btn1IdMatch2[1], btn1Id, 'Button 1 ID changed between snapshots', ); assert.strictEqual( span1IdMatch2[1], span1Id, 'Span 1 ID changed between snapshots', ); assert.notStrictEqual( btn2IdMatch[1], btn1Id, 'Button 2 ID collides with Button 1', ); assert.notStrictEqual( btn2IdMatch[1], btn1Id, 'Button 2 ID collides with Button 1', ); }); }); describe('navigation', () => { const server = serverHooks(); it('resets ids after navigation', async () => { await withMcpContext(async (response, context) => { server.addHtmlRoute( '/page.html', html`
`, ); const page = context.getSelectedMcpPage().pptrPage; await page.goto(server.getRoute('/page.html')); response.includeSnapshot(); const res1 = await response.handle('test', context); const text1 = getTextContent(res1.content[0]); const btn1IdMatch = text1.match(/uid=(\S+) .*Button 1/); assert.ok(btn1IdMatch, 'Button 1 ID not found in first snapshot'); const btn1Id = btn1IdMatch[1]; // Navigate to the same page again (or meaningful navigation) await page.goto(server.getRoute('/page.html')); const res2 = await response.handle('test', context); const text2 = getTextContent(res2.content[0]); const btn1IdMatch2 = text2.match(/uid=(\S+) .*Button 1/); assert.ok(btn1IdMatch2, 'Button 1 ID not found in second snapshot'); const btn1Id2 = btn1IdMatch2[1]; assert.notStrictEqual( btn1Id2, btn1Id, 'ID should reset after navigation', ); }); }); }); it('adds throttling setting when it is not null', async t => { await withMcpContext(async (response, context) => { await context .getSelectedMcpPage() .emulate({networkConditions: 'Slow 3G'}); const {content, structuredContent} = await response.handle( 'test', context, ); assert.equal(content[0].type, 'text'); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('does not include throttling setting when it is null', async t => { await withMcpContext(async (response, context) => { const {content, structuredContent} = await response.handle( 'test', context, ); await context.getSelectedMcpPage().emulate({}); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('adds image when image is attached', async t => { await withMcpContext(async (response, context) => { response.attachImage({data: 'imageBase64', mimeType: 'image/png'}); const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); assert.equal(content[1].type, 'image'); assert.strictEqual(getImageContent(content[1]).data, 'imageBase64'); assert.strictEqual(getImageContent(content[1]).mimeType, 'image/png'); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('adds cpu throttling setting when it is over 1', async t => { await withMcpContext(async (response, context) => { await context.getSelectedMcpPage().emulate({cpuThrottlingRate: 4}); const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('does not include cpu throttling setting when it is 1', async t => { await withMcpContext(async (response, context) => { await context.getSelectedMcpPage().emulate({cpuThrottlingRate: 1}); const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('adds viewport emulation setting when it is set', async t => { await withMcpContext(async (response, context) => { await context.getSelectedMcpPage().emulate({ viewport: {width: 400, height: 400, deviceScaleFactor: 1}, }); const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('adds userAgent emulation setting when it is set', async t => { await withMcpContext(async (response, context) => { await context.getSelectedMcpPage().emulate({userAgent: 'MyUA'}); const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('adds color scheme emulation setting when it is set', async t => { await withMcpContext(async (response, context) => { await context.getSelectedMcpPage().emulate({colorScheme: 'dark'}); const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('adds a prompt dialog', async t => { await withMcpContext(async (response, context) => { const page = context.getSelectedMcpPage(); const dialogPromise = new Promise(resolve => { page.pptrPage.on('dialog', () => { resolve(); }); }); page.pptrPage.evaluate(() => { prompt('message', 'default'); }); await dialogPromise; const {content, structuredContent} = await response.handle( 'test', context, ); await page.getDialog()?.dismiss(); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('adds an alert dialog', async t => { await withMcpContext(async (response, context) => { const page = context.getSelectedMcpPage(); const dialogPromise = new Promise(resolve => { page.pptrPage.on('dialog', () => { resolve(); }); }); page.pptrPage.evaluate(() => { alert('message'); }); await dialogPromise; const {content, structuredContent} = await response.handle( 'test', context, ); await page.getDialog()?.dismiss(); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('add network requests when setting is true', async t => { await withMcpContext(async (response, context) => { response.setIncludeNetworkRequests(true); context.getSelectedMcpPage().getNetworkRequests = () => { return [getMockRequest({stableId: 1}), getMockRequest({stableId: 2})]; }; const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('does not include network requests when setting is false', async t => { await withMcpContext(async (response, context) => { response.setIncludeNetworkRequests(false); context.getSelectedMcpPage().getNetworkRequests = () => { return [getMockRequest()]; }; const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('add network request when attached with POST data', async t => { await withMcpContext(async (response, context) => { response.setIncludeNetworkRequests(true); const httpResponse = getMockResponse(); httpResponse.buffer = () => { return Promise.resolve(Buffer.from(JSON.stringify({response: 'body'}))); }; httpResponse.headers = () => { return { 'Content-Type': 'application/json', }; }; const request = getMockRequest({ method: 'POST', hasPostData: true, postData: JSON.stringify({request: 'body'}), response: httpResponse, }); context.getSelectedMcpPage().getNetworkRequests = () => { return [request]; }; context.getSelectedMcpPage().getNetworkRequestById = () => { return request; }; response.attachNetworkRequest(1); const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('add network request when attached', async t => { await withMcpContext(async (response, context) => { response.setIncludeNetworkRequests(true); const request = getMockRequest(); context.getSelectedMcpPage().getNetworkRequests = () => { return [request]; }; context.getSelectedMcpPage().getNetworkRequestById = () => { return request; }; response.attachNetworkRequest(1); const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('adds console messages when the setting is true', async t => { await withMcpContext(async (response, context) => { response.setIncludeConsoleData(true); const page = context.getSelectedMcpPage().pptrPage; const consoleMessagePromise = new Promise(resolve => { page.on('console', () => { resolve(); }); }); page.evaluate(() => { console.log('Hello from the test'); }); await consoleMessagePromise; const {content, structuredContent} = await response.handle( 'test', context, ); assert.ok(getTextContent(content[0])); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('adds a message when no console messages exist', async t => { await withMcpContext(async (response, context) => { response.setIncludeConsoleData(true); const {content, structuredContent} = await response.handle( 'test', context, ); assert.ok(getTextContent(content[0])); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it("doesn't list the issue message if mapping returns null", async t => { await withMcpContext(async (response, context) => { const mockAggregatedIssue = getMockAggregatedIssue(); const mockDescription = { file: 'not-existing-description-file.md', links: [], }; mockAggregatedIssue.getDescription.returns(mockDescription); response.setIncludeConsoleData(true); context.getSelectedMcpPage().getConsoleData = () => { return [mockAggregatedIssue]; }; const {content, structuredContent} = await response.handle( 'test', context, ); const text = getTextContent(content[0]); assert.ok(text.includes('')); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('throws error if mapping returns null on get issue details', async () => { await withMcpContext(async (response, context) => { const mockAggregatedIssue = getMockAggregatedIssue(); const mockDescription = { file: 'not-existing-description-file.md', links: [], }; mockAggregatedIssue.getDescription.returns(mockDescription); response.attachConsoleMessage(1); context.getSelectedMcpPage().getConsoleMessageById = () => { return mockAggregatedIssue; }; try { await response.handle('test', context); } catch (e) { assert.ok(e.message.includes("Can't provide details for the msgid 1")); } }); }); }); describe('McpResponse network request filtering', () => { it('filters network requests by resource type', async t => { await withMcpContext(async (response, context) => { response.setIncludeNetworkRequests(true, { resourceTypes: ['script', 'stylesheet'], }); context.getSelectedMcpPage().getNetworkRequests = () => { return [ getMockRequest({resourceType: 'script'}), getMockRequest({resourceType: 'image'}), getMockRequest({resourceType: 'stylesheet'}), getMockRequest({resourceType: 'document'}), ]; }; const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('filters network requests by single resource type', async t => { await withMcpContext(async (response, context) => { response.setIncludeNetworkRequests(true, { resourceTypes: ['image'], }); context.getSelectedMcpPage().getNetworkRequests = () => { return [ getMockRequest({resourceType: 'script'}), getMockRequest({resourceType: 'image'}), getMockRequest({resourceType: 'stylesheet'}), ]; }; const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('shows no requests when filter matches nothing', async t => { await withMcpContext(async (response, context) => { response.setIncludeNetworkRequests(true, { resourceTypes: ['font'], }); context.getSelectedMcpPage().getNetworkRequests = () => { return [ getMockRequest({resourceType: 'script'}), getMockRequest({resourceType: 'image'}), getMockRequest({resourceType: 'stylesheet'}), ]; }; const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('shows all requests when no filters are provided', async t => { await withMcpContext(async (response, context) => { response.setIncludeNetworkRequests(true); context.getSelectedMcpPage().getNetworkRequests = () => { return [ getMockRequest({resourceType: 'script'}), getMockRequest({resourceType: 'image'}), getMockRequest({resourceType: 'stylesheet'}), getMockRequest({resourceType: 'document'}), getMockRequest({resourceType: 'font'}), ]; }; const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('shows all requests when empty resourceTypes array is provided', async t => { await withMcpContext(async (response, context) => { response.setIncludeNetworkRequests(true, { resourceTypes: [], }); context.getSelectedMcpPage().getNetworkRequests = () => { return [ getMockRequest({resourceType: 'script'}), getMockRequest({resourceType: 'image'}), getMockRequest({resourceType: 'stylesheet'}), getMockRequest({resourceType: 'document'}), getMockRequest({resourceType: 'font'}), ]; }; const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); }); describe('McpResponse network pagination', () => { it('returns all requests when pagination is not provided', async t => { await withMcpContext(async (response, context) => { const requests = Array.from({length: 5}, () => getMockRequest()); context.getSelectedMcpPage().getNetworkRequests = () => requests; response.setIncludeNetworkRequests(true); const {content, structuredContent} = await response.handle( 'test', context, ); const text = getTextContent(content[0]); assert.ok(text.includes('Showing 1-5 of 5 (Page 1 of 1).')); assert.ok(!text.includes('Next page:')); assert.ok(!text.includes('Previous page:')); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('returns first page by default', async t => { await withMcpContext(async (response, context) => { const requests = Array.from({length: 30}, (_, idx) => getMockRequest({method: `GET-${idx}`}), ); context.getSelectedMcpPage().getNetworkRequests = () => { return requests; }; response.setIncludeNetworkRequests(true, {pageSize: 10}); const {content, structuredContent} = await response.handle( 'test', context, ); const text = getTextContent(content[0]); assert.ok(text.includes('Showing 1-10 of 30 (Page 1 of 3).')); assert.ok(text.includes('Next page: 1')); assert.ok(!text.includes('Previous page:')); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('returns subsequent page when pageIdx provided', async t => { await withMcpContext(async (response, context) => { const requests = Array.from({length: 25}, (_, idx) => getMockRequest({method: `GET-${idx}`}), ); context.getSelectedMcpPage().getNetworkRequests = () => requests; response.setIncludeNetworkRequests(true, { pageSize: 10, pageIdx: 1, }); const {content, structuredContent} = await response.handle( 'test', context, ); const text = getTextContent(content[0]); assert.ok(text.includes('Showing 11-20 of 25 (Page 2 of 3).')); assert.ok(text.includes('Next page: 2')); assert.ok(text.includes('Previous page: 0')); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); it('handles invalid page number by showing first page', async t => { await withMcpContext(async (response, context) => { const requests = Array.from({length: 5}, () => getMockRequest()); context.getSelectedMcpPage().getNetworkRequests = () => requests; response.setIncludeNetworkRequests(true, { pageSize: 2, pageIdx: 10, // Invalid page number }); const {content, structuredContent} = await response.handle( 'test', context, ); const text = getTextContent(content[0]); assert.ok( text.includes('Invalid page number provided. Showing first page.'), ); assert.ok(text.includes('Showing 1-2 of 5 (Page 1 of 3).')); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); describe('trace summaries', () => { it('includes the trace summary text and structured data', async t => { const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz'); const result = await parseRawTraceBuffer(rawData); if (!traceResultIsSuccess(result)) { throw new Error(result.error); } await withMcpContext(async (response, context) => { response.attachTraceSummary(result); const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); const typedStructuredContent = structuredContent as { traceSummary?: string; traceInsights?: unknown[]; }; t.assert.snapshot( JSON.stringify(typedStructuredContent.traceSummary, null, 2), ); t.assert.snapshot( JSON.stringify(typedStructuredContent.traceInsights, null, 2), ); }); }); }); describe('trace insights', () => { it('includes the trace insight output', async t => { const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz'); const result = await parseRawTraceBuffer(rawData); if (!traceResultIsSuccess(result)) { throw new Error(result.error); } await withMcpContext(async (response, context) => { response.attachTraceInsight( result, 'NAVIGATION_0', 'LCPBreakdown' as InsightName, ); const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify( stabilizeStructuredContent(structuredContent), null, 2, ), ); }); }); it('includes error if insight not found', async t => { const rawData = loadTraceAsBuffer('web-dev-with-commit.json.gz'); const result = await parseRawTraceBuffer(rawData); if (!traceResultIsSuccess(result)) { throw new Error(result.error); } await withMcpContext(async (response, context) => { response.attachTraceInsight( result, 'BAD_ID', 'LCPBreakdown' as InsightName, ); const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify( stabilizeStructuredContent(structuredContent), null, 2, ), ); }); }); }); }); describe('extensions', () => { it('lists extensions', async t => { await withMcpContext(async (response, context) => { response.setListExtensions(); // Empty state testing const emptyResult = await response.handle('test', context); const emptyText = getTextContent(emptyResult.content[0]); assert.ok( emptyText.includes('No extensions installed.'), 'Should show message for ampty extensions', ); response.resetResponseLineForTesting(); // Testing with extensions context.listExtensions = async () => Promise.resolve( new Map([ [ 'id1', { id: 'id1', name: 'Extension 1', version: '1.0', enabled: true, path: '/path/to/ext1', } as Extension, ], [ 'id2', { id: 'id2', name: 'Extension 2', version: '2.0', enabled: false, path: '/path/to/ext2', } as Extension, ], ]), ); response.setListExtensions(); const {content, structuredContent} = await response.handle( 'test', context, ); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot(JSON.stringify(structuredContent, null, 2)); }); }); }); describe('lighthouse', () => { it('includes lighthouse report paths', async t => { await withMcpContext(async (response, context) => { const lighthouseResult = { summary: { mode: 'navigation', device: 'desktop', url: 'https://example.com', scores: [ { id: 'performance', title: 'Performance', score: 0.9, }, ], audits: { failed: 1, passed: 10, }, timing: { total: 1000, }, }, reports: ['/tmp/report.json', '/tmp/report.html'], }; response.attachLighthouseResult(lighthouseResult); const {content, structuredContent} = await response.handle( 'test', context, ); const text = getTextContent(content[0]); assert.ok(text.includes('### Reports')); assert.ok(text.includes('- /tmp/report.json')); assert.ok(text.includes('- /tmp/report.html')); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify(stabilizeStructuredContent(structuredContent), null, 2), ); }); }); }); describe('third-party developer tools', () => { function stubToolDiscovery(page: object) { // @ts-expect-error Internal API const client = page._client(); const originalSend = client.send.bind(client); sinon .stub(client, 'send') .callsFake(async (method: string, params?: Record) => { if (method === 'DOMDebugger.getEventListeners') { return { listeners: [ { type: 'devtoolstooldiscovery', useCapture: false, passive: false, once: false, scriptId: '0', lineNumber: 0, columnNumber: 0, }, ], }; } return originalSend(method, params); }); } it('lists third-party developer tools', async t => { await withMcpContext( async (response, context) => { const mcpPage = context.getSelectedMcpPage(); stubToolDiscovery(mcpPage.pptrPage); sinon.stub(mcpPage.pptrPage, 'evaluate').resolves([ { name: 'My Tool Group', description: 'A group of tools', tools: [ { name: 'myTool', description: 'Does something', inputSchema: { type: 'object', properties: { foo: {type: 'string'}, }, }, }, ], }, ]); response.setListThirdPartyDeveloperTools(); const {content, structuredContent} = await response.handle( 'test', context, ); const responseText = getTextContent(content[0]); t.assert.snapshot(responseText); assert.ok( responseText.includes('inputSchema={"type":"object"'), 'Response should include inputSchema', ); t.assert.snapshot(JSON.stringify(structuredContent, null, 2)); }, undefined, {categoryExperimentalThirdParty: true}, ); }); async function testIncludesThirdPartyDeveloperTools( handlerAction: ( response: McpResponse, context: McpContext, ) => Promise, toolName: string, ) { await withMcpContext( async (response, context) => { const mcpPage = context.getSelectedMcpPage(); stubToolDiscovery(mcpPage.pptrPage); const initScript = ` window.__dtmcp = { toolGroup: { name: 'Tool group name', description: 'Test tools', tools: [ { name: '3pDeveloperTool', description: 'A test tool', inputSchema: { type: 'object', properties: {}, }, execute: () => 'result', }, ], }, }; window.addEventListener('devtoolstooldiscovery', (e) => { e.respondWith(window.__dtmcp?.toolGroup); }); `; await mcpPage.pptrPage.evaluateOnNewDocument(initScript); await mcpPage.pptrPage.evaluate(initScript); await handlerAction(response, context); const {content} = await response.handle(toolName, context); const responseText = getTextContent(content[0]); assert.ok( responseText.includes('3pDeveloperTool'), `Should include third-party developer tool name in the ${toolName} response`, ); }, undefined, {categoryExperimentalThirdParty: true}, ); } it('includes third-party developer tools in list_pages response', async () => { await testIncludesThirdPartyDeveloperTools(async (response, context) => { const listPagesDef = listPages({ categoryExperimentalThirdParty: true, } as ParsedArguments); await listPagesDef.handler({params: {}}, response, context); }, 'list_pages'); }); it('includes third-party developer tools in select_page response', async () => { await testIncludesThirdPartyDeveloperTools(async (response, context) => { const pageId = context.getSelectedMcpPage().id; await selectPage.handler({params: {pageId}}, response, context); }, 'select_page'); }); it('includes third-party developer tools in close_page response', async () => { await testIncludesThirdPartyDeveloperTools(async (response, context) => { const pageId = context.getSelectedMcpPage().id; await closePage.handler({params: {pageId}}, response, context); }, 'close_page'); }); it('includes third-party developer tools in navigate_page response', async () => { await testIncludesThirdPartyDeveloperTools(async (response, context) => { await navigatePage().handler( { params: {type: 'url', url: 'about:blank'}, page: context.getSelectedMcpPage(), }, response, context, ); }, 'navigate_page'); }); it('includes third-party developer tools in new_page response', async () => { await testIncludesThirdPartyDeveloperTools(async (response, context) => { // Workaround to ensure the test environment's new page contain third-party developer tools sinon.stub(context, 'newPage').resolves(context.getSelectedMcpPage()); await newPage().handler( { params: {url: 'about:blank'}, }, response, context, ); }, 'new_page'); }); }); describe('replaceHtmlElementsWithUids', () => { it('does nothing for boolean schemas', () => { const schemaTrue: JSONSchema7Definition = true; const schemaFalse: JSONSchema7Definition = false; replaceHtmlElementsWithUids(schemaTrue); replaceHtmlElementsWithUids(schemaFalse); assert.strictEqual(schemaTrue, true); assert.strictEqual(schemaFalse, false); }); it('replaces HTMLElement type with uid string', () => { const schema: JSONSchema7Definition = { type: 'object', properties: { foo: {type: 'string'}, bar: {type: 'number'}, }, required: ['foo'], }; Object.assign(schema, {'x-mcp-type': 'HTMLElement'}); replaceHtmlElementsWithUids(schema); if (typeof schema === 'object') { assert.deepStrictEqual(schema.properties, { uid: {type: 'string'}, }); assert.deepStrictEqual(schema.required, ['uid']); } else { assert.fail('Schema should be an object'); } }); it('does not replace if x-mcp-type is not HTMLElement', () => { const schema: JSONSchema7Definition = { type: 'object', properties: { foo: {type: 'string'}, }, }; Object.assign(schema, {'x-mcp-type': 'OtherType'}); replaceHtmlElementsWithUids(schema); if (typeof schema === 'object') { assert.deepStrictEqual(schema.properties, { foo: {type: 'string'}, }); assert.strictEqual(schema.required, undefined); } else { assert.fail('Schema should be an object'); } }); it('recurses into nested properties', () => { const schema: JSONSchema7Definition = { type: 'object', properties: { element: { type: 'object', properties: { foo: {type: 'string'}, }, }, other: { type: 'string', }, }, }; if (typeof schema === 'object' && schema.properties) { Object.assign(schema.properties.element, {'x-mcp-type': 'HTMLElement'}); } replaceHtmlElementsWithUids(schema); if ( typeof schema === 'object' && schema.properties && typeof schema.properties.element === 'object' ) { const elementSchema = schema.properties.element; assert.deepStrictEqual(elementSchema.properties, { uid: {type: 'string'}, }); assert.deepStrictEqual(elementSchema.required, ['uid']); } else { assert.fail('Unexpected schema structure'); } }); it('recurses into array items (single schema object)', () => { const schema: JSONSchema7Definition = { type: 'array', items: { type: 'object', }, }; if (typeof schema === 'object' && typeof schema.items === 'object') { Object.assign(schema.items, {'x-mcp-type': 'HTMLElement'}); } replaceHtmlElementsWithUids(schema); if (typeof schema === 'object' && typeof schema.items === 'object') { const itemsSchema = schema.items; if (!Array.isArray(itemsSchema)) { assert.deepStrictEqual(itemsSchema.properties, { uid: {type: 'string'}, }); assert.deepStrictEqual(itemsSchema.required, ['uid']); } else { assert.fail('items should not be an array in this test case'); } } else { assert.fail('Unexpected schema structure'); } }); it('recurses into array items (array of schemas)', () => { const schema: JSONSchema7Definition = { type: 'array', items: [ { type: 'object', }, { type: 'string', }, ], }; if (typeof schema === 'object' && Array.isArray(schema.items)) { Object.assign(schema.items[0], {'x-mcp-type': 'HTMLElement'}); } replaceHtmlElementsWithUids(schema); if (typeof schema === 'object' && Array.isArray(schema.items)) { const firstItem = schema.items[0]; if (typeof firstItem === 'object') { assert.deepStrictEqual(firstItem.properties, { uid: {type: 'string'}, }); assert.deepStrictEqual(firstItem.required, ['uid']); } else { assert.fail('First item should be an object'); } const secondItem = schema.items[1]; if (typeof secondItem === 'object') { assert.strictEqual(secondItem.properties, undefined); } else { assert.fail('Second item should be an object'); } } else { assert.fail('Unexpected schema structure'); } }); it('recurses into anyOf', () => { const schema: JSONSchema7Definition = { anyOf: [ { type: 'object', }, { type: 'string', }, ], }; if (typeof schema === 'object' && Array.isArray(schema.anyOf)) { Object.assign(schema.anyOf[0], {'x-mcp-type': 'HTMLElement'}); } replaceHtmlElementsWithUids(schema); if (typeof schema === 'object' && Array.isArray(schema.anyOf)) { const firstItem = schema.anyOf[0]; if (typeof firstItem === 'object') { assert.deepStrictEqual(firstItem.properties, { uid: {type: 'string'}, }); } else { assert.fail('First item should be an object'); } } else { assert.fail('Unexpected schema structure'); } }); it('recurses into allOf', () => { const schema: JSONSchema7Definition = { allOf: [ { type: 'object', }, ], }; if (typeof schema === 'object' && Array.isArray(schema.allOf)) { Object.assign(schema.allOf[0], {'x-mcp-type': 'HTMLElement'}); } replaceHtmlElementsWithUids(schema); if (typeof schema === 'object' && Array.isArray(schema.allOf)) { const firstItem = schema.allOf[0]; if (typeof firstItem === 'object') { assert.deepStrictEqual(firstItem.properties, { uid: {type: 'string'}, }); } else { assert.fail('First item should be an object'); } } else { assert.fail('Unexpected schema structure'); } }); it('recurses into oneOf', () => { const schema: JSONSchema7Definition = { oneOf: [ { type: 'object', }, ], }; if (typeof schema === 'object' && Array.isArray(schema.oneOf)) { Object.assign(schema.oneOf[0], {'x-mcp-type': 'HTMLElement'}); } replaceHtmlElementsWithUids(schema); if (typeof schema === 'object' && Array.isArray(schema.oneOf)) { const firstItem = schema.oneOf[0]; if (typeof firstItem === 'object') { assert.deepStrictEqual(firstItem.properties, { uid: {type: 'string'}, }); } else { assert.fail('First item should be an object'); } } else { assert.fail('Unexpected schema structure'); } }); }); describe('webmcp', () => { async function testIncludesWebmcpTools( t: it.TestContext, parseArguments: Partial, handlerAction: ( response: McpResponse, context: McpContext, ) => Promise, toolName: string, ) { await withMcpContext( async (response, context) => { response.setListWebMcpTools(); await handlerAction(response, context); const page = context.getSelectedMcpPage().pptrPage; const {resolve, promise} = Promise.withResolvers(); page.webmcp.once('toolsadded', () => { resolve(undefined); }); await page.setContent( html`
`, ); await promise; const {content, structuredContent} = await response.handle( toolName, context, ); assert.ok(getTextContent(content[0])); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify( stabilizeStructuredContent(structuredContent), null, 2, ), ); }, {args: ['--enable-features=WebMCP,DevToolsWebMCPSupport']}, parseArguments, ); } it('includes webmcp tools in list_pages response', async t => { await testIncludesWebmcpTools( t, {categoryExperimentalWebmcp: true}, async (response, context) => { await listPages().handler({params: {}}, response, context); }, 'list_pages', ); }); it('includes webmcp tools in select_page response', async t => { await testIncludesWebmcpTools( t, {categoryExperimentalWebmcp: true}, async (response, context) => { const pageId = context.getSelectedMcpPage().id; await selectPage.handler({params: {pageId}}, response, context); }, 'select_page', ); }); it('includes webmcp tools in navigate_page response', async t => { await testIncludesWebmcpTools( t, {categoryExperimentalWebmcp: true}, async (response, context) => { await navigatePage().handler( { params: {type: 'url', url: 'about:blank'}, page: context.getSelectedMcpPage(), }, response, context, ); }, 'navigate_page', ); }); it('list no webmcp tools if there are none', async t => { await withMcpContext( async (response, context) => { response.setListWebMcpTools(); const {content, structuredContent} = await response.handle( 'test', context, ); assert.ok(getTextContent(content[0])); t.assert.snapshot(getTextContent(content[0])); t.assert.snapshot( JSON.stringify( stabilizeStructuredContent(structuredContent), null, 2, ), ); }, {args: ['--enable-features=WebMCP,DevToolsWebMCPSupport']}, {categoryExperimentalWebmcp: true}, ); }); it('list no webmcp tools if experimentalWebmcp is false', async t => { await testIncludesWebmcpTools( t, {categoryExperimentalWebmcp: false}, async (response, context) => { await navigatePage().handler( { params: {type: 'url', url: 'about:blank'}, page: context.getSelectedMcpPage(), }, response, context, ); }, 'navigate_page', ); }); });