Skip to content

Commit 7df6364

Browse files
authored
Add CI workflow and API sync test to catch command drift (#18)
* feat: add CI workflow and API sync test to catch command drift Adds a GitHub Actions CI workflow that runs lint and tests on every PR and push to main — previously only release tags triggered any CI. Adds src/api-sync.test.ts which fetches the live OpenAPI spec and hard-fails if: - client.ts has commands the server no longer exposes (stale) - the server has endpoints not yet in client.ts (missing) Undocumented-but-confirmed endpoints are tracked in UNDOCUMENTED_IN_SPEC (get_location, storage, deposit_credits, withdraw_credits, drone commands, v2 state commands). Running the test found and fixes several more stale/missing commands: - Removed: buy_ship, get_ships (explicitly deprecated by server), get_recipes, shipyard_showroom, set_anonymous (all 404) - Added: fleet, get_notifications, name_ship (in spec but missing from client) Skips gracefully on 429 (rate limiting) rather than failing CI. Set SKIP_API_SYNC=1 to skip locally. * fix: use file paths in CI test steps (bun doesn't support regex as name filter)
1 parent ff16ef6 commit 7df6364

4 files changed

Lines changed: 151 additions & 12 deletions

File tree

.github/workflows/ci.yml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
13+
steps:
14+
- name: Checkout
15+
uses: actions/checkout@v4
16+
17+
- name: Setup Bun
18+
uses: oven-sh/setup-bun@v2
19+
with:
20+
bun-version: latest
21+
22+
- name: Install dependencies
23+
run: bun install
24+
25+
- name: Lint
26+
run: bun run lint
27+
28+
- name: Unit tests
29+
run: bun test src/version.test.ts
30+
31+
- name: API sync test
32+
run: bun test src/api-sync.test.ts

src/api-sync.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
/**
2+
* API sync test — verifies that the commands in client.ts match the live server.
3+
*
4+
* Catches two classes of drift:
5+
* - Stale commands: in client.ts but not in the server API (hard fail)
6+
* - Missing commands: in the server API but not in client.ts (hard fail)
7+
*
8+
* Run with: bun test src/api-sync.test.ts
9+
* Skip with: SKIP_API_SYNC=1 bun test
10+
*/
11+
12+
import { describe, expect, test } from 'bun:test';
13+
import * as fs from 'node:fs';
14+
import * as path from 'node:path';
15+
16+
const OPENAPI_URL = 'https://game.spacemolt.com/api/openapi.json';
17+
18+
// Server endpoints confirmed to exist but not yet documented in the OpenAPI spec.
19+
// Verify periodically with: curl -s https://game.spacemolt.com/api/openapi.json | jq '.paths | keys'
20+
const UNDOCUMENTED_IN_SPEC = new Set([
21+
// Unified interface commands
22+
'get_location', // Returns rich location data despite not being in spec
23+
'storage', // Unified deposit/withdraw/view interface
24+
25+
// Station credit management
26+
'deposit_credits',
27+
'withdraw_credits',
28+
29+
// Drone commands
30+
'deploy_drone',
31+
'recall_drone',
32+
'order_drone',
33+
34+
// v2 state commands (experimental)
35+
'get_state',
36+
'v2_get_player',
37+
'v2_get_ship',
38+
'v2_get_cargo',
39+
'v2_get_missions',
40+
'v2_get_queue',
41+
'v2_get_skills',
42+
]);
43+
44+
/**
45+
* Extracts the command names from the COMMANDS block in client.ts.
46+
* Parses only lines within the COMMANDS const (lines 87–505), not notification
47+
* handlers or other objects that share the same 2-space key format.
48+
*/
49+
function extractClientCommands(src: string): string[] {
50+
// Isolate the COMMANDS block — from its opening brace to the closing `};`
51+
// at column 0, stopping before ERROR_HELP
52+
const start = src.indexOf('const COMMANDS:');
53+
const end = src.indexOf('\nconst ERROR_HELP');
54+
if (start === -1 || end === -1) throw new Error('Could not locate COMMANDS block in client.ts');
55+
56+
const block = src.slice(start, end);
57+
// Match 2-space-indented top-level keys: ` keyname: {` or ` keyname: (`
58+
const matches = [...block.matchAll(/^\s{2}([a-z][a-z0-9_]+):\s*[{(]/gm)];
59+
return matches.map((m) => m[1]);
60+
}
61+
62+
const skip = process.env.SKIP_API_SYNC === '1';
63+
64+
describe('api sync', () => {
65+
test.skipIf(skip)(
66+
'client.ts COMMANDS matches live OpenAPI spec',
67+
async () => {
68+
const clientPath = path.join(import.meta.dir, 'client.ts');
69+
const src = fs.readFileSync(clientPath, 'utf-8');
70+
const clientCommands = new Set(extractClientCommands(src));
71+
72+
// Fetch the live OpenAPI spec
73+
const resp = await fetch(OPENAPI_URL, { signal: AbortSignal.timeout(10_000) });
74+
if (resp.status === 429) {
75+
console.log('[SKIP] OpenAPI spec rate-limited (429) — skipping API sync check');
76+
return;
77+
}
78+
expect(resp.status, `Failed to fetch OpenAPI spec: HTTP ${resp.status}`).toBe(200);
79+
const spec = (await resp.json()) as { paths: Record<string, unknown> };
80+
81+
// All spec paths are POST endpoints at /<command>
82+
const apiEndpoints = new Set(Object.keys(spec.paths).map((p) => p.replace(/^\//, '')));
83+
84+
// Add undocumented endpoints that we've verified exist on the server
85+
for (const cmd of UNDOCUMENTED_IN_SPEC) apiEndpoints.add(cmd);
86+
87+
// Hard fail: commands in client that don't exist in the API
88+
const staleCommands = [...clientCommands].filter((cmd) => !apiEndpoints.has(cmd));
89+
expect(
90+
staleCommands,
91+
`Stale commands in client.ts (not in server API):\n ${staleCommands.join('\n ')}\n\nRemove these or move them to UNDOCUMENTED_IN_SPEC if they exist but aren't in the spec.`,
92+
).toEqual([]);
93+
94+
// Hard fail: API endpoints missing from client
95+
const missingCommands = [...apiEndpoints].filter((cmd) => !clientCommands.has(cmd));
96+
expect(
97+
missingCommands,
98+
`API endpoints missing from client.ts:\n ${missingCommands.join('\n ')}\n\nAdd these to COMMANDS in client.ts.`,
99+
).toEqual([]);
100+
},
101+
15_000,
102+
);
103+
});

src/client.ts

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -159,11 +159,7 @@ const COMMANDS: Record<string, CommandConfig> = {
159159
salvage_wreck: { args: ['wreck_id'], required: ['wreck_id'], usage: '<wreck_id>' },
160160

161161
// Ship management
162-
buy_ship: {
163-
args: ['ship_class'],
164-
required: ['ship_class'],
165-
usage: '<ship_class> (use get_base to see available ships)',
166-
},
162+
name_ship: { args: ['name'], required: ['name'], usage: '<name> (set a custom name for your current ship)' },
167163
sell_ship: {
168164
args: ['ship_id'],
169165
required: ['ship_id'],
@@ -282,8 +278,6 @@ const COMMANDS: Record<string, CommandConfig> = {
282278
// Player settings
283279
set_status: { args: ['status_message', 'clan_tag'] },
284280
set_colors: { args: ['primary_color', 'secondary_color'] },
285-
set_anonymous: { args: ['anonymous'] },
286-
287281
// Notes
288282
create_note: { args: ['title', { rest: 'content' }] },
289283
write_note: { args: ['note_id', { rest: 'content' }] },
@@ -406,7 +400,6 @@ const COMMANDS: Record<string, CommandConfig> = {
406400
sell_wreck: {},
407401

408402
// Shipyard
409-
shipyard_showroom: { args: ['category', 'scale'] },
410403
commission_ship: {
411404
args: ['ship_class', 'provide_materials'],
412405
required: ['ship_class'],
@@ -448,17 +441,16 @@ const COMMANDS: Record<string, CommandConfig> = {
448441
get_poi: {},
449442
get_base: {},
450443
get_ship: {},
451-
get_ships: {},
452444
get_cargo: {},
453445
get_nearby: {},
454446
get_skills: {},
455-
get_recipes: {},
456447
get_map: { args: ['system_id'] },
457448
get_trades: {},
458449
get_wrecks: {},
459450
get_version: { args: ['count', 'page'] },
460451
get_commands: {},
461452
get_location: {},
453+
get_notifications: {},
462454
survey_system: {},
463455
get_action_log: {
464456
args: ['category', 'limit', 'before'],
@@ -476,6 +468,11 @@ const COMMANDS: Record<string, CommandConfig> = {
476468
v2_get_skills: {},
477469

478470
// Unified commands
471+
fleet: {
472+
args: ['action', 'player_id'],
473+
required: ['action'],
474+
usage: '<action> [player_id] (actions: create, invite, accept, decline, leave, kick, disband, status)',
475+
},
479476
storage: {
480477
args: ['action', 'item_id', 'quantity'],
481478
usage: '<action> [item_id] [quantity] (unified storage interface)',
@@ -1225,7 +1222,7 @@ const resultFormatters: ResultFormatter[] = [
12251222
console.log(`\n${c.yellow}WARNING: You are in an Escape Pod!${c.reset}`);
12261223
console.log(` - No cargo capacity, no weapons, no defenses`);
12271224
console.log(` - Infinite fuel - travel anywhere`);
1228-
console.log(` - Get to a station and buy a new ship with 'buy_ship'`);
1225+
console.log(` - Get to a station and commission or buy a ship with 'commission_ship' or 'browse_ships'`);
12291226
}
12301227

12311228
if (r.travel_progress !== undefined) {
@@ -1743,7 +1740,6 @@ ${c.bright}Action Commands (1 per tick, ~10 seconds):${c.reset}
17431740
sell_wreck Sell towed wreck at station
17441741
17451742
${c.cyan}Shipyard:${c.reset}
1746-
shipyard_showroom Browse ships at station shipyard
17471743
commission_ship <class> Order a custom ship build
17481744
commission_quote <class> Get build quote
17491745
commission_status Check build progress

src/version.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,22 +630,30 @@ describe('client.ts source integrity', () => {
630630
const clientPath = path.join(import.meta.dir, 'client.ts');
631631
const src = fs.readFileSync(clientPath, 'utf-8');
632632
const removedCommands = [
633+
// Friends system
633634
'inspect_cargo',
634635
'add_friend',
635636
'remove_friend',
636637
'get_friends',
637638
'get_friend_requests',
638639
'accept_friend_request',
639640
'decline_friend_request',
641+
// Base raiding system
640642
'build_base',
641643
'get_base_cost',
642644
'attack_base',
643645
'raid_status',
644646
'get_base_wrecks',
645647
'loot_base_wreck',
646648
'salvage_base_wreck',
649+
// Other deprecated commands
647650
'get_drones',
648651
'search_changelog',
652+
'buy_ship',
653+
'get_ships',
654+
'get_recipes',
655+
'shipyard_showroom',
656+
'set_anonymous',
649657
];
650658
for (const cmd of removedCommands) {
651659
expect(src).not.toContain(` ${cmd}:`);

0 commit comments

Comments
 (0)