mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-10 02:24:35 +08:00
feat: Implement Cross-CLI Sync Panel for MCP servers
- Added CrossCliSyncPanel component for synchronizing MCP servers between Claude and Codex. - Implemented server selection, copy operations, and result handling. - Added tests for path mapping on Windows drives. - Created E2E tests for ask_question Answer Broker functionality. - Introduced MCP Tools Test Script for validating modified read_file and edit_file tools. - Updated path_mapper to ensure correct drive formatting on Windows. - Added .gitignore for ace-tool directory.
This commit is contained in:
271
ccw/tests/e2e/ask-question-answer-broker.e2e.test.ts
Normal file
271
ccw/tests/e2e/ask-question-answer-broker.e2e.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* E2E: ask_question Answer Broker
|
||||
*
|
||||
* Verifies that when the MCP server runs as a separate stdio process (no local WS clients),
|
||||
* `ask_question` forwards the surface to the Dashboard via /api/hook and later retrieves
|
||||
* the user's answer via /api/a2ui/answer polling.
|
||||
*/
|
||||
import { after, before, describe, it, mock } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import http from 'node:http';
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const serverUrl = new URL('../../dist/core/server.js', import.meta.url);
|
||||
serverUrl.searchParams.set('t', String(Date.now()));
|
||||
|
||||
interface JsonRpcRequest {
|
||||
jsonrpc: string;
|
||||
id: number;
|
||||
method: string;
|
||||
params: any;
|
||||
}
|
||||
|
||||
interface JsonRpcResponse {
|
||||
jsonrpc: string;
|
||||
id: number;
|
||||
result?: any;
|
||||
error?: { code: number; message: string; data?: any };
|
||||
}
|
||||
|
||||
class McpClient {
|
||||
private serverProcess!: ChildProcess;
|
||||
private requestId = 0;
|
||||
private pendingRequests = new Map<number, { resolve: (r: JsonRpcResponse) => void; reject: (e: Error) => void }>();
|
||||
|
||||
private env: Record<string, string | undefined>;
|
||||
|
||||
constructor(env: Record<string, string | undefined>) {
|
||||
this.env = env;
|
||||
}
|
||||
|
||||
async start(): Promise<void> {
|
||||
const serverPath = join(__dirname, '../../bin/ccw-mcp.js');
|
||||
this.serverProcess = spawn('node', [serverPath], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, ...this.env },
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => reject(new Error('MCP server start timeout')), 15000);
|
||||
this.serverProcess.stderr!.on('data', (data) => {
|
||||
const message = data.toString();
|
||||
if (message.includes('started') || message.includes('ccw-tools')) {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
this.serverProcess.on('error', (err) => {
|
||||
clearTimeout(timeout);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
|
||||
this.serverProcess.stdout!.on('data', (data) => {
|
||||
try {
|
||||
const lines = data.toString().split('\n').filter((l: string) => l.trim());
|
||||
for (const line of lines) {
|
||||
const response: JsonRpcResponse = JSON.parse(line);
|
||||
const pending = this.pendingRequests.get(response.id);
|
||||
if (pending) {
|
||||
this.pendingRequests.delete(response.id);
|
||||
pending.resolve(response);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async call(method: string, params: any = {}, timeoutMs = 10000): Promise<JsonRpcResponse> {
|
||||
const id = ++this.requestId;
|
||||
const request: JsonRpcRequest = { jsonrpc: '2.0', id, method, params };
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
this.pendingRequests.delete(id);
|
||||
reject(new Error(`Request timeout for ${method}`));
|
||||
}, timeoutMs);
|
||||
|
||||
this.pendingRequests.set(id, {
|
||||
resolve: (response) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(response);
|
||||
},
|
||||
reject: (error) => {
|
||||
clearTimeout(timeout);
|
||||
reject(error);
|
||||
},
|
||||
});
|
||||
|
||||
this.serverProcess.stdin!.write(JSON.stringify(request) + '\n');
|
||||
});
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.serverProcess?.kill();
|
||||
}
|
||||
}
|
||||
|
||||
function waitForWebSocketOpen(ws: WebSocket, timeoutMs = 10000): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error('WebSocket open timeout')), timeoutMs);
|
||||
ws.addEventListener('open', () => {
|
||||
clearTimeout(t);
|
||||
resolve();
|
||||
});
|
||||
ws.addEventListener('error', () => {
|
||||
clearTimeout(t);
|
||||
reject(new Error('WebSocket error'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function waitForA2UISurface(ws: WebSocket, timeoutMs = 15000): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const t = setTimeout(() => reject(new Error('Timed out waiting for a2ui-surface')), timeoutMs);
|
||||
const handler = (event: MessageEvent) => {
|
||||
try {
|
||||
const data = JSON.parse(String(event.data));
|
||||
if (data?.type === 'a2ui-surface' && data?.payload?.initialState?.questionId) {
|
||||
clearTimeout(t);
|
||||
ws.removeEventListener('message', handler);
|
||||
resolve(data);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
ws.addEventListener('message', handler);
|
||||
});
|
||||
}
|
||||
|
||||
function httpRequest(options: http.RequestOptions, body?: string, timeout = 10000): Promise<{ status: number; body: string }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => resolve({ status: res.statusCode || 0, body: data }));
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.setTimeout(timeout, () => {
|
||||
req.destroy();
|
||||
reject(new Error('Request timeout'));
|
||||
});
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
describe('E2E: ask_question Answer Broker', async () => {
|
||||
let server: http.Server;
|
||||
let port: number;
|
||||
let projectRoot: string;
|
||||
const originalCwd = process.cwd();
|
||||
let mcp: McpClient;
|
||||
let ws: WebSocket;
|
||||
|
||||
before(async () => {
|
||||
process.env.CCW_DISABLE_WARMUP = '1';
|
||||
|
||||
projectRoot = mkdtempSync(join(tmpdir(), 'ccw-e2e-askq-'));
|
||||
process.chdir(projectRoot);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const serverMod: any = await import(serverUrl.href);
|
||||
mock.method(console, 'log', () => {});
|
||||
mock.method(console, 'error', () => {});
|
||||
|
||||
server = await serverMod.startServer({ initialPath: projectRoot, port: 0 });
|
||||
const addr = server.address();
|
||||
port = typeof addr === 'object' && addr ? addr.port : 0;
|
||||
assert.ok(port > 0, 'Server should start on a valid port');
|
||||
|
||||
ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
|
||||
await waitForWebSocketOpen(ws);
|
||||
|
||||
mcp = new McpClient({
|
||||
CCW_PROJECT_ROOT: projectRoot,
|
||||
CCW_ENABLED_TOOLS: 'all',
|
||||
CCW_PORT: String(port),
|
||||
CCW_DISABLE_WARMUP: '1',
|
||||
});
|
||||
await mcp.start();
|
||||
|
||||
// Sanity: broker endpoint should be reachable without auth from localhost
|
||||
const broker = await httpRequest({ hostname: '127.0.0.1', port, path: '/api/a2ui/answer?questionId=nonexistent', method: 'GET' });
|
||||
assert.equal(broker.status, 200);
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
try {
|
||||
ws?.close();
|
||||
} catch {}
|
||||
mcp?.stop();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
process.chdir(originalCwd);
|
||||
rmSync(projectRoot, { recursive: true, force: true });
|
||||
mock.restoreAll();
|
||||
});
|
||||
|
||||
it('returns the answered value via MCP tool call', async () => {
|
||||
const questionId = `e2e-q-${Date.now()}`;
|
||||
|
||||
const toolCallPromise = mcp.call(
|
||||
'tools/call',
|
||||
{
|
||||
name: 'ask_question',
|
||||
arguments: {
|
||||
question: {
|
||||
id: questionId,
|
||||
type: 'confirm',
|
||||
title: 'E2E Confirm',
|
||||
message: 'Confirm this in the test harness',
|
||||
},
|
||||
timeout: 15000,
|
||||
},
|
||||
},
|
||||
30000,
|
||||
);
|
||||
|
||||
const surfaceMsg = await waitForA2UISurface(ws, 15000);
|
||||
const surfaceId = surfaceMsg.payload.surfaceId as string;
|
||||
const receivedQuestionId = surfaceMsg.payload.initialState.questionId as string;
|
||||
assert.equal(receivedQuestionId, questionId);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: 'a2ui-action',
|
||||
actionId: 'confirm',
|
||||
surfaceId,
|
||||
parameters: { questionId },
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await toolCallPromise;
|
||||
assert.equal(response.jsonrpc, '2.0');
|
||||
assert.ok(response.result);
|
||||
assert.ok(Array.isArray(response.result.content));
|
||||
|
||||
const text = response.result.content[0]?.text as string;
|
||||
const parsed = JSON.parse(text);
|
||||
const resultObj = parsed.result ?? parsed;
|
||||
|
||||
assert.equal(resultObj.success, true);
|
||||
assert.equal(resultObj.cancelled, false);
|
||||
assert.ok(Array.isArray(resultObj.answers));
|
||||
assert.equal(resultObj.answers[0].questionId, questionId);
|
||||
assert.equal(resultObj.answers[0].value, true);
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,8 @@
|
||||
* Tests that bash -c commands use single quotes to avoid jq escaping issues
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// Import the convertToClaudeCodeFormat function logic
|
||||
// Since it's in a browser JS file, we'll recreate it here for testing
|
||||
@@ -58,9 +59,9 @@ describe('Hook Quoting Fix (Issue #73)', () => {
|
||||
|
||||
const result = convertToClaudeCodeFormat(hookData);
|
||||
|
||||
expect(result.hooks[0].command).toMatch(/^bash -c '/);
|
||||
expect(result.hooks[0].command).toMatch(/'$/);
|
||||
expect(result.hooks[0].command).not.toMatch(/^bash -c "/);
|
||||
assert.match(result.hooks[0].command, /^bash -c '/);
|
||||
assert.match(result.hooks[0].command, /'$/);
|
||||
assert.doesNotMatch(result.hooks[0].command, /^bash -c "/);
|
||||
});
|
||||
|
||||
it('should preserve jq command double quotes without excessive escaping', () => {
|
||||
@@ -73,9 +74,9 @@ describe('Hook Quoting Fix (Issue #73)', () => {
|
||||
const cmd = result.hooks[0].command;
|
||||
|
||||
// The jq pattern should remain readable
|
||||
expect(cmd).toContain('jq -r ".tool_input.command // empty"');
|
||||
assert.ok(cmd.includes('jq -r ".tool_input.command // empty"'));
|
||||
// Should not have excessive escaping like \\\"
|
||||
expect(cmd).not.toContain('\\\\\\"');
|
||||
assert.ok(!cmd.includes('\\\\\\"'));
|
||||
});
|
||||
|
||||
it('should correctly escape single quotes in script using \'\\\'\'', () => {
|
||||
@@ -88,8 +89,8 @@ describe('Hook Quoting Fix (Issue #73)', () => {
|
||||
const cmd = result.hooks[0].command;
|
||||
|
||||
// Single quotes should be escaped as '\''
|
||||
expect(cmd).toContain("'\\''");
|
||||
expect(cmd).toBe("bash -c 'echo '\\''hello world'\\'''");
|
||||
assert.ok(cmd.includes("'\\''"));
|
||||
assert.equal(cmd, "bash -c 'echo '\\''hello world'\\'''");
|
||||
});
|
||||
|
||||
it('should handle danger-bash-confirm hook template correctly', () => {
|
||||
@@ -102,11 +103,11 @@ describe('Hook Quoting Fix (Issue #73)', () => {
|
||||
const cmd = result.hooks[0].command;
|
||||
|
||||
// Should use single quotes
|
||||
expect(cmd).toMatch(/^bash -c '/);
|
||||
assert.match(cmd, /^bash -c '/);
|
||||
// jq pattern should be intact
|
||||
expect(cmd).toContain('jq -r ".tool_input.command // empty"');
|
||||
assert.ok(cmd.includes('jq -r ".tool_input.command // empty"'));
|
||||
// JSON output should have escaped double quotes (in shell)
|
||||
expect(cmd).toContain('{\\"hookSpecificOutput\\"');
|
||||
assert.ok(cmd.includes('{\\"hookSpecificOutput\\"'));
|
||||
});
|
||||
|
||||
it('should handle non-bash commands with original logic', () => {
|
||||
@@ -117,7 +118,7 @@ describe('Hook Quoting Fix (Issue #73)', () => {
|
||||
|
||||
const result = convertToClaudeCodeFormat(hookData);
|
||||
|
||||
expect(result.hooks[0].command).toBe('ccw memory track --type file --action read');
|
||||
assert.equal(result.hooks[0].command, 'ccw memory track --type file --action read');
|
||||
});
|
||||
|
||||
it('should handle bash commands without -c flag with original logic', () => {
|
||||
@@ -128,7 +129,7 @@ describe('Hook Quoting Fix (Issue #73)', () => {
|
||||
|
||||
const result = convertToClaudeCodeFormat(hookData);
|
||||
|
||||
expect(result.hooks[0].command).toBe('bash script.sh --arg value');
|
||||
assert.equal(result.hooks[0].command, 'bash script.sh --arg value');
|
||||
});
|
||||
|
||||
it('should handle args with spaces in non-bash commands', () => {
|
||||
@@ -139,7 +140,7 @@ describe('Hook Quoting Fix (Issue #73)', () => {
|
||||
|
||||
const result = convertToClaudeCodeFormat(hookData);
|
||||
|
||||
expect(result.hooks[0].command).toBe('echo "hello world" "another arg"');
|
||||
assert.equal(result.hooks[0].command, 'echo "hello world" "another arg"');
|
||||
});
|
||||
|
||||
it('should handle already formatted hook data', () => {
|
||||
@@ -152,7 +153,7 @@ describe('Hook Quoting Fix (Issue #73)', () => {
|
||||
|
||||
const result = convertToClaudeCodeFormat(hookData);
|
||||
|
||||
expect(result).toBe(hookData);
|
||||
assert.equal(result, hookData);
|
||||
});
|
||||
|
||||
it('should handle additional args after bash -c script', () => {
|
||||
@@ -164,8 +165,8 @@ describe('Hook Quoting Fix (Issue #73)', () => {
|
||||
const result = convertToClaudeCodeFormat(hookData);
|
||||
const cmd = result.hooks[0].command;
|
||||
|
||||
expect(cmd).toMatch(/^bash -c 'echo \$1'/);
|
||||
expect(cmd).toContain('"hello world"');
|
||||
assert.match(cmd, /^bash -c 'echo \$1'/);
|
||||
assert.ok(cmd.includes('"hello world"'));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,11 +196,11 @@ describe('Hook Quoting Fix (Issue #73)', () => {
|
||||
const cmd = result.hooks[0].command;
|
||||
|
||||
// All bash -c commands should use single quotes
|
||||
expect(cmd).toMatch(/^bash -c '/);
|
||||
expect(cmd).toMatch(/'$/);
|
||||
assert.match(cmd, /^bash -c '/);
|
||||
assert.match(cmd, /'$/);
|
||||
|
||||
// jq patterns should be intact
|
||||
expect(cmd).toContain('jq -r ".');
|
||||
assert.ok(cmd.includes('jq -r ".'));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -206,9 +206,8 @@ describe('Smart Search Tool Definition', async () => {
|
||||
const modeEnum = params.properties.mode?.enum;
|
||||
|
||||
assert.ok(modeEnum, 'Should have mode enum');
|
||||
assert.ok(modeEnum.includes('auto'), 'Should support auto mode');
|
||||
assert.ok(modeEnum.includes('hybrid'), 'Should support hybrid mode');
|
||||
assert.ok(modeEnum.includes('exact'), 'Should support exact mode');
|
||||
assert.ok(modeEnum.includes('fuzzy'), 'Should support fuzzy mode');
|
||||
assert.ok(modeEnum.includes('semantic'), 'Should support semantic mode');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user