chromedevtools--chrome-devtools-mcp
7548c97663
Enables "third-party developer tools" feature. This allows the inspected web page to expose tools which provide debugging information to Chrome DevTools for Agents. Third-party developer tools enable web applications to expose internal state, component hierarchies, or specific debug data that cannot be deduced through static analysis. This allows Chrome DevTools for Agents to provide richer, more actionable context to AI agents during debugging sessions. 2 additional tools are enabled in Chrome DevTools for Agents for interacting with third-party developer tools: `list_3p_developer_tools()` and `execute_3p_developer_tool`. Code changes in this PR: - Rename "in-page tools" to "third-party developer tools" - Unhide - Make available in CLI - Add documentation
1597 行
49 KiB
TypeScript
1597 行
49 KiB
TypeScript
/**
|
|
* @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.getSelectedPptrPage();
|
|
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.getSelectedPptrPage();
|
|
await page.setContent(
|
|
html`<button>Click me</button>
|
|
<input
|
|
type="text"
|
|
value="Input"
|
|
/>`,
|
|
);
|
|
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.getSelectedPptrPage();
|
|
await page.setContent(
|
|
html`<label
|
|
>username<input
|
|
name="username"
|
|
value="mcp"
|
|
/></label>`,
|
|
);
|
|
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.getSelectedPptrPage();
|
|
await page.setContent(html`<aside>test</aside>`);
|
|
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.getSelectedPptrPage();
|
|
await page.setContent(html`<aside>test</aside>`);
|
|
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.getSelectedPptrPage();
|
|
await page.setContent(html`
|
|
<div>
|
|
<button id="btn1">Button 1</button>
|
|
<span id="span1">Span 1</span>
|
|
</div>
|
|
`);
|
|
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`
|
|
<div>
|
|
<button id="btn1">Button 1</button>
|
|
</div>
|
|
`,
|
|
);
|
|
const page = context.getSelectedPptrPage();
|
|
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.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.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.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.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.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.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.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<void>(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<void>(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.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.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.getNetworkRequests = () => {
|
|
return [request];
|
|
};
|
|
context.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.getNetworkRequests = () => {
|
|
return [request];
|
|
};
|
|
context.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.getSelectedPptrPage();
|
|
const consoleMessagePromise = new Promise<void>(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.getConsoleData = () => {
|
|
return [mockAggregatedIssue];
|
|
};
|
|
|
|
const {content, structuredContent} = await response.handle(
|
|
'test',
|
|
context,
|
|
);
|
|
const text = getTextContent(content[0]);
|
|
assert.ok(text.includes('<no console messages found>'));
|
|
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.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.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.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.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.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.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.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.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.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.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<string, Extension>([
|
|
[
|
|
'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<string, unknown>) => {
|
|
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) => {
|
|
response.setListThirdPartyDeveloperTools();
|
|
const emptyResult = await response.handle('test', context);
|
|
const emptyText = getTextContent(emptyResult.content[0]);
|
|
assert.ok(
|
|
emptyText.includes('No third-party developer tools available.'),
|
|
'Should show message for empty third-party developer tools',
|
|
);
|
|
|
|
response.resetResponseLineForTesting();
|
|
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} as ParsedArguments,
|
|
);
|
|
});
|
|
|
|
async function testIncludesThirdPartyDeveloperTools(
|
|
handlerAction: (
|
|
response: McpResponse,
|
|
context: McpContext,
|
|
) => Promise<void>,
|
|
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} as ParsedArguments,
|
|
);
|
|
}
|
|
|
|
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.getPageId(context.getSelectedMcpPage().pptrPage) ?? 1;
|
|
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.getPageId(context.getSelectedMcpPage().pptrPage) ?? 1;
|
|
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: ParsedArguments,
|
|
handlerAction: (
|
|
response: McpResponse,
|
|
context: McpContext,
|
|
) => Promise<void>,
|
|
toolName: string,
|
|
) {
|
|
await withMcpContext(
|
|
async (response, context) => {
|
|
response.setListWebMcpTools();
|
|
|
|
await handlerAction(response, context);
|
|
|
|
const page = context.getSelectedMcpPage().pptrPage;
|
|
await page.setContent(
|
|
html`<form
|
|
toolname="test_tool"
|
|
tooldescription="A test tool"
|
|
></form>`,
|
|
);
|
|
|
|
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=WebMCPTesting,DevToolsWebMCPSupport']},
|
|
parseArguments,
|
|
);
|
|
}
|
|
|
|
it('includes webmcp tools in list_pages response', async t => {
|
|
await testIncludesWebmcpTools(
|
|
t,
|
|
{categoryExperimentalWebmcp: true} as ParsedArguments,
|
|
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} as ParsedArguments,
|
|
async (response, context) => {
|
|
const pageId =
|
|
context.getPageId(context.getSelectedMcpPage().pptrPage) ?? 1;
|
|
await selectPage.handler({params: {pageId}}, response, context);
|
|
},
|
|
'select_page',
|
|
);
|
|
});
|
|
|
|
it('includes webmcp tools in navigate_page response', async t => {
|
|
await testIncludesWebmcpTools(
|
|
t,
|
|
{categoryExperimentalWebmcp: true} as ParsedArguments,
|
|
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=WebMCPTesting,DevToolsWebMCPSupport']},
|
|
{categoryExperimentalWebmcp: true} as ParsedArguments,
|
|
);
|
|
});
|
|
|
|
it('list no webmcp tools if experimentalWebmcp is false', async t => {
|
|
await testIncludesWebmcpTools(
|
|
t,
|
|
{categoryExperimentalWebmcp: false} as ParsedArguments,
|
|
async (response, context) => {
|
|
await navigatePage().handler(
|
|
{
|
|
params: {type: 'url', url: 'about:blank'},
|
|
page: context.getSelectedMcpPage(),
|
|
},
|
|
response,
|
|
context,
|
|
);
|
|
},
|
|
'navigate_page',
|
|
);
|
|
});
|
|
});
|