mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-14 14:17:11 +00:00
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:
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');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user