mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 00:14:15 +00:00
fix(browser): harden Browser MCP sessions and artifacts
This commit is contained in:
1 parent
4cf826efa2
commit
7732b2039a
2 files changed
+772
-108
No files matched your search
+329
-106
@@ -149,6 +149,10 @@ const DEFAULT_WAIT_TIMEOUT_MS = 2000;
|
|||||||
const DEFAULT_WAIT_POLL_INTERVAL_MS = 100;
|
const DEFAULT_WAIT_POLL_INTERVAL_MS = 100;
|
||||||
const DEFAULT_DRAG_STEPS = 5;
|
const DEFAULT_DRAG_STEPS = 5;
|
||||||
const MAX_POINTER_ACTIONS = 25;
|
const MAX_POINTER_ACTIONS = 25;
|
||||||
|
const SESSION_START_SETTLE_WINDOW_MS = 250;
|
||||||
|
const MAX_ARTIFACT_FILE_BYTES = 5 * 1024 * 1024;
|
||||||
|
const SAFE_ARTIFACT_NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||||
|
const SESSION_CANCELED_ERROR_CODE = 'SESSION_CANCELED';
|
||||||
|
|
||||||
let inputBuffer = Buffer.alloc(0);
|
let inputBuffer = Buffer.alloc(0);
|
||||||
let requestCounter = 0;
|
let requestCounter = 0;
|
||||||
@@ -161,9 +165,11 @@ let activeRecordingSession = null;
|
|||||||
let latestRecordingSession = null;
|
let latestRecordingSession = null;
|
||||||
let nextReplayCounter = 1;
|
let nextReplayCounter = 1;
|
||||||
let activeReplaySession = null;
|
let activeReplaySession = null;
|
||||||
|
let activeReplayTask = null;
|
||||||
let latestReplaySession = null;
|
let latestReplaySession = null;
|
||||||
let nextOrchestrationCounter = 1;
|
let nextOrchestrationCounter = 1;
|
||||||
let activeOrchestrationSession = null;
|
let activeOrchestrationSession = null;
|
||||||
|
let activeOrchestrationTask = null;
|
||||||
let latestOrchestrationSession = null;
|
let latestOrchestrationSession = null;
|
||||||
const interceptRules = [];
|
const interceptRules = [];
|
||||||
const recentRequests = [];
|
const recentRequests = [];
|
||||||
@@ -251,6 +257,21 @@ function writeError(id, code, message) {
|
|||||||
writeMessage({ jsonrpc: '2.0', id, error: { code, message } });
|
writeMessage({ jsonrpc: '2.0', id, error: { code, message } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAvailableTools() {
|
||||||
|
const tools = getTools();
|
||||||
|
if (getBrowserEvalMode() === 'disabled') {
|
||||||
|
return tools.filter((tool) => tool.name !== TOOL_EVAL);
|
||||||
|
}
|
||||||
|
return tools;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailableToolNames() {
|
||||||
|
if (getBrowserEvalMode() === 'disabled') {
|
||||||
|
return TOOL_NAMES.filter((toolName) => toolName !== TOOL_EVAL);
|
||||||
|
}
|
||||||
|
return [...TOOL_NAMES];
|
||||||
|
}
|
||||||
|
|
||||||
function getTools() {
|
function getTools() {
|
||||||
if (!shouldExposeTools()) {
|
if (!shouldExposeTools()) {
|
||||||
return [];
|
return [];
|
||||||
@@ -1483,7 +1504,7 @@ function resolveTargetPage(pages, toolArgs, defaultSelectedId = selectedPageId,
|
|||||||
}
|
}
|
||||||
const selectedIndex = findPageIndexById(pages, defaultSelectedId);
|
const selectedIndex = findPageIndexById(pages, defaultSelectedId);
|
||||||
if (selectedIndex === -1) {
|
if (selectedIndex === -1) {
|
||||||
if (!allowImplicitFallback) {
|
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];
|
const fallbackPage = pages[0];
|
||||||
@@ -2102,6 +2123,59 @@ function getBrowserEvalMode() {
|
|||||||
return 'readonly';
|
return 'readonly';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSessionCanceledError(kind) {
|
||||||
|
const error = new Error(`${kind} canceled`);
|
||||||
|
error.code = SESSION_CANCELED_ERROR_CODE;
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSessionCanceledError(error) {
|
||||||
|
return Boolean(error && typeof error === 'object' && error.code === SESSION_CANCELED_ERROR_CODE);
|
||||||
|
}
|
||||||
|
|
||||||
|
function throwIfSessionCanceled(session, kind) {
|
||||||
|
if (session?.cancelRequested === true) {
|
||||||
|
throw createSessionCanceledError(kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markOrchestrationFailureRecorded(error) {
|
||||||
|
if (error && typeof error === 'object') {
|
||||||
|
error.orchestrationFailureRecorded = true;
|
||||||
|
}
|
||||||
|
return error;
|
||||||
|
}
|
||||||
|
|
||||||
|
function wasOrchestrationFailureRecorded(error) {
|
||||||
|
return Boolean(error && typeof error === 'object' && error.orchestrationFailureRecorded === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDevtoolsResultValue(result) {
|
||||||
|
if (!result || typeof result !== 'object') {
|
||||||
|
return { serializable: false, value: undefined };
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(result, 'value')) {
|
||||||
|
return { serializable: true, value: result.value };
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(result, 'unserializableValue')) {
|
||||||
|
return { serializable: true, value: result.unserializableValue };
|
||||||
|
}
|
||||||
|
if (result.type === 'undefined') {
|
||||||
|
return { serializable: true, value: undefined };
|
||||||
|
}
|
||||||
|
return { serializable: false, value: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireArtifactName(value, label = 'name') {
|
||||||
|
const name = requireNonEmptyString(value, label);
|
||||||
|
if (!SAFE_ARTIFACT_NAME_PATTERN.test(name)) {
|
||||||
|
throw new Error(
|
||||||
|
'artifact name must start with a letter or number and contain only letters, numbers, dots, underscores, or hyphens'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
function parseOptionalNonEmptyString(value) {
|
function parseOptionalNonEmptyString(value) {
|
||||||
return typeof value === 'string' && value.trim() !== '' ? value.trim() : '';
|
return typeof value === 'string' && value.trim() !== '' ? value.trim() : '';
|
||||||
}
|
}
|
||||||
@@ -2389,13 +2463,28 @@ function buildDropFilesExpression(selector, nth, frameSelector, pierceShadow, fi
|
|||||||
dataTransfer.items.add(file);
|
dataTransfer.items.add(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
element.dispatchEvent(new DragEvent('dragenter', { bubbles: true, cancelable: true, dataTransfer }));
|
const dragEnterEvent = new DragEvent('dragenter', {
|
||||||
element.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer }));
|
bubbles: true,
|
||||||
const accepted = element.dispatchEvent(
|
cancelable: true,
|
||||||
new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer })
|
dataTransfer,
|
||||||
);
|
});
|
||||||
|
const dragOverEvent = new DragEvent('dragover', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
dataTransfer,
|
||||||
|
});
|
||||||
|
const dropEvent = new DragEvent('drop', {
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
dataTransfer,
|
||||||
|
});
|
||||||
|
|
||||||
return { accepted };
|
element.dispatchEvent(dragEnterEvent);
|
||||||
|
const dragOverCanceled =
|
||||||
|
element.dispatchEvent(dragOverEvent) === false || dragOverEvent.defaultPrevented;
|
||||||
|
const dropCanceled = element.dispatchEvent(dropEvent) === false || dropEvent.defaultPrevented;
|
||||||
|
|
||||||
|
return { accepted: dragOverCanceled || dropCanceled };
|
||||||
})()`;
|
})()`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2450,13 +2539,19 @@ function getArtifactDir() {
|
|||||||
const scoped = Buffer.from(process.cwd()).toString('base64url');
|
const scoped = Buffer.from(process.cwd()).toString('base64url');
|
||||||
const baseDir = path.join(os.tmpdir(), 'ccs-browser-artifacts', scoped);
|
const baseDir = path.join(os.tmpdir(), 'ccs-browser-artifacts', scoped);
|
||||||
if (!fs.existsSync(baseDir)) {
|
if (!fs.existsSync(baseDir)) {
|
||||||
fs.mkdirSync(baseDir, { recursive: true });
|
fs.mkdirSync(baseDir, { recursive: true, mode: 0o700 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
fs.chmodSync(baseDir, 0o700);
|
||||||
|
} catch {
|
||||||
|
// Best-effort on platforms/filesystems that ignore permission changes.
|
||||||
}
|
}
|
||||||
return baseDir;
|
return baseDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getArtifactPath(name) {
|
function getArtifactPath(name) {
|
||||||
return path.join(getArtifactDir(), `${name}.json`);
|
const safeName = requireArtifactName(name);
|
||||||
|
return path.join(getArtifactDir(), `${safeName}.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildArtifact(kind, name, payload) {
|
function buildArtifact(kind, name, payload) {
|
||||||
@@ -2473,6 +2568,15 @@ function readArtifactFile(filePath) {
|
|||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
throw new Error('artifact file not found');
|
throw new Error('artifact file not found');
|
||||||
}
|
}
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
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`
|
||||||
|
);
|
||||||
|
}
|
||||||
const raw = fs.readFileSync(filePath, 'utf8');
|
const raw = fs.readFileSync(filePath, 'utf8');
|
||||||
let parsed;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
@@ -2562,6 +2666,48 @@ function formatOrchestrationSummary(session) {
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForSessionToSettle(task) {
|
||||||
|
await Promise.race([
|
||||||
|
task.then(() => undefined),
|
||||||
|
sleep(SESSION_START_SETTLE_WINDOW_MS),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createReplaySession(page, pageIndex, steps) {
|
||||||
|
return {
|
||||||
|
replayId: createReplayId(),
|
||||||
|
pageId: page.id,
|
||||||
|
pageIndex,
|
||||||
|
status: 'running',
|
||||||
|
stepCount: steps.length,
|
||||||
|
completedSteps: 0,
|
||||||
|
currentStepIndex: 0,
|
||||||
|
failedStepIndex: null,
|
||||||
|
error: null,
|
||||||
|
cancelRequested: false,
|
||||||
|
steps,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOrchestrationSession(page, pageIndex, blocks) {
|
||||||
|
return {
|
||||||
|
orchestrationId: createOrchestrationId(),
|
||||||
|
pageId: page.id,
|
||||||
|
pageIndex,
|
||||||
|
status: 'running',
|
||||||
|
blockCount: blocks.length,
|
||||||
|
completedBlocks: 0,
|
||||||
|
currentBlockIndex: 0,
|
||||||
|
failedBlockIndex: null,
|
||||||
|
failedSequenceStepIndex: null,
|
||||||
|
error: null,
|
||||||
|
failedCount: 0,
|
||||||
|
failures: [],
|
||||||
|
cancelRequested: false,
|
||||||
|
blocks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function formatRecordingDetail(session) {
|
function formatRecordingDetail(session) {
|
||||||
const lines = [formatRecordingSummary(session), 'steps:'];
|
const lines = [formatRecordingSummary(session), 'steps:'];
|
||||||
if (session.steps.length === 0) {
|
if (session.steps.length === 0) {
|
||||||
@@ -3805,12 +3951,12 @@ async function handleEval(toolArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = response.result;
|
const result = response.result;
|
||||||
const value = Object.prototype.hasOwnProperty.call(result, 'value') ? result.value : undefined;
|
const normalizedResult = normalizeDevtoolsResultValue(result);
|
||||||
if (value === undefined && result.type !== 'undefined') {
|
if (!normalizedResult.serializable) {
|
||||||
throw new Error('evaluation result is not JSON-serializable');
|
throw new Error('evaluation result is not JSON-serializable');
|
||||||
}
|
}
|
||||||
|
|
||||||
return `pageIndex: ${pageIndex}\nmode: ${mode}\nvalue: ${JSON.stringify(value)}`;
|
return `pageIndex: ${pageIndex}\nmode: ${mode}\nvalue: ${JSON.stringify(normalizedResult.value)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getButtonMask(button) {
|
function getButtonMask(button) {
|
||||||
@@ -3867,7 +4013,9 @@ async function handlePointerAction(toolArgs) {
|
|||||||
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId);
|
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, {
|
||||||
|
allowImplicitFallback: false,
|
||||||
|
});
|
||||||
if (!page.webSocketDebuggerUrl) {
|
if (!page.webSocketDebuggerUrl) {
|
||||||
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
|
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
|
||||||
}
|
}
|
||||||
@@ -3942,9 +4090,13 @@ async function handlePointerAction(toolArgs) {
|
|||||||
if (!pressedButton) {
|
if (!pressedButton) {
|
||||||
throw new Error('pointer state error');
|
throw new Error('pointer state error');
|
||||||
}
|
}
|
||||||
const releaseButton = requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || pressedButton;
|
const releaseButton =
|
||||||
await dispatchMousePointerEvent(page, 'mouseReleased', pointerX, pointerY, releaseButton, false);
|
requireEnumString(action.button, 'button', ['left', 'middle', 'right']) || pressedButton;
|
||||||
pressedButton = null;
|
if (releaseButton !== pressedButton) {
|
||||||
|
throw new Error('pointer state error');
|
||||||
|
}
|
||||||
|
await dispatchMousePointerEvent(page, 'mouseReleased', pointerX, pointerY, releaseButton, false);
|
||||||
|
pressedButton = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pressedButton) {
|
if (pressedButton) {
|
||||||
@@ -3960,7 +4112,9 @@ async function handleDragElement(toolArgs) {
|
|||||||
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId);
|
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, {
|
||||||
|
allowImplicitFallback: false,
|
||||||
|
});
|
||||||
if (!page.webSocketDebuggerUrl) {
|
if (!page.webSocketDebuggerUrl) {
|
||||||
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
|
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
|
||||||
}
|
}
|
||||||
@@ -4548,7 +4702,9 @@ async function handleSelectPage(toolArgs) {
|
|||||||
if (pages.length === 0) {
|
if (pages.length === 0) {
|
||||||
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
||||||
}
|
}
|
||||||
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId);
|
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, {
|
||||||
|
allowImplicitFallback: false,
|
||||||
|
});
|
||||||
if (!page.webSocketDebuggerUrl) {
|
if (!page.webSocketDebuggerUrl) {
|
||||||
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
|
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
|
||||||
}
|
}
|
||||||
@@ -4809,7 +4965,9 @@ async function handleSetFileInput(toolArgs) {
|
|||||||
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId);
|
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, {
|
||||||
|
allowImplicitFallback: false,
|
||||||
|
});
|
||||||
const selector = requireNonEmptyString(toolArgs.selector, 'selector');
|
const selector = requireNonEmptyString(toolArgs.selector, 'selector');
|
||||||
const files = validateLocalFiles(requireNonEmptyStringArray(toolArgs.files, 'files'));
|
const files = validateLocalFiles(requireNonEmptyStringArray(toolArgs.files, 'files'));
|
||||||
const nth = toolArgs.nth === undefined ? 0 : requireNonNegativeInteger(toolArgs.nth, 'nth');
|
const nth = toolArgs.nth === undefined ? 0 : requireNonNegativeInteger(toolArgs.nth, 'nth');
|
||||||
@@ -4843,7 +5001,9 @@ async function handleDragFiles(toolArgs) {
|
|||||||
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId);
|
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, {
|
||||||
|
allowImplicitFallback: false,
|
||||||
|
});
|
||||||
const selector = requireNonEmptyString(toolArgs.selector, 'selector');
|
const selector = requireNonEmptyString(toolArgs.selector, 'selector');
|
||||||
const files = readLocalFilesForDrop(requireNonEmptyStringArray(toolArgs.files, 'files'));
|
const files = readLocalFilesForDrop(requireNonEmptyStringArray(toolArgs.files, 'files'));
|
||||||
const nth = toolArgs.nth === undefined ? 0 : requireNonNegativeInteger(toolArgs.nth, 'nth');
|
const nth = toolArgs.nth === undefined ? 0 : requireNonNegativeInteger(toolArgs.nth, 'nth');
|
||||||
@@ -5026,7 +5186,9 @@ async function handleStartRecording(toolArgs) {
|
|||||||
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId);
|
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, {
|
||||||
|
allowImplicitFallback: false,
|
||||||
|
});
|
||||||
if (!page.webSocketDebuggerUrl) {
|
if (!page.webSocketDebuggerUrl) {
|
||||||
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
|
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
|
||||||
}
|
}
|
||||||
@@ -5057,13 +5219,24 @@ async function handleStopRecording() {
|
|||||||
if (!activeRecordingSession) {
|
if (!activeRecordingSession) {
|
||||||
throw new Error('no active recording');
|
throw new Error('no active recording');
|
||||||
}
|
}
|
||||||
await finalizeRecordingCapture(activeRecordingSession);
|
const session = activeRecordingSession;
|
||||||
activeRecordingSession.status = 'stopped';
|
let finalizeError = null;
|
||||||
activeRecordingSession.stoppedAt = new Date().toISOString();
|
try {
|
||||||
const stopped = activeRecordingSession;
|
await finalizeRecordingCapture(session);
|
||||||
|
} catch (error) {
|
||||||
|
finalizeError = error instanceof Error ? error : new Error(String(error));
|
||||||
|
session.warnings.push(
|
||||||
|
`recording capture finalization failed: ${finalizeError.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
session.status = 'stopped';
|
||||||
|
session.stoppedAt = new Date().toISOString();
|
||||||
activeRecordingSession = null;
|
activeRecordingSession = null;
|
||||||
latestRecordingSession = stopped;
|
latestRecordingSession = session;
|
||||||
return formatRecordingSummary(stopped);
|
if (finalizeError) {
|
||||||
|
throw finalizeError;
|
||||||
|
}
|
||||||
|
return formatRecordingSummary(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleGetRecording() {
|
async function handleGetRecording() {
|
||||||
@@ -5175,6 +5348,7 @@ function buildReplayToolArgs(step, replayPage) {
|
|||||||
|
|
||||||
async function executeReplaySteps(session) {
|
async function executeReplaySteps(session) {
|
||||||
for (let index = 0; index < session.steps.length; index += 1) {
|
for (let index = 0; index < session.steps.length; index += 1) {
|
||||||
|
throwIfSessionCanceled(session, 'replay');
|
||||||
const step = session.steps[index];
|
const step = session.steps[index];
|
||||||
session.currentStepIndex = index;
|
session.currentStepIndex = index;
|
||||||
validateReplayStep(step, session.pageId);
|
validateReplayStep(step, session.pageId);
|
||||||
@@ -5201,9 +5375,39 @@ async function executeReplaySteps(session) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
session.completedSteps = index + 1;
|
session.completedSteps = index + 1;
|
||||||
|
throwIfSessionCanceled(session, 'replay');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runReplaySession(session, options = {}) {
|
||||||
|
try {
|
||||||
|
await executeReplaySteps(session);
|
||||||
|
if (session.cancelRequested) {
|
||||||
|
session.status = 'canceled';
|
||||||
|
} else {
|
||||||
|
session.status = 'completed';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isSessionCanceledError(error) || session.cancelRequested) {
|
||||||
|
session.status = 'canceled';
|
||||||
|
session.error = null;
|
||||||
|
} else {
|
||||||
|
session.status = 'failed';
|
||||||
|
session.failedStepIndex = session.currentStepIndex;
|
||||||
|
session.error = error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
latestReplaySession = session;
|
||||||
|
if (options.manageActiveState === true) {
|
||||||
|
if (activeReplaySession === session) {
|
||||||
|
activeReplaySession = null;
|
||||||
|
}
|
||||||
|
activeReplayTask = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleStartReplay(toolArgs) {
|
async function handleStartReplay(toolArgs) {
|
||||||
if (activeReplaySession) {
|
if (activeReplaySession) {
|
||||||
throw new Error('replay already active');
|
throw new Error('replay already active');
|
||||||
@@ -5214,39 +5418,26 @@ async function handleStartReplay(toolArgs) {
|
|||||||
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId);
|
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, {
|
||||||
|
allowImplicitFallback: false,
|
||||||
|
});
|
||||||
if (!page.webSocketDebuggerUrl) {
|
if (!page.webSocketDebuggerUrl) {
|
||||||
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
|
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const steps = requireReplaySteps(toolArgs);
|
const steps = requireReplaySteps(toolArgs);
|
||||||
const session = {
|
const session = createReplaySession(page, pageIndex, steps);
|
||||||
replayId: createReplayId(),
|
|
||||||
pageId: page.id,
|
|
||||||
pageIndex,
|
|
||||||
status: 'running',
|
|
||||||
stepCount: steps.length,
|
|
||||||
completedSteps: 0,
|
|
||||||
currentStepIndex: 0,
|
|
||||||
failedStepIndex: null,
|
|
||||||
error: null,
|
|
||||||
steps,
|
|
||||||
};
|
|
||||||
|
|
||||||
latestReplaySession = session;
|
latestReplaySession = session;
|
||||||
activeReplaySession = session;
|
activeReplaySession = session;
|
||||||
|
const task = Promise.resolve().then(() =>
|
||||||
try {
|
runReplaySession(session, { manageActiveState: true })
|
||||||
await executeReplaySteps(session);
|
);
|
||||||
session.status = 'completed';
|
activeReplayTask = task;
|
||||||
} catch (error) {
|
task.catch(() => {
|
||||||
session.status = 'failed';
|
// Session state is recorded in runReplaySession.
|
||||||
session.failedStepIndex = session.currentStepIndex;
|
});
|
||||||
session.error = error instanceof Error ? error.message : String(error);
|
await waitForSessionToSettle(task);
|
||||||
}
|
|
||||||
|
|
||||||
activeReplaySession = null;
|
|
||||||
latestReplaySession = session;
|
|
||||||
return formatReplaySummary(session);
|
return formatReplaySummary(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5259,11 +5450,10 @@ async function handleCancelReplay() {
|
|||||||
if (!activeReplaySession) {
|
if (!activeReplaySession) {
|
||||||
throw new Error('no active replay');
|
throw new Error('no active replay');
|
||||||
}
|
}
|
||||||
|
activeReplaySession.cancelRequested = true;
|
||||||
activeReplaySession.status = 'canceled';
|
activeReplaySession.status = 'canceled';
|
||||||
const canceled = activeReplaySession;
|
latestReplaySession = activeReplaySession;
|
||||||
activeReplaySession = null;
|
return formatReplaySummary(activeReplaySession);
|
||||||
latestReplaySession = canceled;
|
|
||||||
return formatReplaySummary(canceled);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeOrchestrationBlock(session, block) {
|
async function executeOrchestrationBlock(session, block) {
|
||||||
@@ -5311,9 +5501,15 @@ async function executeOrchestrationBlock(session, block) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (block.type === 'run_replay_sequence') {
|
if (block.type === 'run_replay_sequence') {
|
||||||
const replayResultText = await handleStartReplay({ pageId: session.pageId, steps: block.args.steps });
|
const replaySteps = requireReplaySteps({ steps: block.args.steps });
|
||||||
if (replayResultText.includes('status: failed')) {
|
const replaySession = createReplaySession(
|
||||||
throw new Error(replayResultText);
|
{ id: session.pageId },
|
||||||
|
session.pageIndex,
|
||||||
|
replaySteps
|
||||||
|
);
|
||||||
|
await runReplaySession(replaySession);
|
||||||
|
if (replaySession.status === 'failed' || replaySession.status === 'canceled') {
|
||||||
|
throw new Error(formatReplaySummary(replaySession));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5346,7 +5542,9 @@ async function executeOrchestrationBlock(session, block) {
|
|||||||
|
|
||||||
async function executeSequenceSteps(session, block) {
|
async function executeSequenceSteps(session, block) {
|
||||||
const steps = requireSequenceSteps(block);
|
const steps = requireSequenceSteps(block);
|
||||||
|
session.failedSequenceStepIndex = null;
|
||||||
for (let index = 0; index < steps.length; index += 1) {
|
for (let index = 0; index < steps.length; index += 1) {
|
||||||
|
throwIfSessionCanceled(session, 'orchestration');
|
||||||
const step = steps[index];
|
const step = steps[index];
|
||||||
validateSequenceStep(step);
|
validateSequenceStep(step);
|
||||||
try {
|
try {
|
||||||
@@ -5363,22 +5561,24 @@ async function executeSequenceSteps(session, block) {
|
|||||||
if (step.continueOnError === true) {
|
if (step.continueOnError === true) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
throw error;
|
throw markOrchestrationFailureRecorded(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeOrchestrationBlocks(session) {
|
async function executeOrchestrationBlocks(session) {
|
||||||
for (let index = 0; index < session.blocks.length; index += 1) {
|
for (let index = 0; index < session.blocks.length; index += 1) {
|
||||||
|
throwIfSessionCanceled(session, 'orchestration');
|
||||||
const block = session.blocks[index];
|
const block = session.blocks[index];
|
||||||
session.currentBlockIndex = index;
|
session.currentBlockIndex = index;
|
||||||
|
session.failedSequenceStepIndex = null;
|
||||||
validateOrchestrationBlock(block);
|
validateOrchestrationBlock(block);
|
||||||
try {
|
try {
|
||||||
await executeOrchestrationBlock(session, block);
|
await executeOrchestrationBlock(session, block);
|
||||||
session.completedBlocks = index + 1;
|
session.completedBlocks = index + 1;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
if (session.failedSequenceStepIndex === null || session.failedSequenceStepIndex === undefined) {
|
if (!wasOrchestrationFailureRecorded(error)) {
|
||||||
pushOrchestrationFailure(session, {
|
pushOrchestrationFailure(session, {
|
||||||
blockIndex: index,
|
blockIndex: index,
|
||||||
sequenceStepIndex: null,
|
sequenceStepIndex: null,
|
||||||
@@ -5387,7 +5587,6 @@ async function executeOrchestrationBlocks(session) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (block.continueOnError === true) {
|
if (block.continueOnError === true) {
|
||||||
session.failedSequenceStepIndex = null;
|
|
||||||
session.completedBlocks = index + 1;
|
session.completedBlocks = index + 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -5396,6 +5595,40 @@ async function executeOrchestrationBlocks(session) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function runOrchestrationSession(session, options = {}) {
|
||||||
|
try {
|
||||||
|
await executeOrchestrationBlocks(session);
|
||||||
|
if (session.cancelRequested) {
|
||||||
|
session.status = 'canceled';
|
||||||
|
} else if ((session.failedCount || 0) > 0) {
|
||||||
|
session.status = 'completed_with_failures';
|
||||||
|
} else {
|
||||||
|
session.status = 'completed';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isSessionCanceledError(error) || session.cancelRequested) {
|
||||||
|
session.status = 'canceled';
|
||||||
|
session.error = null;
|
||||||
|
session.errorDetails = undefined;
|
||||||
|
} else {
|
||||||
|
session.status = 'failed';
|
||||||
|
session.failedBlockIndex = session.currentBlockIndex;
|
||||||
|
session.errorDetails = formatOrchestrationError(error);
|
||||||
|
session.error =
|
||||||
|
session.errorDetails.message || (error instanceof Error ? error.message : String(error));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
latestOrchestrationSession = session;
|
||||||
|
if (options.manageActiveState === true) {
|
||||||
|
if (activeOrchestrationSession === session) {
|
||||||
|
activeOrchestrationSession = null;
|
||||||
|
}
|
||||||
|
activeOrchestrationTask = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
async function handleStartOrchestration(toolArgs) {
|
async function handleStartOrchestration(toolArgs) {
|
||||||
if (activeOrchestrationSession) {
|
if (activeOrchestrationSession) {
|
||||||
throw new Error('orchestration already active');
|
throw new Error('orchestration already active');
|
||||||
@@ -5406,41 +5639,26 @@ async function handleStartOrchestration(toolArgs) {
|
|||||||
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId);
|
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, {
|
||||||
|
allowImplicitFallback: false,
|
||||||
|
});
|
||||||
if (!page.webSocketDebuggerUrl) {
|
if (!page.webSocketDebuggerUrl) {
|
||||||
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
|
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const blocks = requireOrchestrationBlocks(toolArgs);
|
const blocks = requireOrchestrationBlocks(toolArgs);
|
||||||
const session = {
|
const session = createOrchestrationSession(page, pageIndex, blocks);
|
||||||
orchestrationId: createOrchestrationId(),
|
|
||||||
pageId: page.id,
|
|
||||||
pageIndex,
|
|
||||||
status: 'running',
|
|
||||||
blockCount: blocks.length,
|
|
||||||
completedBlocks: 0,
|
|
||||||
currentBlockIndex: 0,
|
|
||||||
failedBlockIndex: null,
|
|
||||||
failedSequenceStepIndex: null,
|
|
||||||
error: null,
|
|
||||||
blocks,
|
|
||||||
};
|
|
||||||
|
|
||||||
latestOrchestrationSession = session;
|
latestOrchestrationSession = session;
|
||||||
activeOrchestrationSession = session;
|
activeOrchestrationSession = session;
|
||||||
|
const task = Promise.resolve().then(() =>
|
||||||
try {
|
runOrchestrationSession(session, { manageActiveState: true })
|
||||||
await executeOrchestrationBlocks(session);
|
);
|
||||||
session.status = 'completed';
|
activeOrchestrationTask = task;
|
||||||
} catch (error) {
|
task.catch(() => {
|
||||||
session.status = 'failed';
|
// Session state is recorded in runOrchestrationSession.
|
||||||
session.failedBlockIndex = session.currentBlockIndex;
|
});
|
||||||
session.errorDetails = formatOrchestrationError(error);
|
await waitForSessionToSettle(task);
|
||||||
session.error = session.errorDetails.message || (error instanceof Error ? error.message : String(error));
|
|
||||||
}
|
|
||||||
|
|
||||||
activeOrchestrationSession = null;
|
|
||||||
latestOrchestrationSession = session;
|
|
||||||
return formatOrchestrationSummary(session);
|
return formatOrchestrationSummary(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5449,6 +5667,16 @@ async function handleGetOrchestration() {
|
|||||||
return formatOrchestrationSummary(session);
|
return formatOrchestrationSummary(session);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleCancelOrchestration() {
|
||||||
|
if (!activeOrchestrationSession) {
|
||||||
|
throw new Error('no active orchestration');
|
||||||
|
}
|
||||||
|
activeOrchestrationSession.cancelRequested = true;
|
||||||
|
activeOrchestrationSession.status = 'canceled';
|
||||||
|
latestOrchestrationSession = activeOrchestrationSession;
|
||||||
|
return formatOrchestrationSummary(activeOrchestrationSession);
|
||||||
|
}
|
||||||
|
|
||||||
function getArtifactPayloadForKind(kind) {
|
function getArtifactPayloadForKind(kind) {
|
||||||
if (kind === 'recording') {
|
if (kind === 'recording') {
|
||||||
return requireLatestRecording();
|
return requireLatestRecording();
|
||||||
@@ -5467,7 +5695,7 @@ function resolveArtifactPath(reference) {
|
|||||||
if (value.startsWith('artifact:')) {
|
if (value.startsWith('artifact:')) {
|
||||||
return getArtifactPath(value.slice('artifact:'.length));
|
return getArtifactPath(value.slice('artifact:'.length));
|
||||||
}
|
}
|
||||||
return value;
|
return path.resolve(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteArtifactByName(name) {
|
function deleteArtifactByName(name) {
|
||||||
@@ -5481,14 +5709,20 @@ function deleteArtifactByName(name) {
|
|||||||
|
|
||||||
async function handleExportArtifact(toolArgs) {
|
async function handleExportArtifact(toolArgs) {
|
||||||
const kind = requireNonEmptyString(toolArgs.kind, 'kind');
|
const kind = requireNonEmptyString(toolArgs.kind, 'kind');
|
||||||
const name = requireNonEmptyString(toolArgs.name, 'name');
|
const name = requireArtifactName(toolArgs.name);
|
||||||
const filePath = getArtifactPath(name);
|
const filePath = getArtifactPath(name);
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
throw new Error('artifact already exists');
|
throw new Error('artifact already exists');
|
||||||
}
|
}
|
||||||
const payload = getArtifactPayloadForKind(kind);
|
const payload = getArtifactPayloadForKind(kind);
|
||||||
const artifact = buildArtifact(kind, name, payload);
|
const artifact = buildArtifact(kind, name, payload);
|
||||||
fs.writeFileSync(filePath, JSON.stringify(artifact, null, 2), 'utf8');
|
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`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
fs.writeFileSync(filePath, serialized, { encoding: 'utf8', mode: 0o600 });
|
||||||
return `name: ${name}\nkind: ${kind}\npath: ${filePath}\nstatus: exported`;
|
return `name: ${name}\nkind: ${kind}\npath: ${filePath}\nstatus: exported`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5529,29 +5763,18 @@ async function handleImportArtifact(toolArgs) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleDeleteArtifact(toolArgs) {
|
async function handleDeleteArtifact(toolArgs) {
|
||||||
const name = requireNonEmptyString(toolArgs.name, 'name');
|
const name = requireArtifactName(toolArgs.name);
|
||||||
deleteArtifactByName(name);
|
deleteArtifactByName(name);
|
||||||
return `name: ${name}\nstatus: deleted`;
|
return `name: ${name}\nstatus: deleted`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCancelOrchestration() {
|
|
||||||
if (!activeOrchestrationSession) {
|
|
||||||
throw new Error('no active orchestration');
|
|
||||||
}
|
|
||||||
activeOrchestrationSession.status = 'canceled';
|
|
||||||
const canceled = activeOrchestrationSession;
|
|
||||||
activeOrchestrationSession = null;
|
|
||||||
latestOrchestrationSession = canceled;
|
|
||||||
return formatOrchestrationSummary(canceled);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleToolCall(message) {
|
async function handleToolCall(message) {
|
||||||
const id = message.id;
|
const id = message.id;
|
||||||
const params = message.params || {};
|
const params = message.params || {};
|
||||||
const toolName = params.name || '<missing>';
|
const toolName = params.name || '<missing>';
|
||||||
const toolArgs = params.arguments || {};
|
const toolArgs = params.arguments || {};
|
||||||
|
|
||||||
if (!TOOL_NAMES.includes(toolName)) {
|
if (!getAvailableToolNames().includes(toolName)) {
|
||||||
writeError(id, -32602, `Unknown tool: ${toolName}`);
|
writeError(id, -32602, `Unknown tool: ${toolName}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5961,7 +6184,7 @@ async function handleMessage(message) {
|
|||||||
writeResponse(message.id, {});
|
writeResponse(message.id, {});
|
||||||
return;
|
return;
|
||||||
case 'tools/list':
|
case 'tools/list':
|
||||||
writeResponse(message.id, { tools: getTools() });
|
writeResponse(message.id, { tools: getAvailableTools() });
|
||||||
return;
|
return;
|
||||||
case 'tools/call':
|
case 'tools/call':
|
||||||
await handleToolCall(message);
|
await handleToolCall(message);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { afterEach, describe, expect, it } from 'bun:test';
|
import { afterEach, describe, expect, it } from 'bun:test';
|
||||||
import { spawn } from 'child_process';
|
import { spawn } from 'child_process';
|
||||||
import { cpSync, existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||||
import * as http from 'node:http';
|
import * as http from 'node:http';
|
||||||
import { WebSocketServer } from 'ws';
|
import { WebSocketServer } from 'ws';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
@@ -114,6 +114,7 @@ type MockFileInputPlan = MockFileInputState | MockFileInputState[];
|
|||||||
|
|
||||||
type MockDropzoneState = {
|
type MockDropzoneState = {
|
||||||
accepted?: boolean;
|
accepted?: boolean;
|
||||||
|
acceptedByCancel?: boolean;
|
||||||
requireFiles?: boolean;
|
requireFiles?: boolean;
|
||||||
receivedEventTypes?: string[];
|
receivedEventTypes?: string[];
|
||||||
receivedFiles?: Array<{ name: string; size: number; type: string }>;
|
receivedFiles?: Array<{ name: string; size: number; type: string }>;
|
||||||
@@ -203,6 +204,7 @@ type MockRecordingPlan = {
|
|||||||
events?: MockRecordedEvent[];
|
events?: MockRecordedEvent[];
|
||||||
warnings?: MockRecordingWarning[];
|
warnings?: MockRecordingWarning[];
|
||||||
injectionError?: string;
|
injectionError?: string;
|
||||||
|
finalizeError?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MockFrameState = {
|
type MockFrameState = {
|
||||||
@@ -226,6 +228,7 @@ type MockEvalPlan = Record<
|
|||||||
result?: unknown;
|
result?: unknown;
|
||||||
error?: string;
|
error?: string;
|
||||||
nonSerializable?: boolean;
|
nonSerializable?: boolean;
|
||||||
|
unserializableValue?: string;
|
||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
@@ -1121,6 +1124,15 @@ function createMockBrowser(pagesInput: MockPageState[]) {
|
|||||||
|
|
||||||
if (expression.includes('globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] }')) {
|
if (expression.includes('globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] }')) {
|
||||||
const plan = getMockRecordingPlan(page);
|
const plan = getMockRecordingPlan(page);
|
||||||
|
if (plan.finalizeError) {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
id: message.id,
|
||||||
|
result: { exceptionDetails: { text: plan.finalizeError } },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
reply({
|
reply({
|
||||||
result: {
|
result: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -1174,7 +1186,14 @@ function createMockBrowser(pagesInput: MockPageState[]) {
|
|||||||
size: file.size,
|
size: file.size,
|
||||||
type: file.mimeType,
|
type: file.mimeType,
|
||||||
}));
|
}));
|
||||||
reply({ result: { type: 'object', value: { accepted: target.accepted !== false } } });
|
reply({
|
||||||
|
result: {
|
||||||
|
type: 'object',
|
||||||
|
value: {
|
||||||
|
accepted: target.acceptedByCancel === true ? true : target.accepted !== false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1193,6 +1212,20 @@ function createMockBrowser(pagesInput: MockPageState[]) {
|
|||||||
socket.send(JSON.stringify({ id: message.id, result: { result: { type: 'object' } } }));
|
socket.send(JSON.stringify({ id: message.id, result: { result: { type: 'object' } } }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (typeof evalPlan.unserializableValue === 'string') {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
id: message.id,
|
||||||
|
result: {
|
||||||
|
result: {
|
||||||
|
type: 'number',
|
||||||
|
unserializableValue: evalPlan.unserializableValue,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
socket.send(
|
socket.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
id: message.id,
|
id: message.id,
|
||||||
@@ -5039,6 +5072,9 @@ describe('ccs-browser MCP server', () => {
|
|||||||
'throw new Error("boom")': {
|
'throw new Error("boom")': {
|
||||||
error: 'boom',
|
error: 'boom',
|
||||||
},
|
},
|
||||||
|
Infinity: {
|
||||||
|
unserializableValue: 'Infinity',
|
||||||
|
},
|
||||||
window: {
|
window: {
|
||||||
nonSerializable: true,
|
nonSerializable: true,
|
||||||
},
|
},
|
||||||
@@ -5086,6 +5122,15 @@ describe('ccs-browser MCP server', () => {
|
|||||||
jsonrpc: '2.0',
|
jsonrpc: '2.0',
|
||||||
id: 54,
|
id: 54,
|
||||||
method: 'tools/call',
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_eval',
|
||||||
|
arguments: { expression: 'Infinity' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 55,
|
||||||
|
method: 'tools/call',
|
||||||
params: {
|
params: {
|
||||||
name: 'browser_eval',
|
name: 'browser_eval',
|
||||||
arguments: { expression: 'window' },
|
arguments: { expression: 'window' },
|
||||||
@@ -5111,6 +5156,9 @@ describe('ccs-browser MCP server', () => {
|
|||||||
'Browser MCP failed: boom'
|
'Browser MCP failed: boom'
|
||||||
);
|
);
|
||||||
expect(getResponseText(responses.find((message) => message.id === 54))).toContain(
|
expect(getResponseText(responses.find((message) => message.id === 54))).toContain(
|
||||||
|
'value: "Infinity"'
|
||||||
|
);
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 55))).toContain(
|
||||||
'Browser MCP failed: evaluation result is not JSON-serializable'
|
'Browser MCP failed: evaluation result is not JSON-serializable'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -5151,6 +5199,37 @@ describe('ccs-browser MCP server', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('hides browser_eval when eval mode is disabled', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[{ id: 'page-1', title: 'Eval Disabled Page', currentUrl: 'https://example.com/' }],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 56,
|
||||||
|
method: 'tools/list',
|
||||||
|
params: {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 57,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_eval',
|
||||||
|
arguments: { expression: '1 + 1' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ childEnv: { ...process.env, CCS_BROWSER_EVAL_MODE: 'disabled' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const tools = (responses.find((message) => message.id === 56)?.result as { tools?: Array<{ name?: string }> })
|
||||||
|
?.tools || [];
|
||||||
|
expect(tools.some((tool) => tool.name === 'browser_eval')).toBe(false);
|
||||||
|
expect((responses.find((message) => message.id === 57)?.error as { message?: string })?.message).toBe(
|
||||||
|
'Unknown tool: browser_eval'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('queries inside an iframe selected by frameSelector', async () => {
|
it('queries inside an iframe selected by frameSelector', async () => {
|
||||||
const responses = await runMcpRequests(
|
const responses = await runMcpRequests(
|
||||||
[
|
[
|
||||||
@@ -5763,6 +5842,48 @@ describe('ccs-browser MCP server', () => {
|
|||||||
expect(getResponseText(responses.find((message) => message.id === 1019))).toContain('pageIndex and pageId cannot be used together');
|
expect(getResponseText(responses.find((message) => message.id === 1019))).toContain('pageIndex and pageId cannot be used together');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cleans up recording state when stop finalization fails', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Broken Stop Recording Page',
|
||||||
|
currentUrl: 'https://example.com/broken-stop-recording',
|
||||||
|
recording: {
|
||||||
|
finalizeError: 'recording finalize failed',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1019_1,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_start_recording', arguments: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1019_2,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_stop_recording', arguments: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1019_3,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_start_recording', arguments: {} },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1019_2))).toContain(
|
||||||
|
'recording finalize failed'
|
||||||
|
);
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1019_3))).toContain(
|
||||||
|
'status: recording'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it('rolls back recording state when recorder injection fails', async () => {
|
it('rolls back recording state when recorder injection fails', async () => {
|
||||||
const responses = await runMcpRequests(
|
const responses = await runMcpRequests(
|
||||||
[
|
[
|
||||||
@@ -6069,6 +6190,105 @@ describe('ccs-browser MCP server', () => {
|
|||||||
expect(text).toContain('element index 0 is out of range for selector: #missing');
|
expect(text).toContain('element index 0 is out of range for selector: #missing');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cancels a replay before later steps run', async () => {
|
||||||
|
const pages: MockPageState[] = [
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Replay Cancel Page',
|
||||||
|
currentUrl: 'https://example.com/replay-cancel',
|
||||||
|
click: {
|
||||||
|
'#submit': {},
|
||||||
|
},
|
||||||
|
query: {
|
||||||
|
'#handle': {
|
||||||
|
exists: true,
|
||||||
|
connected: true,
|
||||||
|
rect: {
|
||||||
|
x: 20,
|
||||||
|
y: 30,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
top: 30,
|
||||||
|
right: 60,
|
||||||
|
bottom: 70,
|
||||||
|
left: 20,
|
||||||
|
},
|
||||||
|
display: 'block',
|
||||||
|
visibility: 'visible',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
'#submit': {
|
||||||
|
exists: true,
|
||||||
|
connected: true,
|
||||||
|
rect: {
|
||||||
|
x: 120,
|
||||||
|
y: 30,
|
||||||
|
width: 100,
|
||||||
|
height: 40,
|
||||||
|
top: 30,
|
||||||
|
right: 220,
|
||||||
|
bottom: 70,
|
||||||
|
left: 120,
|
||||||
|
},
|
||||||
|
display: 'block',
|
||||||
|
visibility: 'visible',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const responses = await runMcpRequests(pages, [
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1116,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_start_replay',
|
||||||
|
arguments: {
|
||||||
|
steps: [
|
||||||
|
createReplayStep({
|
||||||
|
type: 'pointer_action',
|
||||||
|
pageId: 'page-1',
|
||||||
|
args: {
|
||||||
|
actions: [
|
||||||
|
{ type: 'move', selector: '#handle' },
|
||||||
|
{ type: 'pause', durationMs: 400 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createReplayStep({
|
||||||
|
type: 'click',
|
||||||
|
pageId: 'page-1',
|
||||||
|
selector: '#submit',
|
||||||
|
args: {},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1117,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_cancel_replay', arguments: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1118,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_get_replay', arguments: {} },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1116))).toContain(
|
||||||
|
'status: running'
|
||||||
|
);
|
||||||
|
const text = getResponseText(responses.find((message) => message.id === 1118));
|
||||||
|
expect(text).toContain('status: canceled');
|
||||||
|
expect(text).not.toContain('completedSteps: 2');
|
||||||
|
});
|
||||||
|
|
||||||
it('replays drag_element and pointer_action steps successfully', async () => {
|
it('replays drag_element and pointer_action steps successfully', async () => {
|
||||||
const pages: MockPageState[] = [
|
const pages: MockPageState[] = [
|
||||||
{
|
{
|
||||||
@@ -6244,6 +6464,112 @@ describe('ccs-browser MCP server', () => {
|
|||||||
expect(getResponseText(responses.find((message) => message.id === 1202))).toContain('status: completed');
|
expect(getResponseText(responses.find((message) => message.id === 1202))).toContain('status: completed');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cancels orchestration before later blocks run', async () => {
|
||||||
|
const pages: MockPageState[] = [
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Orchestration Cancel Page',
|
||||||
|
currentUrl: 'https://example.com/orchestration-cancel',
|
||||||
|
query: {
|
||||||
|
'#handle': {
|
||||||
|
exists: true,
|
||||||
|
connected: true,
|
||||||
|
rect: {
|
||||||
|
x: 20,
|
||||||
|
y: 30,
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
top: 30,
|
||||||
|
right: 60,
|
||||||
|
bottom: 70,
|
||||||
|
left: 20,
|
||||||
|
},
|
||||||
|
display: 'block',
|
||||||
|
visibility: 'visible',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
'#status': {
|
||||||
|
exists: true,
|
||||||
|
connected: true,
|
||||||
|
innerText: 'ready',
|
||||||
|
textContent: 'ready',
|
||||||
|
rect: {
|
||||||
|
x: 80,
|
||||||
|
y: 30,
|
||||||
|
width: 80,
|
||||||
|
height: 20,
|
||||||
|
top: 30,
|
||||||
|
right: 160,
|
||||||
|
bottom: 50,
|
||||||
|
left: 80,
|
||||||
|
},
|
||||||
|
display: 'block',
|
||||||
|
visibility: 'visible',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const responses = await runMcpRequests(pages, [
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1202_1,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_start_orchestration',
|
||||||
|
arguments: {
|
||||||
|
blocks: [
|
||||||
|
createOrchestrationBlock({
|
||||||
|
type: 'run_replay_sequence',
|
||||||
|
args: {
|
||||||
|
steps: [
|
||||||
|
createReplayStep({
|
||||||
|
type: 'pointer_action',
|
||||||
|
pageId: 'page-1',
|
||||||
|
args: {
|
||||||
|
actions: [
|
||||||
|
{ type: 'move', selector: '#handle' },
|
||||||
|
{ type: 'pause', durationMs: 400 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
createOrchestrationBlock({
|
||||||
|
type: 'assert_query',
|
||||||
|
args: {
|
||||||
|
query: { selector: '#status', fields: ['innerText'] },
|
||||||
|
assertions: [{ field: 'innerText', op: 'equals', value: 'ready' }],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1202_2,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_cancel_orchestration', arguments: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1202_3,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_get_orchestration', arguments: {} },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1202_1))).toContain(
|
||||||
|
'status: running'
|
||||||
|
);
|
||||||
|
const text = getResponseText(responses.find((message) => message.id === 1202_3));
|
||||||
|
expect(text).toContain('status: canceled');
|
||||||
|
expect(text).not.toContain('completedBlocks: 2');
|
||||||
|
});
|
||||||
|
|
||||||
it('completes a wait_then_type flow', async () => {
|
it('completes a wait_then_type flow', async () => {
|
||||||
const pages: MockPageState[] = [
|
const pages: MockPageState[] = [
|
||||||
{
|
{
|
||||||
@@ -7108,6 +7434,7 @@ describe('ccs-browser MCP server', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const text = getResponseText(responses.find((message) => message.id === 1502));
|
const text = getResponseText(responses.find((message) => message.id === 1502));
|
||||||
|
expect(text).toContain('status: completed_with_failures');
|
||||||
expect(text).toContain('completedBlocks: 2');
|
expect(text).toContain('completedBlocks: 2');
|
||||||
expect(text).toContain('failedCount: 1');
|
expect(text).toContain('failedCount: 1');
|
||||||
expect(text).toContain('failure[0].blockIndex: 0');
|
expect(text).toContain('failure[0].blockIndex: 0');
|
||||||
@@ -7187,6 +7514,7 @@ describe('ccs-browser MCP server', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const text = getResponseText(responses.find((message) => message.id === 1504));
|
const text = getResponseText(responses.find((message) => message.id === 1504));
|
||||||
|
expect(text).toContain('status: completed_with_failures');
|
||||||
expect(text).toContain('completedBlocks: 1');
|
expect(text).toContain('completedBlocks: 1');
|
||||||
expect(text).toContain('failedCount: 1');
|
expect(text).toContain('failedCount: 1');
|
||||||
expect(text).toContain('failure[0].sequenceStepIndex: 0');
|
expect(text).toContain('failure[0].sequenceStepIndex: 0');
|
||||||
@@ -7305,6 +7633,7 @@ describe('ccs-browser MCP server', () => {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const text = getResponseText(responses.find((message) => message.id === 1507));
|
const text = getResponseText(responses.find((message) => message.id === 1507));
|
||||||
|
expect(text).toContain('status: completed_with_failures');
|
||||||
expect(text).toContain('completedBlocks: 2');
|
expect(text).toContain('completedBlocks: 2');
|
||||||
expect(text).toContain('failedCount: 1');
|
expect(text).toContain('failedCount: 1');
|
||||||
expect(text).toContain('failure[0].sequenceStepIndex: 0');
|
expect(text).toContain('failure[0].sequenceStepIndex: 0');
|
||||||
@@ -7500,6 +7829,10 @@ describe('ccs-browser MCP server', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('rejects duplicate export, invalid import payload, and missing artifact delete target', async () => {
|
it('rejects duplicate export, invalid import payload, and missing artifact delete target', async () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-artifact-failures-'));
|
||||||
|
const oversizedArtifactPath = join(tempDir, 'oversized-artifact.json');
|
||||||
|
writeFileSync(oversizedArtifactPath, Buffer.alloc(5 * 1024 * 1024 + 1, 'a'));
|
||||||
|
|
||||||
const responses = await runMcpRequests(
|
const responses = await runMcpRequests(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -7518,12 +7851,24 @@ describe('ccs-browser MCP server', () => {
|
|||||||
{ jsonrpc: '2.0', id: 1615, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } },
|
{ jsonrpc: '2.0', id: 1615, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: 'dup-artifact' } } },
|
||||||
{ jsonrpc: '2.0', id: 1616, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:invalid-json' } } },
|
{ jsonrpc: '2.0', id: 1616, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:invalid-json' } } },
|
||||||
{ jsonrpc: '2.0', id: 1617, method: 'tools/call', params: { name: 'browser_delete_artifact', arguments: { name: 'missing-artifact' } } },
|
{ jsonrpc: '2.0', id: 1617, method: 'tools/call', params: { name: 'browser_delete_artifact', arguments: { name: 'missing-artifact' } } },
|
||||||
|
{ jsonrpc: '2.0', id: 1618, method: 'tools/call', params: { name: 'browser_export_artifact', arguments: { kind: 'recording', name: '../escape' } } },
|
||||||
|
{ jsonrpc: '2.0', id: 1619, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: 'artifact:../escape' } } },
|
||||||
|
{ jsonrpc: '2.0', id: 1620, method: 'tools/call', params: { name: 'browser_import_artifact', arguments: { path: oversizedArtifactPath } } },
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(getResponseText(responses.find((message) => message.id === 1615))).toContain('artifact already exists');
|
expect(getResponseText(responses.find((message) => message.id === 1615))).toContain('artifact already exists');
|
||||||
expect(getResponseText(responses.find((message) => message.id === 1616))).toContain('artifact file not found');
|
expect(getResponseText(responses.find((message) => message.id === 1616))).toContain('artifact file not found');
|
||||||
expect(getResponseText(responses.find((message) => message.id === 1617))).toContain('artifact not found');
|
expect(getResponseText(responses.find((message) => message.id === 1617))).toContain('artifact not found');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1618))).toContain(
|
||||||
|
'artifact name must start with a letter or number'
|
||||||
|
);
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1619))).toContain(
|
||||||
|
'artifact name must start with a letter or number'
|
||||||
|
);
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1620))).toContain(
|
||||||
|
'artifact file exceeds maximum size'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('runs select_page_then_run and executes the inner single-page block on the selected page', async () => {
|
it('runs select_page_then_run and executes the inner single-page block on the selected page', async () => {
|
||||||
@@ -7798,6 +8143,7 @@ describe('ccs-browser MCP server', () => {
|
|||||||
currentUrl: 'https://example.com/uploads',
|
currentUrl: 'https://example.com/uploads',
|
||||||
dropzones: {
|
dropzones: {
|
||||||
'#dropzone': { accepted: true },
|
'#dropzone': { accepted: true },
|
||||||
|
'#cancel-dropzone': { acceptedByCancel: true },
|
||||||
},
|
},
|
||||||
frames: [
|
frames: [
|
||||||
{
|
{
|
||||||
@@ -7854,6 +8200,18 @@ describe('ccs-browser MCP server', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 904,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_drag_files',
|
||||||
|
arguments: {
|
||||||
|
selector: '#cancel-dropzone',
|
||||||
|
files: [invoicePath],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(getResponseText(responses.find((message) => message.id === 901))).toContain('status: files-dropped');
|
expect(getResponseText(responses.find((message) => message.id === 901))).toContain('status: files-dropped');
|
||||||
@@ -7872,6 +8230,12 @@ describe('ccs-browser MCP server', () => {
|
|||||||
expect(
|
expect(
|
||||||
getMockDropzoneState(pages[0]!, '#shadow-dropzone', { pierceShadow: true })?.receivedFiles
|
getMockDropzoneState(pages[0]!, '#shadow-dropzone', { pierceShadow: true })?.receivedFiles
|
||||||
).toEqual([{ name: 'receipt.png', size: 7, type: 'image/png' }]);
|
).toEqual([{ name: 'receipt.png', size: 7, type: 'image/png' }]);
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 904))).toContain(
|
||||||
|
'status: files-dropped'
|
||||||
|
);
|
||||||
|
expect(getMockDropzoneState(pages[0]!, '#cancel-dropzone')?.receivedFiles).toEqual([
|
||||||
|
{ name: 'invoice.pdf', size: 7, type: 'application/pdf' },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects browser_drag_files for missing files, page conflicts, missing targets, and rejected drops', async () => {
|
it('rejects browser_drag_files for missing files, page conflicts, missing targets, and rejected drops', async () => {
|
||||||
@@ -8337,6 +8701,21 @@ describe('ccs-browser MCP server', () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 920,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_pointer_action',
|
||||||
|
arguments: {
|
||||||
|
actions: [
|
||||||
|
{ type: 'move', selector: '#handle' },
|
||||||
|
{ type: 'down', button: 'left' },
|
||||||
|
{ type: 'up', button: 'right' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(getResponseText(responses.find((message) => message.id === 914))).toContain('pointer state error');
|
expect(getResponseText(responses.find((message) => message.id === 914))).toContain('pointer state error');
|
||||||
@@ -8344,6 +8723,68 @@ describe('ccs-browser MCP server', () => {
|
|||||||
expect(getResponseText(responses.find((message) => message.id === 919))).toContain(
|
expect(getResponseText(responses.find((message) => message.id === 919))).toContain(
|
||||||
'pageIndex and pageId cannot be used together'
|
'pageIndex and pageId cannot be used together'
|
||||||
);
|
);
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 920))).toContain(
|
||||||
|
'pointer state error'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects mutating browser tools when the selected page becomes stale', async () => {
|
||||||
|
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-stale-selection-'));
|
||||||
|
const patchedServerPath = join(tempDir, 'ccs-browser-server.cjs');
|
||||||
|
writeFileSync(
|
||||||
|
patchedServerPath,
|
||||||
|
readFileSync(bundledServerPath, 'utf8').replace(
|
||||||
|
"let selectedPageId = '';",
|
||||||
|
"let selectedPageId = 'page-2';"
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Primary Page',
|
||||||
|
currentUrl: 'https://example.com/primary',
|
||||||
|
query: {
|
||||||
|
'#handle': {
|
||||||
|
exists: true,
|
||||||
|
connected: true,
|
||||||
|
rect: {
|
||||||
|
x: 30,
|
||||||
|
y: 50,
|
||||||
|
width: 40,
|
||||||
|
height: 20,
|
||||||
|
top: 50,
|
||||||
|
right: 70,
|
||||||
|
bottom: 70,
|
||||||
|
left: 30,
|
||||||
|
},
|
||||||
|
display: 'block',
|
||||||
|
visibility: 'visible',
|
||||||
|
opacity: '1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 923,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: {
|
||||||
|
name: 'browser_pointer_action',
|
||||||
|
arguments: {
|
||||||
|
actions: [{ type: 'move', selector: '#handle' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
{ serverPath: patchedServerPath }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 923))).toContain(
|
||||||
|
'Selected page is no longer available; specify pageIndex or pageId explicitly.'
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('waits for a matching navigation event with browser_wait_for_event', async () => {
|
it('waits for a matching navigation event with browser_wait_for_event', async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user