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:
walker1211
2026-04-20 12:44:22 +08:00
co-authored by Claude Opus 4.7
parent 844b5f6c42
commit 17a3434056
2 changed files with 728 additions and 0 deletions
+372
View File
@@ -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');