fix(browser): 修复 DevTools 页面创建和截图诊断

兼容 Chrome DevTools /json/new 的 PUT 方法,过滤不可用的 top-chrome 页面目标,并在截图前对 0x0 viewport 给出明确错误。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
walker1211andClaude Opus 4.7 committed 2026-04-25 14:46:18 +08:00
1 parent fb5b852b94
commit 4c46d6c5ae
4 files changed
+717 -235

No files matched your search

@@ -1,5 +1,15 @@
import { describe, expect, it } from 'bun:test';
import { bundledServerPath, runMcpRequests, getResponseText, getMockDropzoneState, mkdtempSync, readFileSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness';
import {
bundledServerPath,
runMcpRequests,
getResponseText,
getMockDropzoneState,
mkdtempSync,
readFileSync,
writeFileSync,
tmpdir,
join,
} from './browser-mcp-test-harness';
import type { MockPageState } from './browser-mcp-test-harness';
describe('ccs-browser MCP server - advanced interactions', () => {
@@ -88,7 +98,9 @@ describe('ccs-browser MCP server - advanced interactions', () => {
},
]);
expect(getResponseText(responses.find((message) => message.id === 901))).toContain('status: files-dropped');
expect(getResponseText(responses.find((message) => message.id === 901))).toContain(
'status: files-dropped'
);
expect(getMockDropzoneState(pages[0]!, '#dropzone')?.receivedEventTypes).toEqual([
'dragenter',
'dragover',
@@ -99,7 +111,8 @@ describe('ccs-browser MCP server - advanced interactions', () => {
{ name: 'receipt.png', size: 7, type: 'image/png' },
]);
expect(
getMockDropzoneState(pages[0]!, '#frame-dropzone', { frameSelector: '#upload-frame' })?.receivedFiles
getMockDropzoneState(pages[0]!, '#frame-dropzone', { frameSelector: '#upload-frame' })
?.receivedFiles
).toEqual([{ name: 'invoice.pdf', size: 7, type: 'application/pdf' }]);
expect(
getMockDropzoneState(pages[0]!, '#shadow-dropzone', { pierceShadow: true })?.receivedFiles
@@ -145,7 +158,12 @@ describe('ccs-browser MCP server - advanced interactions', () => {
method: 'tools/call',
params: {
name: 'browser_drag_files',
arguments: { pageIndex: 0, pageId: 'page-1', selector: '#reject-dropzone', files: [okPath] },
arguments: {
pageIndex: 0,
pageId: 'page-1',
selector: '#reject-dropzone',
files: [okPath],
},
},
},
{
@@ -169,7 +187,9 @@ describe('ccs-browser MCP server - advanced interactions', () => {
]
);
expect(getResponseText(responses.find((message) => message.id === 904))).toContain('file does not exist');
expect(getResponseText(responses.find((message) => message.id === 904))).toContain(
'file does not exist'
);
expect(getResponseText(responses.find((message) => message.id === 905))).toContain(
'pageIndex and pageId cannot be used together'
);
@@ -297,13 +317,27 @@ describe('ccs-browser MCP server - advanced interactions', () => {
},
]);
expect(getResponseText(responses.find((message) => message.id === 908))).toContain('status: dragged');
expect(getResponseText(responses.find((message) => message.id === 908))).toContain('targetSelector: #lane-b');
expect(getResponseText(responses.find((message) => message.id === 909))).toContain('status: dragged');
expect(getResponseText(responses.find((message) => message.id === 909))).toContain('targetX: 420');
expect(getResponseText(responses.find((message) => message.id === 909))).toContain('targetY: 180');
expect(getResponseText(responses.find((message) => message.id === 916))).toContain('pageIndex: 1');
expect(getResponseText(responses.find((message) => message.id === 916))).toContain('targetSelector: #lane-b');
expect(getResponseText(responses.find((message) => message.id === 908))).toContain(
'status: dragged'
);
expect(getResponseText(responses.find((message) => message.id === 908))).toContain(
'targetSelector: #lane-b'
);
expect(getResponseText(responses.find((message) => message.id === 909))).toContain(
'status: dragged'
);
expect(getResponseText(responses.find((message) => message.id === 909))).toContain(
'targetX: 420'
);
expect(getResponseText(responses.find((message) => message.id === 909))).toContain(
'targetY: 180'
);
expect(getResponseText(responses.find((message) => message.id === 916))).toContain(
'pageIndex: 1'
);
expect(getResponseText(responses.find((message) => message.id === 916))).toContain(
'targetSelector: #lane-b'
);
expect(pages[0]!.drag?.recordedActions).toEqual([
{ type: 'mouseMoved', x: 70, y: 70, button: 'none' },
{ type: 'mousePressed', x: 70, y: 70, button: 'left' },
@@ -360,7 +394,12 @@ describe('ccs-browser MCP server - advanced interactions', () => {
method: 'tools/call',
params: {
name: 'browser_drag_element',
arguments: { selector: '#card-a', targetSelector: '#lane-b', targetX: 400, targetY: 200 },
arguments: {
selector: '#card-a',
targetSelector: '#lane-b',
targetX: 400,
targetY: 200,
},
},
},
{
@@ -387,7 +426,13 @@ describe('ccs-browser MCP server - advanced interactions', () => {
method: 'tools/call',
params: {
name: 'browser_drag_element',
arguments: { pageIndex: 0, pageId: 'page-1', selector: '#card-a', targetX: 400, targetY: 200 },
arguments: {
pageIndex: 0,
pageId: 'page-1',
selector: '#card-a',
targetX: 400,
targetY: 200,
},
},
},
]
@@ -485,19 +530,21 @@ describe('ccs-browser MCP server - advanced interactions', () => {
name: 'browser_pointer_action',
arguments: {
pageId: 'page-2',
actions: [
{ type: 'move', selector: '#handle' },
{ type: 'down' },
{ type: 'up' },
],
actions: [{ type: 'move', selector: '#handle' }, { type: 'down' }, { type: 'up' }],
},
},
},
]);
expect(getResponseText(responses.find((message) => message.id === 913))).toContain('status: pointer-actions-completed');
expect(getResponseText(responses.find((message) => message.id === 918))).toContain('pageIndex: 1');
expect(getResponseText(responses.find((message) => message.id === 918))).toContain('status: pointer-actions-completed');
expect(getResponseText(responses.find((message) => message.id === 913))).toContain(
'status: pointer-actions-completed'
);
expect(getResponseText(responses.find((message) => message.id === 918))).toContain(
'pageIndex: 1'
);
expect(getResponseText(responses.find((message) => message.id === 918))).toContain(
'status: pointer-actions-completed'
);
expect(pages[0]!.drag?.recordedActions).toEqual([
{ type: 'mouseMoved', x: 50, y: 60, button: 'none' },
{ type: 'mousePressed', x: 50, y: 60, button: 'left' },
@@ -592,8 +639,12 @@ describe('ccs-browser MCP server - advanced interactions', () => {
},
]);
expect(getResponseText(responses.find((message) => message.id === 914))).toContain('pointer state error');
expect(getResponseText(responses.find((message) => message.id === 915))).toContain('drag coordinates unavailable');
expect(getResponseText(responses.find((message) => message.id === 914))).toContain(
'pointer state error'
);
expect(getResponseText(responses.find((message) => message.id === 915))).toContain(
'drag coordinates unavailable'
);
expect(getResponseText(responses.find((message) => message.id === 919))).toContain(
'pageIndex and pageId cannot be used together'
);
@@ -1177,6 +1228,37 @@ describe('ccs-browser MCP server - advanced interactions', () => {
);
});
it('reports a clear error before screenshot capture when the selected page has a zero viewport', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Popup Target',
currentUrl: 'https://example.com/',
viewport: { width: 0, height: 0 },
screenshot: { data: 'c2NyZWVuc2hvdA==' },
},
],
[
{
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: {
name: 'browser_take_screenshot',
arguments: {},
},
},
]
);
const response = responses.find((message) => message.id === 3);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain(
'Browser MCP failed: page has no drawable viewport for screenshot'
);
});
it('types into supported editable targets with explicit focus and final-value verification, and rejects unsupported targets', async () => {
const responses = await runMcpRequests(
[
@@ -126,15 +126,21 @@ describe('ccs-browser MCP server - session and interception', () => {
const setDownloadTool = tools.find((tool) => tool.name === 'browser_set_download_behavior');
expect(setDownloadTool?.inputSchema?.properties?.behavior).toMatchObject({ type: 'string' });
expect(setDownloadTool?.inputSchema?.properties?.downloadPath).toMatchObject({ type: 'string' });
expect(setDownloadTool?.inputSchema?.properties?.eventsEnabled).toMatchObject({ type: 'boolean' });
expect(setDownloadTool?.inputSchema?.properties?.downloadPath).toMatchObject({
type: 'string',
});
expect(setDownloadTool?.inputSchema?.properties?.eventsEnabled).toMatchObject({
type: 'boolean',
});
const listDownloadsTool = tools.find((tool) => tool.name === 'browser_list_downloads');
expect(listDownloadsTool?.inputSchema?.properties?.limit).toMatchObject({ type: 'integer' });
expect(listDownloadsTool?.inputSchema?.properties?.pageId).toBeUndefined();
const cancelDownloadTool = tools.find((tool) => tool.name === 'browser_cancel_download');
expect(cancelDownloadTool?.inputSchema?.properties?.downloadId).toMatchObject({ type: 'string' });
expect(cancelDownloadTool?.inputSchema?.properties?.downloadId).toMatchObject({
type: 'string',
});
expect(cancelDownloadTool?.inputSchema?.properties?.guid).toMatchObject({ type: 'string' });
const uploadTool = tools.find((tool) => tool.name === 'browser_set_file_input');
@@ -157,7 +163,9 @@ describe('ccs-browser MCP server - session and interception', () => {
const dragElementTool = tools.find((tool) => tool.name === 'browser_drag_element');
expect(dragElementTool?.inputSchema?.properties?.selector).toMatchObject({ type: 'string' });
expect(dragElementTool?.inputSchema?.properties?.targetSelector).toMatchObject({ type: 'string' });
expect(dragElementTool?.inputSchema?.properties?.targetSelector).toMatchObject({
type: 'string',
});
expect(dragElementTool?.inputSchema?.properties?.targetX).toMatchObject({ type: 'number' });
expect(dragElementTool?.inputSchema?.properties?.targetY).toMatchObject({ type: 'number' });
expect(dragElementTool?.inputSchema?.properties?.steps).toMatchObject({ type: 'integer' });
@@ -166,7 +174,9 @@ describe('ccs-browser MCP server - session and interception', () => {
expect(pointerTool?.inputSchema?.properties?.actions).toMatchObject({ type: 'array' });
const startRecordingTool = tools.find((tool) => tool.name === 'browser_start_recording');
expect(startRecordingTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' });
expect(startRecordingTool?.inputSchema?.properties?.pageIndex).toMatchObject({
type: 'integer',
});
expect(startRecordingTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' });
const stopRecordingTool = tools.find((tool) => tool.name === 'browser_stop_recording');
@@ -189,15 +199,25 @@ describe('ccs-browser MCP server - session and interception', () => {
const cancelReplayTool = tools.find((tool) => tool.name === 'browser_cancel_replay');
expect(cancelReplayTool?.inputSchema).toMatchObject({ type: 'object' });
const startOrchestrationTool = tools.find((tool) => tool.name === 'browser_start_orchestration');
expect(startOrchestrationTool?.inputSchema?.properties?.blocks).toMatchObject({ type: 'array' });
expect(startOrchestrationTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' });
expect(startOrchestrationTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' });
const startOrchestrationTool = tools.find(
(tool) => tool.name === 'browser_start_orchestration'
);
expect(startOrchestrationTool?.inputSchema?.properties?.blocks).toMatchObject({
type: 'array',
});
expect(startOrchestrationTool?.inputSchema?.properties?.pageIndex).toMatchObject({
type: 'integer',
});
expect(startOrchestrationTool?.inputSchema?.properties?.pageId).toMatchObject({
type: 'string',
});
const getOrchestrationTool = tools.find((tool) => tool.name === 'browser_get_orchestration');
expect(getOrchestrationTool?.inputSchema).toMatchObject({ type: 'object' });
const cancelOrchestrationTool = tools.find((tool) => tool.name === 'browser_cancel_orchestration');
const cancelOrchestrationTool = tools.find(
(tool) => tool.name === 'browser_cancel_orchestration'
);
expect(cancelOrchestrationTool?.inputSchema).toMatchObject({ type: 'object' });
const exportArtifactTool = tools.find((tool) => tool.name === 'browser_export_artifact');
@@ -289,6 +309,55 @@ describe('ccs-browser MCP server - session and interception', () => {
expect(text).toContain('Docs text');
});
it('opens a page when Chrome DevTools requires PUT for /json/new', async () => {
const responses = await runMcpRequests(
[{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }],
[
{
jsonrpc: '2.0',
id: 820,
method: 'tools/call',
params: {
name: 'browser_open_page',
arguments: { url: 'https://example.com/new' },
},
},
],
{ requirePutForNewPage: true }
);
const openText = getResponseText(responses.find((message) => message.id === 820));
expect(openText).toContain('status: opened');
expect(openText).toContain('url: https://example.com/new');
});
it('omits non-browser popup targets from session info', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Omnibox Popup',
currentUrl: 'chrome://omnibox-popup.top-chrome/',
},
{ id: 'page-2', title: 'Home', currentUrl: 'https://example.com/' },
],
[
{
jsonrpc: '2.0',
id: 8201,
method: 'tools/call',
params: { name: 'browser_get_session_info', arguments: {} },
},
]
);
const text = getResponseText(responses.find((message) => message.id === 8201));
expect(text).toContain('0. Home');
expect(text).toContain('selected: true');
expect(text).not.toContain('Omnibox Popup');
expect(text).not.toContain('chrome://omnibox-popup.top-chrome/');
});
it('opens a page and makes it selected', async () => {
const responses = await runMcpRequests(
[{ id: 'page-1', title: 'Home', currentUrl: 'https://example.com/' }],
@@ -717,7 +786,9 @@ describe('ccs-browser MCP server - session and interception', () => {
title: 'Docs',
currentUrl: 'https://example.com/docs',
intercept: {
pausedRequests: [{ requestId: 'req-closed', url: 'https://example.com/api/docs', method: 'GET' }],
pausedRequests: [
{ requestId: 'req-closed', url: 'https://example.com/api/docs', method: 'GET' },
],
},
},
],
@@ -801,7 +872,9 @@ describe('ccs-browser MCP server - session and interception', () => {
const response = responses.find((message) => message.id === 951);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain('Browser MCP failed: pageIndex and pageId cannot be used together');
expect(getResponseText(response)).toContain(
'Browser MCP failed: pageIndex and pageId cannot be used together'
);
});
it('rejects browser_add_intercept_rule when action is invalid', async () => {
@@ -825,7 +898,9 @@ describe('ccs-browser MCP server - session and interception', () => {
const response = responses.find((message) => message.id === 952);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain('Browser MCP failed: action must be one of: continue, fail');
expect(getResponseText(response)).toContain(
'Browser MCP failed: action must be one of: continue, fail'
);
});
it('rejects intercept rules when urlPattern and urlRegex are both provided', async () => {
@@ -850,7 +925,9 @@ describe('ccs-browser MCP server - session and interception', () => {
const response = responses.find((message) => message.id === 981);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain('Browser MCP failed: urlPattern and urlRegex cannot be used together');
expect(getResponseText(response)).toContain(
'Browser MCP failed: urlPattern and urlRegex cannot be used together'
);
});
it('rejects intercept rules when priority is not an integer', async () => {
@@ -899,7 +976,9 @@ describe('ccs-browser MCP server - session and interception', () => {
const response = responses.find((message) => message.id === 983);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers must be an array');
expect(getResponseText(response)).toContain(
'Browser MCP failed: headerMatchers must be an array'
);
});
it('rejects intercept rules when a header matcher is missing name', async () => {
@@ -923,7 +1002,9 @@ describe('ccs-browser MCP server - session and interception', () => {
const response = responses.find((message) => message.id === 984);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers.name is required');
expect(getResponseText(response)).toContain(
'Browser MCP failed: headerMatchers.name is required'
);
});
it('rejects intercept rules when a header matcher has no value matcher', async () => {
@@ -947,7 +1028,9 @@ describe('ccs-browser MCP server - session and interception', () => {
const response = responses.find((message) => message.id === 985);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers entry must include valueIncludes or valueRegex');
expect(getResponseText(response)).toContain(
'Browser MCP failed: headerMatchers entry must include valueIncludes or valueRegex'
);
});
it('rejects intercept rules when no matching condition is provided', async () => {
@@ -971,7 +1054,9 @@ describe('ccs-browser MCP server - session and interception', () => {
const response = responses.find((message) => message.id === 986);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain('Browser MCP failed: at least one matching condition is required');
expect(getResponseText(response)).toContain(
'Browser MCP failed: at least one matching condition is required'
);
});
it('adds a resourceType interception rule and lists its richer matching summary', async () => {
@@ -1556,7 +1641,9 @@ describe('ccs-browser MCP server - session and interception', () => {
const response = responses.find((message) => message.id === 968);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain('Browser MCP failed: statusCode must be an integer between 100 and 599');
expect(getResponseText(response)).toContain(
'Browser MCP failed: statusCode must be an integer between 100 and 599'
);
});
it('rejects fulfill rules when responseHeaders is not an array', async () => {
@@ -1582,7 +1669,9 @@ describe('ccs-browser MCP server - session and interception', () => {
const response = responses.find((message) => message.id === 969);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain('Browser MCP failed: responseHeaders must be an array');
expect(getResponseText(response)).toContain(
'Browser MCP failed: responseHeaders must be an array'
);
});
it('rejects fulfill rules when a responseHeaders entry is missing name', async () => {
@@ -1608,7 +1697,9 @@ describe('ccs-browser MCP server - session and interception', () => {
const response = responses.find((message) => message.id === 970);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain('Browser MCP failed: responseHeaders.name is required');
expect(getResponseText(response)).toContain(
'Browser MCP failed: responseHeaders.name is required'
);
});
it('rejects fulfill rules when body is not a string', async () => {
@@ -1657,7 +1748,9 @@ describe('ccs-browser MCP server - session and interception', () => {
const response = responses.find((message) => message.id === 1001);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain('Browser MCP failed: urlRegex must be a valid regular expression');
expect(getResponseText(response)).toContain(
'Browser MCP failed: urlRegex must be a valid regular expression'
);
});
it('rejects intercept rules when headerMatchers.valueRegex is invalid', async () => {
@@ -1681,7 +1774,9 @@ describe('ccs-browser MCP server - session and interception', () => {
const response = responses.find((message) => message.id === 1002);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain('Browser MCP failed: headerMatchers.valueRegex must be a valid regular expression');
expect(getResponseText(response)).toContain(
'Browser MCP failed: headerMatchers.valueRegex must be a valid regular expression'
);
});
it('removes fulfill rules and request summaries after the bound page is closed', async () => {
@@ -1694,13 +1789,22 @@ describe('ccs-browser MCP server - session and interception', () => {
currentUrl: 'https://example.com/mocked',
intercept: {
pausedRequests: [
{ requestId: 'req-fulfill-close', url: 'https://example.com/api/mock/close', method: 'GET' },
{
requestId: 'req-fulfill-close',
url: 'https://example.com/api/mock/close',
method: 'GET',
},
],
},
},
],
[
{ jsonrpc: '2.0', id: 972, method: 'tools/call', params: { name: 'browser_select_page', arguments: { pageIndex: 1 } } },
{
jsonrpc: '2.0',
id: 972,
method: 'tools/call',
params: { name: 'browser_select_page', arguments: { pageIndex: 1 } },
},
{
jsonrpc: '2.0',
id: 973,
@@ -1715,19 +1819,45 @@ describe('ccs-browser MCP server - session and interception', () => {
},
},
},
{ jsonrpc: '2.0', id: 974, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} } },
{ jsonrpc: '2.0', id: 975, method: 'tools/call', params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } } },
{ jsonrpc: '2.0', id: 976, method: 'tools/call', params: { name: 'browser_list_intercept_rules', arguments: {} } },
{ jsonrpc: '2.0', id: 977, method: 'tools/call', params: { name: 'browser_list_requests', arguments: {} } },
{
jsonrpc: '2.0',
id: 974,
method: 'tools/call',
params: { name: 'browser_list_requests', arguments: {} },
},
{
jsonrpc: '2.0',
id: 975,
method: 'tools/call',
params: { name: 'browser_close_page', arguments: { pageId: 'page-2' } },
},
{
jsonrpc: '2.0',
id: 976,
method: 'tools/call',
params: { name: 'browser_list_intercept_rules', arguments: {} },
},
{
jsonrpc: '2.0',
id: 977,
method: 'tools/call',
params: { name: 'browser_list_requests', arguments: {} },
},
],
{
responseTimeoutMs: 12000,
}
);
expect(getResponseText(responses.find((message) => message.id === 974))).toContain('requestId: req-fulfill-close');
expect(getResponseText(responses.find((message) => message.id === 976))).not.toContain('pageId: page-2');
expect(getResponseText(responses.find((message) => message.id === 977))).not.toContain('requestId: req-fulfill-close');
expect(getResponseText(responses.find((message) => message.id === 974))).toContain(
'requestId: req-fulfill-close'
);
expect(getResponseText(responses.find((message) => message.id === 976))).not.toContain(
'pageId: page-2'
);
expect(getResponseText(responses.find((message) => message.id === 977))).not.toContain(
'requestId: req-fulfill-close'
);
});
it('returns only the requested number of recent requests', async () => {
@@ -1739,8 +1869,18 @@ describe('ccs-browser MCP server - session and interception', () => {
currentUrl: 'https://example.com/',
intercept: {
pausedRequests: [
{ requestId: 'req-1', url: 'https://example.com/api/one', method: 'GET', resourceType: 'XHR' },
{ requestId: 'req-2', url: 'https://example.com/api/two', method: 'GET', resourceType: 'XHR' },
{
requestId: 'req-1',
url: 'https://example.com/api/one',
method: 'GET',
resourceType: 'XHR',
},
{
requestId: 'req-2',
url: 'https://example.com/api/two',
method: 'GET',
resourceType: 'XHR',
},
],
},
},
@@ -1813,5 +1953,4 @@ describe('ccs-browser MCP server - session and interception', () => {
const listText = getResponseText(responses.find((message) => message.id === 956));
expect(listText).toBe('status: empty');
});
});
+128 -55
View File
@@ -268,6 +268,7 @@ type MockPageState = {
id: string;
title: string;
currentUrl: string;
targetType?: string;
fileInputs?: Record<string, MockFileInputPlan>;
browser?: MockBrowserState;
frameTree?: MockFrameTree;
@@ -288,6 +289,10 @@ type MockPageState = {
recording?: MockRecordingPlan;
events?: MockPageEventPlan;
intercept?: MockInterceptState;
viewport?: {
width: number;
height: number;
};
screenshot?: {
expectedClip?: {
x: number;
@@ -342,6 +347,7 @@ type RunMcpRequestsOptions = {
serverPath?: string;
childEnv?: NodeJS.ProcessEnv;
responseTimeoutMs?: number;
requirePutForNewPage?: boolean;
};
function encodeMessage(message: unknown): string {
@@ -688,7 +694,7 @@ function createMockBrowser(pagesInput: MockPageState[]) {
JSON.stringify([
...pageEntries.map(([wsPath, page]) => ({
id: page.id,
type: 'page',
type: page.targetType || 'page',
title: page.title,
url: page.currentUrl,
webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`,
@@ -706,6 +712,12 @@ function createMockBrowser(pagesInput: MockPageState[]) {
}
if (req.url?.startsWith('/json/new')) {
if (options.requirePutForNewPage && req.method !== 'PUT') {
res.writeHead(405, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'method not allowed' }));
return;
}
const parsed = new URL(req.url, `http://127.0.0.1:${serverPort}`);
const requestedUrl = parsed.searchParams.get('url') || 'about:blank';
const wsPath = `/devtools/page/${nextPageCounter}`;
@@ -764,7 +776,8 @@ function createMockBrowser(pagesInput: MockPageState[]) {
wsServer = new WebSocketServer({ server: httpServer as http.Server });
wsServer.on('connection', (socket, request) => {
if ((request.url || '') === browserSocketPath) {
const browserState = pagesInput[0]?.browser || (pagesInput[0] ? (pagesInput[0].browser = {}) : {});
const browserState =
pagesInput[0]?.browser || (pagesInput[0] ? (pagesInput[0].browser = {}) : {});
socket.on('message', (raw) => {
const message = JSON.parse(raw.toString()) as {
@@ -782,9 +795,13 @@ function createMockBrowser(pagesInput: MockPageState[]) {
browserState.setDownloadBehaviorCalls.push({
behavior: typeof message.params?.behavior === 'string' ? message.params.behavior : '',
downloadPath:
typeof message.params?.downloadPath === 'string' ? message.params.downloadPath : undefined,
typeof message.params?.downloadPath === 'string'
? message.params.downloadPath
: undefined,
eventsEnabled:
typeof message.params?.eventsEnabled === 'boolean' ? message.params.eventsEnabled : undefined,
typeof message.params?.eventsEnabled === 'boolean'
? message.params.eventsEnabled
: undefined,
});
reply({});
return;
@@ -801,35 +818,41 @@ function createMockBrowser(pagesInput: MockPageState[]) {
for (const page of pagesInput) {
for (const [index, download] of (page.events?.downloads || []).entries()) {
const guid = download.guid || `${page.id}-download-${index + 1}`;
setTimeout(() => {
socket.send(
JSON.stringify({
method: 'Browser.downloadWillBegin',
params: {
frameId: download.frameId || `frame-${page.id}`,
guid,
url: download.url,
suggestedFilename: download.suggestedFilename,
},
})
);
}, 10 + index * 40);
for (const [progressIndex, progress] of (download.progress || []).entries()) {
setTimeout(() => {
setTimeout(
() => {
socket.send(
JSON.stringify({
method: 'Browser.downloadProgress',
method: 'Browser.downloadWillBegin',
params: {
frameId: download.frameId || `frame-${page.id}`,
guid,
totalBytes: progress.totalBytes,
receivedBytes: progress.receivedBytes,
state: progress.state,
filePath: progress.filePath,
url: download.url,
suggestedFilename: download.suggestedFilename,
},
})
);
}, 20 + index * 40 + progressIndex * 20);
},
10 + index * 40
);
for (const [progressIndex, progress] of (download.progress || []).entries()) {
setTimeout(
() => {
socket.send(
JSON.stringify({
method: 'Browser.downloadProgress',
params: {
guid,
totalBytes: progress.totalBytes,
receivedBytes: progress.receivedBytes,
state: progress.state,
filePath: progress.filePath,
},
})
);
},
20 + index * 40 + progressIndex * 20
);
}
}
}
@@ -881,7 +904,31 @@ function createMockBrowser(pagesInput: MockPageState[]) {
if (message.method === 'Page.getFrameTree') {
const nextFrameTree = page.frameTreeSequence?.shift();
reply({ frameTree: nextFrameTree || page.frameTree || { frame: { id: `frame-${page.id}` } } });
reply({
frameTree: nextFrameTree || page.frameTree || { frame: { id: `frame-${page.id}` } },
});
return;
}
if (message.method === 'Page.getLayoutMetrics') {
const width = page.viewport?.width ?? 1280;
const height = page.viewport?.height ?? 720;
reply({
visualViewport: {
pageX: 0,
pageY: 0,
clientWidth: width,
clientHeight: height,
scale: 1,
},
cssVisualViewport: {
pageX: 0,
pageY: 0,
clientWidth: width,
clientHeight: height,
scale: 1,
},
});
return;
}
@@ -916,7 +963,8 @@ function createMockBrowser(pagesInput: MockPageState[]) {
const type = typeof message.params?.type === 'string' ? message.params.type : '';
const x = Number(message.params?.x);
const y = Number(message.params?.y);
const button = typeof message.params?.button === 'string' ? message.params.button : undefined;
const button =
typeof message.params?.button === 'string' ? message.params.button : undefined;
for (const hoverPlan of Object.values(page.hover || {})) {
if (type === 'mouseMoved') {
hoverPlan.lastMouseMove = { x, y };
@@ -1017,28 +1065,33 @@ function createMockBrowser(pagesInput: MockPageState[]) {
? (message.params?.patterns as unknown[])
: [];
if (page.intercept.enableError) {
socket.send(JSON.stringify({ id: message.id, error: { message: page.intercept.enableError } }));
socket.send(
JSON.stringify({ id: message.id, error: { message: page.intercept.enableError } })
);
return;
}
reply({});
const pauseDispatchDelayMs = page.intercept.pauseDispatchDelayMs ?? 10;
for (const [index, paused] of (page.intercept.pausedRequests || []).entries()) {
setTimeout(() => {
socket.send(
JSON.stringify({
method: 'Fetch.requestPaused',
params: {
requestId: paused.requestId || `fetch-${index + 1}`,
resourceType: paused.resourceType || 'XHR',
request: {
url: paused.url,
method: paused.method,
headers: paused.requestHeaders || {},
setTimeout(
() => {
socket.send(
JSON.stringify({
method: 'Fetch.requestPaused',
params: {
requestId: paused.requestId || `fetch-${index + 1}`,
resourceType: paused.resourceType || 'XHR',
request: {
url: paused.url,
method: paused.method,
headers: paused.requestHeaders || {},
},
},
},
})
);
}, pauseDispatchDelayMs + index * 10);
})
);
},
pauseDispatchDelayMs + index * 10
);
}
return;
}
@@ -1056,7 +1109,8 @@ function createMockBrowser(pagesInput: MockPageState[]) {
page.intercept.failedRequests = page.intercept.failedRequests || [];
page.intercept.failedRequests.push({
requestId: String(message.params?.requestId || ''),
errorReason: typeof message.params?.errorReason === 'string' ? message.params.errorReason : '',
errorReason:
typeof message.params?.errorReason === 'string' ? message.params.errorReason : '',
});
reply({});
return;
@@ -1068,7 +1122,9 @@ function createMockBrowser(pagesInput: MockPageState[]) {
page.intercept.fulfilledRequests.push({
requestId: String(message.params?.requestId || ''),
responseCode:
typeof message.params?.responseCode === 'number' ? message.params.responseCode : undefined,
typeof message.params?.responseCode === 'number'
? message.params.responseCode
: undefined,
responseHeaders: Array.isArray(message.params?.responseHeaders)
? (message.params.responseHeaders as Array<{ name: string; value: string }>)
: [],
@@ -1079,10 +1135,13 @@ function createMockBrowser(pagesInput: MockPageState[]) {
}
if (message.method === 'DOM.setFileInputFiles') {
const objectId = typeof message.params?.objectId === 'string' ? message.params.objectId : '';
const objectId =
typeof message.params?.objectId === 'string' ? message.params.objectId : '';
const target = remoteObjects.get(objectId);
if (!target) {
socket.send(JSON.stringify({ id: message.id, error: { message: 'file input handle not found' } }));
socket.send(
JSON.stringify({ id: message.id, error: { message: 'file input handle not found' } })
);
return;
}
target.assignedFiles = Array.isArray(message.params?.files)
@@ -1115,7 +1174,10 @@ function createMockBrowser(pagesInput: MockPageState[]) {
return;
}
if (recordingPayload && (recordingPayload.events?.length || recordingPayload.warnings?.length)) {
if (
recordingPayload &&
(recordingPayload.events?.length || recordingPayload.warnings?.length)
) {
plan.events = recordingPayload.events || [];
plan.warnings = recordingPayload.warnings || [];
}
@@ -1124,7 +1186,11 @@ function createMockBrowser(pagesInput: MockPageState[]) {
return;
}
if (expression.includes('globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] }')) {
if (
expression.includes(
'globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] }'
)
) {
const plan = getMockRecordingPlan(page);
if (plan.finalizeError) {
socket.send(
@@ -1405,7 +1471,8 @@ function createMockBrowser(pagesInput: MockPageState[]) {
}
if (
resolvedClickPlan.expectedOffset &&
(offsetX !== resolvedClickPlan.expectedOffset.x || offsetY !== resolvedClickPlan.expectedOffset.y)
(offsetX !== resolvedClickPlan.expectedOffset.x ||
offsetY !== resolvedClickPlan.expectedOffset.y)
) {
replyError(`unexpected click offset for selector: ${selector}`);
return;
@@ -1721,11 +1788,18 @@ function createMockBrowser(pagesInput: MockPageState[]) {
return;
}
if (expression.includes('window.scrollBy(deltaX, deltaY)') || expression.includes('element.scrollBy(deltaX, deltaY)')) {
if (
expression.includes('window.scrollBy(deltaX, deltaY)') ||
expression.includes('element.scrollBy(deltaX, deltaY)')
) {
const selector = parseJsonArgument(expression, 'selector');
const behavior = parseJsonArgument(expression, 'behavior') || '';
const deltaX = Number(expression.match(/const deltaX = (-?[0-9]+(?:\.[0-9]+)?);/)?.[1] || 0);
const deltaY = Number(expression.match(/const deltaY = (-?[0-9]+(?:\.[0-9]+)?);/)?.[1] || 0);
const deltaX = Number(
expression.match(/const deltaX = (-?[0-9]+(?:\.[0-9]+)?);/)?.[1] || 0
);
const deltaY = Number(
expression.match(/const deltaY = (-?[0-9]+(?:\.[0-9]+)?);/)?.[1] || 0
);
const scrollPlan = selector ? page.scroll?.[selector] : undefined;
if (selector && !scrollPlan) {
replyError(`element not found for selector: ${selector}`);
@@ -1838,7 +1912,6 @@ async function runMcpRequests(
}
}
export {
bundledServerPath,
runMcpRequests,