test(browser): split Browser MCP hook coverage by domain

This commit is contained in:
Tam Nhu Tran
2026-04-22 23:07:50 -04:00
parent 5ff0370c1c
commit 50fa27f0a5
12 changed files with 9545 additions and 9420 deletions
+4 -4
View File
@@ -80,7 +80,7 @@ import { handleError, runCleanup } from './errors';
import { tryHandleRootCommand } from './commands/root-command-router';
// Import extracted utility functions
import { execClaude, stripAnthropicRoutingEnv } from './utils/shell-executor';
import { execClaude, stripAnthropicRoutingEnv, stripBrowserEnv } from './utils/shell-executor';
import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils/glmt-deprecation';
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
import { createLogger } from './services/logging';
@@ -1367,8 +1367,9 @@ async function main(): Promise<void> {
// env free of ANTHROPIC routing/auth while preserving non-routing profile
// env so nested Team/subagent sessions can still inherit model intent and
// other profile-scoped runtime flags.
const settingsRuntimeEnv = stripBrowserEnv({ ...globalEnv, ...settingsEnv });
const claudeRuntimeEnvVars: NodeJS.ProcessEnv = {
...stripAnthropicRoutingEnv({ ...globalEnv, ...settingsEnv }),
...stripAnthropicRoutingEnv(settingsRuntimeEnv),
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
...webSearchEnv,
...imageAnalysisEnv,
@@ -1379,8 +1380,7 @@ async function main(): Promise<void> {
// Non-Claude targets still need effective credentials injected directly.
const envVars: NodeJS.ProcessEnv = {
...globalEnv,
...settingsEnv,
...settingsRuntimeEnv,
...(inheritedClaudeConfigDir ? { CLAUDE_CONFIG_DIR: inheritedClaudeConfigDir } : {}),
...webSearchEnv,
...imageAnalysisEnv,
+4 -2
View File
@@ -390,8 +390,10 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
>
) as Record<string, string>;
const effectiveEnvVarsFiltered = Object.fromEntries(
Object.entries(effectiveEnvVars).filter(([, v]) => v !== undefined)
const effectiveEnvVarsFiltered = stripBrowserEnv(
Object.fromEntries(
Object.entries(effectiveEnvVars).filter(([, v]) => v !== undefined)
) as Record<string, string>
) as Record<string, string>;
const mergedEnv = {
@@ -215,6 +215,42 @@ describe('resolveCliproxyImageAnalysisEnv', () => {
expect(result.warning).toContain('native Read');
});
it('strips browser env injected through settings before building Claude runtime env', () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-env-strip-'));
tempDirs.push(tempDir);
const settingsPath = path.join(tempDir, 'browser-env.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy',
ANTHROPIC_AUTH_TOKEN: 'test-token',
ANTHROPIC_MODEL: 'gemini-2.5-pro',
CCS_BROWSER_USER_DATA_DIR: '/tmp/embedded-browser-runtime',
CCS_BROWSER_PROFILE_DIR: '/tmp/embedded-browser-legacy',
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/settings-env',
},
},
null,
2
) + '\n'
);
const env = buildClaudeEnvironment({
provider: 'agy',
useRemoteProxy: false,
localPort: 8317,
customSettingsPath: settingsPath,
verbose: false,
});
expect(env.CCS_BROWSER_USER_DATA_DIR).toBeUndefined();
expect(env.CCS_BROWSER_PROFILE_DIR).toBeUndefined();
expect(env.CCS_BROWSER_DEVTOOLS_WS_URL).toBeUndefined();
});
it('keeps cliproxy image analysis active when the execution target is reachable', async () => {
const result = await resolveCliproxyImageAnalysisEnv(
{
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,413 @@
import { describe, expect, it } from 'bun:test';
import { runMcpRequests, getResponseText, mkdtempSync, writeFileSync, tmpdir, join } from './browser-mcp-test-harness';
import type { MockPageState } from './browser-mcp-test-harness';
describe('ccs-browser MCP server - downloads and file inputs', () => {
it('applies browser-scoped download behavior, records download summaries, and cancels an in-progress download', async () => {
const pages: MockPageState[] = [
{
id: 'page-1',
title: 'Reports',
currentUrl: 'https://example.com/reports',
browser: {},
events: {
downloads: [
{
guid: 'download-guid-1',
url: 'https://example.com/files/report.csv',
suggestedFilename: 'report.csv',
progress: [{ receivedBytes: 5, totalBytes: 10, state: 'inProgress' }],
},
],
},
},
];
const responses = await runMcpRequests(
pages,
[
{
jsonrpc: '2.0',
id: 57,
method: 'tools/call',
params: {
name: 'browser_set_download_behavior',
arguments: { behavior: 'accept', eventsEnabled: true },
},
},
{
jsonrpc: '2.0',
id: 58,
method: 'tools/call',
params: { name: 'browser_list_downloads', arguments: {} },
},
{
jsonrpc: '2.0',
id: 59,
method: 'tools/call',
params: {
name: 'browser_cancel_download',
arguments: { guid: 'download-guid-1' },
},
},
{
jsonrpc: '2.0',
id: 60,
method: 'tools/call',
params: { name: 'browser_list_downloads', arguments: {} },
},
],
{ responseTimeoutMs: 12000 }
);
expect(getResponseText(responses.find((message) => message.id === 57))).toContain('scope: browser');
expect(getResponseText(responses.find((message) => message.id === 58))).toContain('suggestedFilename: report.csv');
expect(getResponseText(responses.find((message) => message.id === 60))).toContain('status: canceled');
expect(pages[0]?.browser?.setDownloadBehaviorCalls?.[0]?.behavior).toBe('allow');
expect(pages[0]?.browser?.canceledDownloadGuids).toContain('download-guid-1');
});
it('rejects browser_set_download_behavior when behavior is deny and downloadPath is provided', async () => {
const responses = await runMcpRequests(
[{ id: 'page-1', title: 'Reports', currentUrl: 'https://example.com/reports' }],
[
{
jsonrpc: '2.0',
id: 61,
method: 'tools/call',
params: {
name: 'browser_set_download_behavior',
arguments: {
behavior: 'deny',
downloadPath: '/tmp/blocked-downloads',
},
},
},
]
);
const response = responses.find((message) => message.id === 61);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain(
'Browser MCP failed: downloadPath is only allowed when behavior=accept'
);
});
it('rejects browser_cancel_download for completed downloads', async () => {
const pages: MockPageState[] = [
{
id: 'page-1',
title: 'Reports',
currentUrl: 'https://example.com/reports',
browser: {},
events: {
downloads: [
{
guid: 'download-guid-complete',
url: 'https://example.com/files/report.csv',
suggestedFilename: 'report.csv',
progress: [{ receivedBytes: 10, totalBytes: 10, state: 'completed' }],
},
],
},
},
];
const responses = await runMcpRequests(
pages,
[
{
jsonrpc: '2.0',
id: 610,
method: 'tools/call',
params: {
name: 'browser_set_download_behavior',
arguments: { behavior: 'accept', eventsEnabled: true },
},
},
{
jsonrpc: '2.0',
id: 611,
method: 'tools/call',
params: { name: 'browser_list_downloads', arguments: {} },
},
{
jsonrpc: '2.0',
id: 612,
method: 'tools/call',
params: {
name: 'browser_cancel_download',
arguments: { guid: 'download-guid-complete' },
},
},
],
{ responseTimeoutMs: 12000 }
);
expect(getResponseText(responses.find((message) => message.id === 611))).toContain('status: completed');
const response = responses.find((message) => message.id === 612);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain(
'Browser MCP failed: download is not cancelable in status: completed'
);
expect(pages[0]?.browser?.canceledDownloadGuids).toBeUndefined();
});
it('waits for a matching download event with browser_wait_for_event after Phase 8 changes', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Event Page',
currentUrl: 'https://example.com/',
events: {
downloads: [
{
guid: 'download-guid-2',
url: 'https://example.com/files/export.zip',
suggestedFilename: 'export.zip',
},
],
},
},
],
[
{
jsonrpc: '2.0',
id: 62,
method: 'tools/call',
params: {
name: 'browser_wait_for_event',
arguments: {
timeoutMs: 1000,
event: { kind: 'download', suggestedFilenameIncludes: 'export.zip' },
},
},
},
],
{ responseTimeoutMs: 12000 }
);
expect(getResponseText(responses.find((message) => message.id === 62))).toContain('status: observed');
});
it('sets files on selected-page, frameSelector, and pierceShadow file inputs', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-'));
const invoicePath = join(tempDir, 'invoice.pdf');
const receiptPath = join(tempDir, 'receipt.png');
writeFileSync(invoicePath, 'invoice');
writeFileSync(receiptPath, 'receipt');
const pages: MockPageState[] = [
{
id: 'page-1',
title: 'Root Uploads',
currentUrl: 'https://example.com/root',
fileInputs: {
'#root-upload': { kind: 'file', multiple: true },
},
frames: [
{
selector: '#upload-frame',
fileInputs: {
'#frame-upload': { kind: 'file', multiple: true },
},
},
],
shadowRoots: [
{
hostSelector: 'upload-panel',
fileInputs: {
'#shadow-upload': { kind: 'file' },
},
},
],
},
{
id: 'page-2',
title: 'Selected Uploads',
currentUrl: 'https://example.com/selected',
fileInputs: {
'#selected-upload': { kind: 'file', multiple: true },
},
},
];
const responses = await runMcpRequests(
pages,
[
{
jsonrpc: '2.0',
id: 63,
method: 'tools/call',
params: { name: 'browser_select_page', arguments: { pageIndex: 1 } },
},
{
jsonrpc: '2.0',
id: 64,
method: 'tools/call',
params: {
name: 'browser_set_file_input',
arguments: { selector: '#selected-upload', files: [invoicePath, receiptPath] },
},
},
{
jsonrpc: '2.0',
id: 65,
method: 'tools/call',
params: {
name: 'browser_set_file_input',
arguments: {
pageIndex: 0,
selector: '#frame-upload',
files: [invoicePath],
frameSelector: '#upload-frame',
},
},
},
{
jsonrpc: '2.0',
id: 66,
method: 'tools/call',
params: {
name: 'browser_set_file_input',
arguments: {
pageIndex: 0,
selector: '#shadow-upload',
files: [receiptPath],
pierceShadow: true,
},
},
},
]
);
expect(getResponseText(responses.find((message) => message.id === 64))).toContain('pageIndex: 1');
expect(getResponseText(responses.find((message) => message.id === 65))).toContain('frameSelector: #upload-frame');
expect(getResponseText(responses.find((message) => message.id === 66))).toContain('pierceShadow: true');
expect((pages[1]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toEqual([
invoicePath,
receiptPath,
]);
expect(
(pages[0]?.frames?.[0]?.fileInputs?.['#frame-upload'] as MockFileInputState).assignedFiles
).toEqual([invoicePath]);
expect(
(pages[0]?.shadowRoots?.[0]?.fileInputs?.['#shadow-upload'] as MockFileInputState).assignedFiles
).toEqual([receiptPath]);
});
it('uses pageId for browser_set_file_input when provided', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-'));
const assetPath = join(tempDir, 'asset.txt');
writeFileSync(assetPath, 'asset');
const pages: MockPageState[] = [
{
id: 'page-1',
title: 'Selected Uploads',
currentUrl: 'https://example.com/selected',
fileInputs: {
'#selected-upload': { kind: 'file' },
},
},
{
id: 'page-2',
title: 'Explicit Uploads',
currentUrl: 'https://example.com/explicit',
fileInputs: {
'#pageid-upload': { kind: 'file' },
},
},
];
const responses = await runMcpRequests(
pages,
[
{
jsonrpc: '2.0',
id: 67,
method: 'tools/call',
params: { name: 'browser_select_page', arguments: { pageId: 'page-1' } },
},
{
jsonrpc: '2.0',
id: 68,
method: 'tools/call',
params: {
name: 'browser_set_file_input',
arguments: { pageId: 'page-2', selector: '#pageid-upload', files: [assetPath] },
},
},
]
);
expect(getResponseText(responses.find((message) => message.id === 68))).toContain('pageIndex: 1');
expect((pages[1]?.fileInputs?.['#pageid-upload'] as MockFileInputState).assignedFiles).toEqual([
assetPath,
]);
expect((pages[0]?.fileInputs?.['#selected-upload'] as MockFileInputState).assignedFiles).toBeUndefined();
});
it('rejects browser_set_file_input when target is not a file input, local file is missing, or page selectors conflict', async () => {
const tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-upload-'));
const okPath = join(tempDir, 'ok.txt');
const missingPath = join(tempDir, 'missing.txt');
writeFileSync(okPath, 'ok');
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Upload Errors',
currentUrl: 'https://example.com/',
fileInputs: {
'#real-file-input': { kind: 'file' },
'#not-file-input': { kind: 'nonfile' },
},
},
],
[
{
jsonrpc: '2.0',
id: 69,
method: 'tools/call',
params: {
name: 'browser_set_file_input',
arguments: { selector: '#not-file-input', files: [okPath] },
},
},
{
jsonrpc: '2.0',
id: 70,
method: 'tools/call',
params: {
name: 'browser_set_file_input',
arguments: { selector: '#real-file-input', files: [missingPath] },
},
},
{
jsonrpc: '2.0',
id: 71,
method: 'tools/call',
params: {
name: 'browser_set_file_input',
arguments: { pageIndex: 0, pageId: 'page-1', selector: '#real-file-input', files: [okPath] },
},
},
]
);
expect(getResponseText(responses.find((message) => message.id === 69))).toContain(
'element is not a file input for selector: #not-file-input'
);
expect(getResponseText(responses.find((message) => message.id === 70))).toContain(
`file does not exist: ${missingPath}`
);
expect(getResponseText(responses.find((message) => message.id === 71))).toContain(
'pageIndex and pageId cannot be used together'
);
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,626 @@
import { describe, expect, it } from 'bun:test';
import { runMcpRequests, getResponseText, createReplayStep } from './browser-mcp-test-harness';
import type { MockPageState } from './browser-mcp-test-harness';
describe('ccs-browser MCP server - recording and replay', () => {
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('cleans up recording state when stop finalization fails', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Broken Stop Recording Page',
currentUrl: 'https://example.com/broken-stop-recording',
recording: {
finalizeError: 'recording finalize failed',
},
},
],
[
{
jsonrpc: '2.0',
id: 1019_1,
method: 'tools/call',
params: { name: 'browser_start_recording', arguments: {} },
},
{
jsonrpc: '2.0',
id: 1019_2,
method: 'tools/call',
params: { name: 'browser_stop_recording', arguments: {} },
},
{
jsonrpc: '2.0',
id: 1019_3,
method: 'tools/call',
params: { name: 'browser_start_recording', arguments: {} },
},
]
);
expect(getResponseText(responses.find((message) => message.id === 1019_2))).toContain(
'recording finalize failed'
);
expect(getResponseText(responses.find((message) => message.id === 1019_3))).toContain(
'status: recording'
);
});
it('rolls back recording state when recorder injection fails', async () => {
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(
[
{
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('starts a replay, reports progress, and completes basic steps', async () => {
const pages: MockPageState[] = [
{
id: 'page-1',
title: 'Replay Page',
currentUrl: 'https://example.com/replay',
click: {
'#submit': {},
},
query: {
'#submit': {
exists: true,
connected: true,
rect: {
x: 20,
y: 30,
width: 100,
height: 40,
top: 30,
right: 120,
bottom: 70,
left: 20,
},
display: 'block',
visibility: 'visible',
opacity: '1',
},
},
type: {
'#email': { kind: 'input', inputType: 'email', value: '' },
},
scroll: {
'#results': { expectedBehavior: 'by-offset', expectedDeltaX: 0, expectedDeltaY: 240 },
},
},
];
const responses = await runMcpRequests(pages, [
{
jsonrpc: '2.0',
id: 1101,
method: 'tools/call',
params: {
name: 'browser_start_replay',
arguments: {
steps: [
createReplayStep({
type: 'click',
pageId: 'page-1',
selector: '#submit',
nth: 0,
args: { button: 'left', clickCount: 1, offsetX: 12, offsetY: 8 },
}),
createReplayStep({
type: 'type',
pageId: 'page-1',
selector: '#email',
nth: 0,
args: { text: 'walker@example.com' },
}),
createReplayStep({
type: 'scroll',
pageId: 'page-1',
selector: '#results',
args: { deltaX: 0, deltaY: 240 },
}),
],
},
},
},
{
jsonrpc: '2.0',
id: 1102,
method: 'tools/call',
params: { name: 'browser_get_replay', arguments: {} },
},
]);
expect(getResponseText(responses.find((message) => message.id === 1101))).toContain('status: completed');
expect(getResponseText(responses.find((message) => message.id === 1102))).toContain('completedSteps: 3');
expect(getResponseText(responses.find((message) => message.id === 1102))).toContain('status: completed');
});
it('rejects invalid replay payloads before execution starts', async () => {
const responses = await runMcpRequests(
[{ id: 'page-1', title: 'Replay Page', currentUrl: 'https://example.com/replay' }],
[
{
jsonrpc: '2.0',
id: 1111,
method: 'tools/call',
params: { name: 'browser_start_replay', arguments: { steps: [] } },
},
{
jsonrpc: '2.0',
id: 1112,
method: 'tools/call',
params: {
name: 'browser_start_replay',
arguments: {
steps: [createReplayStep({ type: 'unknown-step', pageId: 'page-1' })],
},
},
},
{
jsonrpc: '2.0',
id: 1113,
method: 'tools/call',
params: {
name: 'browser_start_replay',
arguments: {
steps: [createReplayStep({ type: 'click', pageId: 'page-2', selector: '#submit', args: {} })],
pageId: 'page-1',
},
},
},
]
);
expect(getResponseText(responses.find((message) => message.id === 1111))).toContain('steps must be a non-empty array');
expect(getResponseText(responses.find((message) => message.id === 1112))).toContain('unsupported replay step type');
expect(getResponseText(responses.find((message) => message.id === 1113))).toContain('replay step pageId mismatch');
});
it('fails replay on the first failing step and reports failedStepIndex', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Replay Failure Page',
currentUrl: 'https://example.com/replay-failure',
click: {
'#submit': {},
},
query: {
'#submit': {
exists: true,
connected: true,
rect: {
x: 20,
y: 30,
width: 100,
height: 40,
top: 30,
right: 120,
bottom: 70,
left: 20,
},
display: 'block',
visibility: 'visible',
opacity: '1',
},
},
},
],
[
{
jsonrpc: '2.0',
id: 1114,
method: 'tools/call',
params: {
name: 'browser_start_replay',
arguments: {
steps: [
createReplayStep({ type: 'click', pageId: 'page-1', selector: '#submit', args: {} }),
createReplayStep({ type: 'click', pageId: 'page-1', selector: '#missing', args: {} }),
],
},
},
},
{
jsonrpc: '2.0',
id: 1115,
method: 'tools/call',
params: { name: 'browser_get_replay', arguments: {} },
},
]
);
const text = getResponseText(responses.find((message) => message.id === 1115));
expect(text).toContain('status: failed');
expect(text).toContain('failedStepIndex: 1');
expect(text).toContain('element index 0 is out of range for selector: #missing');
});
it('cancels a replay before later steps run', async () => {
const pages: MockPageState[] = [
{
id: 'page-1',
title: 'Replay Cancel Page',
currentUrl: 'https://example.com/replay-cancel',
click: {
'#submit': {},
},
query: {
'#handle': {
exists: true,
connected: true,
rect: {
x: 20,
y: 30,
width: 40,
height: 40,
top: 30,
right: 60,
bottom: 70,
left: 20,
},
display: 'block',
visibility: 'visible',
opacity: '1',
},
'#submit': {
exists: true,
connected: true,
rect: {
x: 120,
y: 30,
width: 100,
height: 40,
top: 30,
right: 220,
bottom: 70,
left: 120,
},
display: 'block',
visibility: 'visible',
opacity: '1',
},
},
},
];
const responses = await runMcpRequests(pages, [
{
jsonrpc: '2.0',
id: 1116,
method: 'tools/call',
params: {
name: 'browser_start_replay',
arguments: {
steps: [
createReplayStep({
type: 'pointer_action',
pageId: 'page-1',
args: {
actions: [
{ type: 'move', selector: '#handle' },
{ type: 'pause', durationMs: 400 },
],
},
}),
createReplayStep({
type: 'click',
pageId: 'page-1',
selector: '#submit',
args: {},
}),
],
},
},
},
{
jsonrpc: '2.0',
id: 1117,
method: 'tools/call',
params: { name: 'browser_cancel_replay', arguments: {} },
},
{
jsonrpc: '2.0',
id: 1118,
method: 'tools/call',
params: { name: 'browser_get_replay', arguments: {} },
},
]);
expect(getResponseText(responses.find((message) => message.id === 1116))).toContain(
'status: running'
);
const text = getResponseText(responses.find((message) => message.id === 1118));
expect(text).toContain('status: canceled');
expect(text).not.toContain('completedSteps: 2');
});
it('replays drag_element and pointer_action steps successfully', async () => {
const pages: MockPageState[] = [
{
id: 'page-1',
title: 'Replay Drag Page',
currentUrl: 'https://example.com/replay-drag',
query: {
'#card-a': {
exists: true,
connected: true,
rect: {
x: 20,
y: 40,
width: 100,
height: 60,
top: 40,
right: 120,
bottom: 100,
left: 20,
},
display: 'block',
visibility: 'visible',
opacity: '1',
},
'#lane-b': {
exists: true,
connected: true,
rect: {
x: 240,
y: 60,
width: 120,
height: 80,
top: 60,
right: 360,
bottom: 140,
left: 240,
},
display: 'block',
visibility: 'visible',
opacity: '1',
},
},
},
];
const responses = await runMcpRequests(pages, [
{
jsonrpc: '2.0',
id: 1121,
method: 'tools/call',
params: {
name: 'browser_start_replay',
arguments: {
steps: [
createReplayStep({
type: 'drag_element',
pageId: 'page-1',
selector: '#card-a',
args: { targetSelector: '#lane-b' },
}),
createReplayStep({
type: 'pointer_action',
pageId: 'page-1',
args: {
actions: [
{ type: 'move', x: 10, y: 20 },
{ type: 'down', button: 'left' },
{ type: 'up', button: 'left' },
],
},
}),
],
},
},
},
]);
expect(getResponseText(responses.find((message) => message.id === 1121))).toContain('status: completed');
expect(getResponseText(responses.find((message) => message.id === 1121))).toContain('completedSteps: 2');
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -348,6 +348,41 @@ server.listen(0, '127.0.0.1', () => {
expect(launchedEnv).not.toContain('stale-settings-env');
});
it('scrubs CCS_BROWSER_* values embedded in settings-profile env when browser is off', () => {
if (process.platform === 'win32') return;
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
ANTHROPIC_AUTH_TOKEN: 'token',
ANTHROPIC_MODEL: 'glm-5',
CCS_BROWSER_USER_DATA_DIR: '/tmp/settings-browser-runtime',
CCS_BROWSER_PROFILE_DIR: '/tmp/settings-browser-legacy',
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/settings-env',
},
},
null,
2
) + '\n'
);
const result = runCcs(['glm', 'smoke'], {
...baseEnv,
});
expect(result.status).toBe(0);
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
expect(launchedArgs).not.toContain(BROWSER_PROMPT_SNIPPET);
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
expect(launchedEnv).not.toContain('/tmp/settings-browser-runtime');
expect(launchedEnv).not.toContain('/tmp/settings-browser-legacy');
expect(launchedEnv).not.toContain('settings-env');
});
it('skips managed browser attach for settings-profile launches when the default CCS browser profile directory is missing', () => {
if (process.platform === 'win32') return;