mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-08 20:21:46 +00:00
feat(cli): add browser automation commands
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
import { afterEach, describe, expect, test, spyOn } from 'bun:test';
|
||||
|
||||
import * as browserUtils from '../../../src/utils/browser';
|
||||
import { handleBrowserCommand } from '../../../src/commands/browser-command';
|
||||
|
||||
function stripAnsi(input: string): string {
|
||||
return input.replace(/\u001b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
async function renderLines(args: string[]): Promise<string> {
|
||||
const lines: string[] = [];
|
||||
await handleBrowserCommand(args, (line) => lines.push(line));
|
||||
return stripAnsi(lines.join('\n'));
|
||||
}
|
||||
|
||||
function currentPlatform(): 'darwin' | 'linux' | 'win32' {
|
||||
if (process.platform === 'darwin') return 'darwin';
|
||||
if (process.platform === 'win32') return 'win32';
|
||||
return 'linux';
|
||||
}
|
||||
|
||||
describe('browser command', () => {
|
||||
afterEach(() => {
|
||||
process.exitCode = 0;
|
||||
});
|
||||
|
||||
test('status renders both browser lanes from the shared status payload', async () => {
|
||||
const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({
|
||||
claude: {
|
||||
enabled: true,
|
||||
source: 'config',
|
||||
overrideActive: false,
|
||||
state: 'ready',
|
||||
title: 'Claude Browser Attach is ready.',
|
||||
detail: 'CCS can reach the configured Chrome DevTools endpoint.',
|
||||
nextStep: 'Launch Claude.',
|
||||
effectiveUserDataDir: '/tmp/browser-profile',
|
||||
recommendedUserDataDir: '/tmp/browser-profile',
|
||||
devtoolsPort: 9222,
|
||||
managedMcpServerName: 'ccs-browser',
|
||||
managedMcpServerPath: '/tmp/ccs-browser-server.cjs',
|
||||
launchCommands: {
|
||||
darwin: 'open -na "Google Chrome" --args',
|
||||
linux: 'google-chrome --remote-debugging-port=9222',
|
||||
win32: 'chrome.exe --remote-debugging-port=9222',
|
||||
},
|
||||
runtimeEnv: {
|
||||
CCS_BROWSER_USER_DATA_DIR: '/tmp/browser-profile',
|
||||
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',
|
||||
},
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
state: 'enabled',
|
||||
title: 'Codex Browser Tools are enabled.',
|
||||
detail: 'CCS can inject the managed Playwright MCP overrides.',
|
||||
nextStep: 'Use a Codex-target launch.',
|
||||
serverName: 'ccs_browser',
|
||||
supportsConfigOverrides: true,
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
version: 'codex-cli 0.120.0',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const rendered = await renderLines(['status']);
|
||||
|
||||
expect(rendered.includes('ccs browser status')).toBe(true);
|
||||
expect(rendered.includes('Claude Browser Attach reuses a local Chrome session')).toBe(true);
|
||||
expect(rendered.includes('Codex Browser Tools inject managed Playwright MCP overrides')).toBe(
|
||||
true
|
||||
);
|
||||
expect(rendered.includes('Managed MCP: ccs-browser')).toBe(true);
|
||||
expect(rendered.includes('Managed server: ccs_browser')).toBe(true);
|
||||
expect(rendered.includes('DevTools endpoint: http://127.0.0.1:9222')).toBe(true);
|
||||
} finally {
|
||||
statusSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('doctor prints env override context and launch guidance when Claude attach is not ready', async () => {
|
||||
const launchCommands = {
|
||||
darwin: 'open -na "Google Chrome" --args --remote-debugging-port=9444',
|
||||
linux:
|
||||
'google-chrome --remote-debugging-port=9444 --user-data-dir="/tmp/browser-profile"',
|
||||
win32: 'chrome.exe --remote-debugging-port=9444 --user-data-dir="/tmp/browser-profile"',
|
||||
};
|
||||
const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({
|
||||
claude: {
|
||||
enabled: true,
|
||||
source: 'CCS_BROWSER_PROFILE_DIR',
|
||||
overrideActive: true,
|
||||
state: 'browser_not_running',
|
||||
title: 'Claude Browser Attach could not find a running browser session.',
|
||||
detail: 'Chrome reuse metadata not found: /tmp/browser-profile/DevToolsActivePort',
|
||||
nextStep: 'Start Chrome with remote debugging.',
|
||||
effectiveUserDataDir: '/tmp/browser-profile',
|
||||
recommendedUserDataDir: '/tmp/browser-profile',
|
||||
devtoolsPort: 9444,
|
||||
managedMcpServerName: 'ccs-browser',
|
||||
managedMcpServerPath: '/tmp/ccs-browser-server.cjs',
|
||||
launchCommands,
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
state: 'unsupported_build',
|
||||
title: 'Codex Browser Tools need a Codex build with --config override support.',
|
||||
detail: 'Detected Codex at /usr/local/bin/codex, but it does not advertise --config overrides.',
|
||||
nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.',
|
||||
serverName: 'ccs_browser',
|
||||
supportsConfigOverrides: false,
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
version: 'codex-cli 0.70.0',
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const rendered = await renderLines(['doctor']);
|
||||
|
||||
expect(rendered.includes('Result: action required')).toBe(true);
|
||||
expect(rendered.includes('Source: CCS_BROWSER_PROFILE_DIR (env override active)')).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
rendered.includes(
|
||||
`Launch command (${currentPlatform()}): ${launchCommands[currentPlatform()]}`
|
||||
)
|
||||
).toBe(true);
|
||||
expect(rendered.includes('Detected Codex at /usr/local/bin/codex')).toBe(true);
|
||||
expect(process.exitCode).toBe(1);
|
||||
} finally {
|
||||
statusSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('doctor stays ready on Claude-only machines when Codex is not installed', async () => {
|
||||
const statusSpy = spyOn(browserUtils, 'getBrowserStatus').mockResolvedValue({
|
||||
claude: {
|
||||
enabled: false,
|
||||
source: 'config',
|
||||
overrideActive: false,
|
||||
state: 'disabled',
|
||||
title: 'Claude Browser Attach is disabled.',
|
||||
detail: 'CCS will not provision the managed browser MCP runtime for Claude launches until this lane is enabled.',
|
||||
nextStep:
|
||||
'Enable Claude Browser Attach in Settings > Browser or in ~/.ccs/config.yaml, then rerun `ccs browser doctor`.',
|
||||
effectiveUserDataDir: '/tmp/browser-profile',
|
||||
recommendedUserDataDir: '/tmp/browser-profile',
|
||||
devtoolsPort: 9222,
|
||||
managedMcpServerName: 'ccs-browser',
|
||||
managedMcpServerPath: '/tmp/ccs-browser-server.cjs',
|
||||
launchCommands: {
|
||||
darwin: 'open -na "Google Chrome" --args --remote-debugging-port=9222',
|
||||
linux:
|
||||
'google-chrome --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"',
|
||||
win32:
|
||||
'chrome.exe --remote-debugging-port=9222 --user-data-dir="/tmp/browser-profile"',
|
||||
},
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
state: 'unsupported_build',
|
||||
title: 'Codex Browser Tools need a Codex build with --config override support.',
|
||||
detail: 'No Codex binary was detected, so CCS cannot confirm managed browser override support.',
|
||||
nextStep: 'Install or upgrade Codex, then rerun browser status/doctor.',
|
||||
serverName: 'ccs_browser',
|
||||
supportsConfigOverrides: false,
|
||||
binaryPath: null,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const rendered = await renderLines(['doctor']);
|
||||
|
||||
expect(rendered.includes('Result: partial')).toBe(true);
|
||||
expect(rendered.includes('run `ccs browser enable`')).toBe(false);
|
||||
expect(process.exitCode).toBe(0);
|
||||
} finally {
|
||||
statusSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ describe('help command parity', () => {
|
||||
|
||||
expect(visibleLines.length).toBeLessThanOrEqual(90);
|
||||
expect(rendered.includes('ccs help <topic>')).toBe(true);
|
||||
expect(rendered.includes('ccs help browser')).toBe(true);
|
||||
expect(rendered.includes('ccs help completion')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -69,6 +70,18 @@ describe('help command parity', () => {
|
||||
expect(rendered.includes('GitHub OAuth is dashboard-only')).toBe(true);
|
||||
});
|
||||
|
||||
test('browser topic explains Claude attach versus Codex browser tools', async () => {
|
||||
const rendered = await renderLines((writeLine) => handleHelpRoute(['browser'], writeLine));
|
||||
|
||||
expect(rendered.includes('CCS Browser Help')).toBe(true);
|
||||
expect(rendered.includes('Claude Browser Attach reuses a local Chrome session')).toBe(true);
|
||||
expect(rendered.includes('Codex Browser Tools inject managed Playwright MCP overrides')).toBe(
|
||||
true
|
||||
);
|
||||
expect(rendered.includes('ccs browser status')).toBe(true);
|
||||
expect(rendered.includes('ccs browser doctor')).toBe(true);
|
||||
});
|
||||
|
||||
test('completion topic documents install and verification paths', async () => {
|
||||
const rendered = await renderLines((writeLine) => handleHelpRoute(['completion'], writeLine));
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import { spawnSync } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
|
||||
interface RunResult {
|
||||
status: number | null;
|
||||
@@ -193,6 +194,47 @@ process.exit(0);
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips Codex browser MCP overrides when browser tooling is disabled in config', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpHome;
|
||||
|
||||
try {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: false,
|
||||
user_data_dir: '',
|
||||
devtools_port: 9222,
|
||||
},
|
||||
codex: {
|
||||
enabled: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const result = runCcs(['default', '--target', 'codex', 'fix failing tests'], {
|
||||
...process.env,
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
CCS_HOME: tmpHome,
|
||||
CCS_CODEX_PATH: fakeCodexPath,
|
||||
CCS_TEST_CODEX_ARGS_OUT: codexArgsLogPath,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const calls = readLoggedCodexCalls(codexArgsLogPath);
|
||||
expect(calls).toEqual([['fix failing tests']]);
|
||||
} finally {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps browser MCP runtime overrides when CCS_THINKING is ignored for native Codex default mode', () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { spawn, spawnSync, type ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
|
||||
const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool';
|
||||
|
||||
@@ -245,4 +246,83 @@ server.listen(0, '127.0.0.1', () => {
|
||||
expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`);
|
||||
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/default-target');
|
||||
});
|
||||
|
||||
it('uses config-backed browser attach settings when env overrides are absent', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpHome;
|
||||
|
||||
try {
|
||||
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/config-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 port = await waitForMockDevtoolsPort(mockServerPortPath);
|
||||
await waitForDevtoolsVersionEndpoint(port);
|
||||
|
||||
fs.mkdirSync(browserProfileDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(browserProfileDir, 'DevToolsActivePort'),
|
||||
`${port}\n/devtools/browser/config-target`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
user_data_dir: browserProfileDir,
|
||||
devtools_port: Number.parseInt(port, 10),
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const result = runCcs(['default', 'smoke'], {
|
||||
...baseEnv,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||
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('wsUrl=ws://127.0.0.1/devtools/browser/config-target');
|
||||
} finally {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import { spawn, spawnSync, type ChildProcess } from 'child_process';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { mutateUnifiedConfig } from '../../../src/config/unified-config-loader';
|
||||
|
||||
const BROWSER_PROMPT_SNIPPET = 'prefer the CCS MCP Browser tool';
|
||||
|
||||
@@ -196,4 +197,89 @@ server.listen(0, '127.0.0.1', () => {
|
||||
expect(launchedEnv).toContain(`httpUrl=http://127.0.0.1:${port}`);
|
||||
expect(launchedEnv).toContain('wsUrl=ws://127.0.0.1/devtools/browser/browser-target');
|
||||
});
|
||||
|
||||
it('uses config-backed browser attach settings for settings-profile launches', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tmpHome;
|
||||
|
||||
try {
|
||||
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/config-settings-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/config-settings-target`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
user_data_dir: browserProfileDir,
|
||||
devtools_port: Number.parseInt(port, 10),
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const result = runCcs(['glm', 'smoke'], {
|
||||
...baseEnv,
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
const launchedArgs = fs.readFileSync(claudeArgsLogPath, 'utf8');
|
||||
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('wsUrl=ws://127.0.0.1/devtools/browser/config-settings-target');
|
||||
} finally {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { mutateUnifiedConfig } from '../../../../src/config/unified-config-loader';
|
||||
import * as chromeReuse from '../../../../src/utils/browser/chrome-reuse';
|
||||
import { getBrowserStatus } from '../../../../src/utils/browser/browser-status';
|
||||
import * as codexDetector from '../../../../src/targets/codex-detector';
|
||||
|
||||
describe('browser status', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalBrowserUserDataDir: string | undefined;
|
||||
let originalBrowserProfileDir: string | undefined;
|
||||
let originalBrowserDevtoolsPort: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = mkdtempSync(join(tmpdir(), 'ccs-browser-status-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalBrowserUserDataDir = process.env.CCS_BROWSER_USER_DATA_DIR;
|
||||
originalBrowserProfileDir = process.env.CCS_BROWSER_PROFILE_DIR;
|
||||
originalBrowserDevtoolsPort = process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||
|
||||
process.env.CCS_HOME = tempHome;
|
||||
delete process.env.CCS_BROWSER_USER_DATA_DIR;
|
||||
delete process.env.CCS_BROWSER_PROFILE_DIR;
|
||||
delete process.env.CCS_BROWSER_DEVTOOLS_PORT;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns a disabled Claude lane with the recommended managed user-data dir by default', async () => {
|
||||
const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({
|
||||
path: '/usr/local/bin/codex',
|
||||
needsShell: false,
|
||||
version: 'codex-cli 0.120.0',
|
||||
features: ['config-overrides'],
|
||||
});
|
||||
|
||||
try {
|
||||
const status = await getBrowserStatus();
|
||||
|
||||
expect(status.claude).toMatchObject({
|
||||
enabled: false,
|
||||
state: 'disabled',
|
||||
source: 'config',
|
||||
effectiveUserDataDir: join(tempHome, '.ccs', 'browser', 'chrome-user-data'),
|
||||
devtoolsPort: 9222,
|
||||
managedMcpServerName: 'ccs-browser',
|
||||
});
|
||||
expect(status.claude.launchCommands.linux).toContain('--remote-debugging-port=9222');
|
||||
expect(status.codex).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'enabled',
|
||||
serverName: 'ccs_browser',
|
||||
supportsConfigOverrides: true,
|
||||
});
|
||||
} finally {
|
||||
codexSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('prefers CCS_BROWSER_USER_DATA_DIR over config when an env override is present', async () => {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
user_data_dir: '/config-browser',
|
||||
devtools_port: 9333,
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
process.env.CCS_BROWSER_USER_DATA_DIR = '/env-browser';
|
||||
process.env.CCS_BROWSER_DEVTOOLS_PORT = '9444';
|
||||
|
||||
const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({
|
||||
CCS_BROWSER_USER_DATA_DIR: '/env-browser',
|
||||
CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1',
|
||||
CCS_BROWSER_DEVTOOLS_PORT: '9444',
|
||||
CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:9444',
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/test',
|
||||
});
|
||||
const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({
|
||||
path: '/usr/local/bin/codex',
|
||||
needsShell: false,
|
||||
version: 'codex-cli 0.120.0',
|
||||
features: ['config-overrides'],
|
||||
});
|
||||
|
||||
try {
|
||||
const status = await getBrowserStatus();
|
||||
|
||||
expect(status.claude).toMatchObject({
|
||||
enabled: true,
|
||||
state: 'ready',
|
||||
source: 'CCS_BROWSER_USER_DATA_DIR',
|
||||
effectiveUserDataDir: '/env-browser',
|
||||
devtoolsPort: 9444,
|
||||
});
|
||||
expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('9444');
|
||||
} finally {
|
||||
runtimeSpy.mockRestore();
|
||||
codexSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reports browser_not_running when attach metadata is missing', async () => {
|
||||
mutateUnifiedConfig((config) => {
|
||||
config.browser = {
|
||||
claude: {
|
||||
enabled: true,
|
||||
user_data_dir: '/tmp/browser-profile',
|
||||
devtools_port: 9222,
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockRejectedValue(
|
||||
new Error('Chrome reuse metadata not found: /tmp/browser-profile/DevToolsActivePort')
|
||||
);
|
||||
const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({
|
||||
path: '/usr/local/bin/codex',
|
||||
needsShell: false,
|
||||
version: 'codex-cli 0.120.0',
|
||||
features: ['config-overrides'],
|
||||
});
|
||||
|
||||
try {
|
||||
const status = await getBrowserStatus();
|
||||
|
||||
expect(status.claude.state).toBe('browser_not_running');
|
||||
expect(status.claude.detail).toContain('DevToolsActivePort');
|
||||
expect(status.claude.nextStep).toContain('--remote-debugging-port=9222');
|
||||
} finally {
|
||||
runtimeSpy.mockRestore();
|
||||
codexSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves legacy metadata-based port discovery when only CCS_BROWSER_PROFILE_DIR is set', async () => {
|
||||
process.env.CCS_BROWSER_PROFILE_DIR = '/legacy-browser';
|
||||
|
||||
const runtimeSpy = spyOn(chromeReuse, 'resolveBrowserRuntimeEnv').mockResolvedValue({
|
||||
CCS_BROWSER_USER_DATA_DIR: '/legacy-browser',
|
||||
CCS_BROWSER_DEVTOOLS_HOST: '127.0.0.1',
|
||||
CCS_BROWSER_DEVTOOLS_PORT: '50123',
|
||||
CCS_BROWSER_DEVTOOLS_HTTP_URL: 'http://127.0.0.1:50123',
|
||||
CCS_BROWSER_DEVTOOLS_WS_URL: 'ws://127.0.0.1/devtools/browser/legacy',
|
||||
});
|
||||
const codexSpy = spyOn(codexDetector, 'getCodexBinaryInfo').mockReturnValue({
|
||||
path: '/usr/local/bin/codex',
|
||||
needsShell: false,
|
||||
version: 'codex-cli 0.120.0',
|
||||
features: ['config-overrides'],
|
||||
});
|
||||
|
||||
try {
|
||||
const status = await getBrowserStatus();
|
||||
|
||||
expect(runtimeSpy.mock.calls[0]?.[0]).toEqual({
|
||||
profileDir: '/legacy-browser',
|
||||
devtoolsPort: undefined,
|
||||
});
|
||||
expect(status.claude.runtimeEnv?.CCS_BROWSER_DEVTOOLS_PORT).toBe('50123');
|
||||
} finally {
|
||||
runtimeSpy.mockRestore();
|
||||
codexSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
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 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;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'false';
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
codex: {
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
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,
|
||||
},
|
||||
codex: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const payload = await response.json();
|
||||
expect(payload.browser.config).toMatchObject({
|
||||
claude: {
|
||||
enabled: true,
|
||||
userDataDir: '/tmp/ccs-browser',
|
||||
devtoolsPort: 9333,
|
||||
},
|
||||
codex: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
expect(config.browser).toMatchObject({
|
||||
claude: {
|
||||
enabled: true,
|
||||
user_data_dir: '/tmp/ccs-browser',
|
||||
devtools_port: 9333,
|
||||
},
|
||||
codex: {
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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.',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user