/** * VICE MCP Integration Test Suite * * Tests all MCP tools against a running VICE instance. * * Prerequisites: * x64sc -binarymonitor -binarymonitoraddress ip4://127.0.0.1:6502 * * Run: * npx tsx test/integration.test.ts */ import { spawn, ChildProcess } from 'child_process'; import * as path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); interface MCPRequest { jsonrpc: '2.0'; id: number; method: string; params?: Record; } interface MCPResponse { jsonrpc: '2.0'; id: number; result?: unknown; error?: { code: number; message: string }; } class MCPTestClient { private process: ChildProcess; private requestId = 0; private pending = new Map void; reject: (e: Error) => void }>(); private buffer = ''; constructor(serverPath: string) { this.process = spawn('node', [serverPath], { stdio: ['pipe', 'pipe', 'pipe'], }); this.process.stdout!.on('data', (data: Buffer) => { this.buffer += data.toString(); this.processBuffer(); }); this.process.stderr!.on('data', (data: Buffer) => { // Log errors but don't fail - some debug output goes to stderr const msg = data.toString().trim(); if (msg && !msg.includes('too large to log')) { console.error('[MCP stderr]', msg); } }); } private processBuffer() { const lines = this.buffer.split('\n'); this.buffer = lines.pop() || ''; for (const line of lines) { if (!line.trim()) continue; try { const response: MCPResponse = JSON.parse(line); const pending = this.pending.get(response.id); if (pending) { this.pending.delete(response.id); if (response.error) { pending.reject(new Error(response.error.message)); } else { pending.resolve(response.result); } } } catch { // Skip non-JSON lines } } } async call(method: string, params?: Record): Promise { const id = ++this.requestId; const request: MCPRequest = { jsonrpc: '2.0', id, method, params, }; return new Promise((resolve, reject) => { this.pending.set(id, { resolve, reject }); this.process.stdin!.write(JSON.stringify(request) + '\n'); // Timeout after 10 seconds setTimeout(() => { if (this.pending.has(id)) { this.pending.delete(id); reject(new Error(`Timeout waiting for response to ${method}`)); } }, 10000); }); } async callTool(name: string, args: Record = {}): Promise { const response = await this.call('tools/call', { name, arguments: args }) as { content?: Array<{ type: string; text: string }>; }; // MCP wraps tool results in content[].text as JSON string if (response?.content?.[0]?.text) { try { return JSON.parse(response.content[0].text); } catch { return response.content[0].text; } } return response; } close() { this.process.kill(); } } // Test utilities function assert(condition: boolean, message: string) { if (!condition) throw new Error(`Assertion failed: ${message}`); } function assertExists(value: unknown, message: string) { if (value === null || value === undefined) { throw new Error(`Assertion failed: ${message} - value is ${value}`); } } function assertNoError(result: unknown, toolName: string) { const r = result as Record; if (r.error) { throw new Error(`${toolName} returned error: ${JSON.stringify(r)}`); } } // Test definitions interface TestResult { name: string; passed: boolean; error?: string; duration: number; } const tests: Array<{ name: string; fn: (client: MCPTestClient) => Promise }> = []; function test(name: string, fn: (client: MCPTestClient) => Promise) { tests.push({ name, fn }); } // === CONNECTION TESTS === test('connect - connects to VICE', async (client) => { const result = await client.callTool('connect') as Record; assertNoError(result, 'connect'); assert(result.connected === true, 'should be connected'); assert(result.host === '127.0.0.1', 'should use default host'); assert(result.port === 6502, 'should use default port'); }); test('status - shows connected state', async (client) => { const result = await client.callTool('status') as Record; assertNoError(result, 'status'); assert(result.connected === true, 'should be connected'); assertExists(result.host, 'should have host'); assertExists(result.port, 'should have port'); }); // === MEMORY TESTS === test('readMemory - reads screen RAM', async (client) => { const result = await client.callTool('readMemory', { address: 0x0400, length: 40, }) as Record; assertNoError(result, 'readMemory'); assertExists(result.hex, 'should have hex output'); assertExists(result.ascii, 'should have ascii output'); assert((result.bytes as number[]).length === 40, 'should read 40 bytes'); }); test('writeMemory - writes to zero page', async (client) => { // Use zero page instead of I/O registers for reliable test const testAddr = 0x00FB; // Unused zero page location // Save original const original = await client.callTool('readMemory', { address: testAddr, length: 1, }) as Record; const originalValue = (original.bytes as number[])[0]; // Write new value const testValue = (originalValue + 1) & 0xFF; // Different value const result = await client.callTool('writeMemory', { address: testAddr, bytes: [testValue], }) as Record; assertNoError(result, 'writeMemory'); // Verify const verify = await client.callTool('readMemory', { address: testAddr, length: 1, }) as Record; assert((verify.bytes as number[])[0] === testValue, `should read back ${testValue}`); // Restore await client.callTool('writeMemory', { address: testAddr, bytes: [originalValue], }); }); // === REGISTER TESTS === test('getRegisters - returns CPU state', async (client) => { const result = await client.callTool('getRegisters') as Record; assertNoError(result, 'getRegisters'); assertExists(result.a, 'should have accumulator'); assertExists(result.x, 'should have X register'); assertExists(result.y, 'should have Y register'); assertExists(result.sp, 'should have stack pointer'); assertExists(result.pc, 'should have program counter'); assertExists(result.flags, 'should have flags'); }); // === SEMANTIC LAYER TESTS === test('readScreen - decodes PETSCII', async (client) => { const result = await client.callTool('readScreen') as Record; assertNoError(result, 'readScreen'); assertExists(result.lines, 'should have lines array'); assert(Array.isArray(result.lines), 'lines should be array'); assert((result.lines as string[]).length === 25, 'should have 25 lines'); assertExists(result.screenAddress, 'should have screen address'); }); test('readVicState - interprets VIC-II', async (client) => { const result = await client.callTool('readVicState') as Record; assertNoError(result, 'readVicState'); assertExists(result.borderColor, 'should have border color'); assertExists(result.backgroundColor, 'should have background color'); assertExists(result.graphicsMode, 'should have graphics mode'); assertExists(result.spriteEnable, 'should have sprite enable'); // Check border color has name const border = result.borderColor as Record; assertExists(border.name, 'border color should have name'); }); test('readSprites - returns all 8 sprites with diagnostics', async (client) => { const result = await client.callTool('readSprites') as Record; assertNoError(result, 'readSprites'); assert(result.count === 8, 'should have 8 sprites'); assertExists(result.sprites, 'should have sprites array'); const sprites = result.sprites as Array>; assert(sprites.length === 8, 'sprites array should have 8 entries'); // Check first sprite has required fields const sprite0 = sprites[0]; assertExists(sprite0.enabled, 'sprite should have enabled'); assertExists(sprite0.position, 'sprite should have position'); assertExists(sprite0.color, 'sprite should have color'); assertExists(sprite0.dataAddress, 'sprite should have dataAddress'); // Check dataAddress has diagnostics (AX design) const dataAddr = sprite0.dataAddress as Record; assertExists(dataAddr.region, 'dataAddress should have region'); assertExists(dataAddr.severity, 'dataAddress should have severity'); }); test('readColorRam - returns color info', async (client) => { const result = await client.callTool('readColorRam', { summary: true }) as Record; assertNoError(result, 'readColorRam'); assertExists(result.summary, 'should have summary'); const summary = result.summary as Record; assertExists(summary.usage, 'should have color usage'); }); // === EXECUTION CONTROL TESTS === test('step - executes single instruction', async (client) => { const before = await client.callTool('getRegisters') as Record; const pcBefore = (before.pc as Record).value; const result = await client.callTool('step') as Record; assertNoError(result, 'step'); const after = await client.callTool('getRegisters') as Record; const pcAfter = (after.pc as Record).value; // PC should have changed (unless we hit a JAM) // Just verify we got a response assertExists(pcAfter, 'should have PC after step'); }); test('continue - resumes execution', async (client) => { const result = await client.callTool('continue') as Record; assertNoError(result, 'continue'); // Give it a moment to run await new Promise(r => setTimeout(r, 100)); const status = await client.callTool('status') as Record; // Should be running now assertExists(status.running, 'should have running state'); }); // === BREAKPOINT TESTS === test('setBreakpoint - creates breakpoint', async (client) => { const result = await client.callTool('setBreakpoint', { address: 0xE000, }) as Record; assertNoError(result, 'setBreakpoint'); assertExists(result.breakpointId, 'should return breakpoint ID'); // Clean up const bpId = result.breakpointId as number; await client.callTool('deleteBreakpoint', { breakpointId: bpId }); }); test('listBreakpoints - shows breakpoints', async (client) => { // Create a breakpoint const bp = await client.callTool('setBreakpoint', { address: 0xE544, }) as Record; const bpId = bp.breakpointId as number; const result = await client.callTool('listBreakpoints') as Record; assertNoError(result, 'listBreakpoints'); assertExists(result.breakpoints, 'should have breakpoints array'); // Clean up await client.callTool('deleteBreakpoint', { breakpointId: bpId }); }); test('setWatchpoint - creates memory watch', async (client) => { const result = await client.callTool('setWatchpoint', { startAddress: 0xD020, type: 'store', }) as Record; assertNoError(result, 'setWatchpoint'); assertExists(result.watchpointId, 'should return watchpoint ID'); // Clean up const wpId = result.watchpointId as number; await client.callTool('deleteBreakpoint', { breakpointId: wpId }); }); // === DISASSEMBLY TEST === test('disassemble - shows assembly', async (client) => { const result = await client.callTool('disassemble', { address: 0xE000, count: 5, }) as Record; assertNoError(result, 'disassemble'); assertExists(result.instructions, 'should have instructions'); const instrs = result.instructions as Array>; assert(instrs.length === 5, 'should have 5 instructions'); // Check instruction format const instr0 = instrs[0]; assertExists(instr0.address, 'instruction should have address'); assertExists(instr0.mnemonic, 'instruction should have mnemonic'); }); // === RESET TEST === test('reset - soft resets machine', async (client) => { const result = await client.callTool('reset', { hard: false }) as Record; assertNoError(result, 'reset'); // Give it time to reset await new Promise(r => setTimeout(r, 500)); // Check we're still connected const status = await client.callTool('status') as Record; assert(status.connected === true, 'should still be connected after reset'); }); // === RUN TESTS === async function runTests() { const serverPath = path.join(__dirname, '..', 'dist', 'index.js'); console.log(`\nVICE MCP Integration Tests`); console.log(`==========================`); console.log(`Server: ${serverPath}\n`); const client = new MCPTestClient(serverPath); const results: TestResult[] = []; // Wait for server to start await new Promise(r => setTimeout(r, 500)); // Initialize MCP session try { await client.call('initialize', { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'integration-test', version: '1.0.0' }, }); } catch (e) { console.error('Failed to initialize MCP session:', e); client.close(); process.exit(1); } for (const { name, fn } of tests) { const start = Date.now(); try { await fn(client); results.push({ name, passed: true, duration: Date.now() - start }); console.log(` ✓ ${name} (${Date.now() - start}ms)`); } catch (e) { const error = e instanceof Error ? e.message : String(e); results.push({ name, passed: false, error, duration: Date.now() - start }); console.log(` ✗ ${name}: ${error}`); } } client.close(); // Summary const passed = results.filter(r => r.passed).length; const failed = results.filter(r => !r.passed).length; const totalTime = results.reduce((sum, r) => sum + r.duration, 0); console.log(`\n---------------------------`); console.log(`Passed: ${passed}/${results.length}`); console.log(`Failed: ${failed}`); console.log(`Total time: ${totalTime}ms`); if (failed > 0) { console.log(`\nFailed tests:`); for (const r of results.filter(r => !r.passed)) { console.log(` - ${r.name}: ${r.error}`); } } process.exit(failed > 0 ? 1 : 0); } runTests().catch(e => { console.error('Test runner error:', e); process.exit(1); });