09f5a3510f
Carries forward the correct fixes from debug-krpc-handshake and adds two new decode-path fixes that the mock server surfaced. Drops639d265(decode error bytes as Error protobuf — wrong) andfc76635(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)
381 lines
14 KiB
TypeScript
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
|