1592 lines
49 KiB
TypeScript
1592 lines
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.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`<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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.getSelectedMcpPage().pptrPage;
|
|
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.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<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.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<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.getSelectedMcpPage().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.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<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) => {
|
|
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<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},
|
|
);
|
|
}
|
|
|
|
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<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;
|
|
const {resolve, promise} = Promise.withResolvers();
|
|
page.webmcp.once('toolsadded', () => {
|
|
resolve(undefined);
|
|
});
|
|
await page.setContent(
|
|
html`<form
|
|
toolname="test_tool"
|
|
tooldescription="A test tool"
|
|
></form>`,
|
|
);
|
|
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',
|
|
);
|
|
});
|
|
});
|