diff --git a/tests/cli-integration.spec.ts b/tests/cli-integration.spec.ts new file mode 100644 index 0000000..dfc8657 --- /dev/null +++ b/tests/cli-integration.spec.ts @@ -0,0 +1,207 @@ +import { expect, test } from '@playwright/test' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import path from 'node:path' +import { API_KEY_HEADER, createTestAgent, deleteTestAgent, createTestTask, deleteTestTask } from './helpers' + +const execFileAsync = promisify(execFile) + +const CLI = path.resolve('scripts/mc-cli.cjs') +const BASE_URL = process.env.E2E_BASE_URL || 'http://127.0.0.1:3005' +const API_KEY = 'test-api-key-e2e-12345' + +/** Run mc-cli command via execFile (no shell) and return parsed JSON output */ +async function mc(...args: string[]): Promise<{ stdout: string; parsed: any; exitCode: number }> { + try { + const { stdout } = await execFileAsync('node', [CLI, ...args, '--json', '--url', BASE_URL, '--api-key', API_KEY], { + timeout: 15000, + env: { ...process.env, MC_URL: BASE_URL, MC_API_KEY: API_KEY }, + }) + let parsed: any + try { parsed = JSON.parse(stdout) } catch { parsed = { raw: stdout } } + return { stdout, parsed, exitCode: 0 } + } catch (err: any) { + const stdout = err.stdout || '' + let parsed: any + try { parsed = JSON.parse(stdout) } catch { parsed = { raw: stdout, stderr: err.stderr } } + return { stdout, parsed, exitCode: err.code ?? 1 } + } +} + +test.describe('CLI Integration', () => { + // --- Help & Usage --- + + test('--help shows usage and exits 0', async () => { + const { stdout, exitCode } = await mc('--help') + expect(exitCode).toBe(0) + expect(stdout).toContain('Mission Control CLI') + expect(stdout).toContain('agents') + expect(stdout).toContain('tasks') + }) + + test('unknown group exits 2 with error', async () => { + const { exitCode } = await mc('nonexistent', 'action') + expect(exitCode).toBe(2) + }) + + test('missing required flag exits 2 with error message', async () => { + const { exitCode, parsed } = await mc('agents', 'get') + expect(exitCode).toBe(2) + expect(parsed.error).toContain('--id') + }) + + // --- Status --- + + test('status health returns healthy', async () => { + const { parsed, exitCode } = await mc('status', 'health') + expect(exitCode).toBe(0) + expect(parsed.data?.status || parsed.status).toBeDefined() + }) + + test('status overview returns system info', async () => { + const { parsed, exitCode } = await mc('status', 'overview') + expect(exitCode).toBe(0) + }) + + // --- Agents CRUD --- + + test.describe('agents', () => { + const agentIds: number[] = [] + + test.afterEach(async ({ request }) => { + for (const id of agentIds.splice(0)) { + await deleteTestAgent(request, id).catch(() => {}) + } + }) + + test('list returns array', async () => { + const { parsed, exitCode } = await mc('agents', 'list') + expect(exitCode).toBe(0) + const data = parsed.data || parsed + expect(data).toBeDefined() + }) + + test('get + heartbeat lifecycle', async ({ request }) => { + const agent = await createTestAgent(request) + agentIds.push(agent.id) + + // Get via CLI + const { parsed: getResult, exitCode: getCode } = await mc('agents', 'get', '--id', String(agent.id)) + expect(getCode).toBe(0) + const agentData = getResult.data?.agent || getResult.data || getResult + expect(agentData).toBeDefined() + + // Heartbeat via CLI + const { exitCode: hbCode } = await mc('agents', 'heartbeat', '--id', String(agent.id)) + expect(hbCode).toBe(0) + }) + + test('memory set and get work', async ({ request }) => { + const agent = await createTestAgent(request) + agentIds.push(agent.id) + + // Set memory — may succeed or fail depending on workspace state + const { exitCode: setCode } = await mc('agents', 'memory', 'set', '--id', String(agent.id), '--content', 'CLI test memory') + expect([0, 2, 6]).toContain(setCode) + + // Get memory + const { exitCode: getCode } = await mc('agents', 'memory', 'get', '--id', String(agent.id)) + expect([0, 2, 6]).toContain(getCode) + }) + + test('attribution returns response', async ({ request }) => { + const agent = await createTestAgent(request) + agentIds.push(agent.id) + + // Attribution may return 403 for test API key depending on auth scope — accept 0 or 4 + const { exitCode } = await mc('agents', 'attribution', '--id', String(agent.id), '--hours', '1') + expect([0, 4]).toContain(exitCode) + }) + }) + + // --- Tasks --- + + test.describe('tasks', () => { + const taskIds: number[] = [] + + test.afterEach(async ({ request }) => { + for (const id of taskIds.splice(0)) { + await deleteTestTask(request, id).catch(() => {}) + } + }) + + test('list returns data', async () => { + const { exitCode } = await mc('tasks', 'list') + expect(exitCode).toBe(0) + }) + + test('queue returns response', async () => { + const { exitCode } = await mc('tasks', 'queue', '--agent', 'e2e-test-agent') + expect(exitCode).toBe(0) + }) + + test('comments list/add lifecycle', async ({ request }) => { + const task = await createTestTask(request) + taskIds.push(task.id) + + // Add comment via CLI + const { exitCode: addCode } = await mc('tasks', 'comments', 'add', '--id', String(task.id), '--content', 'CLI comment test') + expect(addCode).toBe(0) + + // List comments via CLI + const { parsed, exitCode: listCode } = await mc('tasks', 'comments', 'list', '--id', String(task.id)) + expect(listCode).toBe(0) + const comments = parsed.data?.comments || parsed.comments || [] + expect(comments.length).toBeGreaterThanOrEqual(1) + }) + }) + + // --- Sessions --- + + test('sessions list returns response', async () => { + // Sessions endpoint may return various codes depending on gateway state + const { exitCode } = await mc('sessions', 'list') + expect([0, 2, 4, 6]).toContain(exitCode) + }) + + // --- Tokens --- + + test('tokens stats returns data', async () => { + const { exitCode } = await mc('tokens', 'stats') + expect(exitCode).toBe(0) + }) + + test('tokens by-agent returns data', async () => { + const { exitCode } = await mc('tokens', 'by-agent', '--days', '7') + expect(exitCode).toBe(0) + }) + + // --- Skills --- + + test('skills list returns data', async () => { + const { exitCode } = await mc('skills', 'list') + expect(exitCode).toBe(0) + }) + + // --- Cron --- + + test('cron list returns response', async () => { + // Cron may return error in test mode — accept 0, 2, or 6 + const { exitCode } = await mc('cron', 'list') + expect([0, 2, 6]).toContain(exitCode) + }) + + // --- Connect --- + + test('connect list returns data', async () => { + const { exitCode } = await mc('connect', 'list') + expect(exitCode).toBe(0) + }) + + // --- Raw passthrough --- + + test('raw GET /api/status works', async () => { + const { exitCode } = await mc('raw', '--method', 'GET', '--path', '/api/status?action=health') + expect(exitCode).toBe(0) + }) +}) diff --git a/tests/mcp-server.spec.ts b/tests/mcp-server.spec.ts new file mode 100644 index 0000000..7237cd2 --- /dev/null +++ b/tests/mcp-server.spec.ts @@ -0,0 +1,253 @@ +import { expect, test } from '@playwright/test' +import { execFile, spawn } from 'node:child_process' +import { promisify } from 'node:util' +import path from 'node:path' +import { createTestAgent, deleteTestAgent, createTestTask, deleteTestTask } from './helpers' + +const MCP = path.resolve('scripts/mc-mcp-server.cjs') +const BASE_URL = process.env.E2E_BASE_URL || 'http://127.0.0.1:3005' +const API_KEY = 'test-api-key-e2e-12345' + +/** Send JSON-RPC messages to the MCP server and collect responses */ +async function mcpCall(messages: object[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn('node', [MCP], { + env: { ...process.env, MC_URL: BASE_URL, MC_API_KEY: API_KEY }, + stdio: ['pipe', 'pipe', 'pipe'], + }) + + let stdout = '' + child.stdout.on('data', (data: Buffer) => { stdout += data.toString() }) + + let stderr = '' + child.stderr.on('data', (data: Buffer) => { stderr += data.toString() }) + + // Write all messages + for (const msg of messages) { + child.stdin.write(JSON.stringify(msg) + '\n') + } + child.stdin.end() + + const timer = setTimeout(() => { + child.kill() + reject(new Error(`MCP server timeout. stdout: ${stdout}, stderr: ${stderr}`)) + }, 15000) + + child.on('close', () => { + clearTimeout(timer) + const responses = stdout + .split('\n') + .filter(line => line.trim()) + .map(line => { + try { return JSON.parse(line) } catch { return { raw: line } } + }) + resolve(responses) + }) + }) +} + +/** Send a single MCP JSON-RPC request and return the response */ +async function mcpRequest(method: string, params: object = {}, id = 1): Promise { + const responses = await mcpCall([ + { jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2024-11-05', clientInfo: { name: 'test', version: '1.0' }, capabilities: {} } }, + { jsonrpc: '2.0', method: 'notifications/initialized' }, + { jsonrpc: '2.0', id, method, params }, + ]) + // Return the response matching our request id (skip initialize response) + return responses.find(r => r.id === id) || responses[responses.length - 1] +} + +/** Call an MCP tool and return the parsed content */ +async function mcpTool(name: string, args: object = {}): Promise<{ content: string; isError?: boolean }> { + const response = await mcpRequest('tools/call', { name, arguments: args }, 99) + const text = response?.result?.content?.[0]?.text || '' + let parsed: any + try { parsed = JSON.parse(text) } catch { parsed = text } + return { + content: parsed, + isError: response?.result?.isError || false, + } +} + +test.describe('MCP Server Integration', () => { + // --- Protocol --- + + test('initialize returns server info and capabilities', async () => { + const responses = await mcpCall([ + { jsonrpc: '2.0', id: 1, method: 'initialize', params: { protocolVersion: '2024-11-05', clientInfo: { name: 'test', version: '1.0' }, capabilities: {} } }, + ]) + expect(responses).toHaveLength(1) + expect(responses[0].result.serverInfo.name).toBe('mission-control') + expect(responses[0].result.capabilities.tools).toBeDefined() + }) + + test('tools/list returns all tools with schemas', async () => { + const response = await mcpRequest('tools/list') + const tools = response.result.tools + expect(tools.length).toBeGreaterThan(30) + + // Every tool should have name, description, and inputSchema + for (const tool of tools) { + expect(tool.name).toBeTruthy() + expect(tool.description).toBeTruthy() + expect(tool.inputSchema).toBeDefined() + expect(tool.inputSchema.type).toBe('object') + } + + // Check key tools exist + const names = tools.map((t: any) => t.name) + expect(names).toContain('mc_list_agents') + expect(names).toContain('mc_poll_task_queue') + expect(names).toContain('mc_heartbeat') + expect(names).toContain('mc_read_memory') + expect(names).toContain('mc_write_memory') + expect(names).toContain('mc_add_comment') + expect(names).toContain('mc_health') + }) + + test('unknown tool returns isError', async () => { + const result = await mcpTool('mc_nonexistent', {}) + expect(result.isError).toBe(true) + }) + + test('ping responds', async () => { + const response = await mcpRequest('ping') + expect(response.result).toBeDefined() + }) + + test('unknown method returns error code', async () => { + const response = await mcpRequest('foo/bar') + expect(response.error).toBeDefined() + expect(response.error.code).toBe(-32601) + }) + + // --- Status tools --- + + test('mc_health returns status', async () => { + const { content, isError } = await mcpTool('mc_health') + expect(isError).toBe(false) + expect(content).toBeDefined() + }) + + test('mc_dashboard returns system summary', async () => { + const { content, isError } = await mcpTool('mc_dashboard') + expect(isError).toBe(false) + }) + + // --- Agent tools --- + + test.describe('agent tools', () => { + const agentIds: number[] = [] + + test.afterEach(async ({ request }) => { + for (const id of agentIds.splice(0)) { + await deleteTestAgent(request, id).catch(() => {}) + } + }) + + test('mc_list_agents returns agents', async () => { + const { content, isError } = await mcpTool('mc_list_agents') + expect(isError).toBe(false) + }) + + test('mc_heartbeat sends heartbeat', async ({ request }) => { + const agent = await createTestAgent(request) + agentIds.push(agent.id) + + const { isError } = await mcpTool('mc_heartbeat', { id: agent.id }) + expect(isError).toBe(false) + }) + + test('mc_write_memory writes and mc_read_memory reads', async ({ request }) => { + const agent = await createTestAgent(request) + agentIds.push(agent.id) + + // Write + const { isError: writeErr } = await mcpTool('mc_write_memory', { + id: agent.id, + working_memory: 'MCP test memory content', + }) + expect(writeErr).toBe(false) + + // Read back + const { isError: readErr } = await mcpTool('mc_read_memory', { id: agent.id }) + expect(readErr).toBe(false) + }) + + test('mc_clear_memory clears', async ({ request }) => { + const agent = await createTestAgent(request) + agentIds.push(agent.id) + + const { isError } = await mcpTool('mc_clear_memory', { id: agent.id }) + expect(isError).toBe(false) + }) + }) + + // --- Task tools --- + + test.describe('task tools', () => { + const taskIds: number[] = [] + + test.afterEach(async ({ request }) => { + for (const id of taskIds.splice(0)) { + await deleteTestTask(request, id).catch(() => {}) + } + }) + + test('mc_list_tasks returns tasks', async () => { + const { isError } = await mcpTool('mc_list_tasks') + expect(isError).toBe(false) + }) + + test('mc_poll_task_queue returns response', async () => { + const { isError } = await mcpTool('mc_poll_task_queue', { agent: 'e2e-mcp-agent' }) + expect(isError).toBe(false) + }) + + test('mc_create_task creates a task', async ({ request }) => { + const { content, isError } = await mcpTool('mc_create_task', { title: 'MCP e2e test task' }) + expect(isError).toBe(false) + if (content?.task?.id) taskIds.push(content.task.id) + }) + + test('mc_add_comment succeeds', async ({ request }) => { + const task = await createTestTask(request) + taskIds.push(task.id) + + const { isError } = await mcpTool('mc_add_comment', { + id: task.id, + content: 'MCP comment test', + }) + expect(isError).toBe(false) + }) + + test('mc_list_comments returns array', async ({ request }) => { + const task = await createTestTask(request) + taskIds.push(task.id) + + const { isError } = await mcpTool('mc_list_comments', { id: task.id }) + expect(isError).toBe(false) + }) + }) + + // --- Token tools --- + + test('mc_token_stats returns stats', async () => { + const { isError } = await mcpTool('mc_token_stats', { timeframe: 'all' }) + expect(isError).toBe(false) + }) + + // --- Skill tools --- + + test('mc_list_skills returns data', async () => { + const { isError } = await mcpTool('mc_list_skills') + expect(isError).toBe(false) + }) + + // --- Cron tools --- + + test('mc_list_cron returns data', async () => { + const { isError } = await mcpTool('mc_list_cron') + expect(isError).toBe(false) + }) +})