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

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

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

No files matched your search

+309 -121
View File
@@ -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');
}