mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +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:
co-authored by
Claude Opus 4.7
parent
844b5f6c42
commit
17a3434056
@@ -62,6 +62,10 @@ const TOOL_SET_FILE_INPUT = 'browser_set_file_input';
|
||||
const TOOL_DRAG_FILES = 'browser_drag_files';
|
||||
const TOOL_DRAG_ELEMENT = 'browser_drag_element';
|
||||
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_WAIT_FOR = 'browser_wait_for';
|
||||
const TOOL_EVAL = 'browser_eval';
|
||||
@@ -93,6 +97,10 @@ const TOOL_NAMES = [
|
||||
TOOL_DRAG_FILES,
|
||||
TOOL_DRAG_ELEMENT,
|
||||
TOOL_POINTER_ACTION,
|
||||
TOOL_START_RECORDING,
|
||||
TOOL_STOP_RECORDING,
|
||||
TOOL_GET_RECORDING,
|
||||
TOOL_CLEAR_RECORDING,
|
||||
TOOL_TAKE_SCREENSHOT,
|
||||
TOOL_WAIT_FOR,
|
||||
TOOL_EVAL,
|
||||
@@ -128,6 +136,9 @@ let selectedPageId = '';
|
||||
let messageQueue = Promise.resolve();
|
||||
let nextInterceptRuleCounter = 1;
|
||||
let nextDownloadCounter = 1;
|
||||
let nextRecordingCounter = 1;
|
||||
let activeRecordingSession = null;
|
||||
let latestRecordingSession = null;
|
||||
const interceptRules = [];
|
||||
const recentRequests = [];
|
||||
const recentDownloads = [];
|
||||
@@ -709,6 +720,45 @@ function getTools() {
|
||||
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,
|
||||
description:
|
||||
@@ -2230,6 +2280,162 @@ function formatDragFilesResult({ pageIndex, selector, nth, frameSelector, pierce
|
||||
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) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
throw new Error('event is required');
|
||||
@@ -3858,6 +4064,19 @@ async function handleClosePage(toolArgs) {
|
||||
const { page, pageIndex } = resolveTargetPage(pages, toolArgs, selectedPageId, {
|
||||
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)}`);
|
||||
const interceptSession = interceptSessionsByPageId.get(page.id);
|
||||
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) {
|
||||
const id = message.id;
|
||||
const params = message.params || {};
|
||||
@@ -4330,6 +4670,38 @@ async function handleToolCall(message) {
|
||||
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) {
|
||||
const text = await handleScreenshot(toolArgs);
|
||||
writeResponse(id, {
|
||||
|
||||
@@ -132,6 +132,78 @@ type MockDragPlan = {
|
||||
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 = {
|
||||
selector: string;
|
||||
query?: Record<string, MockQueryPlan>;
|
||||
@@ -200,6 +272,7 @@ type MockPageState = {
|
||||
shadowRoots?: MockShadowRootState[];
|
||||
dropzones?: Record<string, MockDropzonePlan>;
|
||||
drag?: MockDragPlan;
|
||||
recording?: MockRecordingPlan;
|
||||
events?: MockPageEventPlan;
|
||||
intercept?: MockInterceptState;
|
||||
screenshot?: {
|
||||
@@ -433,6 +506,36 @@ function parseParsedJsonArrayArgument<T>(expression: string, key: string): T[] {
|
||||
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>(
|
||||
plan: T | T[] | undefined,
|
||||
nth = 0
|
||||
@@ -500,6 +603,11 @@ function pushPointerAction(page: MockPageState, action: MockPointerActionRecord)
|
||||
page.drag.recordedActions.push(action);
|
||||
}
|
||||
|
||||
function getMockRecordingPlan(page: MockPageState): MockRecordingPlan {
|
||||
page.recording = page.recording || {};
|
||||
return page.recording;
|
||||
}
|
||||
|
||||
function shiftPageText(page: MockPageState): string {
|
||||
const queue = page.wait?.pageTextSequence;
|
||||
if (!queue || queue.length === 0) {
|
||||
@@ -961,6 +1069,46 @@ function createMockBrowser(pagesInput: MockPageState[]) {
|
||||
}
|
||||
|
||||
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()')) {
|
||||
const selector = parseJsonArgument(expression, 'selector') || '';
|
||||
@@ -1677,6 +1825,10 @@ describe('ccs-browser MCP server', () => {
|
||||
'browser_drag_files',
|
||||
'browser_drag_element',
|
||||
'browser_pointer_action',
|
||||
'browser_start_recording',
|
||||
'browser_stop_recording',
|
||||
'browser_get_recording',
|
||||
'browser_clear_recording',
|
||||
'browser_take_screenshot',
|
||||
'browser_wait_for',
|
||||
'browser_eval',
|
||||
@@ -1785,6 +1937,19 @@ describe('ccs-browser MCP server', () => {
|
||||
const pointerTool = tools.find((tool) => tool.name === 'browser_pointer_action');
|
||||
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');
|
||||
expect(queryTool?.inputSchema?.properties?.fields).toMatchObject({
|
||||
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 () => {
|
||||
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-drag-files-'));
|
||||
const invoicePath = join(tempDir, 'invoice.pdf');
|
||||
|
||||
Reference in New Issue
Block a user