/** * Mock kRPC server — a real TCP server that speaks the kRPC wire * protocol (length-prefixed protobuf) so we can exercise the * kRPC client + bridge end-to-end without a running KSP install. * * This is a test-only fixture. It lives under tests/ so vitest's * coverage tool ignores it, and other packages import it via a * relative path. * * What it does: * 1. Listens on a free localhost port (or the port you pass in). * 2. On TCP connect, performs the kRPC handshake on both ports: * - RPC port: accepts a ConnectionRequest (type=RPC), replies * with a fixed clientIdentifier, no status/message (which is * what a real kRPC server sends on the happy path). * - Stream port: accepts a ConnectionRequest (type=STREAM), * replies OK, and idles (we don't test streams yet). * 3. Handles a small set of procedure calls by returning hand-encoded * deterministic responses from a fixture table. * 4. Records every call so tests can assert what was called, with * what args, and how many times. * * Usage from a test: * * const server = await startMockKrpcServer(); * try { * const client = new KRPCClient({ * host: '127.0.0.1', * rpcPort: server.rpcPort, * streamPort: server.streamPort, * }); * await client.connect(); * // ... exercise the client * } finally { * await server.close(); * } * * Default stubs cover the four procedures the bridge's extract() * loop calls per tick (GetUT, GetBodies, GetVessels, plus class * methods on CelestialBody / Vessel / Orbit). Tests can add more * stubs with `server.stub(service, procedure, handler)`. * * Wire-format reference: https://krpc.github.io/krpc/communication-protocols/messages.html */ import * as net from 'node:net'; import { Buffer } from 'node:buffer'; import { KRPC } from '../src/schema.js'; import { encodeVarint, recvMessage, sendMessage } from '../src/connection.js'; import { encodeDouble, encodeString, encodeUint64, encodeBool, encodeSint32, } from '../src/_test-encode.js'; void sendMessage; // not used directly in this file export interface MockServerOptions { /** Fixed 16-byte clientIdentifier returned by the RPC handshake. * Default: "mock-krpc-client". */ clientIdentifier?: Buffer; /** Optional logger for server-side events. Defaults to no-op. */ log?: (msg: string) => void; } export interface MockServer { rpcPort: number; streamPort: number; /** Add (or replace) a stub for a service.procedure call. */ stub(service: string, procedure: string, handler: (args: Uint8Array[]) => Uint8Array): void; /** Count of calls received for service.procedure. */ callCount(service: string, procedure: string): number; /** Arguments of the last call to service.procedure. */ lastArgs(service: string, procedure: string): Uint8Array[]; /** Full call log, in arrival order. Each entry: { service, procedure, args }. */ callLog(): { service: string; procedure: string; args: Uint8Array[] }[]; /** Stop both servers and release the ports. */ close(): Promise; } async function freePort(): Promise { return new Promise((resolve, reject) => { const srv = net.createServer(); srv.unref(); srv.on('error', reject); srv.listen(0, '127.0.0.1', () => { const addr = srv.address(); if (addr && typeof addr === 'object') { const p = addr.port; srv.close(() => resolve(p)); } else { srv.close(() => reject(new Error('no addr'))); } }); }); } function replyWithValue(socket: net.Socket, value: Uint8Array): void { const resultMsg = KRPC.ProcedureResult.create({ value: Buffer.from(value), }); const respMsg = KRPC.Response.create({ results: [resultMsg] }); const respBytes = Buffer.from(KRPC.Response.encode(respMsg).finish()); if (process.env.KRPC_DEBUG) { // eslint-disable-next-line no-console console.log(`[mock-krpc] >>> reply (${respBytes.length} bytes):`, respBytes.toString('hex')); } const prefix = encodeVarint(respBytes.length); socket.write(Buffer.concat([prefix, respBytes])); } function replyWithError(socket: net.Socket, name: string, description: string): void { const errMsg = KRPC.Error.create({ service: 'MockKRPC', name, description }); const resultMsg = KRPC.ProcedureResult.create({ error: errMsg, value: Buffer.from([]) }); sendMessage(socket, KRPC.Response, { results: [resultMsg] }); } interface CallRecord { service: string; procedure: string; args: Uint8Array[]; } /** * Build the standard "stock KSP-like" stub set so the bridge's * extract() loop can run end-to-end against the mock. * * The catalog mirrors what a stock KSP save with 1 star, 1 planet, 1 * moon, 0 vessels looks like. Tests that need richer fixtures can * overwrite individual procedures via `server.stub(...)`. */ export function defaultStubs(server: { stub: (svc: string, proc: string, h: (args: Uint8Array[]) => Uint8Array) => void; }): void { // GetServices — return a hand-crafted services message with one // service (SpaceCenter), one procedure (GetUT, returning DOUBLE), // and a few class/enum entries to exercise the cache. server.stub('KRPC', 'GetServices', () => { const servicesMsg = KRPC.Services.create({ services: [ { name: 'KRPC', procedures: [ { name: 'GetStatus', parameters: [], returnType: { code: 203, service: '', name: '', types: [] }, returnIsNullable: false, }, { name: 'GetServices', parameters: [], returnType: { code: 204, service: '', name: '', types: [] }, returnIsNullable: false, }, ], classes: [], enumerations: [], }, { name: 'SpaceCenter', procedures: [ { name: 'GetUT', parameters: [], returnType: { code: 1, service: '', name: '', types: [] }, returnIsNullable: false, }, { name: 'GetBodies', parameters: [], returnType: { code: 301, service: '', name: '', types: [{ code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }], }, returnIsNullable: false, }, { name: 'GetVessels', parameters: [], returnType: { code: 301, service: '', name: '', types: [{ code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] }], }, returnIsNullable: false, }, { name: 'CelestialBody.get_Name', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [], }, nullable: false, }, ], returnType: { code: 8, service: '', name: '', types: [] }, returnIsNullable: false, }, { name: 'CelestialBody.get_Parent', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [], }, nullable: false, }, ], returnType: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [], }, returnIsNullable: true, }, { name: 'CelestialBody.get_Radius', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [], }, nullable: false, }, ], returnType: { code: 1, service: '', name: '', types: [] }, returnIsNullable: false, }, { name: 'CelestialBody.get_SphereOfInfluence', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [], }, nullable: false, }, ], returnType: { code: 1, service: '', name: '', types: [] }, returnIsNullable: false, }, { name: 'CelestialBody.get_GravitationalParameter', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [], }, nullable: false, }, ], returnType: { code: 1, service: '', name: '', types: [] }, returnIsNullable: false, }, { name: 'CelestialBody.get_RotationPeriod', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [], }, nullable: false, }, ], returnType: { code: 1, service: '', name: '', types: [] }, returnIsNullable: false, }, { name: 'CelestialBody.get_AxialTilt', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [], }, nullable: false, }, ], returnType: { code: 1, service: '', name: '', types: [] }, returnIsNullable: false, }, { name: 'CelestialBody.get_Orbit', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [], }, nullable: false, }, ], returnType: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [], }, returnIsNullable: true, }, { name: 'Orbit.get_SemiMajorAxis', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] }, nullable: false, }, ], returnType: { code: 1, service: '', name: '', types: [] }, returnIsNullable: false, }, { name: 'Orbit.get_Eccentricity', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] }, nullable: false, }, ], returnType: { code: 1, service: '', name: '', types: [] }, returnIsNullable: false, }, { name: 'Orbit.get_Inclination', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] }, nullable: false, }, ], returnType: { code: 1, service: '', name: '', types: [] }, returnIsNullable: false, }, { name: 'Orbit.get_LongitudeOfAscendingNode', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] }, nullable: false, }, ], returnType: { code: 1, service: '', name: '', types: [] }, returnIsNullable: false, }, { name: 'Orbit.get_ArgumentOfPeriapsis', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] }, nullable: false, }, ], returnType: { code: 1, service: '', name: '', types: [] }, returnIsNullable: false, }, { name: 'Orbit.get_MeanAnomalyAtEpoch', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] }, nullable: false, }, ], returnType: { code: 1, service: '', name: '', types: [] }, returnIsNullable: false, }, { name: 'Orbit.get_Epoch', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [] }, nullable: false, }, ], returnType: { code: 1, service: '', name: '', types: [] }, returnIsNullable: false, }, { name: 'Vessel.get_Name', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] }, nullable: false, }, ], returnType: { code: 8, service: '', name: '', types: [] }, returnIsNullable: false, }, { name: 'Vessel.get_Type', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] }, nullable: false, }, ], returnType: { code: 101, service: 'SpaceCenter', name: 'VesselType', types: [], }, returnIsNullable: false, }, { name: 'Vessel.get_Situation', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] }, nullable: false, }, ], returnType: { code: 101, service: 'SpaceCenter', name: 'VesselSituation', types: [], }, returnIsNullable: false, }, { name: 'Vessel.get_Orbit', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] }, nullable: false, }, ], returnType: { code: 100, service: 'SpaceCenter', name: 'Orbit', types: [], }, returnIsNullable: true, }, { name: 'Vessel.get_ReferenceBody', parameters: [ { name: 'self', type: { code: 100, service: 'SpaceCenter', name: 'Vessel', types: [] }, nullable: false, }, ], returnType: { code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [], }, returnIsNullable: false, }, ], classes: [{ name: 'CelestialBody' }, { name: 'Vessel' }, { name: 'Orbit' }], enumerations: [ { name: 'VesselType', values: [ { name: 'Ship', value: 0 }, { name: 'Station', value: 1 }, { name: 'Lander', value: 2 }, { name: 'Probe', value: 3 }, { name: 'Debris', value: 8 }, ], }, { name: 'VesselSituation', values: [ { name: 'PreLaunch', value: 0 }, { name: 'Orbiting', value: 1 }, { name: 'Escaping', value: 2 }, { name: 'Flying', value: 3 }, { name: 'Landed', value: 4 }, { name: 'Splashed', value: 5 }, { name: 'Docked', value: 6 }, { name: 'SubOrbital', value: 7 }, ], }, ], }, ], }); return new Uint8Array(KRPC.Services.encode(servicesMsg).finish()); }); // KRPC.GetStatus — return a tiny status message. server.stub('KRPC', 'GetStatus', () => { const statusMsg = KRPC.Status.create({ version: 'mock-0.5.0' }); return new Uint8Array(KRPC.Status.encode(statusMsg).finish()); }); // SpaceCenter.GetUT — current universal time. Always 4_700_000.0 // unless a test overrides it. let mockUt = 4_700_000.0; server.stub('SpaceCenter', 'GetUT', () => encodeDouble(mockUt)); // expose mutator on the server for tests that want ticking time (server as unknown as { setUt?: (v: number) => void }).setUt = (v: number) => { mockUt = v; }; void encodeBool; // keep import alive void encodeSint32; } /** * Start the mock kRPC server. Returns once both ports are bound and * ready to accept connections. */ export async function startMockKrpcServer(options: MockServerOptions = {}): Promise { const clientId = options.clientIdentifier ?? Buffer.from('mock-krpc-client'); const log = options.log ?? (() => undefined); const rpcPort = await freePort(); const streamPort = await freePort(); const stubs = new Map Uint8Array>(); const counts = new Map(); const lastArgs = new Map(); const log_: CallRecord[] = []; const stub = (svc: string, proc: string, handler: (args: Uint8Array[]) => Uint8Array) => { stubs.set(`${svc}.${proc}`, handler); }; const server: MockServer = { rpcPort, streamPort, stub, callCount: (svc, proc) => counts.get(`${svc}.${proc}`) ?? 0, lastArgs: (svc, proc) => lastArgs.get(`${svc}.${proc}`) ?? [], callLog: () => [...log_], close: () => new Promise((resolve) => { rpcServer.close(() => { streamServer.close(() => resolve()); }); }), }; // Server-side log helper: dump the wire bytes of every response we // send. Gated by env var. Used to diagnose framing bugs that the // client-side log can't catch (because the client is reading the // wrong slice of the byte stream). const logSend = (label: string, payload: Uint8Array): void => { if (process.env.KRPC_DEBUG) { // eslint-disable-next-line no-console console.log( `[mock-krpc] >>> ${label} (${payload.length} bytes):`, Buffer.from(payload).toString('hex'), ); } }; // Expose for tests that want to override the helper. (server as unknown as { _logSend: typeof logSend })._logSend = logSend; defaultStubs(server); // Apply a couple of well-known defaults for the standard test fixture // (1 star, 1 planet, 0 vessels) so the bridge's extract() loop has // something to read. Tests that need more bodies / vessels should // override these stubs. stub('SpaceCenter', 'GetBodies', () => { const ids = [encodeUint64(101n), encodeUint64(102n)]; const listMsg = KRPC.List.create({ items: ids }); return new Uint8Array(KRPC.List.encode(listMsg).finish()); }); stub('SpaceCenter', 'GetVessels', () => { const listMsg = KRPC.List.create({ items: [] }); return new Uint8Array(KRPC.List.encode(listMsg).finish()); }); // Kerbol (root, no parent) stub('SpaceCenter', 'CelestialBody.get_Name', (args) => { const id = readVarint(args[0] ?? new Uint8Array()); const name = id === 101n ? 'Kerbol' : id === 102n ? 'Kerbin' : 'Body'; return encodeString(name); }); stub('SpaceCenter', 'CelestialBody.get_Parent', (args) => { const id = readVarint(args[0] ?? new Uint8Array()); if (id === 101n) return encodeUint64(0n); // Kerbol has no parent if (id === 102n) return encodeUint64(101n); // Kerbin's parent is Kerbol return encodeUint64(0n); }); stub('SpaceCenter', 'CelestialBody.get_Radius', (args) => { const id = readVarint(args[0] ?? new Uint8Array()); return encodeDouble(id === 101n ? 261_600_000 : 600_000); }); stub('SpaceCenter', 'CelestialBody.get_SphereOfInfluence', (args) => { const id = readVarint(args[0] ?? new Uint8Array()); return encodeDouble(id === 101n ? 1e30 : 84_159_286); }); stub('SpaceCenter', 'CelestialBody.get_GravitationalParameter', (args) => { const id = readVarint(args[0] ?? new Uint8Array()); return encodeDouble(id === 101n ? 1.172332794e18 : 3.5316e12); }); stub('SpaceCenter', 'CelestialBody.get_RotationPeriod', (args) => { const id = readVarint(args[0] ?? new Uint8Array()); return encodeDouble(id === 101n ? 432_000 : 21_600); }); stub('SpaceCenter', 'CelestialBody.get_AxialTilt', () => encodeDouble(0)); stub('SpaceCenter', 'CelestialBody.get_Orbit', (args) => { const id = readVarint(args[0] ?? new Uint8Array()); // Kerbol (the star) has no orbit in the kRPC model. Real kRPC // returns null for the root body's orbit, so we mirror that. if (id === 101n) return encodeUint64(0n); return encodeUint64(9000n); }); // Orbit params — all bodies share one orbit id (9000) in this stub stub('SpaceCenter', 'Orbit.get_SemiMajorAxis', () => encodeDouble(13_599_840_256)); stub('SpaceCenter', 'Orbit.get_Eccentricity', () => encodeDouble(0.05)); stub('SpaceCenter', 'Orbit.get_Inclination', () => encodeDouble(0)); stub('SpaceCenter', 'Orbit.get_LongitudeOfAscendingNode', () => encodeDouble(0)); stub('SpaceCenter', 'Orbit.get_ArgumentOfPeriapsis', () => encodeDouble(0)); stub('SpaceCenter', 'Orbit.get_MeanAnomalyAtEpoch', () => encodeDouble(0)); stub('SpaceCenter', 'Orbit.get_Epoch', () => encodeDouble(0)); // ── RPC server ───────────────────────────────────────────────────── const rpcServer = net.createServer((socket) => { void (async () => { try { // 1. Handshake. We expect type=0 (RPC). const req = await recvMessage<{ type: number; clientName: string }>( socket, KRPC.ConnectionRequest, ); log(`[mock-krpc] RPC handshake: type=${req.type} name=${req.clientName}`); if (req.type !== 0) { log(`[mock-krpc] unexpected type=${req.type} on RPC port, closing`); socket.destroy(); return; } // Real kRPC server response: just field 3 (clientIdentifier), // no status/message. We do the same — encode ONLY clientIdentifier. const respMsg = KRPC.ConnectionResponse.create({ clientIdentifier: clientId, }); const respBytes = Buffer.from(KRPC.ConnectionResponse.encode(respMsg).finish()); socket.write(Buffer.concat([encodeVarint(respBytes.length), respBytes])); // 2. Procedure loop while (!socket.destroyed) { let callReq: { calls: { service: string; procedure: string; arguments?: { value: Uint8Array }[] }[]; }; try { callReq = await recvMessage(socket, KRPC.Request); } catch { return; } const call = callReq.calls[0]; if (!call) { replyWithError(socket, 'Malformed', 'no call'); continue; } const key = `${call.service}.${call.procedure}`; counts.set(key, (counts.get(key) ?? 0) + 1); const args = (call.arguments ?? []).map((a) => new Uint8Array(a.value)); lastArgs.set(key, args); log_.push({ service: call.service, procedure: call.procedure, args }); log(`[mock-krpc] call ${key} #${counts.get(key)} args.length=${args.length}`); const handler = stubs.get(key); if (!handler) { replyWithError(socket, 'UnknownProcedure', `${key} not stubbed`); continue; } try { const value = handler(args); replyWithValue(socket, value); } catch (e) { replyWithError(socket, 'HandlerError', String(e)); } } } catch (e) { log(`[mock-krpc] RPC socket error: ${String(e)}`); if (!socket.destroyed) socket.destroy(); } })(); }); await new Promise((resolve) => rpcServer.listen(rpcPort, '127.0.0.1', resolve)); // ── Stream server (handshake only, then idle) ───────────────────── const streamServer = net.createServer((socket) => { void (async () => { try { const req = await recvMessage<{ type: number; clientIdentifier: Uint8Array }>( socket, KRPC.ConnectionRequest, ); log(`[mock-krpc] stream handshake: type=${req.type}`); if (req.type !== 1) { socket.destroy(); return; } const respMsg = KRPC.ConnectionResponse.create({}); const respBytes = Buffer.from(KRPC.ConnectionResponse.encode(respMsg).finish()); socket.write(Buffer.concat([encodeVarint(respBytes.length), respBytes])); // Keep alive until socket closes await new Promise(() => undefined); } catch (e) { log(`[mock-krpc] stream socket error: ${String(e)}`); socket.destroy(); } })(); }); await new Promise((resolve) => streamServer.listen(streamPort, '127.0.0.1', resolve)); log(`[mock-krpc] listening on RPC=${rpcPort} stream=${streamPort}`); return server; } // ── helpers ──────────────────────────────────────────────────────────── function readVarint(bytes: Uint8Array): bigint { let v = 0n; let shift = 0n; for (const b of bytes) { v |= BigInt(b & 0x7f) << shift; if ((b & 0x80) === 0) return v; shift += 7n; } return v; }