mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 18:15:48 +00:00
feat(browser): 增加 Phase 10A recording 生命周期
补齐 recording session state、基础 recorder 注入与 capture 读取,打通 start/stop/get/clear 最小闭环。 并补充 pageId 路由、生命周期错误与 page close 停止录制覆盖。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
1 parent
844b5f6c42
commit
17a3434056
2 files changed
+728
No files matched your search
@@ -62,6 +62,10 @@ const TOOL_SET_FILE_INPUT = 'browser_set_file_input';
|
|||||||
const TOOL_DRAG_FILES = 'browser_drag_files';
|
const TOOL_DRAG_FILES = 'browser_drag_files';
|
||||||
const TOOL_DRAG_ELEMENT = 'browser_drag_element';
|
const TOOL_DRAG_ELEMENT = 'browser_drag_element';
|
||||||
const TOOL_POINTER_ACTION = 'browser_pointer_action';
|
const TOOL_POINTER_ACTION = 'browser_pointer_action';
|
||||||
|
const TOOL_START_RECORDING = 'browser_start_recording';
|
||||||
|
const TOOL_STOP_RECORDING = 'browser_stop_recording';
|
||||||
|
const TOOL_GET_RECORDING = 'browser_get_recording';
|
||||||
|
const TOOL_CLEAR_RECORDING = 'browser_clear_recording';
|
||||||
const TOOL_TAKE_SCREENSHOT = 'browser_take_screenshot';
|
const TOOL_TAKE_SCREENSHOT = 'browser_take_screenshot';
|
||||||
const TOOL_WAIT_FOR = 'browser_wait_for';
|
const TOOL_WAIT_FOR = 'browser_wait_for';
|
||||||
const TOOL_EVAL = 'browser_eval';
|
const TOOL_EVAL = 'browser_eval';
|
||||||
@@ -93,6 +97,10 @@ const TOOL_NAMES = [
|
|||||||
TOOL_DRAG_FILES,
|
TOOL_DRAG_FILES,
|
||||||
TOOL_DRAG_ELEMENT,
|
TOOL_DRAG_ELEMENT,
|
||||||
TOOL_POINTER_ACTION,
|
TOOL_POINTER_ACTION,
|
||||||
|
TOOL_START_RECORDING,
|
||||||
|
TOOL_STOP_RECORDING,
|
||||||
|
TOOL_GET_RECORDING,
|
||||||
|
TOOL_CLEAR_RECORDING,
|
||||||
TOOL_TAKE_SCREENSHOT,
|
TOOL_TAKE_SCREENSHOT,
|
||||||
TOOL_WAIT_FOR,
|
TOOL_WAIT_FOR,
|
||||||
TOOL_EVAL,
|
TOOL_EVAL,
|
||||||
@@ -128,6 +136,9 @@ let selectedPageId = '';
|
|||||||
let messageQueue = Promise.resolve();
|
let messageQueue = Promise.resolve();
|
||||||
let nextInterceptRuleCounter = 1;
|
let nextInterceptRuleCounter = 1;
|
||||||
let nextDownloadCounter = 1;
|
let nextDownloadCounter = 1;
|
||||||
|
let nextRecordingCounter = 1;
|
||||||
|
let activeRecordingSession = null;
|
||||||
|
let latestRecordingSession = null;
|
||||||
const interceptRules = [];
|
const interceptRules = [];
|
||||||
const recentRequests = [];
|
const recentRequests = [];
|
||||||
const recentDownloads = [];
|
const recentDownloads = [];
|
||||||
@@ -709,6 +720,45 @@ function getTools() {
|
|||||||
additionalProperties: false,
|
additionalProperties: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: TOOL_START_RECORDING,
|
||||||
|
description: 'Start recording real browser interactions on the selected page and store them as structured steps.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
pageIndex: { type: 'integer', minimum: 0 },
|
||||||
|
pageId: { type: 'string' },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: TOOL_STOP_RECORDING,
|
||||||
|
description: 'Stop the active browser recording session and keep the recorded result in session-local state.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: TOOL_GET_RECORDING,
|
||||||
|
description: 'Read the current browser recording result from session-local state.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: TOOL_CLEAR_RECORDING,
|
||||||
|
description: 'Clear the current browser recording result from session-local state.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: TOOL_TAKE_SCREENSHOT,
|
name: TOOL_TAKE_SCREENSHOT,
|
||||||
description:
|
description:
|
||||||
@@ -2230,6 +2280,162 @@ function formatDragFilesResult({ pageIndex, selector, nth, frameSelector, pierce
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createRecordingId() {
|
||||||
|
const recordingId = `rec_${String(nextRecordingCounter).padStart(4, '0')}`;
|
||||||
|
nextRecordingCounter += 1;
|
||||||
|
return recordingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireLatestRecording() {
|
||||||
|
if (!latestRecordingSession) {
|
||||||
|
throw new Error('no recording available');
|
||||||
|
}
|
||||||
|
return latestRecordingSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRecordingSummary(session) {
|
||||||
|
return [
|
||||||
|
`recordingId: ${session.recordingId}`,
|
||||||
|
`pageId: ${session.pageId}`,
|
||||||
|
`pageIndex: ${session.pageIndex}`,
|
||||||
|
`stepCount: ${session.steps.length}`,
|
||||||
|
`warningCount: ${session.warnings.length}`,
|
||||||
|
`status: ${session.status}`,
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRecordingDetail(session) {
|
||||||
|
const lines = [formatRecordingSummary(session), 'steps:'];
|
||||||
|
if (session.steps.length === 0) {
|
||||||
|
lines.push('[]');
|
||||||
|
} else {
|
||||||
|
session.steps.forEach((step, index) => {
|
||||||
|
lines.push(`- [${index}] type: ${step.type}`);
|
||||||
|
lines.push(` pageId: ${step.pageId}`);
|
||||||
|
lines.push(` timestamp: ${step.timestamp}`);
|
||||||
|
if (step.selector) {
|
||||||
|
lines.push(` selector: ${step.selector}`);
|
||||||
|
}
|
||||||
|
if (step.frameSelector) {
|
||||||
|
lines.push(` frameSelector: ${step.frameSelector}`);
|
||||||
|
}
|
||||||
|
if (step.pierceShadow === true) {
|
||||||
|
lines.push(' pierceShadow: true');
|
||||||
|
}
|
||||||
|
if (step.args && typeof step.args === 'object') {
|
||||||
|
for (const [key, value] of Object.entries(step.args)) {
|
||||||
|
if (value !== undefined) {
|
||||||
|
lines.push(` ${key}: ${JSON.stringify(value)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push('warnings:');
|
||||||
|
if (session.warnings.length === 0) {
|
||||||
|
lines.push('[]');
|
||||||
|
} else {
|
||||||
|
session.warnings.forEach((warning) => {
|
||||||
|
lines.push(`- ${warning}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRecordedEvent(pageId, event) {
|
||||||
|
if (!event || typeof event !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const timestamp = typeof event.timestamp === 'number' ? event.timestamp : Date.now();
|
||||||
|
if (event.kind === 'click') {
|
||||||
|
return {
|
||||||
|
type: 'click',
|
||||||
|
pageId,
|
||||||
|
timestamp,
|
||||||
|
selector: event.selector,
|
||||||
|
nth: event.nth ?? 0,
|
||||||
|
frameSelector: event.frameSelector,
|
||||||
|
pierceShadow: event.pierceShadow === true,
|
||||||
|
args: {
|
||||||
|
button: event.button || 'left',
|
||||||
|
clickCount: event.clickCount || 1,
|
||||||
|
offsetX: event.offsetX,
|
||||||
|
offsetY: event.offsetY,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (event.kind === 'type') {
|
||||||
|
return {
|
||||||
|
type: 'type',
|
||||||
|
pageId,
|
||||||
|
timestamp,
|
||||||
|
selector: event.selector,
|
||||||
|
nth: event.nth ?? 0,
|
||||||
|
frameSelector: event.frameSelector,
|
||||||
|
pierceShadow: event.pierceShadow === true,
|
||||||
|
args: {
|
||||||
|
text: event.text || '',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (event.kind === 'press_key') {
|
||||||
|
return {
|
||||||
|
type: 'press_key',
|
||||||
|
pageId,
|
||||||
|
timestamp,
|
||||||
|
args: {
|
||||||
|
key: event.key,
|
||||||
|
modifiers: Array.isArray(event.modifiers) ? event.modifiers : [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (event.kind === 'scroll') {
|
||||||
|
return {
|
||||||
|
type: 'scroll',
|
||||||
|
pageId,
|
||||||
|
timestamp,
|
||||||
|
selector: event.selector,
|
||||||
|
frameSelector: event.frameSelector,
|
||||||
|
pierceShadow: event.pierceShadow === true,
|
||||||
|
args: {
|
||||||
|
deltaX: event.deltaX || 0,
|
||||||
|
deltaY: event.deltaY || 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (event.kind === 'drag_element') {
|
||||||
|
return {
|
||||||
|
type: 'drag_element',
|
||||||
|
pageId,
|
||||||
|
timestamp,
|
||||||
|
selector: event.selector,
|
||||||
|
nth: event.nth ?? 0,
|
||||||
|
frameSelector: event.frameSelector,
|
||||||
|
pierceShadow: event.pierceShadow === true,
|
||||||
|
args: {
|
||||||
|
targetSelector: event.targetSelector,
|
||||||
|
targetX: event.targetX,
|
||||||
|
targetY: event.targetY,
|
||||||
|
targetNth: event.targetNth,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (event.kind === 'pointer_action') {
|
||||||
|
return {
|
||||||
|
type: 'pointer_action',
|
||||||
|
pageId,
|
||||||
|
timestamp,
|
||||||
|
args: {
|
||||||
|
actions: Array.isArray(event.actions) ? event.actions : [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function parseEventCondition(value) {
|
function parseEventCondition(value) {
|
||||||
if (!value || typeof value !== 'object') {
|
if (!value || typeof value !== 'object') {
|
||||||
throw new Error('event is required');
|
throw new Error('event is required');
|
||||||
@@ -3858,6 +4064,19 @@ async function handleClosePage(toolArgs) {
|
|||||||
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, {
|
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, {
|
||||||
allowImplicitFallback: false,
|
allowImplicitFallback: false,
|
||||||
});
|
});
|
||||||
|
if (activeRecordingSession && activeRecordingSession.pageId === page.id) {
|
||||||
|
try {
|
||||||
|
await finalizeRecordingCapture(activeRecordingSession);
|
||||||
|
} catch {
|
||||||
|
// Keep the recording session and still report the page-close warning below.
|
||||||
|
}
|
||||||
|
activeRecordingSession.status = 'stopped';
|
||||||
|
activeRecordingSession.stoppedAt = new Date().toISOString();
|
||||||
|
activeRecordingSession.warnings.push('recording stopped because target page was closed');
|
||||||
|
latestRecordingSession = activeRecordingSession;
|
||||||
|
activeRecordingSession = null;
|
||||||
|
}
|
||||||
|
|
||||||
await fetchJson(`${getHttpUrl()}/json/close/${encodeURIComponent(page.id)}`);
|
await fetchJson(`${getHttpUrl()}/json/close/${encodeURIComponent(page.id)}`);
|
||||||
const interceptSession = interceptSessionsByPageId.get(page.id);
|
const interceptSession = interceptSessionsByPageId.get(page.id);
|
||||||
if (interceptSession) {
|
if (interceptSession) {
|
||||||
@@ -4142,6 +4361,127 @@ async function handleDragFiles(toolArgs) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildRecordingInstallExpression(recordingPayload) {
|
||||||
|
return `(() => {
|
||||||
|
const recordingPayload = JSON.parse(${JSON.stringify(JSON.stringify(recordingPayload))});
|
||||||
|
globalThis.__CCS_BROWSER_RECORDING_RECORDER__ = {
|
||||||
|
installed: true,
|
||||||
|
events: Array.isArray(recordingPayload.events) ? recordingPayload.events : [],
|
||||||
|
warnings: Array.isArray(recordingPayload.warnings) ? recordingPayload.warnings : [],
|
||||||
|
};
|
||||||
|
return { installed: true };
|
||||||
|
})()`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installRecorderAndCapture(page) {
|
||||||
|
const response = await sendCdpCommand(page, 'Runtime.evaluate', {
|
||||||
|
expression: buildRecordingInstallExpression({ events: [], warnings: [] }),
|
||||||
|
returnByValue: true,
|
||||||
|
awaitPromise: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.exceptionDetails) {
|
||||||
|
throw new Error(response.exceptionDetails.text || 'recording injection failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = response?.result || null;
|
||||||
|
if (!result) {
|
||||||
|
throw new Error('recording injection failed');
|
||||||
|
}
|
||||||
|
if (result.subtype === 'error') {
|
||||||
|
throw new Error(result.description || 'recording injection failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.value || { installed: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function finalizeRecordingCapture(session) {
|
||||||
|
const page = {
|
||||||
|
id: session.pageId,
|
||||||
|
webSocketDebuggerUrl: session.pageWebSocketDebuggerUrl,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await sendCdpCommand(page, 'Runtime.evaluate', {
|
||||||
|
expression: `(() => globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] })()`,
|
||||||
|
returnByValue: true,
|
||||||
|
awaitPromise: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response?.exceptionDetails) {
|
||||||
|
throw new Error(response.exceptionDetails.text || 'recording injection failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = response?.result?.value || { events: [], warnings: [] };
|
||||||
|
const rawEvents = Array.isArray(value.events) ? value.events : [];
|
||||||
|
const warnings = Array.isArray(value.warnings) ? value.warnings : [];
|
||||||
|
|
||||||
|
session.rawEvents = rawEvents;
|
||||||
|
session.steps = rawEvents.map((event) => normalizeRecordedEvent(session.pageId, event)).filter(Boolean);
|
||||||
|
session.warnings = warnings.map((warning) => String(warning.message || warning));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStartRecording(toolArgs) {
|
||||||
|
if (activeRecordingSession) {
|
||||||
|
throw new Error('recording already active');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = await listPageTargets();
|
||||||
|
if (pages.length === 0) {
|
||||||
|
throw new Error('Browser MCP did not find any page targets in the current Chrome session.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId);
|
||||||
|
if (!page.webSocketDebuggerUrl) {
|
||||||
|
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
activeRecordingSession = {
|
||||||
|
recordingId: createRecordingId(),
|
||||||
|
pageId: page.id,
|
||||||
|
pageIndex,
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
stoppedAt: null,
|
||||||
|
status: 'recording',
|
||||||
|
steps: [],
|
||||||
|
warnings: [],
|
||||||
|
rawEvents: [],
|
||||||
|
};
|
||||||
|
latestRecordingSession = activeRecordingSession;
|
||||||
|
|
||||||
|
await installRecorderAndCapture(page);
|
||||||
|
activeRecordingSession.pageWebSocketDebuggerUrl = page.webSocketDebuggerUrl;
|
||||||
|
activeRecordingSession.captureInstalled = true;
|
||||||
|
|
||||||
|
return formatRecordingSummary(activeRecordingSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStopRecording() {
|
||||||
|
if (!activeRecordingSession) {
|
||||||
|
throw new Error('no active recording');
|
||||||
|
}
|
||||||
|
await finalizeRecordingCapture(activeRecordingSession);
|
||||||
|
activeRecordingSession.status = 'stopped';
|
||||||
|
activeRecordingSession.stoppedAt = new Date().toISOString();
|
||||||
|
const stopped = activeRecordingSession;
|
||||||
|
activeRecordingSession = null;
|
||||||
|
latestRecordingSession = stopped;
|
||||||
|
return formatRecordingSummary(stopped);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleGetRecording() {
|
||||||
|
const session = requireLatestRecording();
|
||||||
|
return formatRecordingDetail(session);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleClearRecording() {
|
||||||
|
if (!latestRecordingSession && !activeRecordingSession) {
|
||||||
|
throw new Error('no recording available');
|
||||||
|
}
|
||||||
|
activeRecordingSession = null;
|
||||||
|
latestRecordingSession = null;
|
||||||
|
return 'status: cleared';
|
||||||
|
}
|
||||||
|
|
||||||
async function handleToolCall(message) {
|
async function handleToolCall(message) {
|
||||||
const id = message.id;
|
const id = message.id;
|
||||||
const params = message.params || {};
|
const params = message.params || {};
|
||||||
@@ -4330,6 +4670,38 @@ async function handleToolCall(message) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (toolName === TOOL_START_RECORDING) {
|
||||||
|
const text = await handleStartRecording(toolArgs);
|
||||||
|
writeResponse(id, {
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === TOOL_STOP_RECORDING) {
|
||||||
|
const text = await handleStopRecording(toolArgs);
|
||||||
|
writeResponse(id, {
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === TOOL_GET_RECORDING) {
|
||||||
|
const text = await handleGetRecording(toolArgs);
|
||||||
|
writeResponse(id, {
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toolName === TOOL_CLEAR_RECORDING) {
|
||||||
|
const text = await handleClearRecording(toolArgs);
|
||||||
|
writeResponse(id, {
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (toolName === TOOL_TAKE_SCREENSHOT) {
|
if (toolName === TOOL_TAKE_SCREENSHOT) {
|
||||||
const text = await handleScreenshot(toolArgs);
|
const text = await handleScreenshot(toolArgs);
|
||||||
writeResponse(id, {
|
writeResponse(id, {
|
||||||
|
|||||||
@@ -132,6 +132,78 @@ type MockDragPlan = {
|
|||||||
recordedActions?: MockPointerActionRecord[];
|
recordedActions?: MockPointerActionRecord[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MockRecordedEvent =
|
||||||
|
| {
|
||||||
|
kind: 'click';
|
||||||
|
selector: string;
|
||||||
|
nth?: number;
|
||||||
|
frameSelector?: string;
|
||||||
|
pierceShadow?: boolean;
|
||||||
|
button?: 'left' | 'middle' | 'right';
|
||||||
|
clickCount?: number;
|
||||||
|
offsetX?: number;
|
||||||
|
offsetY?: number;
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'type';
|
||||||
|
selector: string;
|
||||||
|
text: string;
|
||||||
|
nth?: number;
|
||||||
|
frameSelector?: string;
|
||||||
|
pierceShadow?: boolean;
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'press_key';
|
||||||
|
key: string;
|
||||||
|
modifiers?: string[];
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'scroll';
|
||||||
|
selector?: string;
|
||||||
|
deltaX?: number;
|
||||||
|
deltaY?: number;
|
||||||
|
frameSelector?: string;
|
||||||
|
pierceShadow?: boolean;
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'drag_element';
|
||||||
|
selector: string;
|
||||||
|
targetSelector?: string;
|
||||||
|
targetX?: number;
|
||||||
|
targetY?: number;
|
||||||
|
nth?: number;
|
||||||
|
targetNth?: number;
|
||||||
|
frameSelector?: string;
|
||||||
|
pierceShadow?: boolean;
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: 'pointer_action';
|
||||||
|
actions: Array<{
|
||||||
|
type: 'move' | 'down' | 'up' | 'pause';
|
||||||
|
selector?: string;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
|
button?: 'left' | 'middle' | 'right';
|
||||||
|
durationMs?: number;
|
||||||
|
}>;
|
||||||
|
timestamp?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MockRecordingWarning = {
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MockRecordingPlan = {
|
||||||
|
events?: MockRecordedEvent[];
|
||||||
|
warnings?: MockRecordingWarning[];
|
||||||
|
injectionError?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type MockFrameState = {
|
type MockFrameState = {
|
||||||
selector: string;
|
selector: string;
|
||||||
query?: Record<string, MockQueryPlan>;
|
query?: Record<string, MockQueryPlan>;
|
||||||
@@ -200,6 +272,7 @@ type MockPageState = {
|
|||||||
shadowRoots?: MockShadowRootState[];
|
shadowRoots?: MockShadowRootState[];
|
||||||
dropzones?: Record<string, MockDropzonePlan>;
|
dropzones?: Record<string, MockDropzonePlan>;
|
||||||
drag?: MockDragPlan;
|
drag?: MockDragPlan;
|
||||||
|
recording?: MockRecordingPlan;
|
||||||
events?: MockPageEventPlan;
|
events?: MockPageEventPlan;
|
||||||
intercept?: MockInterceptState;
|
intercept?: MockInterceptState;
|
||||||
screenshot?: {
|
screenshot?: {
|
||||||
@@ -433,6 +506,36 @@ function parseParsedJsonArrayArgument<T>(expression: string, key: string): T[] {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parseParsedJsonObjectArgument<T>(expression: string, key: string): T | null {
|
||||||
|
const marker = `const ${key} = JSON.parse(`;
|
||||||
|
const start = expression.indexOf(marker);
|
||||||
|
if (start === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const quoteStart = start + marker.length;
|
||||||
|
const quote = expression[quoteStart];
|
||||||
|
if (quote !== '"' && quote !== "'") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let index = quoteStart + 1;
|
||||||
|
while (index < expression.length) {
|
||||||
|
if (expression[index] === '\\') {
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (expression[index] === quote) {
|
||||||
|
const encoded = expression.slice(quoteStart, index + 1);
|
||||||
|
const decoded = JSON.parse(encoded) as string;
|
||||||
|
return JSON.parse(decoded) as T;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function pickMockMatch<T>(
|
function pickMockMatch<T>(
|
||||||
plan: T | T[] | undefined,
|
plan: T | T[] | undefined,
|
||||||
nth = 0
|
nth = 0
|
||||||
@@ -500,6 +603,11 @@ function pushPointerAction(page: MockPageState, action: MockPointerActionRecord)
|
|||||||
page.drag.recordedActions.push(action);
|
page.drag.recordedActions.push(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getMockRecordingPlan(page: MockPageState): MockRecordingPlan {
|
||||||
|
page.recording = page.recording || {};
|
||||||
|
return page.recording;
|
||||||
|
}
|
||||||
|
|
||||||
function shiftPageText(page: MockPageState): string {
|
function shiftPageText(page: MockPageState): string {
|
||||||
const queue = page.wait?.pageTextSequence;
|
const queue = page.wait?.pageTextSequence;
|
||||||
if (!queue || queue.length === 0) {
|
if (!queue || queue.length === 0) {
|
||||||
@@ -961,6 +1069,46 @@ function createMockBrowser(pagesInput: MockPageState[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const expression = String(message.params?.expression || '');
|
const expression = String(message.params?.expression || '');
|
||||||
|
const recordingPayload = parseParsedJsonObjectArgument<{
|
||||||
|
events?: MockRecordedEvent[];
|
||||||
|
warnings?: MockRecordingWarning[];
|
||||||
|
}>(expression, 'recordingPayload');
|
||||||
|
|
||||||
|
if (expression.includes('globalThis.__CCS_BROWSER_RECORDING_RECORDER__ =')) {
|
||||||
|
const plan = getMockRecordingPlan(page);
|
||||||
|
if (plan.injectionError) {
|
||||||
|
reply({
|
||||||
|
result: {
|
||||||
|
type: 'object',
|
||||||
|
subtype: 'error',
|
||||||
|
description: plan.injectionError,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (recordingPayload && (recordingPayload.events?.length || recordingPayload.warnings?.length)) {
|
||||||
|
plan.events = recordingPayload.events || [];
|
||||||
|
plan.warnings = recordingPayload.warnings || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
reply({ result: { type: 'object', value: { installed: true } } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (expression.includes('globalThis.__CCS_BROWSER_RECORDING_RECORDER__ || { events: [], warnings: [] }')) {
|
||||||
|
const plan = getMockRecordingPlan(page);
|
||||||
|
reply({
|
||||||
|
result: {
|
||||||
|
type: 'object',
|
||||||
|
value: {
|
||||||
|
events: plan.events || [],
|
||||||
|
warnings: plan.warnings || [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (expression.includes('new DragEvent') && expression.includes('new DataTransfer()')) {
|
if (expression.includes('new DragEvent') && expression.includes('new DataTransfer()')) {
|
||||||
const selector = parseJsonArgument(expression, 'selector') || '';
|
const selector = parseJsonArgument(expression, 'selector') || '';
|
||||||
@@ -1677,6 +1825,10 @@ describe('ccs-browser MCP server', () => {
|
|||||||
'browser_drag_files',
|
'browser_drag_files',
|
||||||
'browser_drag_element',
|
'browser_drag_element',
|
||||||
'browser_pointer_action',
|
'browser_pointer_action',
|
||||||
|
'browser_start_recording',
|
||||||
|
'browser_stop_recording',
|
||||||
|
'browser_get_recording',
|
||||||
|
'browser_clear_recording',
|
||||||
'browser_take_screenshot',
|
'browser_take_screenshot',
|
||||||
'browser_wait_for',
|
'browser_wait_for',
|
||||||
'browser_eval',
|
'browser_eval',
|
||||||
@@ -1785,6 +1937,19 @@ describe('ccs-browser MCP server', () => {
|
|||||||
const pointerTool = tools.find((tool) => tool.name === 'browser_pointer_action');
|
const pointerTool = tools.find((tool) => tool.name === 'browser_pointer_action');
|
||||||
expect(pointerTool?.inputSchema?.properties?.actions).toMatchObject({ type: 'array' });
|
expect(pointerTool?.inputSchema?.properties?.actions).toMatchObject({ type: 'array' });
|
||||||
|
|
||||||
|
const startRecordingTool = tools.find((tool) => tool.name === 'browser_start_recording');
|
||||||
|
expect(startRecordingTool?.inputSchema?.properties?.pageIndex).toMatchObject({ type: 'integer' });
|
||||||
|
expect(startRecordingTool?.inputSchema?.properties?.pageId).toMatchObject({ type: 'string' });
|
||||||
|
|
||||||
|
const stopRecordingTool = tools.find((tool) => tool.name === 'browser_stop_recording');
|
||||||
|
expect(stopRecordingTool?.inputSchema).toMatchObject({ type: 'object' });
|
||||||
|
|
||||||
|
const getRecordingTool = tools.find((tool) => tool.name === 'browser_get_recording');
|
||||||
|
expect(getRecordingTool?.inputSchema).toMatchObject({ type: 'object' });
|
||||||
|
|
||||||
|
const clearRecordingTool = tools.find((tool) => tool.name === 'browser_clear_recording');
|
||||||
|
expect(clearRecordingTool?.inputSchema).toMatchObject({ type: 'object' });
|
||||||
|
|
||||||
const queryTool = tools.find((tool) => tool.name === 'browser_query');
|
const queryTool = tools.find((tool) => tool.name === 'browser_query');
|
||||||
expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({
|
expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({
|
||||||
type: 'array',
|
type: 'array',
|
||||||
@@ -5438,6 +5603,197 @@ describe('ccs-browser MCP server', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('starts, stops, reads, and clears a recording session', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Recording Page',
|
||||||
|
currentUrl: 'https://example.com/recording',
|
||||||
|
recording: {
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
kind: 'click',
|
||||||
|
selector: '#submit',
|
||||||
|
button: 'left',
|
||||||
|
clickCount: 1,
|
||||||
|
offsetX: 12,
|
||||||
|
offsetY: 8,
|
||||||
|
timestamp: 1710000000000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ jsonrpc: '2.0', id: 1001, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1002, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1003, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1004, method: 'tools/call', params: { name: 'browser_clear_recording', arguments: {} } },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1001))).toContain('status: recording');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1002))).toContain('status: stopped');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1003))).toContain('type: click');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1004))).toContain('status: cleared');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid recording lifecycle operations', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Recording Page',
|
||||||
|
currentUrl: 'https://example.com/recording',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ jsonrpc: '2.0', id: 1011, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1012, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1013, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1014, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1015, method: 'tools/call', params: { name: 'browser_clear_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1016, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1012))).toContain('recording already active');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1014))).toContain('no active recording');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1016))).toContain('no recording available');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes recording start by pageId and rejects page conflicts', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{ id: 'page-1', title: 'First', currentUrl: 'https://example.com/1' },
|
||||||
|
{ id: 'page-2', title: 'Second', currentUrl: 'https://example.com/2' },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1017,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_start_recording', arguments: { pageId: 'page-2' } },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1018,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_clear_recording', arguments: {} },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: 1019,
|
||||||
|
method: 'tools/call',
|
||||||
|
params: { name: 'browser_start_recording', arguments: { pageIndex: 0, pageId: 'page-1' } },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1017))).toContain('pageIndex: 1');
|
||||||
|
expect(getResponseText(responses.find((message) => message.id === 1019))).toContain('pageIndex and pageId cannot be used together');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes type, press_key, scroll, and warnings in a recording result', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Normalize Page',
|
||||||
|
currentUrl: 'https://example.com/normalize',
|
||||||
|
recording: {
|
||||||
|
events: [
|
||||||
|
{ kind: 'type', selector: '#email', text: 'walker@example.com', timestamp: 1710000000100 },
|
||||||
|
{ kind: 'press_key', key: 'Enter', modifiers: ['Shift'], timestamp: 1710000000200 },
|
||||||
|
{ kind: 'scroll', selector: '#results', deltaX: 0, deltaY: 320, timestamp: 1710000000300 },
|
||||||
|
],
|
||||||
|
warnings: [{ message: 'cross-origin frame events were skipped' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ jsonrpc: '2.0', id: 1021, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1022, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1023, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = getResponseText(responses.find((message) => message.id === 1023));
|
||||||
|
expect(text).toContain('type: type');
|
||||||
|
expect(text).toContain('selector: #email');
|
||||||
|
expect(text).toContain('type: press_key');
|
||||||
|
expect(text).toContain('type: scroll');
|
||||||
|
expect(text).toContain('cross-origin frame events were skipped');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes drag_element and pointer_action recordings', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Drag Recording Page',
|
||||||
|
currentUrl: 'https://example.com/drag-recording',
|
||||||
|
recording: {
|
||||||
|
events: [
|
||||||
|
{
|
||||||
|
kind: 'drag_element',
|
||||||
|
selector: '#card-a',
|
||||||
|
targetSelector: '#lane-b',
|
||||||
|
timestamp: 1710000000400,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'pointer_action',
|
||||||
|
actions: [
|
||||||
|
{ type: 'move', x: 10, y: 20 },
|
||||||
|
{ type: 'down', button: 'left' },
|
||||||
|
{ type: 'up', button: 'left' },
|
||||||
|
],
|
||||||
|
timestamp: 1710000000500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ jsonrpc: '2.0', id: 1031, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1032, method: 'tools/call', params: { name: 'browser_stop_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1033, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = getResponseText(responses.find((message) => message.id === 1033));
|
||||||
|
expect(text).toContain('type: drag_element');
|
||||||
|
expect(text).toContain('selector: #card-a');
|
||||||
|
expect(text).toContain('targetSelector: "#lane-b"');
|
||||||
|
expect(text).toContain('type: pointer_action');
|
||||||
|
expect(text).toContain('actions:');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops recording with a warning when the target page becomes unavailable', async () => {
|
||||||
|
const responses = await runMcpRequests(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'Closing Page',
|
||||||
|
currentUrl: 'https://example.com/closing',
|
||||||
|
recording: {
|
||||||
|
events: [{ kind: 'click', selector: '#submit', timestamp: 1710000000600 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ jsonrpc: '2.0', id: 1034, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } },
|
||||||
|
{ jsonrpc: '2.0', id: 1035, method: 'tools/call', params: { name: 'browser_close_page', arguments: { pageId: 'page-1' } } },
|
||||||
|
{ jsonrpc: '2.0', id: 1036, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const text = getResponseText(responses.find((message) => message.id === 1036));
|
||||||
|
expect(text).toContain('status: stopped');
|
||||||
|
expect(text).toContain('recording stopped because target page was closed');
|
||||||
|
});
|
||||||
|
|
||||||
it('drags local files onto normal, frame, and shadow dropzones', async () => {
|
it('drags local files onto normal, frame, and shadow dropzones', async () => {
|
||||||
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-'));
|
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-'));
|
||||||
const invoicePath = join(tempDir, 'invoice.pdf');
|
const invoicePath = join(tempDir, 'invoice.pdf');
|
||||||
|
|||||||
Reference in new issue
Block a user