mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-20 00:14:58 +00:00
feat(browser): 完成 browser MCP 第一阶段能力与配置打通
- 增加 browser_wait_for、browser_eval 及 click/query 扩展能力 - 打通 browser eval_mode 的配置、状态、启动链路与设置界面 - 补充浏览器相关测试隔离、文档和 CI parity 收尾调整
This commit is contained in:
1 parent
a0183073b3
commit
b90eae3892
25 files changed
+2370
-97
No files matched your search
@@ -41,6 +41,11 @@ const TOOL_NAVIGATE = 'browser_navigate';
|
||||
const TOOL_CLICK = 'browser_click';
|
||||
const TOOL_TYPE = 'browser_type';
|
||||
const TOOL_TAKE_SCREENSHOT = 'browser_take_screenshot';
|
||||
const TOOL_WAIT_FOR = 'browser_wait_for';
|
||||
const TOOL_EVAL = 'browser_eval';
|
||||
const TOOL_HOVER = 'browser_hover';
|
||||
const TOOL_QUERY = 'browser_query';
|
||||
const TOOL_TAKE_ELEMENT_SCREENSHOT = 'browser_take_element_screenshot';
|
||||
const TOOL_NAMES = [
|
||||
TOOL_SESSION_INFO,
|
||||
TOOL_URL_TITLE,
|
||||
@@ -50,9 +55,30 @@ const TOOL_NAMES = [
|
||||
TOOL_CLICK,
|
||||
TOOL_TYPE,
|
||||
TOOL_TAKE_SCREENSHOT,
|
||||
TOOL_WAIT_FOR,
|
||||
TOOL_EVAL,
|
||||
TOOL_HOVER,
|
||||
TOOL_QUERY,
|
||||
TOOL_TAKE_ELEMENT_SCREENSHOT,
|
||||
];
|
||||
const SUPPORTED_QUERY_FIELDS = [
|
||||
'exists',
|
||||
'count',
|
||||
'innerText',
|
||||
'textContent',
|
||||
'boundingClientRect',
|
||||
'display',
|
||||
'visibility',
|
||||
'opacity',
|
||||
'href',
|
||||
'onclick',
|
||||
];
|
||||
const DEFAULT_QUERY_FIELDS = [...SUPPORTED_QUERY_FIELDS];
|
||||
const SUPPORTED_QUERY_FIELD_SET = new Set(SUPPORTED_QUERY_FIELDS);
|
||||
const CDP_TIMEOUT_MS = 5000;
|
||||
const NAVIGATION_POLL_INTERVAL_MS = 100;
|
||||
const DEFAULT_WAIT_TIMEOUT_MS = 2000;
|
||||
const DEFAULT_WAIT_POLL_INTERVAL_MS = 100;
|
||||
|
||||
let inputBuffer = Buffer.alloc(0);
|
||||
let requestCounter = 0;
|
||||
@@ -221,7 +247,7 @@ function getTools() {
|
||||
{
|
||||
name: TOOL_CLICK,
|
||||
description:
|
||||
'Click the first element matching a CSS selector in the selected page using a minimal mouse event chain with click fallback. Optionally choose a page by index.',
|
||||
'Click the first element matching a CSS selector in the selected page using a minimal mouse event chain with click fallback. Optionally choose a page by index and match index.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
@@ -234,6 +260,11 @@ function getTools() {
|
||||
type: 'string',
|
||||
description: 'Required CSS selector for the element to click.',
|
||||
},
|
||||
nth: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based match index for selectors returning multiple elements.',
|
||||
},
|
||||
},
|
||||
required: ['selector'],
|
||||
additionalProperties: false,
|
||||
@@ -288,6 +319,145 @@ function getTools() {
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_WAIT_FOR,
|
||||
description:
|
||||
'Poll until a selector-scoped or page-level condition is satisfied. Supports selector existence/visibility/text waits and page text waits.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
selector: {
|
||||
type: 'string',
|
||||
description: 'Optional CSS selector for selector-scoped waiting.',
|
||||
},
|
||||
nth: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based match index for selectors returning multiple elements.',
|
||||
},
|
||||
timeoutMs: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
description: 'Optional timeout in milliseconds.',
|
||||
},
|
||||
pollIntervalMs: {
|
||||
type: 'integer',
|
||||
minimum: 1,
|
||||
description: 'Optional polling interval in milliseconds.',
|
||||
},
|
||||
condition: {
|
||||
type: 'object',
|
||||
description: 'Required wait condition.',
|
||||
},
|
||||
},
|
||||
required: ['condition'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_EVAL,
|
||||
description:
|
||||
'Evaluate page-side JavaScript for inspection or mutation, gated by CCS_BROWSER_EVAL_MODE.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
expression: {
|
||||
type: 'string',
|
||||
description: 'Required JavaScript expression to evaluate in the page.',
|
||||
},
|
||||
mode: {
|
||||
type: 'string',
|
||||
enum: ['readonly', 'readwrite'],
|
||||
description: 'Optional evaluation mode. Defaults to readonly.',
|
||||
},
|
||||
},
|
||||
required: ['expression'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_HOVER,
|
||||
description:
|
||||
'Move the browser mouse pointer onto the first element matching a CSS selector in the selected page to trigger hover state. Optionally choose a page by index.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
selector: {
|
||||
type: 'string',
|
||||
description: 'Required CSS selector for the hover target.',
|
||||
},
|
||||
},
|
||||
required: ['selector'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_QUERY,
|
||||
description:
|
||||
'Return diagnostic state for selector-matched elements in the selected page. Optionally choose a page by index, zero-based match index, and a subset of fields.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
selector: {
|
||||
type: 'string',
|
||||
description: 'Required CSS selector for the query target.',
|
||||
},
|
||||
nth: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based match index for selectors returning multiple elements.',
|
||||
},
|
||||
fields: {
|
||||
type: 'array',
|
||||
items: { type: 'string' },
|
||||
description: 'Optional list of diagnostic fields to return.',
|
||||
},
|
||||
},
|
||||
required: ['selector'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: TOOL_TAKE_ELEMENT_SCREENSHOT,
|
||||
description:
|
||||
'Capture a PNG screenshot clipped to the first element matching a CSS selector in the selected page. Optionally choose a page by index.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
pageIndex: {
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
description: 'Optional zero-based page index from browser_get_session_info. Defaults to the first page.',
|
||||
},
|
||||
selector: {
|
||||
type: 'string',
|
||||
description: 'Required CSS selector for the screenshot target.',
|
||||
},
|
||||
},
|
||||
required: ['selector'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -521,6 +691,48 @@ function requireValidHttpUrl(value) {
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function parseQueryFields(value) {
|
||||
if (value === undefined) {
|
||||
return DEFAULT_QUERY_FIELDS;
|
||||
}
|
||||
if (!Array.isArray(value) || value.some((field) => typeof field !== 'string')) {
|
||||
throw new Error('fields must be an array of strings');
|
||||
}
|
||||
const unknownField = value.find((field) => !SUPPORTED_QUERY_FIELD_SET.has(field));
|
||||
if (unknownField) {
|
||||
throw new Error(`unknown query field: ${unknownField}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireOptionalNonNegativeInteger(value, label) {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`${label} must be a non-negative integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePositiveIntegerOrDefault(value, label, fallback) {
|
||||
if (value === undefined) {
|
||||
return fallback;
|
||||
}
|
||||
if (!Number.isInteger(value) || value <= 0) {
|
||||
throw new Error(`${label} must be a positive integer`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function getBrowserEvalMode() {
|
||||
const raw = String(process.env.CCS_BROWSER_EVAL_MODE || 'readonly').trim();
|
||||
if (raw === 'disabled' || raw === 'readonly' || raw === 'readwrite') {
|
||||
return raw;
|
||||
}
|
||||
return 'readonly';
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -601,12 +813,16 @@ async function handleNavigate(toolArgs) {
|
||||
async function handleClick(toolArgs) {
|
||||
const { page, pageIndex } = await getSelectedPage(toolArgs);
|
||||
const selector = requireNonEmptyString(toolArgs.selector, 'selector');
|
||||
const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth');
|
||||
const targetIndex = nth ?? 0;
|
||||
|
||||
const expression = `(() => {
|
||||
const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))});
|
||||
const element = document.querySelector(selector);
|
||||
const nth = ${nth === undefined ? 'undefined' : String(nth)};
|
||||
const matches = Array.from(document.querySelectorAll(selector));
|
||||
const element = matches[nth ?? 0];
|
||||
if (!element) {
|
||||
throw new Error('element not found for selector: ' + selector);
|
||||
throw new Error('element index ' + (nth ?? 0) + ' is out of range for selector: ' + selector);
|
||||
}
|
||||
if (!element.isConnected) {
|
||||
throw new Error('element is detached for selector: ' + selector);
|
||||
@@ -660,7 +876,7 @@ async function handleClick(toolArgs) {
|
||||
})()`;
|
||||
|
||||
await evaluateExpression(page, expression);
|
||||
return `pageIndex: ${pageIndex}\nselector: ${selector}\nstatus: clicked`;
|
||||
return `pageIndex: ${pageIndex}\nselector: ${selector}\nnth: ${targetIndex}\nstatus: clicked`;
|
||||
}
|
||||
|
||||
async function handleType(toolArgs) {
|
||||
@@ -758,6 +974,385 @@ async function handleScreenshot(toolArgs) {
|
||||
return `pageIndex: ${pageIndex}\nformat: png\nfullPage: ${fullPage ? 'true' : 'false'}\ndata: ${data}`;
|
||||
}
|
||||
|
||||
async function getElementDiagnostics(page, selector, nth) {
|
||||
const expression = `(() => {
|
||||
const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))});
|
||||
const nth = ${nth === undefined ? 'undefined' : String(nth)};
|
||||
const matches = Array.from(document.querySelectorAll(selector));
|
||||
const count = matches.length;
|
||||
const targetIndex = nth ?? 0;
|
||||
const element = matches[targetIndex];
|
||||
if (!element) {
|
||||
return JSON.stringify({
|
||||
exists: nth === undefined ? count > 0 : count > targetIndex,
|
||||
count,
|
||||
targetIndex,
|
||||
targetMissing: true,
|
||||
});
|
||||
}
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
const text = typeof element.innerText === 'string' ? element.innerText : (element.textContent || '');
|
||||
return JSON.stringify({
|
||||
exists: true,
|
||||
count,
|
||||
targetIndex,
|
||||
connected: element.isConnected,
|
||||
text,
|
||||
innerText: typeof element.innerText === 'string' ? element.innerText : '',
|
||||
textContent: element.textContent || '',
|
||||
boundingClientRect: {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
left: rect.left,
|
||||
},
|
||||
display: style.display,
|
||||
visibility: style.visibility,
|
||||
opacity: style.opacity,
|
||||
href: typeof element.getAttribute === 'function' ? element.getAttribute('href') || '' : '',
|
||||
onclick: typeof element.getAttribute === 'function' ? element.getAttribute('onclick') || '' : '',
|
||||
interactable:
|
||||
element.isConnected &&
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0,
|
||||
});
|
||||
})()`;
|
||||
return JSON.parse(await evaluateExpression(page, expression));
|
||||
}
|
||||
|
||||
async function getScrolledElementState(page, selector) {
|
||||
const expression = `(() => {
|
||||
const selector = JSON.parse(${JSON.stringify(JSON.stringify(selector))});
|
||||
const element = document.querySelector(selector);
|
||||
if (!element) {
|
||||
return JSON.stringify({ exists: false });
|
||||
}
|
||||
if (!element.isConnected) {
|
||||
return JSON.stringify({ exists: true, connected: false });
|
||||
}
|
||||
element.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const rect = element.getBoundingClientRect();
|
||||
const style = window.getComputedStyle(element);
|
||||
const viewportWidth = window.innerWidth || document.documentElement.clientWidth || 0;
|
||||
const viewportHeight = window.innerHeight || document.documentElement.clientHeight || 0;
|
||||
const clipX = Math.max(0, rect.left);
|
||||
const clipY = Math.max(0, rect.top);
|
||||
const clipRight = Math.min(viewportWidth, rect.right);
|
||||
const clipBottom = Math.min(viewportHeight, rect.bottom);
|
||||
return JSON.stringify({
|
||||
exists: true,
|
||||
connected: true,
|
||||
display: style.display,
|
||||
visibility: style.visibility,
|
||||
opacity: style.opacity,
|
||||
interactable:
|
||||
style.display !== 'none' &&
|
||||
style.visibility !== 'hidden' &&
|
||||
rect.width > 0 &&
|
||||
rect.height > 0,
|
||||
boundingClientRect: {
|
||||
x: rect.x,
|
||||
y: rect.y,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
top: rect.top,
|
||||
right: rect.right,
|
||||
bottom: rect.bottom,
|
||||
left: rect.left,
|
||||
},
|
||||
centerPoint: {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
},
|
||||
visibleClip: {
|
||||
x: clipX,
|
||||
y: clipY,
|
||||
width: Math.max(0, clipRight - clipX),
|
||||
height: Math.max(0, clipBottom - clipY),
|
||||
scale: 1,
|
||||
},
|
||||
});
|
||||
})()`;
|
||||
return JSON.parse(await evaluateExpression(page, expression));
|
||||
}
|
||||
|
||||
function formatQueryValue(field, value) {
|
||||
if (field === 'boundingClientRect') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function hasTargetSpecificQueryField(fields) {
|
||||
return fields.some((field) => field !== 'exists' && field !== 'count');
|
||||
}
|
||||
|
||||
function parseWaitCondition(value, hasSelector) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new Error('condition is required');
|
||||
}
|
||||
const condition = value;
|
||||
if (condition.kind === 'existence') {
|
||||
if (!hasSelector) {
|
||||
throw new Error('page-level wait only supports text conditions in Phase 1');
|
||||
}
|
||||
return { kind: 'existence', exists: condition.exists !== false };
|
||||
}
|
||||
if (condition.kind === 'visibility') {
|
||||
if (!hasSelector) {
|
||||
throw new Error('page-level wait only supports text conditions in Phase 1');
|
||||
}
|
||||
return {
|
||||
kind: 'visibility',
|
||||
visibility: condition.visibility === 'hidden' ? 'hidden' : 'visible',
|
||||
opacityGt: typeof condition.opacityGt === 'number' ? condition.opacityGt : undefined,
|
||||
};
|
||||
}
|
||||
if (condition.kind === 'text') {
|
||||
if (typeof condition.includes !== 'string' || condition.includes === '') {
|
||||
throw new Error('condition.includes is required');
|
||||
}
|
||||
return { kind: 'text', includes: condition.includes };
|
||||
}
|
||||
throw new Error(`unknown wait condition kind: ${String(condition.kind || '')}`);
|
||||
}
|
||||
|
||||
function isVisibleObservation(observation, opacityGt) {
|
||||
if (!observation || observation.targetMissing) {
|
||||
return false;
|
||||
}
|
||||
const opacity = Number.parseFloat(String(observation.opacity ?? '1'));
|
||||
return (
|
||||
observation.display !== 'none' &&
|
||||
observation.visibility === 'visible' &&
|
||||
Number(observation.boundingClientRect?.width || 0) > 0 &&
|
||||
Number(observation.boundingClientRect?.height || 0) > 0 &&
|
||||
(opacityGt === undefined || opacity > opacityGt)
|
||||
);
|
||||
}
|
||||
|
||||
function isHiddenObservation(observation) {
|
||||
if (!observation || observation.targetMissing) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
observation.display === 'none' ||
|
||||
observation.visibility !== 'visible' ||
|
||||
Number(observation.boundingClientRect?.width || 0) <= 0 ||
|
||||
Number(observation.boundingClientRect?.height || 0) <= 0
|
||||
);
|
||||
}
|
||||
|
||||
function isWaitConditionSatisfied(observation, condition) {
|
||||
if (condition.kind === 'existence') {
|
||||
return condition.exists ? observation.exists === true : observation.exists === false;
|
||||
}
|
||||
if (condition.kind === 'visibility') {
|
||||
return condition.visibility === 'hidden'
|
||||
? isHiddenObservation(observation)
|
||||
: isVisibleObservation(observation, condition.opacityGt);
|
||||
}
|
||||
if (condition.kind === 'text') {
|
||||
return String(observation.text || '').includes(condition.includes);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function formatWaitObservation(observation) {
|
||||
if (!observation) {
|
||||
return 'unavailable';
|
||||
}
|
||||
if ('exists' in observation || 'count' in observation || 'display' in observation) {
|
||||
return [
|
||||
`exists=${observation.exists === true ? 'true' : 'false'}`,
|
||||
`count=${String(observation.count ?? 0)}`,
|
||||
`display=${String(observation.display ?? '')}`,
|
||||
`visibility=${String(observation.visibility ?? '')}`,
|
||||
`opacity=${String(observation.opacity ?? '')}`,
|
||||
].join(', ');
|
||||
}
|
||||
if ('text' in observation) {
|
||||
return `text=${JSON.stringify(observation.text || '')}`;
|
||||
}
|
||||
return 'unavailable';
|
||||
}
|
||||
|
||||
function formatQueryResponse(pageIndex, selector, nth, diagnostics, fields) {
|
||||
const lines = [`pageIndex: ${pageIndex}`, `selector: ${selector}`];
|
||||
if (nth !== undefined) {
|
||||
lines.push(`nth: ${nth}`);
|
||||
}
|
||||
if (diagnostics.targetMissing) {
|
||||
if (hasTargetSpecificQueryField(fields)) {
|
||||
throw new Error(`element index ${diagnostics.targetIndex} is out of range for selector: ${selector}`);
|
||||
}
|
||||
for (const field of fields) {
|
||||
if (field === 'exists') {
|
||||
lines.push(`exists: ${diagnostics.exists ? 'true' : 'false'}`);
|
||||
continue;
|
||||
}
|
||||
if (field === 'count') {
|
||||
lines.push(`count: ${formatQueryValue(field, diagnostics.count)}`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
for (const field of fields) {
|
||||
lines.push(`${field}: ${formatQueryValue(field, diagnostics[field])}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function getWaitPageObservation(page) {
|
||||
const text = await evaluateExpression(
|
||||
page,
|
||||
`(() => document.body ? document.body.innerText || '' : '')()`
|
||||
);
|
||||
return { text };
|
||||
}
|
||||
|
||||
async function getWaitSelectorObservation(page, selector, nth) {
|
||||
return getElementDiagnostics(page, selector, nth);
|
||||
}
|
||||
|
||||
async function handleWaitFor(toolArgs) {
|
||||
const { page, pageIndex } = await getSelectedPage(toolArgs);
|
||||
const selector = typeof toolArgs.selector === 'string' ? toolArgs.selector.trim() : '';
|
||||
const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth');
|
||||
const timeoutMs = requirePositiveIntegerOrDefault(toolArgs.timeoutMs, 'timeoutMs', DEFAULT_WAIT_TIMEOUT_MS);
|
||||
const pollIntervalMs = requirePositiveIntegerOrDefault(
|
||||
toolArgs.pollIntervalMs,
|
||||
'pollIntervalMs',
|
||||
DEFAULT_WAIT_POLL_INTERVAL_MS
|
||||
);
|
||||
const condition = parseWaitCondition(toolArgs.condition, selector !== '');
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastObserved = null;
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
lastObserved = selector
|
||||
? await getWaitSelectorObservation(page, selector, nth)
|
||||
: await getWaitPageObservation(page);
|
||||
if (isWaitConditionSatisfied(lastObserved, condition)) {
|
||||
return `pageIndex: ${pageIndex}${selector ? `\nselector: ${selector}` : ''}\nstatus: satisfied`;
|
||||
}
|
||||
if (Date.now() + pollIntervalMs > deadline) {
|
||||
break;
|
||||
}
|
||||
await sleep(pollIntervalMs);
|
||||
}
|
||||
|
||||
throw new Error(`wait condition timed out\nlastObserved: ${formatWaitObservation(lastObserved)}`);
|
||||
}
|
||||
|
||||
async function handleEval(toolArgs) {
|
||||
const { page, pageIndex } = await getSelectedPage(toolArgs);
|
||||
const expression = requireNonEmptyString(toolArgs.expression, 'expression');
|
||||
const mode = toolArgs.mode === 'readwrite' ? 'readwrite' : 'readonly';
|
||||
const evalMode = getBrowserEvalMode();
|
||||
|
||||
if (evalMode === 'disabled') {
|
||||
throw new Error('browser_eval is disabled by CCS_BROWSER_EVAL_MODE=disabled');
|
||||
}
|
||||
if (mode === 'readwrite' && evalMode !== 'readwrite') {
|
||||
throw new Error(`browser_eval readwrite mode is disabled by CCS_BROWSER_EVAL_MODE=${evalMode}`);
|
||||
}
|
||||
|
||||
const response = await sendCdpCommand(page, 'Runtime.evaluate', {
|
||||
expression,
|
||||
returnByValue: true,
|
||||
awaitPromise: true,
|
||||
...(mode === 'readonly' ? { throwOnSideEffect: true } : {}),
|
||||
});
|
||||
|
||||
if (response?.exceptionDetails?.text) {
|
||||
throw new Error(response.exceptionDetails.text);
|
||||
}
|
||||
if (!response?.result) {
|
||||
throw new Error('evaluation result is not JSON-serializable');
|
||||
}
|
||||
|
||||
const result = response.result;
|
||||
const value = Object.prototype.hasOwnProperty.call(result, 'value') ? result.value : undefined;
|
||||
if (value === undefined && result.type !== 'undefined') {
|
||||
throw new Error('evaluation result is not JSON-serializable');
|
||||
}
|
||||
|
||||
return `pageIndex: ${pageIndex}\nmode: ${mode}\nvalue: ${JSON.stringify(value)}`;
|
||||
}
|
||||
|
||||
async function handleHover(toolArgs) {
|
||||
const { page, pageIndex } = await getSelectedPage(toolArgs);
|
||||
const selector = requireNonEmptyString(toolArgs.selector, 'selector');
|
||||
const state = await getScrolledElementState(page, selector);
|
||||
if (!state.exists) {
|
||||
throw new Error(`element not found for selector: ${selector}`);
|
||||
}
|
||||
if (state.connected !== true) {
|
||||
throw new Error(`element is detached for selector: ${selector}`);
|
||||
}
|
||||
if (state.interactable !== true || !state.centerPoint) {
|
||||
throw new Error(`element is hidden or not interactable for selector: ${selector}`);
|
||||
}
|
||||
|
||||
await sendCdpCommand(page, 'Input.dispatchMouseEvent', {
|
||||
type: 'mouseMoved',
|
||||
x: state.centerPoint.x,
|
||||
y: state.centerPoint.y,
|
||||
button: 'none',
|
||||
buttons: 0,
|
||||
pointerType: 'mouse',
|
||||
});
|
||||
|
||||
return `pageIndex: ${pageIndex}\nselector: ${selector}\nstatus: hovered`;
|
||||
}
|
||||
|
||||
async function handleQuery(toolArgs) {
|
||||
const { page, pageIndex } = await getSelectedPage(toolArgs);
|
||||
const selector = requireNonEmptyString(toolArgs.selector, 'selector');
|
||||
const nth = requireOptionalNonNegativeInteger(toolArgs.nth, 'nth');
|
||||
const fields = parseQueryFields(toolArgs.fields);
|
||||
const diagnostics = await getElementDiagnostics(page, selector, nth);
|
||||
return formatQueryResponse(pageIndex, selector, nth, diagnostics, fields);
|
||||
}
|
||||
|
||||
async function handleElementScreenshot(toolArgs) {
|
||||
const { page, pageIndex } = await getSelectedPage(toolArgs);
|
||||
const selector = requireNonEmptyString(toolArgs.selector, 'selector');
|
||||
const state = await getScrolledElementState(page, selector);
|
||||
if (!state.exists) {
|
||||
throw new Error(`element not found for selector: ${selector}`);
|
||||
}
|
||||
if (state.connected !== true) {
|
||||
throw new Error(`element is detached for selector: ${selector}`);
|
||||
}
|
||||
if (state.interactable !== true || !state.visibleClip) {
|
||||
throw new Error(`element has empty bounds for selector: ${selector}`);
|
||||
}
|
||||
if (state.visibleClip.width <= 0 || state.visibleClip.height <= 0) {
|
||||
throw new Error(`element has empty bounds for selector: ${selector}`);
|
||||
}
|
||||
|
||||
const response = await sendCdpCommand(page, 'Page.captureScreenshot', {
|
||||
format: 'png',
|
||||
clip: state.visibleClip,
|
||||
});
|
||||
|
||||
const data = response && typeof response.data === 'string' ? response.data : '';
|
||||
if (!data) {
|
||||
throw new Error('screenshot capture failed');
|
||||
}
|
||||
|
||||
return `pageIndex: ${pageIndex}\nselector: ${selector}\nformat: png\ndata: ${data}`;
|
||||
}
|
||||
|
||||
async function handleToolCall(message) {
|
||||
const id = message.id;
|
||||
const params = message.params || {};
|
||||
@@ -823,6 +1418,46 @@ async function handleToolCall(message) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName === TOOL_WAIT_FOR) {
|
||||
const text = await handleWaitFor(toolArgs);
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName === TOOL_EVAL) {
|
||||
const text = await handleEval(toolArgs);
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName === TOOL_HOVER) {
|
||||
const text = await handleHover(toolArgs);
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName === TOOL_QUERY) {
|
||||
const text = await handleQuery(toolArgs);
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (toolName === TOOL_TAKE_ELEMENT_SCREENSHOT) {
|
||||
const text = await handleElementScreenshot(toolArgs);
|
||||
writeResponse(id, {
|
||||
content: [{ type: 'text', text }],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { page, pageIndex } = await getSelectedPage(toolArgs);
|
||||
|
||||
if (toolName === TOOL_URL_TITLE) {
|
||||
|
||||
Reference in new issue
Block a user