mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
test(browser): split Browser MCP hook coverage by domain
This commit is contained in:
+4
-4
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user