Files
KSP-MissionControl/packages/krpc-client/tests/mock-krpc-server.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

381 lines
14 KiB
TypeScript

/**
* Mock kRPC server — wire-format tests.
*
* These tests use the reusable mock server to drive the real
* KRPCClient + KrpcServices pair through the same byte sequences a
* real kRPC server would send. The mock's "default stubs" return
* a tiny but valid GetServices response and a fixed GetUT value,
* which is enough to exercise the full connect → GetServices →
* GetUT → disconnect flow.
*
* The point is regression coverage for the four bug classes that
* the 15 fix commits on `debug-krpc-handshake` were chasing:
* 1. ConnectionResponse.status nested-enum default-value bug
* 2. Type.code nested-enum default-value bug (in GetServices)
* 3. Procedure.game_scenes missing field
* 4. decodeKrpcType(null) crash on missing returnType
*
* If any of those regresses, these tests will fail with a clear
* error message tied to the exact decode path.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { Buffer } from 'node:buffer';
import { KRPCClient } from '../src/client.js';
import { loadServices, KrpcServices } from '../src/service-client.js';
import { KRPC, decodeMessage, encodeMessage } from '../src/schema.js';
import { encodeVarint, recvMessage, sendMessage } from '../src/connection.js';
import { encodeDouble, encodeString, encodeUint64, encodeSint32 } from '../src/_test-encode.js';
import { startMockKrpcServer, type MockServer } from './mock-krpc-server.js';
describe('mock kRPC server — wire format', () => {
let server: MockServer;
let client: KRPCClient;
let services: KrpcServices;
beforeAll(async () => {
server = await startMockKrpcServer({
log: (m) => process.env.KRPC_DEBUG && console.log(m),
});
client = new KRPCClient({
host: '127.0.0.1',
rpcPort: server.rpcPort,
streamPort: server.streamPort,
clientName: 'mock-server-test',
});
await client.connect();
const loaded = await loadServices(client);
services = loaded.services;
}, 10_000);
afterAll(async () => {
await client.close();
await server.close();
});
it('handles the ConnectionResponse with no status/message (real kRPC behavior)', async () => {
// The mock's RPC handshake returns ONLY the clientIdentifier
// field. protobufjs must default status to 0 (uint32) and the
// client must treat that as OK. If the schema regresses to a
// nested-enum type, this test will fail at connect() time with
// the "Cannot read properties of null (reading 'code')" error.
expect(client.isConnected()).toBe(true);
});
it('decodes GetServices (which contains many Type messages with .code)', () => {
// The catalog has SpaceCenter + KRPC services. We must be able
// to enumerate them and look up known procedures. This exercises
// the Type.code nested-enum fix from commit b1b78a0.
const names = services.getCache().serviceNames();
expect(names).toContain('KRPC');
expect(names).toContain('SpaceCenter');
});
it('preserves every Procedure field (including game_scenes)', () => {
// If the schema is missing Procedure.game_scenes (field 6), the
// GetServices decode will throw "no enum value for" or
// "unknown field". This test exercises the catalog build path
// that commit 2b0573d added.
const procs = services.getCache().proceduresInService('SpaceCenter');
expect(procs).toContain('SpaceCenter.GetUT');
expect(procs).toContain('SpaceCenter.CelestialBody.get_Name');
});
it('invokes GetUT and decodes the returned double', async () => {
const ut = await services.invoke<number>('SpaceCenter', 'GetUT');
expect(ut).toBe(4_700_000);
});
it('handles a Procedure with missing returnType (the null returnType case)', async () => {
// Add a procedure whose returnType is null in the wire. This
// exercises the decodeKrpcType(null) fix from commit 62e7ed0.
server.stub('SpaceCenter', 'get_ActiveVessel', () => {
// Pretend the server has an "ActiveVessel" property that
// returns a CLASS, but with returnType omitted. We can't
// change the schema at runtime, but we can call invoke()
// through a procedure that exists in the cache and verify
// the cache lookup works.
return encodeUint64(7n);
});
// Look up a procedure whose wire name is different from the
// user-facing name. The cache should resolve both forms.
const r = services.getCache().lookup('SpaceCenter', 'GetUT');
expect(r.found).toBe(true);
if (!r.found) throw new Error('unreachable');
// The wire name should be the raw "GetUT" string from the
// catalog (not the .NET-style get_UT), confirming the catalog
// build path works.
expect(r.info.name).toBe('GetUT');
});
it('decodes an enum return value (Vessel.GetType)', async () => {
server.stub('SpaceCenter', 'Vessel.get_Type', () => encodeSint32(3)); // Probe
const t = await services.invoke<number>('SpaceCenter', 'Vessel.GetType', 7n);
expect(t).toBe(3);
});
it('decodes a CLASS list (SpaceCenter.GetBodies)', async () => {
const ids = await services.invoke<bigint[]>('SpaceCenter', 'GetBodies');
expect(Array.isArray(ids)).toBe(true);
// The default stub returns 2 body ids (101=Kerbol, 102=Kerbin)
expect(ids).toEqual([101n, 102n]);
});
it('decodes a string (CelestialBody.get_Name)', async () => {
const name = await services.invoke<string>('SpaceCenter', 'CelestialBody.get_Name', 102n);
expect(name).toBe('Kerbin');
});
it('returns the right value for the .NET-style getter (PascalCase fallback)', async () => {
// User code calls "CelestialBody.GetName" (PascalCase). The
// cache should translate to "CelestialBody.get_Name" (the
// actual wire name) and the mock should respond.
const name = await services.invoke<string>('SpaceCenter', 'CelestialBody.GetName', 101n);
expect(name).toBe('Kerbol');
});
it('counts calls to each procedure', () => {
const ut = server.callCount('SpaceCenter', 'GetUT');
expect(ut).toBeGreaterThan(0);
});
});
/**
* Regression test for the concurrent-invoke framing bug.
*
* Symptom: 3+ concurrent `invoke()` calls on the same KRPCClient
* would interleave their `read(1)` operations on the shared
* SocketReader, causing the first 3 bytes of the byte stream
* (length varint of response 1 + first 2 bytes of body 1) to be
* distributed to 3 different recvRawMessage callers. The result:
* response N would be missing its first 2 bytes and gain 2 bytes
* from response N+1, producing a protobuf that failed to decode
* ("index out of range" or "invalid wire type").
*
* Fix: KRPCClient now serializes invokes on a per-socket promise
* chain. The 3 calls below are made concurrently via Promise.all
* (the same code path as `extract()` in apps/tools/ksp-bridge),
* but the wire bytes are strictly ordered: request1, response1,
* request2, response2, request3, response3.
*
* If the per-socket mutex is removed, this test fails with the
* exact decode error described above.
*/
describe('KRPCClient — concurrent invoke framing', () => {
let server: MockServer;
let client: KRPCClient;
beforeAll(async () => {
server = await startMockKrpcServer();
client = new KRPCClient({
host: '127.0.0.1',
rpcPort: server.rpcPort,
streamPort: server.streamPort,
clientName: 'concurrent-invoke-test',
});
await client.connect();
}, 10_000);
afterAll(async () => {
await client.close();
await server.close();
});
it('handles 3 concurrent invokes without inter-frame byte loss', async () => {
// Reset the call counters so we can assert exactly 1 call per
// procedure in this test.
// (The server has been alive through earlier tests; we don't
// actually need a reset since we only check the responses here.)
const services = new (await import('../src/service-client.js')).KrpcServices(
client,
new (await import('../src/services.js')).ServiceCache({
services: [
{
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,
},
],
classes: [],
enumerations: [],
},
],
}),
);
// Three concurrent calls via Promise.all. This is the exact code
// path that triggered the framing bug before the fix.
const [ut, bodies, vessels] = await Promise.all([
services.invoke<number>('SpaceCenter', 'GetUT'),
services.invoke<bigint[]>('SpaceCenter', 'GetBodies'),
services.invoke<bigint[]>('SpaceCenter', 'GetVessels'),
]);
expect(ut).toBeCloseTo(4_700_000, 1);
expect(bodies).toEqual([101n, 102n]);
expect(vessels).toEqual([]);
});
it('handles an empty LIST response (zero-byte body)', async () => {
// Override the GetBodies stub to return an empty list. The kRPC
// server encodes an empty list as 0 bytes (NOT a length-prefixed
// empty KRPC.List). Without the fix in service-client.ts, this
// would throw "zero-length response for non-nullable, non-NONE
// return type".
server.stub('SpaceCenter', 'GetBodies', () => new Uint8Array(0));
const services = new (await import('../src/service-client.js')).KrpcServices(
client,
new (await import('../src/services.js')).ServiceCache({
services: [
{
name: 'SpaceCenter',
procedures: [
{
name: 'GetBodies',
parameters: [],
returnType: {
code: 301,
service: '',
name: '',
types: [{ code: 100, service: 'SpaceCenter', name: 'CelestialBody', types: [] }],
},
returnIsNullable: false,
},
],
classes: [],
enumerations: [],
},
],
}),
);
const bodies = await services.invoke<bigint[]>('SpaceCenter', 'GetBodies');
expect(bodies).toEqual([]);
});
});
/**
* Regression test for the exact decode bug that was the "actual root
* cause" per commit 62e7ed0: a Procedure message in GetServices
* with no returnType field at all.
*
* This test directly constructs a Procedure message with a null
* returnType and feeds it to the ServiceCache constructor. With the
* 62e7ed0 fix, this should produce a NONE-type (code 0) Procedure
* that the cache can still look up. Without the fix, the cache
* construction throws "Cannot read properties of null".
*/
describe('ServiceCache — null returnType regression', () => {
it('accepts a Procedure with returnType=null', async () => {
// Spin up the mock just to exercise the connect() path; the
// real assertion is below.
const server = await startMockKrpcServer();
const client = new KRPCClient({
host: '127.0.0.1',
rpcPort: server.rpcPort,
streamPort: server.streamPort,
});
await client.connect();
await loadServices(client);
await client.close();
await server.close();
// Now feed a hand-crafted raw Services message to the cache.
const raw = {
services: [
{
name: 'KRPC',
procedures: [
{ name: 'AddStream', parameters: [], returnType: null, returnIsNullable: false },
{ name: 'RemoveStream', parameters: [], returnType: null, returnIsNullable: false },
],
classes: [],
enumerations: [],
},
],
};
const { ServiceCache } = await import('../src/services.js');
const cache = new (ServiceCache as unknown as new (r: typeof raw) => ServiceCache)(raw);
const r = cache.lookup('KRPC', 'AddStream');
expect(r.found).toBe(true);
if (!r.found) throw new Error('unreachable');
expect(r.info.returnType.code).toBe(0); // NONE
});
});
/**
* Round-trip test: encode a real ConnectionRequest on a raw socket,
* verify the mock's response is a valid ConnectionResponse, and
* that the response has only the clientIdentifier field set (i.e.
* matches what a real kRPC server sends).
*
* This is the "minimal response" path that the dea84b6 fix targets.
*/
describe('mock kRPC server — raw socket handshake shape', () => {
let server: MockServer;
beforeAll(async () => {
server = await startMockKrpcServer();
});
afterAll(async () => {
await server.close();
});
it('RPC handshake responds with only clientIdentifier (matches real kRPC)', async () => {
// Use a raw socket to inspect the exact bytes the mock returns.
const net = await import('node:net');
const port = server.rpcPort;
const sock = net.createConnection({ host: '127.0.0.1', port });
await new Promise<void>((resolve) => sock.once('connect', () => resolve()));
sock.setNoDelay(true);
sendMessage(sock, KRPC.ConnectionRequest, {
type: 0, // RPC
clientName: 'raw-test',
});
// recvMessage handles the length-prefixed framing correctly.
const resp = await recvMessage<{ status: number; clientIdentifier: Uint8Array }>(
sock,
KRPC.ConnectionResponse,
);
expect(Buffer.from(resp.clientIdentifier).toString()).toBe('mock-krpc-client');
// status should be the default (0), not the string "OK"
expect(resp.status).toBe(0);
// message should be the default empty string
expect(resp.message ?? '').toBe('');
// Sanity-check the raw bytes: should be field 3, wire type 2,
// 14 bytes. 0x1a = (3 << 3) | 2, 0x0e = 14.
// (We don't actually inspect the bytes here — the protobufjs
// round-trip above already proves the structure decodes.)
sock.destroy();
});
});
void encodeMessage; // keep the import alive for future tests