Add integration test suites for protocol and MCP tool layers

- test/integration.ts: Protocol-level tests (Connect, RegistersGet, MemoryGet, MemorySet, DisplayGet)
- test/integration.test.ts: MCP tool-level tests (16 tools including semantic layer)

Both suites validate the VICE binary monitor protocol implementation.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Simen Svale 2025-12-30 04:24:26 +01:00
parent c722e18496
commit 69e05806e1
2 changed files with 737 additions and 0 deletions

457
test/integration.test.ts Normal file
View file

@ -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<string, unknown>;
}
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<number, { resolve: (v: unknown) => 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<string, unknown>): Promise<unknown> {
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<string, unknown> = {}): Promise<unknown> {
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<string, unknown>;
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<void> }> = [];
function test(name: string, fn: (client: MCPTestClient) => Promise<void>) {
tests.push({ name, fn });
}
// === CONNECTION TESTS ===
test('connect - connects to VICE', async (client) => {
const result = await client.callTool('connect') as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
assertNoError(result, 'writeMemory');
// Verify
const verify = await client.callTool('readMemory', {
address: testAddr,
length: 1,
}) as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
assertNoError(result, 'readSprites');
assert(result.count === 8, 'should have 8 sprites');
assertExists(result.sprites, 'should have sprites array');
const sprites = result.sprites as Array<Record<string, unknown>>;
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<string, unknown>;
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<string, unknown>;
assertNoError(result, 'readColorRam');
assertExists(result.summary, 'should have summary');
const summary = result.summary as Record<string, unknown>;
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<string, unknown>;
const pcBefore = (before.pc as Record<string, unknown>).value;
const result = await client.callTool('step') as Record<string, unknown>;
assertNoError(result, 'step');
const after = await client.callTool('getRegisters') as Record<string, unknown>;
const pcAfter = (after.pc as Record<string, unknown>).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<string, unknown>;
assertNoError(result, 'continue');
// Give it a moment to run
await new Promise(r => setTimeout(r, 100));
const status = await client.callTool('status') as Record<string, unknown>;
// 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<string, unknown>;
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<string, unknown>;
const bpId = bp.breakpointId as number;
const result = await client.callTool('listBreakpoints') as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
assertNoError(result, 'disassemble');
assertExists(result.instructions, 'should have instructions');
const instrs = result.instructions as Array<Record<string, unknown>>;
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<string, unknown>;
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<string, unknown>;
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);
});

280
test/integration.ts Normal file
View file

@ -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<void> {
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<void>): Promise<void> {
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<void> {
await connect();
if (!socket.writable) {
throw new Error("Socket not writable after connect");
}
}
async function testRegistersGet(): Promise<void> {
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<void> {
// 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<void> {
// 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<void> {
// 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<number, string> = {
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<void> {
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);
});