mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-18 12:13:58 +00:00
chore: merge dev into pr-976 branch
This commit is contained in:
commit
31c464a3c5
43 files changed
+4187
-189
No files matched your search
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import { maybeWarnAboutResumeLaneMismatch } from '../../../src/auth/resume-lane-warning';
|
||||
|
||||
function stripAnsi(input: string): string {
|
||||
return input.replace(/\u001b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
describe('resume lane warning', () => {
|
||||
it('prints guidance when the plain ccs lane differs from the account lane', async () => {
|
||||
const logs: string[] = [];
|
||||
@@ -15,10 +19,12 @@ describe('resume lane warning', () => {
|
||||
}),
|
||||
});
|
||||
|
||||
expect(logs[0]).toContain('Resume for account "work" will search that account lane');
|
||||
expect(logs).toContain('[i] Account lane: /tmp/account-lane');
|
||||
expect(logs).toContain('[i] Plain ccs lane: native Claude lane (/tmp/native-lane)');
|
||||
expect(logs).toContain('[i] Recover the original lane first: ccs -r');
|
||||
const plainLogs = logs.map((message) => stripAnsi(message));
|
||||
|
||||
expect(plainLogs[0]).toContain('Resume for account "work" will search that account lane');
|
||||
expect(plainLogs).toContain('[i] Account lane: /tmp/account-lane');
|
||||
expect(plainLogs).toContain('[i] Plain ccs lane: native Claude lane (/tmp/native-lane)');
|
||||
expect(plainLogs).toContain('[i] Recover the original lane first: ccs -r');
|
||||
});
|
||||
|
||||
it('does not log anything when resume is not requested', async () => {
|
||||
|
||||
@@ -774,6 +774,11 @@ describe('ToolSanitizationProxy Integration', () => {
|
||||
});
|
||||
|
||||
it('handles upstream connection errors gracefully', async () => {
|
||||
const originalNoProxy = process.env.NO_PROXY;
|
||||
const originalNoProxyLower = process.env.no_proxy;
|
||||
process.env.NO_PROXY = '127.0.0.1,localhost';
|
||||
process.env.no_proxy = '127.0.0.1,localhost';
|
||||
|
||||
const proxy = new ToolSanitizationProxy({
|
||||
upstreamBaseUrl: 'http://127.0.0.1:1', // Invalid port
|
||||
timeoutMs: 1000,
|
||||
@@ -790,6 +795,16 @@ describe('ToolSanitizationProxy Integration', () => {
|
||||
expect(response.status).toBe(502);
|
||||
} finally {
|
||||
proxy.stop();
|
||||
if (originalNoProxy === undefined) {
|
||||
delete process.env.NO_PROXY;
|
||||
} else {
|
||||
process.env.NO_PROXY = originalNoProxy;
|
||||
}
|
||||
if (originalNoProxyLower === undefined) {
|
||||
delete process.env.no_proxy;
|
||||
} else {
|
||||
process.env.no_proxy = originalNoProxyLower;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
|
||||
|
||||
function stripAnsi(input: string): string {
|
||||
return input.replace(/\u001b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
let calls: string[] = [];
|
||||
let logLines: string[] = [];
|
||||
let originalConsoleLog: typeof console.log;
|
||||
@@ -60,7 +64,7 @@ describe('config-auth command routing', () => {
|
||||
await handleConfigAuthCommand(['--help']);
|
||||
|
||||
expect(calls).toEqual([]);
|
||||
expect(logLines.join('\n')).toContain('Dashboard Auth Management');
|
||||
expect(stripAnsi(logLines.join('\n'))).toContain('Dashboard Auth Management');
|
||||
});
|
||||
|
||||
it('rejects trailing arguments for zero-arg subcommands', async () => {
|
||||
|
||||
@@ -73,6 +73,8 @@ function stubProcessExit(): void {
|
||||
beforeEach(async () => {
|
||||
tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ccs-persist-handler-test-'));
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
// Clear CLAUDE_CONFIG_DIR so scoped CCS_HOME takes effect (leaks from host CCS session)
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
originalProcessExit = process.exit;
|
||||
originalFsOpen = fs.promises.open;
|
||||
originalFsRename = fs.promises.rename;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -12,6 +12,12 @@ import { tmpdir } from 'node:os';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const hookPath = join(process.cwd(), 'lib', 'hooks', 'websearch-transformer.cjs');
|
||||
|
||||
/**
|
||||
* Neutralise CCS_PROFILE_TYPE so shouldSkipHook() does not short-circuit
|
||||
* when tests run inside a CCS-managed Claude session (where it is 'account').
|
||||
*/
|
||||
const NEUTRAL_PROFILE_TYPE = '';
|
||||
type HookOutput = {
|
||||
hookSpecificOutput: {
|
||||
additionalContext: string;
|
||||
@@ -107,6 +113,7 @@ function runHookWithMockedFetch(mode: 'success' | 'empty' | 'non-result' | 'fail
|
||||
}),
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE,
|
||||
CCS_WEBSEARCH_ENABLED: '1',
|
||||
CCS_WEBSEARCH_SKIP: '0',
|
||||
CCS_WEBSEARCH_BRAVE: '0',
|
||||
@@ -555,6 +562,7 @@ global.fetch = async (url) => {
|
||||
}),
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE,
|
||||
CCS_HOME: ccsHome,
|
||||
CCS_WEBSEARCH_TRACE: '1',
|
||||
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'hook-trace-test',
|
||||
@@ -631,6 +639,7 @@ global.fetch = async (url) => {
|
||||
}),
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE,
|
||||
CCS_HOME: ccsHome,
|
||||
CCS_WEBSEARCH_TRACE: '1',
|
||||
CCS_WEBSEARCH_TRACE_FILE: disallowedTracePath,
|
||||
@@ -712,6 +721,7 @@ global.fetch = async (url) => {
|
||||
}),
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE,
|
||||
CCS_HOME: ccsHome,
|
||||
CCS_WEBSEARCH_TRACE: '1',
|
||||
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'quota-fallback-test',
|
||||
@@ -826,6 +836,7 @@ global.fetch = async (url) => {
|
||||
}),
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE,
|
||||
CCS_HOME: ccsHome,
|
||||
CCS_WEBSEARCH_TRACE: '1',
|
||||
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'cooldown-skip-test',
|
||||
@@ -926,6 +937,7 @@ global.fetch = async (url) => {
|
||||
}),
|
||||
env: {
|
||||
...process.env,
|
||||
CCS_PROFILE_TYPE: NEUTRAL_PROFILE_TYPE,
|
||||
CCS_HOME: ccsHome,
|
||||
CCS_WEBSEARCH_TRACE: '1',
|
||||
CCS_WEBSEARCH_TRACE_LAUNCH_ID: 'transient-retry-test',
|
||||
|
||||
@@ -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(() => {});
|
||||
|
||||
@@ -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,214 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import { request as httpRequest } from 'http';
|
||||
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;
|
||||
}
|
||||
|
||||
async function waitForMockDevtoolsPort(portFilePath: string, timeoutMs = 5000): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
try {
|
||||
const port = fs.readFileSync(portFilePath, 'utf8').trim();
|
||||
if (/^\d+$/.test(port)) {
|
||||
return port;
|
||||
}
|
||||
} catch {
|
||||
// Keep polling until timeout.
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for mock DevTools server port to become ready');
|
||||
}
|
||||
|
||||
async function waitForDevtoolsVersionEndpoint(port: string, timeoutMs = 5000): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() <= deadline) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const req = httpRequest(
|
||||
{
|
||||
hostname: '127.0.0.1',
|
||||
port: Number.parseInt(port, 10),
|
||||
path: '/json/version',
|
||||
method: 'GET',
|
||||
},
|
||||
(res) => {
|
||||
res.resume();
|
||||
if (res.statusCode === 200) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
reject(new Error(`Unexpected status: ${res.statusCode ?? 'unknown'}`));
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.end();
|
||||
});
|
||||
return;
|
||||
} catch {
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Timed out waiting for mock DevTools endpoint to become ready');
|
||||
}
|
||||
|
||||
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('does not consume an empty mock DevTools port file before the port is written', async () => {
|
||||
if (process.platform === 'win32') return;
|
||||
|
||||
const delayedPortFile = path.join(tmpHome, 'delayed-port.txt');
|
||||
fs.writeFileSync(delayedPortFile, '', 'utf8');
|
||||
setTimeout(() => {
|
||||
fs.writeFileSync(delayedPortFile, '43123', 'utf8');
|
||||
}, 50);
|
||||
|
||||
await expect(waitForMockDevtoolsPort(delayedPortFile, 500)).resolves.toBe('43123');
|
||||
});
|
||||
|
||||
it('passes browser runtime env through default Claude launches when reuse is configured', 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/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 port = await waitForMockDevtoolsPort(mockServerPortPath);
|
||||
await waitForDevtoolsVersionEndpoint(port);
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -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,237 @@
|
||||
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', () => {
|
||||
const isolatedHome = createTempDir('ccs-chrome-home-');
|
||||
const env = {
|
||||
HOME: isolatedHome,
|
||||
USERPROFILE: isolatedHome,
|
||||
LOCALAPPDATA: 'C:/Users/test/AppData/Local',
|
||||
};
|
||||
|
||||
expect(resolveDefaultChromeUserDataDir('darwin', env)).toBe(
|
||||
path.join(isolatedHome, 'Library', 'Application Support', 'Google', 'Chrome')
|
||||
);
|
||||
expect(resolveDefaultChromeUserDataDir('linux', env)).toBe(
|
||||
path.join(isolatedHome, '.config', 'google-chrome')
|
||||
);
|
||||
expect(resolveDefaultChromeUserDataDir('win32', env)).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();
|
||||
});
|
||||
});
|
||||
@@ -30,6 +30,7 @@ let baselineSighupListeners: Array<(...args: unknown[]) => void> = [];
|
||||
let originalCcsHome: string | undefined;
|
||||
let originalCcsClaudePath: string | undefined;
|
||||
let originalDisableAutoUpdater: string | undefined;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
const realSpawn = childProcess.spawn.bind(childProcess);
|
||||
const realSpawnSync = childProcess.spawnSync.bind(childProcess);
|
||||
const realExecSync = childProcess.execSync.bind(childProcess);
|
||||
@@ -165,10 +166,17 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
beforeEach(() => {
|
||||
spawnCalls.length = 0;
|
||||
process.env.CCS_QUIET = '1';
|
||||
|
||||
// Save original env values for restoration in afterEach
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
originalCcsClaudePath = process.env.CCS_CLAUDE_PATH;
|
||||
originalDisableAutoUpdater = process.env.DISABLE_AUTOUPDATER;
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
// Clear CCS-managed env vars that leak from host sessions
|
||||
delete process.env.DISABLE_AUTOUPDATER;
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
baselineSigintListeners = process.listeners('SIGINT');
|
||||
baselineSigtermListeners = process.listeners('SIGTERM');
|
||||
baselineSighupListeners = process.listeners('SIGHUP');
|
||||
@@ -189,6 +197,8 @@ describe('CLAUDECODE environment stripping', () => {
|
||||
} else {
|
||||
delete process.env.DISABLE_AUTOUPDATER;
|
||||
}
|
||||
if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
else delete process.env.CLAUDE_CONFIG_DIR;
|
||||
|
||||
for (const listener of process.listeners('SIGINT')) {
|
||||
if (!baselineSigintListeners.includes(listener)) {
|
||||
|
||||
+52
-27
@@ -6,6 +6,25 @@
|
||||
|
||||
const assert = require('assert');
|
||||
|
||||
function stripAnsi(input) {
|
||||
return input.replace(/\u001b\[[0-9;]*m/g, '');
|
||||
}
|
||||
|
||||
function withoutForceColor(callback) {
|
||||
const originalForceColor = process.env.FORCE_COLOR;
|
||||
delete process.env.FORCE_COLOR;
|
||||
|
||||
try {
|
||||
callback();
|
||||
} finally {
|
||||
if (originalForceColor === undefined) {
|
||||
delete process.env.FORCE_COLOR;
|
||||
} else {
|
||||
process.env.FORCE_COLOR = originalForceColor;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('UI Module', function () {
|
||||
let ui;
|
||||
|
||||
@@ -25,18 +44,21 @@ describe('UI Module', function () {
|
||||
});
|
||||
|
||||
it('should return plain text when NO_COLOR is set', function () {
|
||||
const originalNoColor = process.env.NO_COLOR;
|
||||
process.env.NO_COLOR = '1';
|
||||
withoutForceColor(() => {
|
||||
const originalNoColor = process.env.NO_COLOR;
|
||||
process.env.NO_COLOR = '1';
|
||||
|
||||
const result = ui.color('test', 'success');
|
||||
assert.strictEqual(result, 'test', 'should return unmodified text');
|
||||
|
||||
// Restore
|
||||
if (originalNoColor === undefined) {
|
||||
delete process.env.NO_COLOR;
|
||||
} else {
|
||||
process.env.NO_COLOR = originalNoColor;
|
||||
}
|
||||
try {
|
||||
const result = ui.color('test', 'success');
|
||||
assert.strictEqual(result, 'test', 'should return unmodified text');
|
||||
} finally {
|
||||
if (originalNoColor === undefined) {
|
||||
delete process.env.NO_COLOR;
|
||||
} else {
|
||||
process.env.NO_COLOR = originalNoColor;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should apply bold formatting', function () {
|
||||
@@ -51,7 +73,7 @@ describe('UI Module', function () {
|
||||
|
||||
it('should apply gradient to text', function () {
|
||||
const result = ui.gradientText('gradient header');
|
||||
assert.ok(result.includes('gradient header'), 'should contain original text');
|
||||
assert.ok(stripAnsi(result).includes('gradient header'), 'should contain original text');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,7 +151,7 @@ describe('UI Module', function () {
|
||||
describe('Headers', function () {
|
||||
it('should format section header', function () {
|
||||
const result = ui.header('Section Title');
|
||||
assert.ok(result.includes('Section Title'), 'should include title text');
|
||||
assert.ok(stripAnsi(result).includes('Section Title'), 'should include title text');
|
||||
});
|
||||
|
||||
it('should format subsection header', function () {
|
||||
@@ -153,21 +175,24 @@ describe('UI Module', function () {
|
||||
|
||||
describe('NO_COLOR Compliance', function () {
|
||||
it('should disable colors when NO_COLOR is set', function () {
|
||||
const originalNoColor = process.env.NO_COLOR;
|
||||
process.env.NO_COLOR = '1';
|
||||
withoutForceColor(() => {
|
||||
const originalNoColor = process.env.NO_COLOR;
|
||||
process.env.NO_COLOR = '1';
|
||||
|
||||
// All color functions should return plain text
|
||||
assert.strictEqual(ui.color('text', 'success'), 'text');
|
||||
assert.strictEqual(ui.bold('text'), 'text');
|
||||
assert.strictEqual(ui.dim('text'), 'text');
|
||||
assert.strictEqual(ui.gradientText('text'), 'text');
|
||||
|
||||
// Restore
|
||||
if (originalNoColor === undefined) {
|
||||
delete process.env.NO_COLOR;
|
||||
} else {
|
||||
process.env.NO_COLOR = originalNoColor;
|
||||
}
|
||||
try {
|
||||
// All color functions should return plain text
|
||||
assert.strictEqual(ui.color('text', 'success'), 'text');
|
||||
assert.strictEqual(ui.bold('text'), 'text');
|
||||
assert.strictEqual(ui.dim('text'), 'text');
|
||||
assert.strictEqual(ui.gradientText('text'), 'text');
|
||||
} finally {
|
||||
if (originalNoColor === undefined) {
|
||||
delete process.env.NO_COLOR;
|
||||
} else {
|
||||
process.env.NO_COLOR = originalNoColor;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user