feat(browser): 接入 CCS browser MCP 与最小交互闭环

新增 browser MCP 安装、Chrome 复用与运行时接线,
实现 navigate、click、type、take_screenshot 四个 phase-1 工具并补齐相关单测。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
walkerandClaude Opus 4.6 authored and walker1211 committed 2026-04-13 11:19:16 +08:00
1 parent efecd7c7d9
commit eb8149e8fa
29 files changed
+3627 -118

No files matched your search

@@ -0,0 +1,827 @@
import { afterEach, describe, expect, it } from 'bun:test';
import { spawn } from 'child_process';
import { mkdtempSync, rmSync } from 'node:fs';
import * as http from 'node:http';
import { WebSocketServer } from 'ws';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
type JsonRpcMessage = Record<string, unknown>;
type MockPageState = {
id: string;
title: string;
currentUrl: string;
readyStateSequence?: string[];
visibleText?: string;
domSnapshot?: string;
navigate?: Record<string, { finalUrl: string; readyStates?: string[]; errorText?: string }>;
click?: Record<string, { error?: string; disabled?: boolean }>;
screenshot?: {
data?: string;
lastCaptureBeyondViewport?: boolean;
};
type?: Record<
string,
{
kind: 'input' | 'textarea' | 'contenteditable' | 'unsupported' | 'noneditable';
inputType?: string;
value?: string;
expectedValueWhenClearFirst?: string;
expectedValueWhenAppend?: string;
requireFocus?: boolean;
focused?: boolean;
}
>;
};
const serverPath = join(process.cwd(), 'lib', 'mcp', 'ccs-browser-server.cjs');
function encodeMessage(message: unknown): string {
return `${JSON.stringify(message)}\n`;
}
function collectResponses(
child: ReturnType<typeof spawn>,
expectedCount: number
): Promise<Array<Record<string, unknown>>> {
return new Promise((resolve, reject) => {
let buffer = Buffer.alloc(0);
const responses: Array<Record<string, unknown>> = [];
const timer = setTimeout(() => reject(new Error('Timed out waiting for MCP responses')), 7000);
function tryParse(): void {
while (true) {
const newlineIndex = buffer.indexOf('\n');
if (newlineIndex === -1) {
return;
}
const body = buffer.subarray(0, newlineIndex).toString('utf8').replace(/\r$/, '').trim();
buffer = buffer.subarray(newlineIndex + 1);
if (!body) {
continue;
}
responses.push(JSON.parse(body) as Record<string, unknown>);
if (responses.length >= expectedCount) {
clearTimeout(timer);
resolve(responses);
return;
}
}
}
if (!child.stdout) {
clearTimeout(timer);
reject(new Error('MCP child stdout is unavailable'));
return;
}
child.stdout.on('data', (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk]);
try {
tryParse();
} catch (error) {
clearTimeout(timer);
reject(error);
}
});
child.on('error', (error) => {
clearTimeout(timer);
reject(error);
});
});
}
function getResponseText(message: Record<string, unknown> | undefined): string {
const result = (message?.result as { content?: Array<{ text?: string }> }) || {};
return result.content?.[0]?.text || '';
}
function parseJsonArgument(expression: string, key: string): string | undefined {
const marker = `const ${key} = JSON.parse(`;
const start = expression.indexOf(marker);
if (start === -1) {
return undefined;
}
const quoteStart = start + marker.length;
const quote = expression[quoteStart];
if (quote !== '"' && quote !== "'") {
return undefined;
}
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;
if (!decoded.startsWith('"') && !decoded.startsWith("'")) {
return undefined;
}
return JSON.parse(decoded) as string;
}
index += 1;
}
return undefined;
}
function createMockBrowser(pagesInput: MockPageState[]) {
let tempDir = '';
let httpServer: http.Server | null = null;
let wsServer: WebSocketServer | null = null;
const pageStates = new Map<string, MockPageState>();
for (const [index, page] of pagesInput.entries()) {
pageStates.set(`/devtools/page/${index + 1}`, {
visibleText: 'Hello from visible text',
domSnapshot: '<html><body>Hello from DOM snapshot</body></html>',
...page,
});
}
async function start() {
tempDir = mkdtempSync(join(tmpdir(), 'ccs-browser-mcp-server-'));
const port = await new Promise<number>((resolve, reject) => {
httpServer = http.createServer((req, res) => {
if (req.url === '/json/list') {
const address = httpServer?.address();
const serverPort = address && typeof address !== 'string' ? address.port : 0;
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify(
Array.from(pageStates.entries()).map(([wsPath, page]) => ({
id: page.id,
type: 'page',
title: page.title,
url: page.currentUrl,
webSocketDebuggerUrl: `ws://127.0.0.1:${serverPort}${wsPath}`,
}))
)
);
return;
}
res.writeHead(404);
res.end('not found');
});
httpServer.once('error', reject);
httpServer.listen(0, '127.0.0.1', () => {
const address = httpServer?.address();
if (!address || typeof address === 'string') {
reject(new Error('Failed to resolve mock browser server port'));
return;
}
resolve(address.port);
});
});
wsServer = new WebSocketServer({ server: httpServer as http.Server });
wsServer.on('connection', (socket, request) => {
const page = pageStates.get(request.url || '');
if (!page) {
socket.close();
return;
}
socket.on('message', (raw) => {
const message = JSON.parse(raw.toString()) as {
id: number;
method: string;
params?: Record<string, unknown>;
};
function reply(result: unknown): void {
socket.send(JSON.stringify({ id: message.id, result }));
}
function replyError(errorText: string): void {
socket.send(JSON.stringify({ id: message.id, result: { result: { subtype: 'error', description: errorText } } }));
}
if (message.method === 'Page.navigate') {
const targetUrl = typeof message.params?.url === 'string' ? message.params.url : '';
const navigatePlan = page.navigate?.[targetUrl];
if (navigatePlan?.errorText) {
reply({ frameId: 'frame-1', errorText: navigatePlan.errorText });
return;
}
if (navigatePlan) {
page.currentUrl = navigatePlan.finalUrl;
page.readyStateSequence = [...(navigatePlan.readyStates || ['loading', 'interactive'])];
}
reply({ frameId: 'frame-1' });
return;
}
if (message.method === 'Page.captureScreenshot') {
if (!page.screenshot) {
reply({ data: '' });
return;
}
page.screenshot.lastCaptureBeyondViewport = message.params?.captureBeyondViewport === true;
reply({ data: page.screenshot.data || '' });
return;
}
if (message.method !== 'Runtime.evaluate') {
return;
}
const expression = String(message.params?.expression || '');
if (expression.includes('document.title') && expression.includes('location.href')) {
reply({ result: { type: 'string', value: JSON.stringify({ title: page.title, url: page.currentUrl }) } });
return;
}
if (expression.includes('document.body ? document.body.innerText')) {
reply({ result: { type: 'string', value: page.visibleText || '' } });
return;
}
if (expression.includes('document.documentElement ? document.documentElement.outerHTML')) {
reply({ result: { type: 'string', value: page.domSnapshot || '' } });
return;
}
if (expression.includes('document.readyState') && expression.includes('location.href')) {
const readyState = page.readyStateSequence?.shift() || 'complete';
reply({
result: {
type: 'string',
value: JSON.stringify({ href: page.currentUrl, readyState }),
},
});
return;
}
if (expression.includes('scrollIntoView') && expression.includes('.click()')) {
const selector = parseJsonArgument(expression, 'selector') || '';
const clickPlan = page.click?.[selector];
if (!clickPlan) {
replyError(`element not found for selector: ${selector}`);
return;
}
if (clickPlan.disabled) {
replyError(`element is disabled for selector: ${selector}`);
return;
}
if (clickPlan.error) {
replyError(clickPlan.error);
return;
}
reply({ result: { type: 'string', value: 'ok' } });
return;
}
if (expression.includes('focusTarget') && expression.includes('typedLength')) {
const selector = parseJsonArgument(expression, 'selector') || '';
const text = parseJsonArgument(expression, 'text') ?? '';
const clearFirst = expression.includes('const clearFirst = true');
const typePlan = page.type?.[selector];
if (!typePlan) {
replyError(`element not found for selector: ${selector}`);
return;
}
if (typePlan.kind === 'unsupported') {
replyError(`element is not text-editable for selector: ${selector}`);
return;
}
if (typePlan.kind === 'noneditable') {
replyError(`element is not text-editable for selector: ${selector}`);
return;
}
if (typePlan.requireFocus && !expression.includes('focusTarget(element)')) {
replyError(`focus was not requested for selector: ${selector}`);
return;
}
const currentValue = typePlan.value || '';
const expectedValue = clearFirst
? (typePlan.expectedValueWhenClearFirst ?? text)
: (typePlan.expectedValueWhenAppend ?? `${currentValue}${text}`);
typePlan.focused = true;
typePlan.value = expectedValue;
reply({
result: {
type: 'string',
value: JSON.stringify({
value: expectedValue,
typedLength: expectedValue.length,
}),
},
});
return;
}
});
});
const child = spawn('node', [serverPath], {
cwd: tempDir,
env: {
...process.env,
CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${port}`,
},
stdio: ['pipe', 'pipe', 'pipe'],
});
return child;
}
async function stop() {
await new Promise<void>((resolve) => {
wsServer?.close(() => resolve());
if (!wsServer) resolve();
});
wsServer = null;
await new Promise<void>((resolve) => {
httpServer?.close(() => resolve());
if (!httpServer) resolve();
});
httpServer = null;
if (tempDir) {
rmSync(tempDir, { recursive: true, force: true });
tempDir = '';
}
}
return { start, stop };
}
async function runMcpRequests(pages: MockPageState[], requests: JsonRpcMessage[]) {
const browser = createMockBrowser(pages);
const child = await browser.start();
try {
const responsesPromise = collectResponses(child, requests.length + 1);
child.stdin.write(
encodeMessage({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: {
protocolVersion: '2024-11-05',
capabilities: {},
clientInfo: { name: 'bun-test', version: '1.0.0' },
},
})
);
for (const request of requests) {
child.stdin.write(encodeMessage(request));
}
return await responsesPromise;
} finally {
child.kill();
await browser.stop();
}
}
describe('ccs-browser MCP server', () => {
afterEach(() => {
// Cleanup is handled per test via runMcpRequests/createMockBrowser.
});
it('lists browser tools including navigate, click, type, and screenshot', async () => {
const responses = await runMcpRequests(
[{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }],
[{ jsonrpc: '2.0', id: 2, method: 'tools/list' }]
);
const tools = (responses.find((message) => message.id === 2)?.result as {
tools: Array<{ name: string; inputSchema?: { properties?: Record<string, { type?: string; minimum?: number }> } }>;
}).tools;
expect(tools.map((tool) => tool.name)).toEqual([
'browser_get_session_info',
'browser_get_url_and_title',
'browser_get_visible_text',
'browser_get_dom_snapshot',
'browser_navigate',
'browser_click',
'browser_type',
'browser_take_screenshot',
]);
for (const tool of tools.filter((candidate) => candidate.inputSchema?.properties?.pageIndex)) {
expect(tool.inputSchema?.properties?.pageIndex).toMatchObject({
type: 'integer',
minimum: 0,
});
}
});
it('navigates successfully after readiness polling', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Example Page',
currentUrl: 'https://example.com/',
navigate: {
'https://example.com/next': {
finalUrl: 'https://example.com/next',
readyStates: ['loading', 'interactive'],
},
},
},
],
[
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'browser_navigate',
arguments: { url: 'https://example.com/next' },
},
},
]
);
const text = getResponseText(responses.find((message) => message.id === 2));
expect(text).toContain('pageIndex: 0');
expect(text).toContain('url: https://example.com/next');
expect(text).toContain('status: navigated');
});
it(
'returns a handled error when navigation readiness times out',
async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Example Page',
currentUrl: 'https://example.com/',
navigate: {
'https://example.com/slow': {
finalUrl: 'https://example.com/',
readyStates: new Array(60).fill('loading'),
},
},
},
],
[
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'browser_navigate',
arguments: { url: 'https://example.com/slow' },
},
},
]
);
const response = responses.find((message) => message.id === 2);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain('Browser MCP failed: navigation did not complete');
},
8000
);
it('returns handled errors for missing URL, malformed URL, invalid page index, and out-of-range page index', async () => {
const responses = await runMcpRequests(
[{ id: 'page-1', title: 'Example Page', currentUrl: 'https://example.com/' }],
[
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'browser_navigate', arguments: {} },
},
{
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: { name: 'browser_navigate', arguments: { url: 'file:///tmp/example' } },
},
{
jsonrpc: '2.0',
id: 4,
method: 'tools/call',
params: { name: 'browser_navigate', arguments: { pageIndex: 1.5, url: 'https://example.com/next' } },
},
{
jsonrpc: '2.0',
id: 5,
method: 'tools/call',
params: { name: 'browser_navigate', arguments: { pageIndex: 9, url: 'https://example.com/next' } },
},
]
);
expect(getResponseText(responses.find((message) => message.id === 2))).toContain('Browser MCP failed: url is required');
expect(getResponseText(responses.find((message) => message.id === 3))).toContain(
'Browser MCP failed: url must be an absolute http or https URL'
);
expect(getResponseText(responses.find((message) => message.id === 4))).toContain(
'Browser MCP failed: pageIndex must be a non-negative integer'
);
expect(getResponseText(responses.find((message) => message.id === 5))).toContain('page index 9 is out of range');
});
it('surfaces Page.navigate CDP failures as handled errors', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Example Page',
currentUrl: 'https://example.com/start',
navigate: {
'https://example.com/fail': {
finalUrl: 'https://example.com/start',
errorText: 'net::ERR_ABORTED',
},
},
},
],
[
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'browser_navigate',
arguments: { url: 'https://example.com/fail' },
},
},
]
);
const response = responses.find((message) => message.id === 2);
expect((response?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(response)).toContain(
'Browser MCP failed: navigation failed for URL: https://example.com/fail: net::ERR_ABORTED'
);
});
it('treats redirects as successful navigation', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Example Page',
currentUrl: 'https://example.com/start',
navigate: {
'https://example.com/redirect': {
finalUrl: 'https://example.com/final',
readyStates: ['loading', 'interactive'],
},
},
},
],
[
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'browser_navigate',
arguments: { url: 'https://example.com/redirect' },
},
},
]
);
const text = getResponseText(responses.find((message) => message.id === 2));
expect(text).toContain('status: navigated');
expect(text).toContain('url: https://example.com/final');
});
it('clicks matching elements and reports selector failures', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Example Page',
currentUrl: 'https://example.com/',
click: {
'#submit': {},
'#disabled': { disabled: true },
'#throws': { error: 'click exploded' },
},
},
],
[
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: { name: 'browser_click', arguments: { selector: '#submit' } },
},
{
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: { name: 'browser_click', arguments: { selector: '#missing' } },
},
{
jsonrpc: '2.0',
id: 4,
method: 'tools/call',
params: { name: 'browser_click', arguments: { selector: '#disabled' } },
},
{
jsonrpc: '2.0',
id: 5,
method: 'tools/call',
params: { name: 'browser_click', arguments: { selector: '#throws' } },
},
]
);
expect(getResponseText(responses.find((message) => message.id === 2))).toContain('status: clicked');
expect(getResponseText(responses.find((message) => message.id === 2))).toContain('selector: #submit');
const selectorMiss = responses.find((message) => message.id === 3);
expect((selectorMiss?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(selectorMiss)).toContain('element not found for selector: #missing');
const disabledError = responses.find((message) => message.id === 4);
expect((disabledError?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(disabledError)).toContain('element is disabled for selector: #disabled');
const pageSideError = responses.find((message) => message.id === 5);
expect((pageSideError?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(pageSideError)).toContain('click exploded');
});
it('captures screenshots and reports empty payload failures', async () => {
const screenshotPlan: MockPageState['screenshot'] = { data: 'c2NyZWVuc2hvdA==' };
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Example Page',
currentUrl: 'https://example.com/',
screenshot: screenshotPlan,
},
],
[
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'browser_take_screenshot',
arguments: { fullPage: true },
},
},
]
);
const text = getResponseText(responses.find((message) => message.id === 2));
expect(text).toContain('pageIndex: 0');
expect(text).toContain('format: png');
expect(text).toContain('fullPage: true');
expect(text).toContain('data: c2NyZWVuc2hvdA==');
expect(screenshotPlan.lastCaptureBeyondViewport).toBe(true);
const errorResponses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Example Page',
currentUrl: 'https://example.com/',
screenshot: { data: '' },
},
],
[
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'browser_take_screenshot',
arguments: {},
},
},
]
);
const errorResponse = errorResponses.find((message) => message.id === 2);
expect((errorResponse?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(errorResponse)).toContain('Browser MCP failed: screenshot capture failed');
});
it('types into supported editable targets with explicit focus and final-value verification, and rejects unsupported targets', async () => {
const responses = await runMcpRequests(
[
{
id: 'page-1',
title: 'Example Page',
currentUrl: 'https://example.com/',
type: {
'input[name="email"]': {
kind: 'input',
inputType: 'email',
value: 'old@example.com',
expectedValueWhenAppend: 'old@example.comhi@example.com',
requireFocus: true,
},
'#notes': {
kind: 'textarea',
value: 'old note',
expectedValueWhenClearFirst: '',
requireFocus: true,
},
'#editor': {
kind: 'contenteditable',
value: 'old content',
expectedValueWhenAppend: 'old contentrich text',
requireFocus: true,
},
'#color': { kind: 'unsupported', inputType: 'color', value: '#ff0000' },
'#plain': { kind: 'noneditable', value: 'plain' },
},
},
],
[
{
jsonrpc: '2.0',
id: 2,
method: 'tools/call',
params: {
name: 'browser_type',
arguments: { selector: 'input[name="email"]', text: 'hi@example.com' },
},
},
{
jsonrpc: '2.0',
id: 3,
method: 'tools/call',
params: {
name: 'browser_type',
arguments: { selector: '#notes', text: '', clearFirst: true },
},
},
{
jsonrpc: '2.0',
id: 4,
method: 'tools/call',
params: {
name: 'browser_type',
arguments: { selector: '#editor', text: 'rich text' },
},
},
{
jsonrpc: '2.0',
id: 5,
method: 'tools/call',
params: {
name: 'browser_type',
arguments: { selector: '#color', text: 'ignored' },
},
},
{
jsonrpc: '2.0',
id: 6,
method: 'tools/call',
params: {
name: 'browser_type',
arguments: { selector: '#plain', text: 'ignored' },
},
},
]
);
const typed = getResponseText(responses.find((message) => message.id === 2));
expect(typed).toContain('status: typed');
expect(typed).toContain('selector: input[name="email"]');
expect(typed).toContain('typedLength: 29');
const clearFirst = getResponseText(responses.find((message) => message.id === 3));
expect(clearFirst).toContain('status: typed');
expect(clearFirst).toContain('selector: #notes');
expect(clearFirst).toContain('typedLength: 0');
const contenteditable = getResponseText(responses.find((message) => message.id === 4));
expect(contenteditable).toContain('status: typed');
expect(contenteditable).toContain('selector: #editor');
expect(contenteditable).toContain('typedLength: 20');
const unsupported = responses.find((message) => message.id === 5);
expect((unsupported?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(unsupported)).toContain('element is not text-editable for selector: #color');
const nonEditable = responses.find((message) => message.id === 6);
expect((nonEditable?.result as { isError?: boolean }).isError).toBe(true);
expect(getResponseText(nonEditable)).toContain('element is not text-editable for selector: #plain');
});
});
+53 -1
View File
@@ -144,7 +144,7 @@ describe('InstanceManager MCP sync', () => {
});
});
it('repairs managed ccs-websearch entries from global config while preserving other instance MCP overrides', () => {
it('repairs managed ccs-websearch and ccs-browser entries from global config while preserving other instance MCP overrides', () => {
fs.writeFileSync(
path.join(tempRoot, '.claude.json'),
JSON.stringify(
@@ -156,6 +156,12 @@ describe('InstanceManager MCP sync', () => {
args: ['/global/server.cjs'],
env: {},
},
'ccs-browser': {
type: 'stdio',
command: 'node',
args: ['/global/browser-server.cjs'],
env: {},
},
shared: { command: 'global-shared' },
},
},
@@ -179,6 +185,12 @@ describe('InstanceManager MCP sync', () => {
args: ['/old/server.cjs'],
env: {},
},
'ccs-browser': {
type: 'stdio',
command: 'node',
args: ['/old/browser-server.cjs'],
env: {},
},
shared: { command: 'instance-shared' },
instanceOnly: { command: 'instance-only' },
},
@@ -201,11 +213,51 @@ describe('InstanceManager MCP sync', () => {
args: ['/global/server.cjs'],
env: {},
},
'ccs-browser': {
type: 'stdio',
command: 'node',
args: ['/global/browser-server.cjs'],
env: {},
},
shared: { command: 'instance-shared' },
instanceOnly: { command: 'instance-only' },
});
});
it('preserves existing instance .claude.json permissions when syncing managed MCP servers', () => {
fs.writeFileSync(
path.join(tempRoot, '.claude.json'),
JSON.stringify(
{
mcpServers: {
'ccs-browser': {
type: 'stdio',
command: 'node',
args: ['/global/browser.cjs'],
env: {},
},
},
},
null,
2
),
'utf8'
);
const manager = new InstanceManager();
const instancePath = manager.getInstancePath('work');
fs.mkdirSync(instancePath, { recursive: true });
const instanceClaudeJson = path.join(instancePath, '.claude.json');
fs.writeFileSync(instanceClaudeJson, JSON.stringify({ existing: true }, null, 2) + '\n', {
encoding: 'utf8',
mode: 0o640,
});
fs.chmodSync(instanceClaudeJson, 0o640);
expect(manager.syncMcpServers(instancePath)).toBe(true);
expect(fs.statSync(instanceClaudeJson).mode & 0o777).toBe(0o640);
});
it('logs warning when global MCP sync fails', () => {
fs.writeFileSync(path.join(tempRoot, '.claude.json'), '{invalid-json', 'utf8');
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
+41 -1
View File
@@ -1,6 +1,10 @@
import { describe, expect, test } from 'bun:test';
import { CodexAdapter } from '../../../src/targets/codex-adapter';
import {
buildCodexBrowserMcpOverrides,
getCodexBrowserMcpServerName,
} from '../../../src/utils/browser-codex-overrides';
describe('CodexAdapter', () => {
const adapter = new CodexAdapter();
@@ -21,6 +25,16 @@ describe('CodexAdapter', () => {
).toEqual(['--search']);
});
test('builds browser MCP overrides with Codex-safe defaults', () => {
const serverName = getCodexBrowserMcpServerName();
expect(buildCodexBrowserMcpOverrides()).toEqual([
`mcp_servers.${serverName}.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`,
`mcp_servers.${serverName}.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`,
`mcp_servers.${serverName}.enabled=true`,
`mcp_servers.${serverName}.tool_timeout_sec=30`,
]);
});
test('translates default-mode reasoning overrides into transient codex config', () => {
const args = adapter.buildArgs('default', ['--search'], {
profileType: 'default',
@@ -40,6 +54,29 @@ describe('CodexAdapter', () => {
expect(args).toEqual(['-c', 'model_reasoning_effort="medium"', '--search']);
});
test('injects runtime config overrides for native Codex default launches', () => {
const runtimeConfigOverrides = buildCodexBrowserMcpOverrides();
const args = adapter.buildArgs('default', ['--search'], {
profileType: 'default',
creds: {
profile: 'default',
baseUrl: '',
apiKey: '',
runtimeConfigOverrides,
},
binaryInfo: {
path: '/tmp/codex',
needsShell: false,
features: ['config-overrides'],
},
});
expect(args).toEqual([
...runtimeConfigOverrides.flatMap((override) => ['-c', override]),
'--search',
]);
});
test('rejects default-mode reasoning overrides when codex lacks config override support', () => {
expect(() =>
adapter.buildArgs('default', ['--search'], {
@@ -61,6 +98,7 @@ describe('CodexAdapter', () => {
});
test('injects transient config overrides for CCS-backed launches', () => {
const runtimeConfigOverrides = buildCodexBrowserMcpOverrides();
const args = adapter.buildArgs('codex', ['--search'], {
profileType: 'cliproxy',
creds: {
@@ -69,6 +107,7 @@ describe('CodexAdapter', () => {
apiKey: 'cliproxy-token',
model: 'gpt-5.4',
reasoningOverride: 'high',
runtimeConfigOverrides,
},
binaryInfo: {
path: '/tmp/codex',
@@ -81,8 +120,9 @@ describe('CodexAdapter', () => {
expect(args).toContain('model_provider="ccs_runtime"');
expect(args).toContain('model_providers.ccs_runtime.env_key="CCS_CODEX_API_KEY"');
expect(args).toContain('model="gpt-5.4"');
expect(args).toContain(`mcp_servers.${getCodexBrowserMcpServerName()}.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`);
expect(args).toContain('model_reasoning_effort="high"');
expect(args.at(-1)).toBe('--search');
expect(args[args.length - 1]).toBe('--search');
});
test('fails fast when Codex binary lacks config override support', () => {
@@ -162,7 +162,7 @@ process.exit(0);
fs.rmSync(tmpHome, { recursive: true, force: true });
});
it('does not preflight native Codex default launches when no runtime overrides are needed', () => {
it('injects browser MCP runtime overrides for native Codex default launches', () => {
if (process.platform === 'win32') return;
const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], {
@@ -177,10 +177,23 @@ process.exit(0);
expect(result.status).toBe(0);
const calls = readLoggedCodexCalls(codexArgsLogPath);
expect(calls).toEqual([['fix failing tests']]);
expect(calls).toEqual([
['-c', 'model="gpt-5"', '--version'],
[
'-c',
`mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`,
'-c',
`mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`,
'-c',
'mcp_servers.ccs_browser.enabled=true',
'-c',
'mcp_servers.ccs_browser.tool_timeout_sec=30',
'fix failing tests',
],
]);
});
it('ignores off-style CCS_THINKING env overrides for native Codex default mode', () => {
it('keeps browser MCP runtime overrides when CCS_THINKING is ignored for native Codex default mode', () => {
if (process.platform === 'win32') return;
const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], {
@@ -195,7 +208,20 @@ process.exit(0);
expect(result.status).toBe(0);
const calls = readLoggedCodexCalls(codexArgsLogPath);
expect(calls).toEqual([['fix failing tests']]);
expect(calls).toEqual([
['-c', 'model="gpt-5"', '--version'],
[
'-c',
`mcp_servers.ccs_browser.command=${JSON.stringify(process.platform === 'win32' ? 'npx.cmd' : 'npx')}`,
'-c',
`mcp_servers.ccs_browser.args=${JSON.stringify(['-y', '@playwright/mcp@0.0.70'])}`,
'-c',
'mcp_servers.ccs_browser.enabled=true',
'-c',
'mcp_servers.ccs_browser.tool_timeout_sec=30',
'fix failing tests',
],
]);
});
for (const versionFlag of ['--version', '-v']) {
@@ -291,7 +317,19 @@ process.exit(0);
expect(fs.statSync(freshCodexHome).isDirectory()).toBe(true);
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
['-c', 'model="gpt-5"', '--version'],
['-c', 'model_reasoning_effort="high"', 'fix failing tests'],
[
'-c',
'mcp_servers.ccs_browser.command="npx"',
'-c',
'mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]',
'-c',
'mcp_servers.ccs_browser.enabled=true',
'-c',
'mcp_servers.ccs_browser.tool_timeout_sec=30',
'-c',
'model_reasoning_effort="high"',
'fix failing tests',
],
]);
const loggedEnv = readLoggedCodexEnv(codexEnvLogPath);
expect(loggedEnv).toHaveLength(2);
@@ -481,7 +519,19 @@ process.exit(0);
expect(result.status).toBe(0);
expect(readLoggedCodexCalls(codexArgsLogPath)).toEqual([
['-c', 'model="gpt-5"', '--version'],
['-c', 'model_reasoning_effort="high"', 'fix failing tests'],
[
'-c',
'mcp_servers.ccs_browser.command="npx"',
'-c',
'mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]',
'-c',
'mcp_servers.ccs_browser.enabled=true',
'-c',
'mcp_servers.ccs_browser.tool_timeout_sec=30',
'-c',
'model_reasoning_effort="high"',
'fix failing tests',
],
]);
});
@@ -126,6 +126,8 @@ exit 0
expect(argsLog).toContain('model_provider="ccs_runtime"');
expect(argsLog).toContain('model_providers.ccs_runtime.base_url="http://127.0.0.1:8317/api/provider/codex"');
expect(argsLog).toContain('model_reasoning_effort="high"');
expect(argsLog).toContain('mcp_servers.ccs_browser.command=');
expect(argsLog).toContain('mcp_servers.ccs_browser.args=["-y","@playwright/mcp@0.0.70"]');
expect(argsLog).toContain('smoke');
expect(fs.readFileSync(codexEnvLogPath, 'utf8')).toBe('bridge-token');
});
@@ -0,0 +1,154 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { spawn, spawnSync, type ChildProcess } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool';
interface RunResult {
status: number | null;
stdout: string;
stderr: string;
}
function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts');
const result = spawnSync(process.execPath, [ccsEntry, ...args], {
encoding: 'utf8',
env,
timeout: 5000,
});
return {
status: result.status,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
describe('default profile browser launch', () => {
let tmpHome = '';
let fakeClaudePath = '';
let claudeArgsLogPath = '';
let claudeEnvLogPath = '';
let browserProfileDir = '';
let devtoolsServer: ChildProcess | undefined;
let baseEnv: NodeJS.ProcessEnv;
beforeEach(() => {
if (process.platform === 'win32') {
return;
}
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-default-browser-launch-'));
fakeClaudePath = path.join(tmpHome, 'fake-claude.sh');
claudeArgsLogPath = path.join(tmpHome, 'claude-args.txt');
claudeEnvLogPath = path.join(tmpHome, 'claude-env.txt');
browserProfileDir = path.join(tmpHome, 'chrome-user-data');
fs.writeFileSync(
fakeClaudePath,
`#!/bin/sh
printf "%s\n" "$@" > "${claudeArgsLogPath}"
{
printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR"
printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST"
printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT"
printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL"
printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL"
} > "${claudeEnvLogPath}"
exit 0
`,
{ encoding: 'utf8', mode: 0o755 }
);
fs.chmodSync(fakeClaudePath, 0o755);
baseEnv = {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CLAUDE_PATH: fakeClaudePath,
CCS_DEBUG: '1',
};
});
afterEach(() => {
if (devtoolsServer) {
devtoolsServer.kill();
devtoolsServer = undefined;
}
if (process.platform === 'win32') {
return;
}
fs.rmSync(tmpHome, { recursive: true, force: true });
});
it('passes browser runtime env through default Claude launches when reuse is configured', () => {
if (process.platform === 'win32') return;
const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js');
const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port.txt');
fs.writeFileSync(
mockServerScriptPath,
`const { createServer } = require('http');
const fs = require('fs');
const server = createServer((req, res) => {
if (req.url === '/json/version') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/default-target' }));
return;
}
res.writeHead(404);
res.end('not found');
});
server.listen(0, '127.0.0.1', () => {
const address = server.address();
fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8');
});
`,
'utf8'
);
devtoolsServer = spawn(process.execPath, [mockServerScriptPath], {
stdio: 'ignore',
env: baseEnv,
});
const startDeadline = Date.now() + 5000;
while (!fs.existsSync(mockServerPortPath)) {
if (Date.now() > startDeadline) {
throw new Error('Timed out waiting for mock DevTools server to start');
}
}
const port = fs.readFileSync(mockServerPortPath, 'utf8').trim();
fs.mkdirSync(browserProfileDir, { recursive: true });
fs.writeFileSync(
path.join(browserProfileDir, 'DevToolsActivePort'),
`${port}\n/devtools/browser/default-target`,
'utf8'
);
const result = runCcs(['default', 'smoke'], {
...baseEnv,
CCS_BROWSER_PROFILE_DIR: browserProfileDir,
});
expect(result.stderr).not.toContain('Browser MCP is enabled, but CCS could not prepare the local browser tool.');
expect(result.stderr).not.toContain('could not sync the browser MCP config');
expect(result.stderr).not.toContain('Chrome reuse metadata not found');
expect(result.status).toBe(0);
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
expect(launchedArgs).toContain('--append-system-prompt');
expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET);
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`);
expect(launchedEnv).toContain(`port=${port}`);
expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`);
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target');
});
});
@@ -0,0 +1,199 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { spawn, spawnSync, type ChildProcess } from 'child_process';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool';
interface RunResult {
status: number | null;
stdout: string;
stderr: string;
}
function runCcs(args: string[], env: NodeJS.ProcessEnv): RunResult {
const ccsEntry = path.join(process.cwd(), 'src', 'ccs.ts');
const result = spawnSync(process.execPath, [ccsEntry, ...args], {
encoding: 'utf8',
env,
timeout: 8000,
});
return {
status: result.status,
stdout: result.stdout || '',
stderr: result.stderr || '',
};
}
describe('settings profile browser launch', () => {
let tmpHome = '';
let ccsDir = '';
let settingsPath = '';
let fakeClaudePath = '';
let claudeArgsLogPath = '';
let claudeEnvLogPath = '';
let browserProfileDir = '';
let devtoolsServer: ChildProcess | undefined;
let baseEnv: NodeJS.ProcessEnv;
beforeEach(() => {
if (process.platform === 'win32') {
return;
}
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-launch-'));
ccsDir = path.join(tmpHome, '.ccs');
settingsPath = path.join(ccsDir, 'glm.settings.json');
fakeClaudePath = path.join(tmpHome, 'fake-claude.sh');
claudeArgsLogPath = path.join(tmpHome, 'claude-args.txt');
claudeEnvLogPath = path.join(tmpHome, 'claude-env.txt');
browserProfileDir = path.join(tmpHome, 'chrome-user-data');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: { glm: settingsPath } }, null, 2) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
['version: 12', 'websearch:', ' enabled: false', 'image_analysis:', ' enabled: false', ''].join('\n'),
'utf8'
);
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
ANTHROPIC_AUTH_TOKEN: 'token',
ANTHROPIC_MODEL: 'glm-5',
},
},
null,
2
) + '\n'
);
fs.writeFileSync(
fakeClaudePath,
`#!/bin/sh
printf "%s\n" "$@" > "${claudeArgsLogPath}"
{
printf "userDataDir=%s\n" "$CCS_BROWSER_USER_DATA_DIR"
printf "host=%s\n" "$CCS_BROWSER_DEVTOOLS_HOST"
printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT"
printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL"
printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL"
} > "${claudeEnvLogPath}"
exit 0
`,
{ encoding: 'utf8', mode: 0o755 }
);
fs.chmodSync(fakeClaudePath, 0o755);
baseEnv = {
...process.env,
CI: '1',
NO_COLOR: '1',
CCS_HOME: tmpHome,
CCS_CLAUDE_PATH: fakeClaudePath,
CCS_DEBUG: '1',
};
});
afterEach(() => {
if (devtoolsServer) {
devtoolsServer.kill();
devtoolsServer = undefined;
}
if (process.platform === 'win32') {
return;
}
fs.rmSync(tmpHome, { recursive: true, force: true });
});
it('fails before Claude launch when browser reuse cannot resolve DevToolsActivePort', () => {
if (process.platform === 'win32') return;
fs.mkdirSync(browserProfileDir, { recursive: true });
const result = runCcs(['glm', 'smoke'], {
...baseEnv,
CCS_BROWSER_PROFILE_DIR: browserProfileDir,
});
expect(result.status).toBe(1);
expect(result.stderr).toContain('DevToolsActivePort');
expect(fs.existsSync(claudeArgsLogPath)).toBe(false);
});
it('passes browser reuse env and steering prompt into the Claude launch', async () => {
if (process.platform === 'win32') return;
const mockServerScriptPath = path.join(tmpHome, 'mock-devtools-server.js');
const mockServerPortPath = path.join(tmpHome, 'mock-devtools-port.txt');
fs.writeFileSync(
mockServerScriptPath,
`const { createServer } = require('http');
const fs = require('fs');
const server = createServer((req, res) => {
if (req.url === '/json/version') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ Browser: 'Chrome/136.0.0.0', webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/browser-target' }));
return;
}
res.writeHead(404);
res.end('not found');
});
server.listen(0, '127.0.0.1', () => {
const address = server.address();
fs.writeFileSync(${JSON.stringify(mockServerPortPath)}, String(address.port), 'utf8');
});
`,
'utf8'
);
devtoolsServer = spawn(process.execPath, [mockServerScriptPath], {
stdio: 'ignore',
env: baseEnv,
});
const startDeadline = Date.now() + 5000;
while (!fs.existsSync(mockServerPortPath)) {
if (Date.now() > startDeadline) {
throw new Error('Timed out waiting for mock DevTools server to start');
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
const port = fs.readFileSync(mockServerPortPath, 'utf8').trim();
fs.mkdirSync(browserProfileDir, { recursive: true });
fs.writeFileSync(
path.join(browserProfileDir, 'DevToolsActivePort'),
`${port}\n/devtools/browser/browser-target`,
'utf8'
);
const result = runCcs(['glm', 'smoke'], {
...baseEnv,
CCS_BROWSER_PROFILE_DIR: browserProfileDir,
});
expect(result.stderr).not.toContain('Browser MCP is enabled, but CCS could not prepare the local browser tool.');
expect(result.stderr).not.toContain('could not sync the browser MCP config');
expect(result.stderr).not.toContain('Chrome reuse metadata not found');
expect(result.status).toBe(0);
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
expect(launchedArgs).toContain('--append-system-prompt');
expect(launchedArgs).toContain(BROWSER_PROMPT_SNIPPET);
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
expect(launchedEnv).toContain(`userDataDir=${browserProfileDir}`);
expect(launchedEnv).toContain(`port=${port}`);
expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`);
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target');
});
});
+43 -1
View File
@@ -98,7 +98,49 @@ describe('ClaudeAdapter', () => {
expect(env['ANTHROPIC_MODEL']).toBe('claude-opus-4-6');
});
it('should pass through args unchanged', () => {
it('should append browser steering prompt when browser runtime env exists', () => {
const args = adapter.buildArgs('gemini', ['-p', 'hello', '--verbose'], {
creds: {
profile: 'gemini',
baseUrl: 'https://api.example.com',
apiKey: 'test-key',
browserRuntimeEnv: {
CCS_BROWSER_USER_DATA_DIR: '/tmp/chrome',
CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1',
CCS_BROWSER_DEVTOOLS_PORT: '9222',
CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222',
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test',
},
},
profileType: 'settings',
});
expect(args).toContain('--append-system-prompt');
expect(args.join(' ')).toContain('prefer the CCS MCP Browser tool');
});
it('should merge browser runtime env into Claude launch env', () => {
const env = adapter.buildEnv(
{
profile: 'gemini',
baseUrl: 'https://api.example.com',
apiKey: 'test-key',
browserRuntimeEnv: {
CCS_BROWSER_USER_DATA_DIR: '/tmp/chrome',
CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1',
CCS_BROWSER_DEVTOOLS_PORT: '9222',
CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9222',
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test',
},
},
'settings'
);
expect(env['CCS_BROWSER_USER_DATA_DIR']).toBe('/tmp/chrome');
expect(env['CCS_BROWSER_DEVTOOLS_WS_URL']).toBe('ws://127.0.0.1/devtools/browser/test');
});
it('should pass through args unchanged when browser runtime env is absent', () => {
const args = adapter.buildArgs('gemini', ['-p', 'hello', '--verbose']);
expect(args).toEqual(['-p', 'hello', '--verbose']);
});
@@ -0,0 +1,227 @@
import { afterEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
resolveBrowserRuntimeEnv,
resolveDefaultChromeUserDataDir,
} from '../../../../src/utils/browser/chrome-reuse';
describe('chrome reuse resolver', () => {
const originalLocalAppData = process.env.LOCALAPPDATA;
let tempDirs: string[] = [];
let servers: Array<{ stop: () => void }> = [];
function createTempDir(prefix: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
tempDirs.push(dir);
return dir;
}
function writeDevToolsActivePort(profileDir: string, content: string): void {
fs.mkdirSync(profileDir, { recursive: true });
fs.writeFileSync(path.join(profileDir, 'DevToolsActivePort'), content, 'utf8');
}
async function startDevToolsServer(versionPayload: Record<string, unknown>) {
const server = Bun.serve({
port: 0,
fetch(request: Request) {
if (new URL(request.url).pathname === '/json/version') {
return Response.json(versionPayload);
}
return new Response('not found', { status: 404 });
},
});
servers.push(server);
return server;
}
async function startFailingDevToolsServer(status: number, body = 'error') {
const server = Bun.serve({
port: 0,
fetch(request: Request) {
if (new URL(request.url).pathname === '/json/version') {
return new Response(body, { status });
}
return new Response('not found', { status: 404 });
},
});
servers.push(server);
return server;
}
async function startMalformedJsonDevToolsServer() {
const server = Bun.serve({
port: 0,
fetch(request: Request) {
if (new URL(request.url).pathname === '/json/version') {
return new Response('{invalid-json', {
status: 200,
headers: { 'content-type': 'application/json' },
});
}
return new Response('not found', { status: 404 });
},
});
servers.push(server);
return server;
}
async function reserveClosedPort(): Promise<number> {
const server = Bun.serve({
port: 0,
fetch() {
return new Response('ok');
},
});
const port = server.port;
server.stop(true);
return port;
}
afterEach(() => {
for (const server of servers) {
server.stop(true);
}
servers = [];
for (const dir of tempDirs) {
fs.rmSync(dir, { recursive: true, force: true });
}
tempDirs = [];
if (originalLocalAppData === undefined) {
delete process.env.LOCALAPPDATA;
} else {
process.env.LOCALAPPDATA = originalLocalAppData;
}
});
it('uses explicit profile-dir before the default path and resolves the websocket target', async () => {
const explicitProfileDir = createTempDir('ccs-chrome-explicit-');
const defaultProfileDir = createTempDir('ccs-chrome-default-');
const server = await startDevToolsServer({
Browser: 'Chrome/136.0.0.0',
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/target-1',
});
writeDevToolsActivePort(explicitProfileDir, `${server.port}\n/devtools/browser/from-explicit`);
const runtimeEnv = await resolveBrowserRuntimeEnv({
profileDir: explicitProfileDir,
});
expect(runtimeEnv).toEqual({
CCS_BROWSER_USER_DATA_DIR: explicitProfileDir,
CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1',
CCS_BROWSER_DEVTOOLS_PORT: String(server.port),
CCS_BROWSER_DEVTOOLS_HTTP_URL: `http://127.0.0.1:${server.port}`,
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/target-1',
});
expect(fs.existsSync(path.join(defaultProfileDir, 'DevToolsActivePort'))).toBe(false);
});
it('uses an explicit devtools port override when metadata is missing', async () => {
const profileDir = createTempDir('ccs-chrome-explicit-port-');
const server = await startDevToolsServer({
Browser: 'Chrome/136.0.0.0',
webSocketDebuggerUrl: 'ws://127.0.0.1/devtools/browser/explicit-port',
});
const runtimeEnv = await resolveBrowserRuntimeEnv({
profileDir,
devtoolsPort: String(server.port),
});
expect(runtimeEnv.CCS_BROWSER_DEVTOOLS_PORT).toBe(String(server.port));
expect(runtimeEnv.CCS_BROWSER_DEVTOOLS_WS_URL).toBe('ws://127.0.0.1/devtools/browser/explicit-port');
});
it('throws a clear error when DevToolsActivePort metadata is missing', async () => {
const profileDir = createTempDir('ccs-chrome-missing-metadata-');
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
`Chrome reuse metadata not found: ${path.join(profileDir, 'DevToolsActivePort')}`
);
});
it('throws a clear error when DevToolsActivePort metadata is invalid', async () => {
const profileDir = createTempDir('ccs-chrome-invalid-metadata-');
writeDevToolsActivePort(profileDir, 'not-a-port\n/devtools/browser/target');
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
`Chrome reuse metadata is invalid: ${path.join(profileDir, 'DevToolsActivePort')}`
);
});
it('throws before launch fallback when the DevTools endpoint is stale or unreachable', async () => {
const profileDir = createTempDir('ccs-chrome-unreachable-');
const port = await reserveClosedPort();
writeDevToolsActivePort(profileDir, `${port}\n/devtools/browser/stale`);
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
`Chrome DevTools endpoint is unreachable: http://127.0.0.1:${port}`
);
});
it('throws a clear error when the DevTools endpoint is reachable without a websocket target', async () => {
const profileDir = createTempDir('ccs-chrome-missing-ws-');
const server = await startDevToolsServer({ Browser: 'Chrome/136.0.0.0' });
writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/no-ws`);
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
`Chrome DevTools endpoint did not provide a websocket target: http://127.0.0.1:${server.port}/json/version`
);
});
it('throws a clear error when the DevTools endpoint returns a non-200 response', async () => {
const profileDir = createTempDir('ccs-chrome-bad-status-');
const server = await startFailingDevToolsServer(500);
writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-status`);
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
`Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}`
);
});
it('throws a clear error when the DevTools endpoint returns malformed JSON', async () => {
const profileDir = createTempDir('ccs-chrome-bad-json-');
const server = await startMalformedJsonDevToolsServer();
writeDevToolsActivePort(profileDir, `${server.port}\n/devtools/browser/bad-json`);
await expect(resolveBrowserRuntimeEnv({ profileDir })).rejects.toThrow(
`Chrome DevTools endpoint is unreachable: http://127.0.0.1:${server.port}`
);
});
it('resolves platform default Chrome user-data-dir paths', () => {
process.env.LOCALAPPDATA = 'C:/Users/test/AppData/Local';
expect(resolveDefaultChromeUserDataDir('darwin')).toBe(
path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome')
);
expect(resolveDefaultChromeUserDataDir('linux')).toBe(
path.join(os.homedir(), '.config', 'google-chrome')
);
expect(resolveDefaultChromeUserDataDir('win32')).toBe(
path.normalize('C:/Users/test/AppData/Local/Google/Chrome/User Data')
);
});
it('throws on win32 when LOCALAPPDATA is missing', () => {
delete process.env.LOCALAPPDATA;
expect(() => resolveDefaultChromeUserDataDir('win32')).toThrow(
'LOCALAPPDATA is required to resolve the default Chrome user-data-dir on Windows'
);
});
it('throws a clear error when the resolved profile directory does not exist', async () => {
const missingProfileDir = path.join(createTempDir('ccs-chrome-missing-dir-'), 'missing-profile');
await expect(resolveBrowserRuntimeEnv({ profileDir: missingProfileDir })).rejects.toThrow(
`Chrome profile directory is invalid: ${missingProfileDir}`
);
});
});
@@ -0,0 +1,43 @@
import { describe, expect, it } from 'bun:test';
import { appendBrowserToolArgs } from '../../../../src/utils/browser/claude-tool-args';
const BROWSER_STEERING_PROMPT =
'For DOM/screenshots/elements/page actions, prefer the CCS MCP Browser tool, reuse the configured running Chrome context whenever possible, and if the tool or context is unavailable, explain that clearly instead of pretending page state is available.';
describe('appendBrowserToolArgs', () => {
it('appends the browser steering prompt when it is missing', () => {
expect(appendBrowserToolArgs(['navigate'])).toEqual([
'navigate',
'--append-system-prompt',
BROWSER_STEERING_PROMPT,
]);
});
it('does not append the prompt when it already exists in equals form', () => {
expect(
appendBrowserToolArgs([
'navigate',
`--append-system-prompt=${BROWSER_STEERING_PROMPT}`,
])
).toEqual(['navigate', `--append-system-prompt=${BROWSER_STEERING_PROMPT}`]);
});
it('does not append the prompt when it already exists in split-flag form', () => {
expect(
appendBrowserToolArgs([
'navigate',
'--append-system-prompt',
BROWSER_STEERING_PROMPT,
])
).toEqual(['navigate', '--append-system-prompt', BROWSER_STEERING_PROMPT]);
});
it('inserts the prompt before the end-of-options terminator', () => {
expect(appendBrowserToolArgs(['--', 'take screenshot'])).toEqual([
'--append-system-prompt',
BROWSER_STEERING_PROMPT,
'--',
'take screenshot',
]);
});
});
@@ -0,0 +1,232 @@
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as browserInstaller from '../../../../src/utils/browser/mcp-installer';
import {
ensureBrowserMcp,
ensureBrowserMcpConfig,
ensureBrowserMcpOrThrow,
getBrowserMcpServerName,
getBrowserMcpServerPath,
syncBrowserMcpToConfigDir,
uninstallBrowserMcp,
} from '../../../../src/utils/browser';
describe('ensureBrowserMcp', () => {
let tempHome: string | undefined;
let originalCcsHome: string | undefined;
function setupTempHome(): string {
const nextTempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-browser-mcp-'));
tempHome = nextTempHome;
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = nextTempHome;
return nextTempHome;
}
function getManagedConfig() {
return {
type: 'stdio',
command: 'node',
args: [getBrowserMcpServerPath()],
env: {
NODE_PATH: path.join(process.cwd(), 'node_modules'),
},
};
}
afterEach(() => {
mock.restore();
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
tempHome = undefined;
originalCcsHome = undefined;
});
it('installs the bundled browser MCP server and preserves existing user mcpServers entries', () => {
setupTempHome();
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
fs.writeFileSync(
claudeUserConfigPath,
JSON.stringify(
{
mcpServers: {
existing: { command: 'uvx', args: ['some-server'] },
},
},
null,
2
) + '\n',
'utf8'
);
expect(ensureBrowserMcp()).toBe(true);
expect(fs.existsSync(getBrowserMcpServerPath())).toBe(true);
const config = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as {
mcpServers: Record<string, unknown>;
};
expect(config.mcpServers.existing).toEqual({ command: 'uvx', args: ['some-server'] });
expect(config.mcpServers[getBrowserMcpServerName()]).toEqual(getManagedConfig());
});
it('preserves the existing ~/.claude.json permissions when provisioning browser MCP', () => {
setupTempHome();
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
fs.writeFileSync(claudeUserConfigPath, JSON.stringify({ existing: true }, null, 2) + '\n', {
encoding: 'utf8',
mode: 0o600,
});
fs.chmodSync(claudeUserConfigPath, 0o600);
expect(ensureBrowserMcpConfig()).toBe(true);
expect(fs.statSync(claudeUserConfigPath).mode & 0o777).toBe(0o600);
});
it('copies the bundled browser MCP artifact while preserving source permissions', () => {
setupTempHome();
const bundledServerPath = path.join(
process.cwd(),
'lib',
'mcp',
'ccs-browser-server.cjs'
);
const originalMode = fs.statSync(bundledServerPath).mode & 0o777;
expect(ensureBrowserMcp()).toBe(true);
expect(fs.statSync(getBrowserMcpServerPath()).mode & 0o777).toBe(originalMode);
});
it('reconciles installed browser MCP permissions when contents already match', () => {
setupTempHome();
const bundledServerPath = path.join(
process.cwd(),
'lib',
'mcp',
'ccs-browser-server.cjs'
);
const originalMode = fs.statSync(bundledServerPath).mode & 0o777;
expect(ensureBrowserMcp()).toBe(true);
fs.chmodSync(getBrowserMcpServerPath(), 0o600);
expect(ensureBrowserMcp()).toBe(true);
expect(fs.statSync(getBrowserMcpServerPath()).mode & 0o777).toBe(originalMode);
});
it('removes the managed browser runtime while preserving unrelated server entries', () => {
setupTempHome();
const claudeUserConfigPath = path.join(tempHome as string, '.claude.json');
fs.writeFileSync(
claudeUserConfigPath,
JSON.stringify(
{
mcpServers: {
existing: { command: 'uvx', args: ['some-server'] },
},
},
null,
2
) + '\n',
'utf8'
);
expect(ensureBrowserMcp()).toBe(true);
const instancePath = path.join(tempHome as string, '.ccs', 'instances', 'work');
fs.mkdirSync(instancePath, { recursive: true });
fs.writeFileSync(
path.join(instancePath, '.claude.json'),
JSON.stringify(
{
mcpServers: {
existing: { command: 'uvx', args: ['instance-server'] },
[getBrowserMcpServerName()]: { command: 'node', args: ['/tmp/override.cjs'] },
},
otherKey: 'keep-me',
},
null,
2
) + '\n',
'utf8'
);
expect(uninstallBrowserMcp()).toBe(true);
expect(fs.existsSync(getBrowserMcpServerPath())).toBe(false);
const globalConfig = JSON.parse(fs.readFileSync(claudeUserConfigPath, 'utf8')) as {
mcpServers: Record<string, { command: string; args: string[] }>;
};
expect(globalConfig.mcpServers).toEqual({
existing: { command: 'uvx', args: ['some-server'] },
});
const instanceConfig = JSON.parse(
fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8')
) as {
otherKey: string;
mcpServers: Record<string, { command: string; args: string[] }>;
};
expect(instanceConfig.otherKey).toBe('keep-me');
expect(instanceConfig.mcpServers).toEqual({
existing: { command: 'uvx', args: ['instance-server'] },
});
});
it('syncs the managed browser MCP entry into an instance config dir', () => {
setupTempHome();
fs.writeFileSync(
path.join(tempHome as string, '.claude.json'),
JSON.stringify(
{
mcpServers: {
[getBrowserMcpServerName()]: getManagedConfig(),
},
},
null,
2
) + '\n',
'utf8'
);
const instancePath = path.join(tempHome as string, '.ccs', 'instances', 'work');
fs.mkdirSync(instancePath, { recursive: true });
expect(syncBrowserMcpToConfigDir(instancePath)).toBe(true);
const instanceConfig = JSON.parse(
fs.readFileSync(path.join(instancePath, '.claude.json'), 'utf8')
) as {
mcpServers: Record<string, unknown>;
};
expect(instanceConfig.mcpServers[getBrowserMcpServerName()]).toEqual(getManagedConfig());
});
it('throws when the bundled browser MCP runtime cannot be prepared', () => {
setupTempHome();
const ensureSpy = spyOn(browserInstaller, 'ensureBrowserMcp').mockReturnValue(false);
expect(() => ensureBrowserMcpOrThrow()).toThrow(
'Browser MCP is enabled, but CCS could not prepare the local browser tool.'
);
expect(ensureSpy).toHaveBeenCalled();
});
});