feat(browser): 完成 Phase 10A 录制动作规范化

补齐 recording 安装阶段的真实页面监听与注入失败回滚,避免空录制和损坏会话状态。
并完善高层 step 规范化与相关回归覆盖。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
walker1211
2026-04-20 12:50:15 +08:00
co-authored by Claude Opus 4.7
parent 17a3434056
commit 5288387151
2 changed files with 107 additions and 7 deletions
+85 -7
View File
@@ -4364,10 +4364,86 @@ async function handleDragFiles(toolArgs) {
function buildRecordingInstallExpression(recordingPayload) {
return `(() => {
const recordingPayload = JSON.parse(${JSON.stringify(JSON.stringify(recordingPayload))});
const existing = globalThis.__CCS_BROWSER_RECORDING_RECORDER__;
if (existing && existing.installed === true) {
return { installed: true };
}
const events = Array.isArray(recordingPayload.events) ? [...recordingPayload.events] : [];
const warnings = Array.isArray(recordingPayload.warnings) ? [...recordingPayload.warnings] : [];
const getSelector = (element) => {
if (!(element instanceof Element)) {
return undefined;
}
if (element.id) {
return '#' + element.id;
}
const attr = typeof element.getAttribute === 'function' ? element.getAttribute('data-testid') : '';
if (attr) {
return '[data-testid="' + attr + '"]';
}
return element.tagName ? element.tagName.toLowerCase() : undefined;
};
const pushEvent = (event) => {
events.push(event);
};
const onClick = (event) => {
pushEvent({
kind: 'click',
selector: getSelector(event.target),
button: event.button === 1 ? 'middle' : event.button === 2 ? 'right' : 'left',
clickCount: event.detail || 1,
offsetX: typeof event.offsetX === 'number' ? event.offsetX : undefined,
offsetY: typeof event.offsetY === 'number' ? event.offsetY : undefined,
timestamp: Date.now(),
});
};
const onInput = (event) => {
const target = event.target;
let text = '';
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
text = target.value;
} else if (target && target.isContentEditable === true) {
text = target.textContent || '';
} else {
return;
}
pushEvent({ kind: 'type', selector: getSelector(target), text, timestamp: Date.now() });
};
const onKeyDown = (event) => {
const modifiers = [];
if (event.altKey) modifiers.push('Alt');
if (event.ctrlKey) modifiers.push('Control');
if (event.metaKey) modifiers.push('Meta');
if (event.shiftKey) modifiers.push('Shift');
pushEvent({ kind: 'press_key', key: event.key, modifiers, timestamp: Date.now() });
};
const onScroll = (event) => {
const target = event.target === document ? document.scrollingElement || document.documentElement : event.target;
pushEvent({
kind: 'scroll',
selector: getSelector(target),
deltaX: 0,
deltaY: 0,
timestamp: Date.now(),
});
};
document.addEventListener('click', onClick, true);
document.addEventListener('input', onInput, true);
document.addEventListener('keydown', onKeyDown, true);
document.addEventListener('scroll', onScroll, true);
globalThis.__CCS_BROWSER_RECORDING_RECORDER__ = {
installed: true,
events: Array.isArray(recordingPayload.events) ? recordingPayload.events : [],
warnings: Array.isArray(recordingPayload.warnings) ? recordingPayload.warnings : [],
events,
warnings,
teardown: () => {
document.removeEventListener('click', onClick, true);
document.removeEventListener('input', onInput, true);
document.removeEventListener('keydown', onKeyDown, true);
document.removeEventListener('scroll', onScroll, true);
},
};
return { installed: true };
})()`;
@@ -4435,7 +4511,7 @@ async function handleStartRecording(toolArgs) {
throw new Error(`Browser MCP page ${pageIndex} does not expose a websocket debugger URL.`);
}
activeRecordingSession = {
const session = {
recordingId: createRecordingId(),
pageId: page.id,
pageIndex,
@@ -4445,14 +4521,16 @@ async function handleStartRecording(toolArgs) {
steps: [],
warnings: [],
rawEvents: [],
pageWebSocketDebuggerUrl: page.webSocketDebuggerUrl,
captureInstalled: false,
};
latestRecordingSession = activeRecordingSession;
await installRecorderAndCapture(page);
activeRecordingSession.pageWebSocketDebuggerUrl = page.webSocketDebuggerUrl;
activeRecordingSession.captureInstalled = true;
session.captureInstalled = true;
activeRecordingSession = session;
latestRecordingSession = session;
return formatRecordingSummary(activeRecordingSession);
return formatRecordingSummary(session);
}
async function handleStopRecording() {
@@ -5695,6 +5695,28 @@ describe('ccs-browser MCP server', () => {
expect(getResponseText(responses.find((message) => message.id === 1019))).toContain('pageIndex and pageId cannot be used together');
});
it('rolls back recording state when recorder injection fails', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Broken Recording Page',
currentUrl: 'https://example.com/broken-recording',
recording: {
injectionError: 'recording injection failed',
},
},
],
[
{ jsonrpc: '2.0', id: 1020, method: 'tools/call', params: { name: 'browser_start_recording', arguments: {} } },
{ jsonrpc: '2.0', id: 1020_1, method: 'tools/call', params: { name: 'browser_get_recording', arguments: {} } },
]
);
expect(getResponseText(responses.find((message) => message.id === 1020))).toContain('recording injection failed');
expect(getResponseText(responses.find((message) => message.id === 1020_1))).toContain('no recording available');
});
it('normalizes type, press_key, scroll, and warnings in a recording result', async () => {
const responses = await runMcpRequests(
[