mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-15 16:19:12 +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:
co-authored by
Claude Opus 4.7
parent
fb5b852b94
commit
4c46d6c5ae
+309
-121
@@ -298,7 +298,8 @@ function getTools() {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
description:
|
||||
'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -314,7 +315,8 @@ function getTools() {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
description:
|
||||
'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -330,7 +332,8 @@ function getTools() {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
description:
|
||||
'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
@@ -346,7 +349,8 @@ function getTools() {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
description:
|
||||
'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
url: {
|
||||
type: 'string',
|
||||
@@ -367,7 +371,8 @@ function getTools() {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
description:
|
||||
'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
selector: {
|
||||
type: 'string',
|
||||
@@ -376,11 +381,13 @@ function getTools() {
|
||||
nth: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based match index for selectors returning multiple elements.',
|
||||
description:
|
||||
'Optional zero-based match index for selectors returning multiple elements.',
|
||||
},
|
||||
frameSelector: {
|
||||
type: 'string',
|
||||
description: 'Optional CSS selector for an iframe whose document should be used as the query root.',
|
||||
description:
|
||||
'Optional CSS selector for an iframe whose document should be used as the query root.',
|
||||
},
|
||||
pierceShadow: {
|
||||
type: 'boolean',
|
||||
@@ -388,11 +395,13 @@ function getTools() {
|
||||
},
|
||||
offsetX: {
|
||||
type: 'number',
|
||||
description: "Optional horizontal offset in CSS pixels from the target element's left edge.",
|
||||
description:
|
||||
"Optional horizontal offset in CSS pixels from the target element's left edge.",
|
||||
},
|
||||
offsetY: {
|
||||
type: 'number',
|
||||
description: "Optional vertical offset in CSS pixels from the target element's top edge.",
|
||||
description:
|
||||
"Optional vertical offset in CSS pixels from the target element's top edge.",
|
||||
},
|
||||
button: {
|
||||
type: 'string',
|
||||
@@ -419,7 +428,8 @@ function getTools() {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
description:
|
||||
'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
selector: {
|
||||
type: 'string',
|
||||
@@ -448,7 +458,8 @@ function getTools() {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
description:
|
||||
'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
key: {
|
||||
type: 'string',
|
||||
@@ -482,7 +493,8 @@ function getTools() {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
description:
|
||||
'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
selector: {
|
||||
type: 'string',
|
||||
@@ -490,7 +502,8 @@ function getTools() {
|
||||
},
|
||||
frameSelector: {
|
||||
type: 'string',
|
||||
description: 'Optional CSS selector for an iframe whose document should be used as the query root.',
|
||||
description:
|
||||
'Optional CSS selector for an iframe whose document should be used as the query root.',
|
||||
},
|
||||
pierceShadow: {
|
||||
type: 'boolean',
|
||||
@@ -516,7 +529,8 @@ function getTools() {
|
||||
},
|
||||
{
|
||||
name: TOOL_SELECT_PAGE,
|
||||
description: 'Select the current page target by page index or page id for subsequent browser tool calls.',
|
||||
description:
|
||||
'Select the current page target by page index or page id for subsequent browser tool calls.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -539,7 +553,8 @@ function getTools() {
|
||||
},
|
||||
{
|
||||
name: TOOL_CLOSE_PAGE,
|
||||
description: 'Close a browser page target by page index or page id. Defaults to the selected page.',
|
||||
description:
|
||||
'Close a browser page target by page index or page id. Defaults to the selected page.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -769,7 +784,8 @@ function getTools() {
|
||||
},
|
||||
{
|
||||
name: TOOL_START_RECORDING,
|
||||
description: 'Start recording real browser interactions on the selected page and store them as structured steps.',
|
||||
description:
|
||||
'Start recording real browser interactions on the selected page and store them as structured steps.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -781,7 +797,8 @@ function getTools() {
|
||||
},
|
||||
{
|
||||
name: TOOL_STOP_RECORDING,
|
||||
description: 'Stop the active browser recording session and keep the recorded result in session-local state.',
|
||||
description:
|
||||
'Stop the active browser recording session and keep the recorded result in session-local state.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
@@ -808,7 +825,8 @@ function getTools() {
|
||||
},
|
||||
{
|
||||
name: TOOL_START_REPLAY,
|
||||
description: 'Start replaying a sequence of structured Browser MCP steps on the selected page.',
|
||||
description:
|
||||
'Start replaying a sequence of structured Browser MCP steps on the selected page.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -840,7 +858,8 @@ function getTools() {
|
||||
},
|
||||
{
|
||||
name: TOOL_START_ORCHESTRATION,
|
||||
description: 'Start an orchestration session that runs fixed browser workflow blocks on the selected page.',
|
||||
description:
|
||||
'Start an orchestration session that runs fixed browser workflow blocks on the selected page.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -854,7 +873,8 @@ function getTools() {
|
||||
},
|
||||
{
|
||||
name: TOOL_GET_ORCHESTRATION,
|
||||
description: 'Read the current orchestration status and result summary from session-local state.',
|
||||
description:
|
||||
'Read the current orchestration status and result summary from session-local state.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
@@ -863,7 +883,8 @@ function getTools() {
|
||||
},
|
||||
{
|
||||
name: TOOL_CANCEL_ORCHESTRATION,
|
||||
description: 'Cancel the active orchestration session and keep the summary in session-local state.',
|
||||
description:
|
||||
'Cancel the active orchestration session and keep the summary in session-local state.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
@@ -872,7 +893,8 @@ function getTools() {
|
||||
},
|
||||
{
|
||||
name: TOOL_EXPORT_ARTIFACT,
|
||||
description: 'Export the current recording, replay, or orchestration artifact to a local JSON file.',
|
||||
description:
|
||||
'Export the current recording, replay, or orchestration artifact to a local JSON file.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -885,7 +907,8 @@ function getTools() {
|
||||
},
|
||||
{
|
||||
name: TOOL_IMPORT_ARTIFACT,
|
||||
description: 'Import a recording, replay, or orchestration artifact from a local JSON file into session-local state.',
|
||||
description:
|
||||
'Import a recording, replay, or orchestration artifact from a local JSON file into session-local state.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -926,7 +949,8 @@ function getTools() {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
description:
|
||||
'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
fullPage: {
|
||||
type: 'boolean',
|
||||
@@ -946,7 +970,8 @@ function getTools() {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
description:
|
||||
'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
selector: {
|
||||
type: 'string',
|
||||
@@ -955,11 +980,13 @@ function getTools() {
|
||||
nth: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based match index for selectors returning multiple elements.',
|
||||
description:
|
||||
'Optional zero-based match index for selectors returning multiple elements.',
|
||||
},
|
||||
frameSelector: {
|
||||
type: 'string',
|
||||
description: 'Optional CSS selector for an iframe whose document should be used as the query root.',
|
||||
description:
|
||||
'Optional CSS selector for an iframe whose document should be used as the query root.',
|
||||
},
|
||||
pierceShadow: {
|
||||
type: 'boolean',
|
||||
@@ -994,7 +1021,8 @@ function getTools() {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
description:
|
||||
'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
expression: {
|
||||
type: 'string',
|
||||
@@ -1020,7 +1048,8 @@ function getTools() {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
description:
|
||||
'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
selector: {
|
||||
type: 'string',
|
||||
@@ -1028,7 +1057,8 @@ function getTools() {
|
||||
},
|
||||
frameSelector: {
|
||||
type: 'string',
|
||||
description: 'Optional CSS selector for an iframe whose document should be used as the query root.',
|
||||
description:
|
||||
'Optional CSS selector for an iframe whose document should be used as the query root.',
|
||||
},
|
||||
pierceShadow: {
|
||||
type: 'boolean',
|
||||
@@ -1049,7 +1079,8 @@ function getTools() {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
description:
|
||||
'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
selector: {
|
||||
type: 'string',
|
||||
@@ -1058,11 +1089,13 @@ function getTools() {
|
||||
nth: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based match index for selectors returning multiple elements.',
|
||||
description:
|
||||
'Optional zero-based match index for selectors returning multiple elements.',
|
||||
},
|
||||
frameSelector: {
|
||||
type: 'string',
|
||||
description: 'Optional CSS selector for an iframe whose document should be used as the query root.',
|
||||
description:
|
||||
'Optional CSS selector for an iframe whose document should be used as the query root.',
|
||||
},
|
||||
pierceShadow: {
|
||||
type: 'boolean',
|
||||
@@ -1088,7 +1121,8 @@ function getTools() {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
description:
|
||||
'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
selector: {
|
||||
type: 'string',
|
||||
@@ -1096,7 +1130,8 @@ function getTools() {
|
||||
},
|
||||
frameSelector: {
|
||||
type: 'string',
|
||||
description: 'Optional CSS selector for an iframe whose document should be used as the query root.',
|
||||
description:
|
||||
'Optional CSS selector for an iframe whose document should be used as the query root.',
|
||||
},
|
||||
pierceShadow: {
|
||||
type: 'boolean',
|
||||
@@ -1116,7 +1151,8 @@ function getTools() {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
description:
|
||||
'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
timeoutMs: {
|
||||
type: 'integer',
|
||||
@@ -1135,14 +1171,25 @@ function getTools() {
|
||||
];
|
||||
}
|
||||
|
||||
async function fetchJson(url) {
|
||||
const response = await fetch(url);
|
||||
async function fetchJson(url, options = undefined) {
|
||||
const response = await fetch(url, options);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status} for ${url}`);
|
||||
}
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
function isUsablePageTarget(target) {
|
||||
if (!target || typeof target !== 'object' || target.type !== 'page') {
|
||||
return false;
|
||||
}
|
||||
const url = typeof target.url === 'string' ? target.url : '';
|
||||
if (url.startsWith('chrome://omnibox-popup') || url.startsWith('chrome://top-chrome')) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getHttpUrl() {
|
||||
const value = process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL;
|
||||
if (!value) {
|
||||
@@ -1157,16 +1204,14 @@ async function listPageTargets() {
|
||||
throw new Error('Browser MCP received an invalid /json/list response.');
|
||||
}
|
||||
|
||||
return targets
|
||||
.filter((target) => target && typeof target === 'object' && target.type === 'page')
|
||||
.map((target) => ({
|
||||
id: typeof target.id === 'string' ? target.id : '',
|
||||
title: typeof target.title === 'string' ? target.title : '',
|
||||
url: typeof target.url === 'string' ? target.url : '',
|
||||
type: typeof target.type === 'string' ? target.type : 'page',
|
||||
webSocketDebuggerUrl:
|
||||
typeof target.webSocketDebuggerUrl === 'string' ? target.webSocketDebuggerUrl : '',
|
||||
}));
|
||||
return targets.filter(isUsablePageTarget).map((target) => ({
|
||||
id: typeof target.id === 'string' ? target.id : '',
|
||||
title: typeof target.title === 'string' ? target.title : '',
|
||||
url: typeof target.url === 'string' ? target.url : '',
|
||||
type: typeof target.type === 'string' ? target.type : 'page',
|
||||
webSocketDebuggerUrl:
|
||||
typeof target.webSocketDebuggerUrl === 'string' ? target.webSocketDebuggerUrl : '',
|
||||
}));
|
||||
}
|
||||
|
||||
function parsePageIndex(toolArgs) {
|
||||
@@ -1314,9 +1359,13 @@ function parseOptionalHeaderMatchers(value) {
|
||||
}
|
||||
const name = requireNonEmptyString(entry.name, 'headerMatchers.name');
|
||||
const valueIncludes =
|
||||
entry.valueIncludes === undefined ? '' : requireNonEmptyString(entry.valueIncludes, 'headerMatchers.valueIncludes');
|
||||
entry.valueIncludes === undefined
|
||||
? ''
|
||||
: requireNonEmptyString(entry.valueIncludes, 'headerMatchers.valueIncludes');
|
||||
const valueRegex =
|
||||
entry.valueRegex === undefined ? '' : requireNonEmptyString(entry.valueRegex, 'headerMatchers.valueRegex');
|
||||
entry.valueRegex === undefined
|
||||
? ''
|
||||
: requireNonEmptyString(entry.valueRegex, 'headerMatchers.valueRegex');
|
||||
if (!valueIncludes && !valueRegex) {
|
||||
throw new Error('headerMatchers entry must include valueIncludes or valueRegex');
|
||||
}
|
||||
@@ -1342,7 +1391,14 @@ function validateInterceptMatcherSet({
|
||||
if (urlPattern && urlRegex) {
|
||||
throw new Error('urlPattern and urlRegex cannot be used together');
|
||||
}
|
||||
if (!urlIncludes && !method && !resourceType && !urlPattern && !urlRegex && headerMatchers.length === 0) {
|
||||
if (
|
||||
!urlIncludes &&
|
||||
!method &&
|
||||
!resourceType &&
|
||||
!urlPattern &&
|
||||
!urlRegex &&
|
||||
headerMatchers.length === 0
|
||||
) {
|
||||
throw new Error('at least one matching condition is required');
|
||||
}
|
||||
}
|
||||
@@ -1505,7 +1561,9 @@ function resolveTargetPage(pages, toolArgs, defaultSelectedId = selectedPageId,
|
||||
const selectedIndex = findPageIndexById(pages, defaultSelectedId);
|
||||
if (selectedIndex === -1) {
|
||||
if (!allowImplicitFallback && defaultSelectedId) {
|
||||
throw new Error('Selected page is no longer available; specify pageIndex or pageId explicitly.');
|
||||
throw new Error(
|
||||
'Selected page is no longer available; specify pageIndex or pageId explicitly.'
|
||||
);
|
||||
}
|
||||
const fallbackPage = pages[0];
|
||||
if (!fallbackPage) {
|
||||
@@ -1526,7 +1584,9 @@ async function getSelectedPage(toolArgs) {
|
||||
const pageIndex = parsePageIndex(toolArgs);
|
||||
const page = pages[pageIndex];
|
||||
if (!page) {
|
||||
throw new Error(`Browser MCP page index ${pageIndex} is out of range (found ${pages.length} pages).`);
|
||||
throw new Error(
|
||||
`Browser MCP page index ${pageIndex} is out of range (found ${pages.length} pages).`
|
||||
);
|
||||
}
|
||||
if (!page.webSocketDebuggerUrl) {
|
||||
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
|
||||
@@ -1655,7 +1715,11 @@ async function getBrowserTarget() {
|
||||
const browserTarget = Array.isArray(targets)
|
||||
? targets.find((target) => target && typeof target === 'object' && target.type === 'browser')
|
||||
: null;
|
||||
if (!browserTarget || typeof browserTarget.webSocketDebuggerUrl !== 'string' || !browserTarget.webSocketDebuggerUrl) {
|
||||
if (
|
||||
!browserTarget ||
|
||||
typeof browserTarget.webSocketDebuggerUrl !== 'string' ||
|
||||
!browserTarget.webSocketDebuggerUrl
|
||||
) {
|
||||
throw new Error('browser-level download events are unavailable');
|
||||
}
|
||||
return browserTarget;
|
||||
@@ -1795,11 +1859,15 @@ async function ensureBrowserDownloadSession() {
|
||||
receivedBytes: Number(message.params?.receivedBytes || 0),
|
||||
totalBytes: Number(message.params?.totalBytes || 0),
|
||||
savedPath: typeof message.params?.filePath === 'string' ? message.params.filePath : '',
|
||||
finishedAt: status === 'completed' || status === 'canceled' ? new Date().toISOString() : '',
|
||||
finishedAt:
|
||||
status === 'completed' || status === 'canceled' ? new Date().toISOString() : '',
|
||||
});
|
||||
}
|
||||
})();
|
||||
activityChain = activityChain.catch(() => {}).then(() => activity).catch(() => {});
|
||||
activityChain = activityChain
|
||||
.catch(() => {})
|
||||
.then(() => activity)
|
||||
.catch(() => {});
|
||||
void activity.catch(() => {});
|
||||
});
|
||||
|
||||
@@ -1836,7 +1904,7 @@ async function evaluateInPage(page, kind) {
|
||||
throw new Error(result.description || 'DevTools evaluation returned an error.');
|
||||
}
|
||||
|
||||
return typeof result.value === 'string' ? result.value : result.value ?? '';
|
||||
return typeof result.value === 'string' ? result.value : (result.value ?? '');
|
||||
}
|
||||
|
||||
async function evaluateExpression(page, expression) {
|
||||
@@ -1854,7 +1922,7 @@ async function evaluateExpression(page, expression) {
|
||||
throw new Error(result.description || 'DevTools evaluation returned an error.');
|
||||
}
|
||||
|
||||
return typeof result.value === 'string' ? result.value : result.value ?? '';
|
||||
return typeof result.value === 'string' ? result.value : (result.value ?? '');
|
||||
}
|
||||
|
||||
async function withPageCommandSession(page, callback) {
|
||||
@@ -2084,7 +2152,10 @@ function requireOptionalStringArray(value, label, allowedValues) {
|
||||
if (value === undefined) {
|
||||
return [];
|
||||
}
|
||||
if (!Array.isArray(value) || value.some((item) => typeof item !== 'string' || item.trim() === '')) {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.some((item) => typeof item !== 'string' || item.trim() === '')
|
||||
) {
|
||||
throw new Error(`${label} must be an array of non-empty strings`);
|
||||
}
|
||||
const normalized = value.map((item) => item.trim());
|
||||
@@ -2282,7 +2353,10 @@ function buildScopedMatchesExpression(selector, nth, frameSelector, pierceShadow
|
||||
}
|
||||
|
||||
async function getScopedDiagnostics(page, selector, nth, frameSelector, pierceShadow) {
|
||||
const raw = await evaluateExpression(page, buildScopedMatchesExpression(selector, nth, frameSelector, pierceShadow));
|
||||
const raw = await evaluateExpression(
|
||||
page,
|
||||
buildScopedMatchesExpression(selector, nth, frameSelector, pierceShadow)
|
||||
);
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
|
||||
@@ -2573,9 +2647,7 @@ function readArtifactFile(filePath) {
|
||||
throw new Error('artifact file is not a regular file');
|
||||
}
|
||||
if (stat.size > MAX_ARTIFACT_FILE_BYTES) {
|
||||
throw new Error(
|
||||
`artifact file exceeds maximum size of ${MAX_ARTIFACT_FILE_BYTES} bytes`
|
||||
);
|
||||
throw new Error(`artifact file exceeds maximum size of ${MAX_ARTIFACT_FILE_BYTES} bytes`);
|
||||
}
|
||||
const raw = fs.readFileSync(filePath, 'utf8');
|
||||
let parsed;
|
||||
@@ -2590,7 +2662,11 @@ function readArtifactFile(filePath) {
|
||||
if (parsed.version !== 1) {
|
||||
throw new Error('unsupported artifact version');
|
||||
}
|
||||
if (typeof parsed.kind !== 'string' || typeof parsed.name !== 'string' || !('payload' in parsed)) {
|
||||
if (
|
||||
typeof parsed.kind !== 'string' ||
|
||||
typeof parsed.name !== 'string' ||
|
||||
!('payload' in parsed)
|
||||
) {
|
||||
throw new Error('invalid artifact payload');
|
||||
}
|
||||
return parsed;
|
||||
@@ -2667,10 +2743,7 @@ function formatOrchestrationSummary(session) {
|
||||
}
|
||||
|
||||
async function waitForSessionToSettle(task) {
|
||||
await Promise.race([
|
||||
task.then(() => undefined),
|
||||
sleep(SESSION_START_SETTLE_WINDOW_MS),
|
||||
]);
|
||||
await Promise.race([task.then(() => undefined), sleep(SESSION_START_SETTLE_WINDOW_MS)]);
|
||||
}
|
||||
|
||||
function createReplaySession(page, pageIndex, steps) {
|
||||
@@ -2961,7 +3034,12 @@ function validateSequenceStep(step) {
|
||||
}
|
||||
|
||||
function requireCrossPageRunBlock(block) {
|
||||
if (!block.args || typeof block.args !== 'object' || !block.args.run || typeof block.args.run !== 'object') {
|
||||
if (
|
||||
!block.args ||
|
||||
typeof block.args !== 'object' ||
|
||||
!block.args.run ||
|
||||
typeof block.args.run !== 'object'
|
||||
) {
|
||||
throw new Error('cross-page run block is required');
|
||||
}
|
||||
if (CROSS_PAGE_BLOCK_TYPES.has(block.args.run.type)) {
|
||||
@@ -3117,7 +3195,8 @@ function matchesObservedEvent(event, observed) {
|
||||
if (event.kind === 'download') {
|
||||
return (
|
||||
(!event.urlIncludes || String(observed.url || '').includes(event.urlIncludes)) &&
|
||||
(!event.suggestedFilenameIncludes || String(observed.suggestedFilename || '').includes(event.suggestedFilenameIncludes))
|
||||
(!event.suggestedFilenameIncludes ||
|
||||
String(observed.suggestedFilename || '').includes(event.suggestedFilenameIncludes))
|
||||
);
|
||||
}
|
||||
return false;
|
||||
@@ -3128,7 +3207,10 @@ function sleep(ms) {
|
||||
}
|
||||
|
||||
async function getNavigationState(page) {
|
||||
const raw = await evaluateExpression(page, `JSON.stringify({ href: location.href, readyState: document.readyState })`);
|
||||
const raw = await evaluateExpression(
|
||||
page,
|
||||
`JSON.stringify({ href: location.href, readyState: document.readyState })`
|
||||
);
|
||||
const parsed = JSON.parse(raw);
|
||||
return {
|
||||
href: typeof parsed.href === 'string' ? parsed.href : '',
|
||||
@@ -3207,10 +3289,16 @@ async function handleClick(toolArgs) {
|
||||
const targetIndex = nth ?? 0;
|
||||
const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector);
|
||||
const pierceShadow = toolArgs.pierceShadow === true;
|
||||
const offsetX = toolArgs.offsetX === undefined ? undefined : requireFiniteNumber(toolArgs.offsetX, 'offsetX');
|
||||
const offsetY = toolArgs.offsetY === undefined ? undefined : requireFiniteNumber(toolArgs.offsetY, 'offsetY');
|
||||
const button = requireEnumString(toolArgs.button, 'button', ['left', 'middle', 'right']) || 'left';
|
||||
const clickCount = toolArgs.clickCount === undefined ? 1 : requirePositiveInteger(toolArgs.clickCount, 'clickCount');
|
||||
const offsetX =
|
||||
toolArgs.offsetX === undefined ? undefined : requireFiniteNumber(toolArgs.offsetX, 'offsetX');
|
||||
const offsetY =
|
||||
toolArgs.offsetY === undefined ? undefined : requireFiniteNumber(toolArgs.offsetY, 'offsetY');
|
||||
const button =
|
||||
requireEnumString(toolArgs.button, 'button', ['left', 'middle', 'right']) || 'left';
|
||||
const clickCount =
|
||||
toolArgs.clickCount === undefined
|
||||
? 1
|
||||
: requirePositiveInteger(toolArgs.clickCount, 'clickCount');
|
||||
|
||||
const expression = `(() => {
|
||||
const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))});
|
||||
@@ -3445,7 +3533,8 @@ async function handleType(toolArgs) {
|
||||
|
||||
const raw = await evaluateExpression(page, expression);
|
||||
const parsed = JSON.parse(raw);
|
||||
const typedLength = typeof parsed.typedLength === 'number' ? parsed.typedLength : String(parsed.value || '').length;
|
||||
const typedLength =
|
||||
typeof parsed.typedLength === 'number' ? parsed.typedLength : String(parsed.value || '').length;
|
||||
return `pageIndex: ${pageIndex}\nselector: ${selector}\ntypedLength: ${typedLength}\nstatus: typed`;
|
||||
}
|
||||
|
||||
@@ -3458,7 +3547,8 @@ async function handlePressKey(toolArgs) {
|
||||
'Meta',
|
||||
'Shift',
|
||||
]);
|
||||
const repeat = toolArgs.repeat === undefined ? 1 : requirePositiveInteger(toolArgs.repeat, 'repeat');
|
||||
const repeat =
|
||||
toolArgs.repeat === undefined ? 1 : requirePositiveInteger(toolArgs.repeat, 'repeat');
|
||||
const modifierMask =
|
||||
(modifiers.includes('Alt') ? 1 : 0) |
|
||||
(modifiers.includes('Control') ? 2 : 0) |
|
||||
@@ -3626,8 +3716,19 @@ async function handleScroll(toolArgs) {
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function ensureDrawableViewport(page) {
|
||||
const metrics = await sendCdpCommand(page, 'Page.getLayoutMetrics');
|
||||
const viewport = metrics.cssVisualViewport || metrics.visualViewport || {};
|
||||
const width = Number(viewport.clientWidth || 0);
|
||||
const height = Number(viewport.clientHeight || 0);
|
||||
if (width <= 0 || height <= 0) {
|
||||
throw new Error('page has no drawable viewport for screenshot');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleScreenshot(toolArgs) {
|
||||
const { page, pageIndex } = await getSelectedPage(toolArgs);
|
||||
await ensureDrawableViewport(page);
|
||||
const fullPage = toolArgs.fullPage === true;
|
||||
const response = await sendCdpCommand(page, 'Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
@@ -3642,11 +3743,23 @@ async function handleScreenshot(toolArgs) {
|
||||
return `pageIndex: ${pageIndex}\nformat: png\nfullPage: ${fullPage ? 'true' : 'false'}\ndata: ${data}`;
|
||||
}
|
||||
|
||||
async function getElementDiagnostics(page, selector, nth, frameSelector = '', pierceShadow = false) {
|
||||
async function getElementDiagnostics(
|
||||
page,
|
||||
selector,
|
||||
nth,
|
||||
frameSelector = '',
|
||||
pierceShadow = false
|
||||
) {
|
||||
return await getScopedDiagnostics(page, selector, nth, frameSelector, pierceShadow);
|
||||
}
|
||||
|
||||
async function getScrolledElementStateAt(page, selector, nth, frameSelector = '', pierceShadow = false) {
|
||||
async function getScrolledElementStateAt(
|
||||
page,
|
||||
selector,
|
||||
nth,
|
||||
frameSelector = '',
|
||||
pierceShadow = false
|
||||
) {
|
||||
const expression = `(() => {
|
||||
const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))});
|
||||
const nth = ${nth === undefined ? 'undefined' : String(nth)};
|
||||
@@ -3860,7 +3973,9 @@ function formatQueryResponse(pageIndex, selector, nth, diagnostics, fields) {
|
||||
}
|
||||
if (diagnostics.targetMissing) {
|
||||
if (hasTargetSpecificQueryField(fields)) {
|
||||
throw new Error(`element index ${diagnostics.targetIndex} is out of range for selector: ${selector}`);
|
||||
throw new Error(
|
||||
`element index ${diagnostics.targetIndex} is out of range for selector: ${selector}`
|
||||
);
|
||||
}
|
||||
for (const field of fields) {
|
||||
if (field === 'exists') {
|
||||
@@ -3897,7 +4012,11 @@ async function handleWaitFor(toolArgs) {
|
||||
const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth');
|
||||
const frameSelector = parseOptionalNonEmptyString(toolArgs.frameSelector);
|
||||
const pierceShadow = toolArgs.pierceShadow === true;
|
||||
const timeoutMs = requirePositiveIntegerOrDefault(toolArgs.timeoutMs, 'timeoutMs', DEFAULT_WAIT_TIMEOUT_MS);
|
||||
const timeoutMs = requirePositiveIntegerOrDefault(
|
||||
toolArgs.timeoutMs,
|
||||
'timeoutMs',
|
||||
DEFAULT_WAIT_TIMEOUT_MS
|
||||
);
|
||||
const pollIntervalMs = requirePositiveIntegerOrDefault(
|
||||
toolArgs.pollIntervalMs,
|
||||
'pollIntervalMs',
|
||||
@@ -3993,7 +4112,14 @@ function interpolatePoints(sourcePoint, targetPoint, steps) {
|
||||
return points;
|
||||
}
|
||||
|
||||
async function resolveElementCenterPoint(page, selector, nth, frameSelector, pierceShadow, missingLabel) {
|
||||
async function resolveElementCenterPoint(
|
||||
page,
|
||||
selector,
|
||||
nth,
|
||||
frameSelector,
|
||||
pierceShadow,
|
||||
missingLabel
|
||||
) {
|
||||
const state = await getScrolledElementStateAt(page, selector, nth, frameSelector, pierceShadow);
|
||||
if (!state.exists) {
|
||||
throw new Error(missingLabel);
|
||||
@@ -4041,7 +4167,10 @@ async function handlePointerAction(toolArgs) {
|
||||
}
|
||||
|
||||
if (action.type === 'pause') {
|
||||
const durationMs = action.durationMs === undefined ? 0 : requireNonNegativeInteger(action.durationMs, 'durationMs');
|
||||
const durationMs =
|
||||
action.durationMs === undefined
|
||||
? 0
|
||||
: requireNonNegativeInteger(action.durationMs, 'durationMs');
|
||||
if (durationMs > 0) {
|
||||
await sleep(durationMs);
|
||||
}
|
||||
@@ -4070,7 +4199,14 @@ async function handlePointerAction(toolArgs) {
|
||||
|
||||
pointerX = point.x;
|
||||
pointerY = point.y;
|
||||
await dispatchMousePointerEvent(page, 'mouseMoved', pointerX, pointerY, pressedButton || 'left', Boolean(pressedButton));
|
||||
await dispatchMousePointerEvent(
|
||||
page,
|
||||
'mouseMoved',
|
||||
pointerX,
|
||||
pointerY,
|
||||
pressedButton || 'left',
|
||||
Boolean(pressedButton)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -4082,21 +4218,36 @@ async function handlePointerAction(toolArgs) {
|
||||
if (pressedButton) {
|
||||
throw new Error('pointer state error');
|
||||
}
|
||||
pressedButton = requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || 'left';
|
||||
await dispatchMousePointerEvent(page, 'mousePressed', pointerX, pointerY, pressedButton, true);
|
||||
pressedButton =
|
||||
requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || 'left';
|
||||
await dispatchMousePointerEvent(
|
||||
page,
|
||||
'mousePressed',
|
||||
pointerX,
|
||||
pointerY,
|
||||
pressedButton,
|
||||
true
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!pressedButton) {
|
||||
throw new Error('pointer state error');
|
||||
}
|
||||
const releaseButton =
|
||||
requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || pressedButton;
|
||||
if (releaseButton !== pressedButton) {
|
||||
throw new Error('pointer state error');
|
||||
}
|
||||
await dispatchMousePointerEvent(page, 'mouseReleased', pointerX, pointerY, releaseButton, false);
|
||||
pressedButton = null;
|
||||
const releaseButton =
|
||||
requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || pressedButton;
|
||||
if (releaseButton !== pressedButton) {
|
||||
throw new Error('pointer state error');
|
||||
}
|
||||
await dispatchMousePointerEvent(
|
||||
page,
|
||||
'mouseReleased',
|
||||
pointerX,
|
||||
pointerY,
|
||||
releaseButton,
|
||||
false
|
||||
);
|
||||
pressedButton = null;
|
||||
}
|
||||
|
||||
if (pressedButton) {
|
||||
@@ -4161,14 +4312,24 @@ async function handleDragElement(toolArgs) {
|
||||
y: requireFiniteNumber(toolArgs.targetY, 'targetY'),
|
||||
};
|
||||
|
||||
const steps = toolArgs.steps === undefined ? DEFAULT_DRAG_STEPS : requirePositiveInteger(toolArgs.steps, 'steps');
|
||||
const steps =
|
||||
toolArgs.steps === undefined
|
||||
? DEFAULT_DRAG_STEPS
|
||||
: requirePositiveInteger(toolArgs.steps, 'steps');
|
||||
|
||||
await dispatchMousePointerEvent(page, 'mouseMoved', sourcePoint.x, sourcePoint.y, 'left', false);
|
||||
await dispatchMousePointerEvent(page, 'mousePressed', sourcePoint.x, sourcePoint.y, 'left', true);
|
||||
for (const point of interpolatePoints(sourcePoint, targetPoint, steps)) {
|
||||
await dispatchMousePointerEvent(page, 'mouseMoved', point.x, point.y, 'left', true);
|
||||
}
|
||||
await dispatchMousePointerEvent(page, 'mouseReleased', targetPoint.x, targetPoint.y, 'left', false);
|
||||
await dispatchMousePointerEvent(
|
||||
page,
|
||||
'mouseReleased',
|
||||
targetPoint.x,
|
||||
targetPoint.y,
|
||||
'left',
|
||||
false
|
||||
);
|
||||
|
||||
return [
|
||||
`pageIndex: ${pageIndex}`,
|
||||
@@ -4197,7 +4358,14 @@ async function handleHover(toolArgs) {
|
||||
throw new Error(`element is hidden or not interactable for selector: ${selector}`);
|
||||
}
|
||||
|
||||
await dispatchMousePointerEvent(page, 'mouseMoved', state.centerPoint.x, state.centerPoint.y, 'left', false);
|
||||
await dispatchMousePointerEvent(
|
||||
page,
|
||||
'mouseMoved',
|
||||
state.centerPoint.x,
|
||||
state.centerPoint.y,
|
||||
'left',
|
||||
false
|
||||
);
|
||||
|
||||
return `pageIndex: ${pageIndex}\nselector: ${selector}${formatScopedSelectorSuffix(frameSelector, pierceShadow)}\nstatus: hovered`;
|
||||
}
|
||||
@@ -4231,6 +4399,7 @@ async function handleElementScreenshot(toolArgs) {
|
||||
if (state.visibleClip.width <= 0 || state.visibleClip.height <= 0) {
|
||||
throw new Error(`element has empty bounds for selector: ${selector}`);
|
||||
}
|
||||
await ensureDrawableViewport(page);
|
||||
|
||||
const response = await sendCdpCommand(page, 'Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
@@ -4363,7 +4532,11 @@ async function waitForBrowserDownloadEvent(page, timeoutMs, event) {
|
||||
const browserTarget = Array.isArray(targets)
|
||||
? targets.find((target) => target && typeof target === 'object' && target.type === 'browser')
|
||||
: null;
|
||||
if (!browserTarget || typeof browserTarget.webSocketDebuggerUrl !== 'string' || !browserTarget.webSocketDebuggerUrl) {
|
||||
if (
|
||||
!browserTarget ||
|
||||
typeof browserTarget.webSocketDebuggerUrl !== 'string' ||
|
||||
!browserTarget.webSocketDebuggerUrl
|
||||
) {
|
||||
throw new Error('browser-level download events are unavailable');
|
||||
}
|
||||
|
||||
@@ -4449,7 +4622,11 @@ async function waitForMatchingEvent({ page, timeoutMs, event }) {
|
||||
|
||||
async function handleWaitForEvent(toolArgs) {
|
||||
const { page, pageIndex } = await getSelectedPage(toolArgs);
|
||||
const timeoutMs = requirePositiveIntegerOrDefault(toolArgs.timeoutMs, 'timeoutMs', DEFAULT_WAIT_TIMEOUT_MS);
|
||||
const timeoutMs = requirePositiveIntegerOrDefault(
|
||||
toolArgs.timeoutMs,
|
||||
'timeoutMs',
|
||||
DEFAULT_WAIT_TIMEOUT_MS
|
||||
);
|
||||
const event = parseEventCondition(toolArgs.event);
|
||||
const observed = await waitForMatchingEvent({ page, pageIndex, timeoutMs, event });
|
||||
return `pageIndex: ${pageIndex}\nevent: ${event.kind}\nstatus: observed\ndetail: ${JSON.stringify(observed)}`;
|
||||
@@ -4628,7 +4805,9 @@ async function ensureInterceptSession(page) {
|
||||
return;
|
||||
}
|
||||
const paused = message.params || {};
|
||||
const matchedRule = getRulesForMatching(page.id).find((rule) => matchesInterceptRule(rule, paused));
|
||||
const matchedRule = getRulesForMatching(page.id).find((rule) =>
|
||||
matchesInterceptRule(rule, paused)
|
||||
);
|
||||
const action = matchedRule ? matchedRule.action : 'continue';
|
||||
if (action === 'fail') {
|
||||
ws.send(
|
||||
@@ -4671,7 +4850,10 @@ async function ensureInterceptSession(page) {
|
||||
statusCode: action === 'fulfill' ? matchedRule.statusCode : 0,
|
||||
});
|
||||
})();
|
||||
activityChain = activityChain.catch(() => {}).then(() => activity).catch(() => {});
|
||||
activityChain = activityChain
|
||||
.catch(() => {})
|
||||
.then(() => activity)
|
||||
.catch(() => {});
|
||||
void activity.catch(() => {});
|
||||
});
|
||||
|
||||
@@ -4716,7 +4898,7 @@ async function handleOpenPage(toolArgs) {
|
||||
const query = toolArgs?.url
|
||||
? `?${new URLSearchParams({ url: requireValidHttpUrl(toolArgs.url) }).toString()}`
|
||||
: '';
|
||||
const createdTarget = await fetchJson(`${getHttpUrl()}/json/new${query}`);
|
||||
const createdTarget = await fetchJson(`${getHttpUrl()}/json/new${query}`, { method: 'PUT' });
|
||||
const pageId = typeof createdTarget?.id === 'string' ? createdTarget.id : '';
|
||||
if (!pageId) {
|
||||
throw new Error('Browser MCP failed to create a new page target.');
|
||||
@@ -4763,7 +4945,10 @@ async function handleClosePage(toolArgs) {
|
||||
if (findPageIndexById(remainingPages, previousSelectedPageId) !== -1) {
|
||||
selectedPageId = previousSelectedPageId;
|
||||
} else {
|
||||
selectedPageId = resolveFallbackSelectedPageId(remainingPages, pageIndex > 0 ? pageIndex - 1 : 0);
|
||||
selectedPageId = resolveFallbackSelectedPageId(
|
||||
remainingPages,
|
||||
pageIndex > 0 ? pageIndex - 1 : 0
|
||||
);
|
||||
}
|
||||
const selectedIndex = findPageIndexById(remainingPages, selectedPageId);
|
||||
return [
|
||||
@@ -4805,7 +4990,9 @@ async function handleAddInterceptRule(toolArgs) {
|
||||
});
|
||||
const statusCode = parseOptionalStatusCode(toolArgs.statusCode);
|
||||
const contentType =
|
||||
toolArgs.contentType === undefined ? '' : requireNonEmptyString(toolArgs.contentType, 'contentType');
|
||||
toolArgs.contentType === undefined
|
||||
? ''
|
||||
: requireNonEmptyString(toolArgs.contentType, 'contentType');
|
||||
const body = parseOptionalBody(toolArgs.body);
|
||||
const responseHeaders = parseOptionalResponseHeaders(toolArgs.responseHeaders, contentType);
|
||||
const rule = {
|
||||
@@ -4900,7 +5087,8 @@ async function handleListRequests(toolArgs) {
|
||||
async function handleSetDownloadBehavior(toolArgs) {
|
||||
const behavior = requireEnumString(toolArgs.behavior, 'behavior', ['accept', 'deny']);
|
||||
const downloadPath = parseOptionalNonEmptyString(toolArgs.downloadPath);
|
||||
const eventsEnabled = toolArgs.eventsEnabled === undefined ? true : toolArgs.eventsEnabled === true;
|
||||
const eventsEnabled =
|
||||
toolArgs.eventsEnabled === undefined ? true : toolArgs.eventsEnabled === true;
|
||||
|
||||
if (behavior === 'deny' && downloadPath) {
|
||||
throw new Error('downloadPath is only allowed when behavior=accept');
|
||||
@@ -5172,7 +5360,9 @@ async function finalizeRecordingCapture(session) {
|
||||
const warnings = Array.isArray(value.warnings) ? value.warnings : [];
|
||||
|
||||
session.rawEvents = rawEvents;
|
||||
session.steps = rawEvents.map((event) => normalizeRecordedEvent(session.pageId, event)).filter(Boolean);
|
||||
session.steps = rawEvents
|
||||
.map((event) => normalizeRecordedEvent(session.pageId, event))
|
||||
.filter(Boolean);
|
||||
session.warnings = warnings.map((warning) => String(warning.message || warning));
|
||||
}
|
||||
|
||||
@@ -5225,9 +5415,7 @@ async function handleStopRecording() {
|
||||
await finalizeRecordingCapture(session);
|
||||
} catch (error) {
|
||||
finalizeError = error instanceof Error ? error : new Error(String(error));
|
||||
session.warnings.push(
|
||||
`recording capture finalization failed: ${finalizeError.message}`
|
||||
);
|
||||
session.warnings.push(`recording capture finalization failed: ${finalizeError.message}`);
|
||||
}
|
||||
session.status = 'stopped';
|
||||
session.stoppedAt = new Date().toISOString();
|
||||
@@ -5325,7 +5513,9 @@ function buildReplayToolArgs(step, replayPage) {
|
||||
nth: step.nth,
|
||||
frameSelector: step.frameSelector,
|
||||
pierceShadow: step.pierceShadow,
|
||||
...(step.args?.targetSelector !== undefined ? { targetSelector: step.args.targetSelector } : {}),
|
||||
...(step.args?.targetSelector !== undefined
|
||||
? { targetSelector: step.args.targetSelector }
|
||||
: {}),
|
||||
...(step.args?.targetNth !== undefined ? { targetNth: step.args.targetNth } : {}),
|
||||
...(step.args?.targetX !== undefined ? { targetX: step.args.targetX } : {}),
|
||||
...(step.args?.targetY !== undefined ? { targetY: step.args.targetY } : {}),
|
||||
@@ -5430,9 +5620,7 @@ async function handleStartReplay(toolArgs) {
|
||||
|
||||
latestReplaySession = session;
|
||||
activeReplaySession = session;
|
||||
const task = Promise.resolve().then(() =>
|
||||
runReplaySession(session, { manageActiveState: true })
|
||||
);
|
||||
const task = Promise.resolve().then(() => runReplaySession(session, { manageActiveState: true }));
|
||||
activeReplayTask = task;
|
||||
task.catch(() => {
|
||||
// Session state is recorded in runReplaySession.
|
||||
@@ -5718,9 +5906,7 @@ async function handleExportArtifact(toolArgs) {
|
||||
const artifact = buildArtifact(kind, name, payload);
|
||||
const serialized = JSON.stringify(artifact, null, 2);
|
||||
if (Buffer.byteLength(serialized, 'utf8') > MAX_ARTIFACT_FILE_BYTES) {
|
||||
throw new Error(
|
||||
`artifact file exceeds maximum size of ${MAX_ARTIFACT_FILE_BYTES} bytes`
|
||||
);
|
||||
throw new Error(`artifact file exceeds maximum size of ${MAX_ARTIFACT_FILE_BYTES} bytes`);
|
||||
}
|
||||
fs.writeFileSync(filePath, serialized, { encoding: 'utf8', mode: 0o600 });
|
||||
return `name: ${name}\nkind: ${kind}\npath: ${filePath}\nstatus: exported`;
|
||||
@@ -5736,12 +5922,14 @@ async function handleListArtifacts() {
|
||||
return 'artifacts: []';
|
||||
}
|
||||
return entries
|
||||
.map((entry) => [
|
||||
`name: ${entry.name}`,
|
||||
`kind: ${entry.kind}`,
|
||||
`createdAt: ${entry.createdAt}`,
|
||||
`path: ${getArtifactPath(entry.name)}`,
|
||||
].join('\n'))
|
||||
.map((entry) =>
|
||||
[
|
||||
`name: ${entry.name}`,
|
||||
`kind: ${entry.kind}`,
|
||||
`createdAt: ${entry.createdAt}`,
|
||||
`path: ${getArtifactPath(entry.name)}`,
|
||||
].join('\n')
|
||||
)
|
||||
.join('\n---\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -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