Files
ccs/tests/unit/web-server/browser-routes.test.ts
T
walker1211 b90eae3892 feat(browser): 完成 browser MCP 第一阶段能力与配置打通
- 增加 browser_wait_for、browser_eval 及 click/query 扩展能力
- 打通 browser eval_mode 的配置、状态、启动链路与设置界面
- 补充浏览器相关测试隔离、文档和 CI parity 收尾调整
2026-04-18 00:29:43 +08:00

320 lines
10 KiB
TypeScript

import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
import express from 'express';
import { mkdtempSync, rmSync } from 'node:fs';
import type { Server } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import browserRoutes from '../../../src/web-server/routes/browser-routes';
import { loadOrCreateUnifiedConfig } from '../../../src/config/unified-config-loader';
describe('browser routes', () => {
let server: Server;
let baseUrl = '';
let tempHome = '';
let originalCcsHome: string | undefined;
let originalDashboardAuthEnabled: string | undefined;
let originalBrowserUserDataDir: string | undefined;
let originalBrowserProfileDir: string | undefined;
let originalBrowserDevtoolsPort: string | undefined;
let originalBrowserDevtoolsHost: string | undefined;
let originalBrowserDevtoolsHttpUrl: string | undefined;
let originalBrowserDevtoolsWsUrl: string | undefined;
let originalBrowserEvalMode: string | undefined;
let forcedRemoteAddress = '127.0.0.1';
beforeAll(async () => {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
Object.defineProperty(req.socket, 'remoteAddress', {
value: forcedRemoteAddress,
configurable: true,
});
next();
});
app.use('/api/browser', browserRoutes);
await new Promise<void>((resolve, reject) => {
server = app.listen(0, '127.0.0.1');
const onError = (error: Error) => reject(error);
server.once('error', onError);
server.once('listening', () => {
server.off('error', onError);
resolve();
});
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Unable to resolve test server port');
}
baseUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
beforeEach(() => {
tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-routes-'));
originalCcsHome = process.env.CCS_HOME;
originalDashboardAuthEnabled = process.env.CCS_DASHBOARD_AUTH_ENABLED;
originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR;
originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR;
originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT;
originalBrowserDevtoolsHost = process.env.CCS_BROWSER_DEVTOOLS_HOST;
originalBrowserDevtoolsHttpUrl = process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL;
originalBrowserDevtoolsWsUrl = process.env.CCS_BROWSER_DEVTOOLS_WS_URL;
originalBrowserEvalMode = process.env.CCS_BROWSER_EVAL_MODE;
process.env.CCS_HOME = tempHome;
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
delete process.env.CCS_BROWSER_USER_DATA_DIR;
delete process.env.CCS_BROWSER_PROFILE_DIR;
delete process.env.CCS_BROWSER_DEVTOOLS_PORT;
delete process.env.CCS_BROWSER_DEVTOOLS_HOST;
delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL;
delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL;
delete process.env.CCS_BROWSER_EVAL_MODE;
forcedRemoteAddress = '127.0.0.1';
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (originalDashboardAuthEnabled !== undefined) {
process.env.CCS_DASHBOARD_AUTH_ENABLED = originalDashboardAuthEnabled;
} else {
delete process.env.CCS_DASHBOARD_AUTH_ENABLED;
}
if (originalBrowserUserDataDir !== undefined) {
process.env.CCS_BROWSER_USER_DATA_DIR = originalBrowserUserDataDir;
} else {
delete process.env.CCS_BROWSER_USER_DATA_DIR;
}
if (originalBrowserProfileDir !== undefined) {
process.env.CCS_BROWSER_PROFILE_DIR = originalBrowserProfileDir;
} else {
delete process.env.CCS_BROWSER_PROFILE_DIR;
}
if (originalBrowserDevtoolsPort !== undefined) {
process.env.CCS_BROWSER_DEVTOOLS_PORT = originalBrowserDevtoolsPort;
} else {
delete process.env.CCS_BROWSER_DEVTOOLS_PORT;
}
if (originalBrowserDevtoolsHost !== undefined) {
process.env.CCS_BROWSER_DEVTOOLS_HOST = originalBrowserDevtoolsHost;
} else {
delete process.env.CCS_BROWSER_DEVTOOLS_HOST;
}
if (originalBrowserDevtoolsHttpUrl !== undefined) {
process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL = originalBrowserDevtoolsHttpUrl;
} else {
delete process.env.CCS_BROWSER_DEVTOOLS_HTTP_URL;
}
if (originalBrowserDevtoolsWsUrl !== undefined) {
process.env.CCS_BROWSER_DEVTOOLS_WS_URL = originalBrowserDevtoolsWsUrl;
} else {
delete process.env.CCS_BROWSER_DEVTOOLS_WS_URL;
}
if (originalBrowserEvalMode !== undefined) {
process.env.CCS_BROWSER_EVAL_MODE = originalBrowserEvalMode;
} else {
delete process.env.CCS_BROWSER_EVAL_MODE;
}
rmSync(tempHome, { recursive: true, force: true });
});
it('blocks remote access when dashboard auth is disabled', async () => {
forcedRemoteAddress = '10.10.0.24';
const response = await fetch(`${baseUrl}/api/browser`);
expect(response.status).toBe(403);
expect(await response.json()).toEqual({
error: 'Browser endpoints require localhost access when dashboard auth is disabled.',
});
});
it('returns the default browser config and status payload', async () => {
const response = await fetch(`${baseUrl}/api/browser`);
expect(response.status).toBe(200);
const payload = await response.json();
expect(payload.config).toMatchObject({
claude: {
enabled: false,
userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
devtoolsPort: 9222,
evalMode: 'readonly',
},
codex: {
enabled: true,
evalMode: 'readonly',
},
});
expect(payload.status.claude).toMatchObject({
state: 'disabled',
managedMcpServerName: 'ccs-browser',
});
expect(payload.status.codex).toMatchObject({
enabled: true,
serverName: 'ccs_browser',
});
});
it('updates the saved browser config through the dashboard route', async () => {
const response = await fetch(`${baseUrl}/api/browser`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claude: {
enabled: true,
userDataDir: '/tmp/ccs-browser',
devtoolsPort: 9333,
evalMode: 'readwrite',
},
codex: {
enabled: false,
evalMode: 'disabled',
},
}),
});
expect(response.status).toBe(200);
const payload = await response.json();
expect(payload.browser.config).toMatchObject({
claude: {
enabled: true,
userDataDir: '/tmp/ccs-browser',
devtoolsPort: 9333,
evalMode: 'readwrite',
},
codex: {
enabled: false,
evalMode: 'disabled',
},
});
const config = loadOrCreateUnifiedConfig();
expect(config.browser).toMatchObject({
claude: {
enabled: true,
user_data_dir: '/tmp/ccs-browser',
devtools_port: 9333,
eval_mode: 'readwrite',
},
codex: {
enabled: false,
eval_mode: 'disabled',
},
});
});
it('treats a blank user-data directory as a reset to the recommended path', async () => {
const firstResponse = await fetch(`${baseUrl}/api/browser`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claude: {
enabled: true,
userDataDir: '/tmp/ccs-browser-custom',
devtoolsPort: 9333,
},
}),
});
expect(firstResponse.status).toBe(200);
const resetResponse = await fetch(`${baseUrl}/api/browser`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claude: {
userDataDir: ' ',
},
}),
});
expect(resetResponse.status).toBe(200);
const payload = await resetResponse.json();
expect(payload.browser.config.claude).toMatchObject({
enabled: true,
userDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
devtoolsPort: 9333,
evalMode: 'readonly',
});
const config = loadOrCreateUnifiedConfig();
expect(config.browser).toMatchObject({
claude: {
enabled: true,
user_data_dir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
devtools_port: 9333,
eval_mode: 'readonly',
},
});
});
it('rejects invalid DevTools ports at the route boundary', async () => {
const response = await fetch(`${baseUrl}/api/browser`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claude: {
devtoolsPort: 0,
},
}),
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({
error: 'Invalid value for claude.devtoolsPort. Must be an integer between 1 and 65535.',
});
});
it('rejects invalid eval modes at the route boundary', async () => {
const claudeResponse = await fetch(`${baseUrl}/api/browser`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claude: {
evalMode: 'dangerous',
},
}),
});
expect(claudeResponse.status).toBe(400);
expect(await claudeResponse.json()).toEqual({
error: 'Invalid value for claude.evalMode. Must be one of: disabled, readonly, readwrite.',
});
const codexResponse = await fetch(`${baseUrl}/api/browser`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
codex: {
evalMode: 'dangerous',
},
}),
});
expect(codexResponse.status).toBe(400);
expect(await codexResponse.json()).toEqual({
error: 'Invalid value for codex.evalMode. Must be one of: disabled, readonly, readwrite.',
});
});
});