- 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>
457 lines
14 KiB
TypeScript
457 lines
14 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|