/** * Bridge integration test — drives the full `KRPCAdapter` + `extract()` * pipeline against the in-process mock kRPC server. * * This is the most important test in this branch: it exercises the * exact code path that was failing when the bridge connected to a * real kRPC server (handshake → GetServices → 280+ procedure calls * per tick → snapshot build). If any of the four bug classes from * the 15 fix commits regresses, this test will fail with a clear * error message naming the decode path. * * It also doubles as a test of the `KSP_KRPC_PORT` / `KSP_KRPC_HOST` * env var contract, since the adapter is the only thing that reads * those (the rest of the bridge uses `KERBAL_RT_API_URL`). */ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { KRPCAdapter } from '../src/krpc-adapter.js'; import { buildSnapshot } from '../src/extract.js'; import { startMockKrpcServer, type MockServer, } from '../../../../packages/krpc-client/tests/mock-krpc-server.js'; import { encodeDouble, encodeString, encodeUint64, encodeSint32, } from '../../../../packages/krpc-client/src/_test-encode.js'; import { Buffer } from 'node:buffer'; import { KRPC } from '../../../../packages/krpc-client/src/schema.js'; describe('Bridge integration — full extract() against mock kRPC', () => { let server: MockServer; let adapter: KRPCAdapter; beforeAll(async () => { server = await startMockKrpcServer(); adapter = new KRPCAdapter({ host: '127.0.0.1', rpcPort: server.rpcPort, streamPort: server.streamPort, clientName: 'bridge-integration-test', }); await adapter.connect(); }, 10_000); afterAll(async () => { await adapter.disconnect(); await server.close(); }); it('connects and loads the service catalog', () => { expect(adapter.isConnected()).toBe(true); const services = adapter.getServices(); expect(services.getCache().serviceNames()).toEqual( expect.arrayContaining(['KRPC', 'SpaceCenter']), ); }); it('runs extract() and produces a UniverseSnapshot', async () => { // First do SEQUENTIAL calls to verify the mock + decoder are correct // before we try Promise.all (which is what extract() uses). const services = adapter.getServices(); const ut = await services.invoke('SpaceCenter', 'GetUT'); expect(ut).toBeCloseTo(4_700_000, 1); const bodyIds = await services.invoke('SpaceCenter', 'GetBodies'); expect(bodyIds).toEqual([101n, 102n]); const vesselIds = await services.invoke('SpaceCenter', 'GetVessels'); expect(vesselIds).toEqual([]); // Now exercise the full extract() which uses Promise.all const state = await adapter.readState(); expect(state.ut).toBeCloseTo(4_700_000, 1); expect(state.bodies.length).toBeGreaterThan(0); const bodyNames = state.bodies.map((b) => b.name).sort(); expect(bodyNames).toEqual(['Kerbin', 'Kerbol']); expect(state.vessels.length).toBe(0); }); it('buildSnapshot produces a valid UniverseSnapshot', async () => { const state = await adapter.readState(); const snap = buildSnapshot(state, '2026-06-03T14:00:00Z'); expect(snap.ut).toBeCloseTo(4_700_000, 1); expect(snap.capturedAt).toBe('2026-06-03T14:00:00Z'); expect(snap.bodies.length).toBe(2); // Kerbin's id is "kerbin" (lowercased, slugified) const kerbin = snap.bodies.find((b) => b.id === 'kerbin'); expect(kerbin).toBeDefined(); if (!kerbin) throw new Error('unreachable'); expect(kerbin.name).toBe('Kerbin'); expect(kerbin.kind).toBe('planet'); expect(kerbin.parentId).toBe('kerbol'); expect(kerbin.radius).toBe(600_000); expect(kerbin.sphereOfInfluence).toBe(84_159_286); // Kerbin's orbit expect(kerbin.orbit.semiMajorAxis).toBe(13_599_840_256); expect(kerbin.orbit.eccentricity).toBeCloseTo(0.05, 6); }); it('records the procedure call counts (proves the wire path is exercised)', () => { // After the extract() calls above, the mock should have seen: // - 3 top-level SpaceCenter calls (GetUT, GetBodies, GetVessels) // - 2 + 2 = 4 body-name/parent lookups (Kerbol has no parent lookup // since the parent's name is cached on subsequent calls; Kerbin // fetches Kerbol's name on first call) // - 2*8 = 16 Orbit field lookups (one for Kerbin, one for the // shared orbit id) // - 2 * 6 = 12 CelestialBody field lookups (name, parent, radius, // soi, gm, rotation, tilt, orbit) = 8 per body * 2 bodies = 16 // // Exact counts depend on caching, but we can at least verify // SpaceCenter.GetUT was called. expect(server.callCount('SpaceCenter', 'GetUT')).toBeGreaterThanOrEqual(2); expect(server.callCount('SpaceCenter', 'GetBodies')).toBeGreaterThanOrEqual(2); }); }); /** * Custom fixture: 1 star + 1 planet + 1 moon + 1 vessel. This is the * "minimum nontrivial save" shape, and exercises the nested * parent-name resolution that extract() does. */ describe('Bridge integration — custom fixture (1 star, 1 planet, 1 moon, 1 vessel)', () => { let server: MockServer; let adapter: KRPCAdapter; // Object id map for the test fixture const IDS = { kerbol: 1n, kerbin: 2n, mun: 3n, vessel: 4n, }; const ORBIT_KERBOL = 100n; // Kerbol doesn't have a real orbit; we use 0 in the default const ORBIT_KERBIN = 101n; const ORBIT_MUN = 102n; const ORBIT_VESSEL = 103n; beforeAll(async () => { server = await startMockKrpcServer(); // Override the default stubs to use our object ids server.stub('SpaceCenter', 'GetBodies', () => { const listMsg = KRPC.List.create({ items: [encodeUint64(IDS.kerbol), encodeUint64(IDS.kerbin), encodeUint64(IDS.mun)], }); return new Uint8Array(KRPC.List.encode(listMsg).finish()); }); server.stub('SpaceCenter', 'GetVessels', () => { const listMsg = KRPC.List.create({ items: [encodeUint64(IDS.vessel)], }); return new Uint8Array(KRPC.List.encode(listMsg).finish()); }); // Name lookups const NAMES: Record = { [IDS.kerbol.toString()]: 'Kerbol', [IDS.kerbin.toString()]: 'Kerbin', [IDS.mun.toString()]: 'Mun', }; server.stub('SpaceCenter', 'CelestialBody.get_Name', (args) => { const id = readVarint(args[0] ?? new Uint8Array()); return encodeString(NAMES[id.toString()] ?? 'Body'); }); // Parent lookups const PARENTS: Record = { [IDS.kerbol.toString()]: 0n, // no parent [IDS.kerbin.toString()]: IDS.kerbol, [IDS.mun.toString()]: IDS.kerbin, }; server.stub('SpaceCenter', 'CelestialBody.get_Parent', (args) => { const id = readVarint(args[0] ?? new Uint8Array()); return encodeUint64(PARENTS[id.toString()] ?? 0n); }); // Body scalar fields — distinct per body const RADII: Record = { [IDS.kerbol.toString()]: 261_600_000, [IDS.kerbin.toString()]: 600_000, [IDS.mun.toString()]: 200_000, }; server.stub('SpaceCenter', 'CelestialBody.get_Radius', (args) => { const id = readVarint(args[0] ?? new Uint8Array()); return encodeDouble(RADII[id.toString()] ?? 0); }); const SOI: Record = { [IDS.kerbol.toString()]: 1e30, [IDS.kerbin.toString()]: 84_159_286, [IDS.mun.toString()]: 2_429_581, }; server.stub('SpaceCenter', 'CelestialBody.get_SphereOfInfluence', (args) => { const id = readVarint(args[0] ?? new Uint8Array()); return encodeDouble(SOI[id.toString()] ?? 0); }); const GM: Record = { [IDS.kerbol.toString()]: 1.172332794e18, [IDS.kerbin.toString()]: 3.5316e12, [IDS.mun.toString()]: 6.5138398e10, }; server.stub('SpaceCenter', 'CelestialBody.get_GravitationalParameter', (args) => { const id = readVarint(args[0] ?? new Uint8Array()); return encodeDouble(GM[id.toString()] ?? 0); }); const ROT: Record = { [IDS.kerbol.toString()]: 432_000, [IDS.kerbin.toString()]: 21_600, [IDS.mun.toString()]: 138_984.38, }; server.stub('SpaceCenter', 'CelestialBody.get_RotationPeriod', (args) => { const id = readVarint(args[0] ?? new Uint8Array()); return encodeDouble(ROT[id.toString()] ?? 0); }); server.stub('SpaceCenter', 'CelestialBody.get_AxialTilt', () => encodeDouble(0)); // Orbit ids — each body/vessel has its own const ORBIT_FOR_BODY: Record = { [IDS.kerbol.toString()]: 0n, // Kerbol has no orbit [IDS.kerbin.toString()]: ORBIT_KERBIN, [IDS.mun.toString()]: ORBIT_MUN, }; server.stub('SpaceCenter', 'CelestialBody.get_Orbit', (args) => { const id = readVarint(args[0] ?? new Uint8Array()); return encodeUint64(ORBIT_FOR_BODY[id.toString()] ?? 0n); }); server.stub('SpaceCenter', 'Vessel.get_Orbit', () => encodeUint64(ORBIT_VESSEL)); // Orbit parameters (vary by orbit id) const SMA: Record = { [ORBIT_KERBIN.toString()]: 13_599_840_256, [ORBIT_MUN.toString()]: 12_000_000, [ORBIT_VESSEL.toString()]: 7_500_000, }; const ECC: Record = { [ORBIT_KERBIN.toString()]: 0.05, [ORBIT_MUN.toString()]: 0, [ORBIT_VESSEL.toString()]: 0.01, }; const INC: Record = { [ORBIT_KERBIN.toString()]: 0, [ORBIT_MUN.toString()]: 0, [ORBIT_VESSEL.toString()]: 0.05, }; const setOrbitStub = (proc: string, table: Record, def: number) => { server.stub('SpaceCenter', `Orbit.${proc}`, (args) => { const id = readVarint(args[0] ?? new Uint8Array()); return encodeDouble(table[id.toString()] ?? def); }); }; setOrbitStub('get_SemiMajorAxis', SMA, 0); setOrbitStub('get_Eccentricity', ECC, 0); setOrbitStub('get_Inclination', INC, 0); setOrbitStub('get_LongitudeOfAscendingNode', {}, 0); setOrbitStub('get_ArgumentOfPeriapsis', {}, 0); setOrbitStub('get_MeanAnomalyAtEpoch', {}, 0); setOrbitStub('get_Epoch', {}, 0); // Vessel fields server.stub('SpaceCenter', 'Vessel.get_Name', () => encodeString('Test Probe')); server.stub('SpaceCenter', 'Vessel.get_Type', () => encodeSint32(3)); // Probe server.stub('SpaceCenter', 'Vessel.get_Situation', () => encodeSint32(1)); // Orbiting server.stub('SpaceCenter', 'Vessel.get_ReferenceBody', () => encodeUint64(IDS.kerbin)); adapter = new KRPCAdapter({ host: '127.0.0.1', rpcPort: server.rpcPort, streamPort: server.streamPort, clientName: 'custom-fixture-test', }); await adapter.connect(); }, 10_000); afterAll(async () => { await adapter.disconnect(); await server.close(); }); it('extracts 3 bodies and 1 vessel', async () => { const state = await adapter.readState(); expect(state.bodies.length).toBe(3); expect(state.vessels.length).toBe(1); const bodyNames = state.bodies.map((b) => b.name).sort(); expect(bodyNames).toEqual(['Kerbin', 'Kerbol', 'Mun']); const v = state.vessels[0]; expect(v?.name).toBe('Test Probe'); expect(v?.type).toBe('Probe'); expect(v?.referenceBodyName).toBe('Kerbin'); }); it('produces a UniverseSnapshot with the right id slugs', async () => { const state = await adapter.readState(); const snap = buildSnapshot(state, '2026-06-03T14:00:00Z'); const ids = snap.bodies.map((b) => b.id).sort(); expect(ids).toEqual(['kerbin', 'kerbol', 'mun']); const kerbin = snap.bodies.find((b) => b.id === 'kerbin'); expect(kerbin?.parentId).toBe('kerbol'); const mun = snap.bodies.find((b) => b.id === 'mun'); expect(mun?.parentId).toBe('kerbin'); const kerbol = snap.bodies.find((b) => b.id === 'kerbol'); expect(kerbol?.parentId).toBeNull(); }); it('decodes the vessel situation and type from the enum values', async () => { const state = await adapter.readState(); const snap = buildSnapshot(state, '2026-06-03T14:00:00Z'); expect(snap.vessels[0]?.situation).toBe('ORBITING'); expect(snap.vessels[0]?.type).toBe('Probe'); expect(snap.vessels[0]?.referenceBodyId).toBe('kerbin'); }); }); 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; } void Buffer; // keep import alive