diff --git a/test/integration.test.ts b/test/integration.test.ts new file mode 100644 index 0000000..4062a38 --- /dev/null +++ b/test/integration.test.ts @@ -0,0 +1,457 @@ +/** + * 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); +}); diff --git a/test/integration.ts b/test/integration.ts new file mode 100644 index 0000000..2a7b105 --- /dev/null +++ b/test/integration.ts @@ -0,0 +1,280 @@ +#!/usr/bin/env npx ts-node +/** + * VICE MCP Integration Tests + * + * Run against a VICE instance with binary monitor enabled: + * x64sc -binarymonitor -binarymonitoraddress ip4://127.0.0.1:6502 + * + * Usage: + * npx ts-node test/integration.ts + */ + +import { Socket } from "net"; + +const HOST = "127.0.0.1"; +const PORT = 6502; + +// Protocol constants +const STX = 0x02; +const API_VERSION = 0x02; + +// Commands +const CMD_MEMORY_GET = 0x01; +const CMD_MEMORY_SET = 0x02; +const CMD_REGISTERS_GET = 0x31; +const CMD_DISPLAY_GET = 0x84; + +// Response types +const RESP_MEMORY_GET = 0x01; +const RESP_REGISTER_INFO = 0x31; +const RESP_STOPPED = 0x62; +const RESP_DISPLAY_GET = 0x84; + +let requestId = 0; +let socket: Socket; +let responseBuffer = Buffer.alloc(0); + +interface TestResult { + name: string; + passed: boolean; + message: string; + duration: number; +} + +const results: TestResult[] = []; + +function nextRequestId(): number { + requestId = (requestId + 1) & 0xffffffff; + return requestId; +} + +function buildPacket(command: number, body: Buffer): Buffer { + const header = Buffer.alloc(11); + header[0] = STX; + header[1] = API_VERSION; + header.writeUInt32LE(body.length, 2); + header.writeUInt32LE(nextRequestId(), 6); + header[10] = command; + return Buffer.concat([header, body]); +} + +async function sendCommand(command: number, body: Buffer): Promise<{ responseType: number; errorCode: number; body: Buffer }> { + return new Promise((resolve, reject) => { + const packet = buildPacket(command, body); + const reqId = requestId; + + console.log(` Sending cmd 0x${command.toString(16)}, body ${body.length} bytes, reqId ${reqId}`); + + const timeout = setTimeout(() => { + reject(new Error("Timeout waiting for response")); + }, 10000); + + const handleData = () => { + // Response header: STX(1) + API(1) + bodyLength(4) + responseType(1) + errorCode(1) + requestId(4) = 12 bytes + while (responseBuffer.length >= 12) { + if (responseBuffer[0] !== STX) { + responseBuffer = responseBuffer.subarray(1); + continue; + } + + const bodyLength = responseBuffer.readUInt32LE(2); + const totalLength = 12 + bodyLength; + + if (responseBuffer.length < totalLength) { + break; // Wait for more data + } + + const responseType = responseBuffer[6]; + const errorCode = responseBuffer[7]; + const respReqId = responseBuffer.readUInt32LE(8); + const respBody = responseBuffer.subarray(12, totalLength); + + responseBuffer = responseBuffer.subarray(totalLength); + + // Skip async events (Stopped, Resumed) unless they match our request + if (respReqId === 0xffffffff) { + console.log(` Got async event type 0x${responseType.toString(16)}`); + continue; + } + + if (respReqId === reqId || responseType === RESP_REGISTER_INFO) { + clearTimeout(timeout); + socket.off("data", onData); + resolve({ responseType, errorCode, body: respBody }); + return; + } + } + }; + + const onData = (data: Buffer) => { + console.log(` Received ${data.length} bytes: ${data.subarray(0, Math.min(20, data.length)).toString("hex")}...`); + responseBuffer = Buffer.concat([responseBuffer, data]); + handleData(); + }; + + socket.on("data", onData); + socket.write(packet); + }); +} + +async function connect(): Promise { + return new Promise((resolve, reject) => { + socket = new Socket(); + socket.on("connect", resolve); + socket.on("error", reject); + socket.connect(PORT, HOST); + }); +} + +async function runTest(name: string, fn: () => Promise): Promise { + const start = Date.now(); + try { + await fn(); + const duration = Date.now() - start; + results.push({ name, passed: true, message: "OK", duration }); + console.log(`✅ ${name} (${duration}ms)`); + } catch (error) { + const duration = Date.now() - start; + const message = error instanceof Error ? error.message : String(error); + results.push({ name, passed: false, message, duration }); + console.log(`❌ ${name}: ${message} (${duration}ms)`); + } +} + +// Tests + +async function testConnect(): Promise { + await connect(); + if (!socket.writable) { + throw new Error("Socket not writable after connect"); + } +} + +async function testRegistersGet(): Promise { + const body = Buffer.alloc(1); + body[0] = 0; // MainCPU memspace + + const resp = await sendCommand(CMD_REGISTERS_GET, body); + + if (resp.errorCode !== 0) { + throw new Error(`Error code 0x${resp.errorCode.toString(16)}`); + } + if (resp.body.length < 10) { + throw new Error(`Response too short: ${resp.body.length} bytes`); + } + console.log(` Got ${resp.body.length} bytes of register data`); +} + +async function testMemoryGet(): Promise { + // Read $0400-$04FF (screen RAM) + const body = Buffer.alloc(8); + body[0] = 0; // No side effects + body.writeUInt16LE(0x0400, 1); // Start + body.writeUInt16LE(0x04ff, 3); // End + body[5] = 0; // MainCPU memspace + body.writeUInt16LE(0, 6); // Bank ID + + const resp = await sendCommand(CMD_MEMORY_GET, body); + + if (resp.errorCode !== 0) { + throw new Error(`Error code 0x${resp.errorCode.toString(16)}`); + } + + // Response body: length(2) + data + const dataLength = resp.body.readUInt16LE(0); + if (dataLength !== 256) { + throw new Error(`Expected 256 bytes, got ${dataLength}`); + } + console.log(` Got ${dataLength} bytes of memory data`); +} + +async function testMemorySet(): Promise { + // Write a single byte to $D020 (border color) + const body = Buffer.alloc(9); + body[0] = 0; // No side effects + body.writeUInt16LE(0xd020, 1); // Start + body.writeUInt16LE(0xd020, 3); // End + body[5] = 0; // MainCPU memspace + body.writeUInt16LE(0, 6); // Bank ID + body[8] = 0x00; // Black + + const resp = await sendCommand(CMD_MEMORY_SET, body); + + if (resp.errorCode !== 0) { + throw new Error(`Error code 0x${resp.errorCode.toString(16)}`); + } + console.log(` Successfully wrote to $D020`); +} + +async function testDisplayGet(): Promise { + // DisplayGet: VC(1) + FM(1) + const body = Buffer.alloc(2); + body[0] = 1; // Use VIC-II + body[1] = 0; // Indexed 8-bit format + + const resp = await sendCommand(CMD_DISPLAY_GET, body); + + if (resp.errorCode !== 0) { + throw new Error(`Error code 0x${resp.errorCode.toString(16)} (${getErrorName(resp.errorCode)})`); + } + + // Parse response: FL(4) + DW(2) + DH(2) + ... + if (resp.body.length < 21) { + throw new Error(`Response too short: ${resp.body.length} bytes`); + } + + const fieldsLength = resp.body.readUInt32LE(0); + const width = resp.body.readUInt16LE(4); + const height = resp.body.readUInt16LE(6); + const bufferLength = resp.body.readUInt32LE(17); + + console.log(` Display: ${width}x${height}, buffer ${bufferLength} bytes, total response ${resp.body.length} bytes`); + + if (bufferLength < 100000) { + throw new Error(`Buffer too small: ${bufferLength} bytes (expected ~157KB)`); + } +} + +function getErrorName(code: number): string { + const names: Record = { + 0x00: "OK", + 0x01: "ObjectMissing", + 0x02: "InvalidMemspace", + 0x80: "InvalidCmdLength", + 0x81: "InvalidParameterLength", + 0x82: "InvalidAPI", + 0x83: "InvalidCmdType", + 0x84: "InvalidTarget", + 0x85: "InvalidParameter", + }; + return names[code] || `Unknown(0x${code.toString(16)})`; +} + +async function main(): Promise { + console.log("VICE MCP Integration Tests"); + console.log("==========================\n"); + console.log(`Connecting to VICE at ${HOST}:${PORT}...\n`); + + await runTest("Connect", testConnect); + await runTest("RegistersGet", testRegistersGet); + await runTest("MemoryGet", testMemoryGet); + await runTest("MemorySet", testMemorySet); + await runTest("DisplayGet", testDisplayGet); + + // Summary + console.log("\n=========================="); + const passed = results.filter(r => r.passed).length; + const failed = results.filter(r => !r.passed).length; + console.log(`Results: ${passed} passed, ${failed} failed`); + + if (socket) { + socket.end(); + } + + process.exit(failed > 0 ? 1 : 0); +} + +main().catch(err => { + console.error("Fatal error:", err); + process.exit(1); +});