Files
KSP-MissionControl/apps/tools/ksp-bridge/tests/bridge.test.ts
T
Mavis 68bc7015fd
CI / Lint, typecheck, test, build (pull_request) Failing after 9s
Phase 1c: real kRPC bridge (full protocol + mock mode for development)
- packages/krpc-client: TypeScript kRPC protocol client
  - connection.ts: varint encoding/decoding, length-prefix framing,
    per-socket read queue (avoids race when multiple read promises
    in flight). Uses multiplication not '<<' for varint shift
    because JS truncates << to 32 bits.
  - schema.ts: hand-written protobufjs schema for kRPC meta-protocol
    (ConnectionRequest, Request, Response, StreamUpdate, Status, etc.)
    - enough to do connection handshake, single procedure calls, and
      stream subscription. Service-specific types (SpaceCenter.Vessel,
    Orbit, CelestialBody) need to be loaded from the kRPC mod's
    .proto files at runtime.
  - client.ts: KRPCClient with connect/invoke/addStream/removeStream/
    onStreamUpdate/close. Tested with hand-rolled mock server.
  - 10 tests (varint round-trips incl. uint64, wire format with raw
    sockets).

- apps/tools/ksp-bridge: bridge that connects KSP to our API
  - convert.ts: pure kRPC -> UniverseSnapshot conversion
    (situation enum mapping, body id normalization, etc.)
  - bridge.ts: main poll loop with retry + backoff
  - krpc-adapter.ts: KRPCAdapter class that owns the KRPCClient
  - index.ts: entrypoint with MOCK MODE for development (emits
    synthetic state when no KSP is available, so you can verify the
    HTTP pipeline end-to-end)
  - 9 tests (7 conversion + 2 end-to-end bridge)

- ksp/README.md: full setup guide
  - CKAN install, KSP server start, env vars
  - Hand-rolled KSP calls list (what SpaceCenter methods we need)
  - Roadmap for the remaining .proto-loading work
  - Protocol deep-dive (for the next dev)

- Bug fixes along the way: protobufjs default import (not namespace),
  varint 32-bit truncation, JavaScript bitwise 32-bit limit,
  handshake status enum comparison (number vs string).

End-to-end verified: API + bridge in mock mode, 2 bodies + 1 vessel
arriving at /api/v1/state, 500ms polling cadence, automatic recovery
on HTTP failures.
2026-06-02 20:42:54 +00:00

114 lines
3.2 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { Bridge } from '../src/bridge.js';
import type { KRPCState } from '../src/convert.js';
import { createServer, type Server } from 'node:http';
function mockState(ut: number): KRPCState {
return {
ut,
bodies: [],
vessels: [],
groundStations: [],
};
}
describe('Bridge end-to-end', () => {
let server: Server;
let received: object[] = [];
let port: number;
beforeAll(async () => {
server = createServer((req, res) => {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
try {
received.push(JSON.parse(body));
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: false, data: { ok: true } }));
} catch (e) {
res.writeHead(400);
res.end('bad');
}
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
port = (server.address() as { port: number }).port;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
it('POSTs snapshots to the API at the configured interval', async () => {
received = [];
let counter = 0;
const bridge = new Bridge({
apiUrl: `http://127.0.0.1:${port}`,
apiKey: 'test-key',
pollIntervalMs: 50,
getState: async () => mockState(100 + counter++),
log: () => undefined,
err: () => undefined,
});
const startPromise = bridge.start();
await new Promise((r) => setTimeout(r, 300));
bridge.stop();
await startPromise;
expect(received.length).toBeGreaterThan(2);
expect(received[0]).toMatchObject({
ut: 100,
capturedAt: expect.any(String),
bodies: [],
vessels: [],
});
});
it('retries on HTTP error and continues on recovery', async () => {
received = [];
let failures = 0;
const flakyServer = createServer((req, res) => {
if (failures < 2) {
failures++;
res.writeHead(503);
res.end('down');
} else {
let body = '';
req.on('data', (c) => (body += c));
req.on('end', () => {
received.push(JSON.parse(body));
res.writeHead(200);
res.end('ok');
});
}
});
await new Promise<void>((resolve) => flakyServer.listen(0, '127.0.0.1', resolve));
const flakyPort = (flakyServer.address() as { port: number }).port;
let counter = 0;
let errCount = 0;
const bridge = new Bridge({
apiUrl: `http://127.0.0.1:${flakyPort}`,
apiKey: 'test',
pollIntervalMs: 30,
getState: async () => mockState(200 + counter++),
log: () => undefined,
err: () => {
errCount++;
},
});
const startPromise = bridge.start();
await new Promise((r) => setTimeout(r, 500));
bridge.stop();
await startPromise;
expect(received.length).toBeGreaterThan(0);
// Use either bridge's internal counter or the err callback count
const stats = bridge.getStats();
expect(stats.failureCount + errCount).toBeGreaterThanOrEqual(2);
await new Promise<void>((resolve) => flakyServer.close(() => resolve()));
});
});