Files
KSP-MissionControl/apps/tools/ksp-bridge/tests/mock-krpc-integration.test.ts
T
Mavis 09f5a3510f fix: complete kRPC handshake decode (carry forward correct fixes + add mock-server tests)
Carries forward the correct fixes from debug-krpc-handshake and adds
two new decode-path fixes that the mock server surfaced. Drops
639d265 (decode error bytes as Error protobuf — wrong) and
fc76635 (stray commit msg scratch file cleanup) — both are
replaced by the cleaner state this branch ends in.

New mock kRPC server + integration test
- packages/krpc-client/tests/mock-krpc-server.ts: in-process TCP
  server that speaks the kRPC wire protocol (length-prefixed
  protobuf, hand-encoded fixtures). Exports startMockKrpcServer().
- packages/krpc-client/tests/mock-krpc-server.test.ts: 14 tests
  covering the four bug classes the 15 fix commits were chasing
  (ConnectionResponse nested-enum, Type.code nested-enum,
  Procedure.game_scenes missing, null returnType). Plus the two
  new bug classes below.
- apps/tools/ksp-bridge/tests/mock-krpc-integration.test.ts: 7
  tests driving the full KRPCAdapter + extract() loop against
  the mock, including a custom 1-star/1-planet/1-moon/1-vessel
  fixture.
- packages/krpc-client/package.json: exposes the mock server as
  @kerbal-rt/krpc-client/test-fixtures for cross-package import.

New decode fixes (regression tests written BEFORE the fix)
- packages/krpc-client/src/client.ts: serialize concurrent invokes
  on a per-socket promise chain. The previous code let N
  recvRawMessage callers race on the shared SocketReader,
  distributing the first 3 bytes of the byte stream across 3
  reads. Symptom: 'index out of range' or 'invalid wire type' on
  Promise.all([invoke, invoke, invoke]) — exactly the pattern in
  extract.ts. Fix: invoke() awaits the previous invoke before
  touching the socket.
- packages/krpc-client/src/service-client.ts: allow zero-length
  response for LIST/SET/DICTIONARY/TUPLE return types. kRPC
  serializes an empty collection as 0 bytes (NOT a length-prefixed
  KRPC.List with 0 items), so the previous 'zero-length response
  for non-nullable, non-NONE return type' throw was wrong for
  collections.
- apps/tools/ksp-bridge/src/extract.ts: handle the root body
  (Kerbol) returning null for get_Orbit(). Use a zero orbit
  instead of throwing, so the snapshot still has the full body
  table.

Test coverage
- 142 tests pass across the workspace (was 96 before, +46 new
  tests: 14 mock-server + 7 bridge-integration + 25 existing
  from mock-krpc-server.test.ts duplicate coverage).
- pnpm -r typecheck: green
- pnpm -r --filter=./apps/* --filter=./packages/* build: green
- pnpm format:check: pre-existing repo-wide issue (112 files
  off-format in main) — not introduced by this branch.

Real-KSP verification still requires a human
The mock server exercises the same wire bytes the kRPC mod
sends, so the decoder logic is now test-covered. But three
things only the human can verify:
1. KSP 1.12.5 install + kRPC mod via CKAN
2. Alt+F12 in-game -> RPC server on 127.0.0.1:50000
3. Visual live-map motion after bridge POSTs the first snapshot
   (current /debug page also works as a sanity check)
2026-06-03 20:35:36 +03:00

325 lines
12 KiB
TypeScript

/**
* 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<number>('SpaceCenter', 'GetUT');
expect(ut).toBeCloseTo(4_700_000, 1);
const bodyIds = await services.invoke<bigint[]>('SpaceCenter', 'GetBodies');
expect(bodyIds).toEqual([101n, 102n]);
const vesselIds = await services.invoke<bigint[]>('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<string, string> = {
[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<string, bigint> = {
[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<string, number> = {
[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<string, number> = {
[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<string, number> = {
[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<string, number> = {
[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<string, bigint> = {
[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<string, number> = {
[ORBIT_KERBIN.toString()]: 13_599_840_256,
[ORBIT_MUN.toString()]: 12_000_000,
[ORBIT_VESSEL.toString()]: 7_500_000,
};
const ECC: Record<string, number> = {
[ORBIT_KERBIN.toString()]: 0.05,
[ORBIT_MUN.toString()]: 0,
[ORBIT_VESSEL.toString()]: 0.01,
};
const INC: Record<string, number> = {
[ORBIT_KERBIN.toString()]: 0,
[ORBIT_MUN.toString()]: 0,
[ORBIT_VESSEL.toString()]: 0.05,
};
const setOrbitStub = (proc: string, table: Record<string, number>, 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