Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: latest

- name: Install dependencies
run: bun install

- name: Lint
run: bun run lint

- name: Unit tests
run: bun test src/version.test.ts

- name: API sync test
run: bun test src/api-sync.test.ts
103 changes: 103 additions & 0 deletions src/api-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* API sync test — verifies that the commands in client.ts match the live server.
*
* Catches two classes of drift:
* - Stale commands: in client.ts but not in the server API (hard fail)
* - Missing commands: in the server API but not in client.ts (hard fail)
*
* Run with: bun test src/api-sync.test.ts
* Skip with: SKIP_API_SYNC=1 bun test
*/

import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';

const OPENAPI_URL = 'https://game.spacemolt.com/api/openapi.json';

// Server endpoints confirmed to exist but not yet documented in the OpenAPI spec.
// Verify periodically with: curl -s https://game.spacemolt.com/api/openapi.json | jq '.paths | keys'
const UNDOCUMENTED_IN_SPEC = new Set([
// Unified interface commands
'get_location', // Returns rich location data despite not being in spec
'storage', // Unified deposit/withdraw/view interface

// Station credit management
'deposit_credits',
'withdraw_credits',

// Drone commands
'deploy_drone',
'recall_drone',
'order_drone',

// v2 state commands (experimental)
'get_state',
'v2_get_player',
'v2_get_ship',
'v2_get_cargo',
'v2_get_missions',
'v2_get_queue',
'v2_get_skills',
]);

/**
* Extracts the command names from the COMMANDS block in client.ts.
* Parses only lines within the COMMANDS const (lines 87–505), not notification
* handlers or other objects that share the same 2-space key format.
*/
function extractClientCommands(src: string): string[] {
// Isolate the COMMANDS block — from its opening brace to the closing `};`
// at column 0, stopping before ERROR_HELP
const start = src.indexOf('const COMMANDS:');
const end = src.indexOf('\nconst ERROR_HELP');
if (start === -1 || end === -1) throw new Error('Could not locate COMMANDS block in client.ts');

const block = src.slice(start, end);
// Match 2-space-indented top-level keys: ` keyname: {` or ` keyname: (`
const matches = [...block.matchAll(/^\s{2}([a-z][a-z0-9_]+):\s*[{(]/gm)];
return matches.map((m) => m[1]);
}

const skip = process.env.SKIP_API_SYNC === '1';

describe('api sync', () => {
test.skipIf(skip)(
'client.ts COMMANDS matches live OpenAPI spec',
async () => {
const clientPath = path.join(import.meta.dir, 'client.ts');
const src = fs.readFileSync(clientPath, 'utf-8');
const clientCommands = new Set(extractClientCommands(src));

// Fetch the live OpenAPI spec
const resp = await fetch(OPENAPI_URL, { signal: AbortSignal.timeout(10_000) });
if (resp.status === 429) {
console.log('[SKIP] OpenAPI spec rate-limited (429) — skipping API sync check');
return;
}
expect(resp.status, `Failed to fetch OpenAPI spec: HTTP ${resp.status}`).toBe(200);
const spec = (await resp.json()) as { paths: Record<string, unknown> };

// All spec paths are POST endpoints at /<command>
const apiEndpoints = new Set(Object.keys(spec.paths).map((p) => p.replace(/^\//, '')));

// Add undocumented endpoints that we've verified exist on the server
for (const cmd of UNDOCUMENTED_IN_SPEC) apiEndpoints.add(cmd);

// Hard fail: commands in client that don't exist in the API
const staleCommands = [...clientCommands].filter((cmd) => !apiEndpoints.has(cmd));
expect(
staleCommands,
`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.`,
).toEqual([]);

// Hard fail: API endpoints missing from client
const missingCommands = [...apiEndpoints].filter((cmd) => !clientCommands.has(cmd));
expect(
missingCommands,
`API endpoints missing from client.ts:\n ${missingCommands.join('\n ')}\n\nAdd these to COMMANDS in client.ts.`,
).toEqual([]);
},
15_000,
);
});
20 changes: 8 additions & 12 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,11 +159,7 @@ const COMMANDS: Record<string, CommandConfig> = {
salvage_wreck: { args: ['wreck_id'], required: ['wreck_id'], usage: '<wreck_id>' },

// Ship management
buy_ship: {
args: ['ship_class'],
required: ['ship_class'],
usage: '<ship_class> (use get_base to see available ships)',
},
name_ship: { args: ['name'], required: ['name'], usage: '<name> (set a custom name for your current ship)' },
sell_ship: {
args: ['ship_id'],
required: ['ship_id'],
Expand Down Expand Up @@ -282,8 +278,6 @@ const COMMANDS: Record<string, CommandConfig> = {
// Player settings
set_status: { args: ['status_message', 'clan_tag'] },
set_colors: { args: ['primary_color', 'secondary_color'] },
set_anonymous: { args: ['anonymous'] },

// Notes
create_note: { args: ['title', { rest: 'content' }] },
write_note: { args: ['note_id', { rest: 'content' }] },
Expand Down Expand Up @@ -406,7 +400,6 @@ const COMMANDS: Record<string, CommandConfig> = {
sell_wreck: {},

// Shipyard
shipyard_showroom: { args: ['category', 'scale'] },
commission_ship: {
args: ['ship_class', 'provide_materials'],
required: ['ship_class'],
Expand Down Expand Up @@ -448,17 +441,16 @@ const COMMANDS: Record<string, CommandConfig> = {
get_poi: {},
get_base: {},
get_ship: {},
get_ships: {},
get_cargo: {},
get_nearby: {},
get_skills: {},
get_recipes: {},
get_map: { args: ['system_id'] },
get_trades: {},
get_wrecks: {},
get_version: { args: ['count', 'page'] },
get_commands: {},
get_location: {},
get_notifications: {},
survey_system: {},
get_action_log: {
args: ['category', 'limit', 'before'],
Expand All @@ -476,6 +468,11 @@ const COMMANDS: Record<string, CommandConfig> = {
v2_get_skills: {},

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

if (r.travel_progress !== undefined) {
Expand Down Expand Up @@ -1743,7 +1740,6 @@ ${c.bright}Action Commands (1 per tick, ~10 seconds):${c.reset}
sell_wreck Sell towed wreck at station

${c.cyan}Shipyard:${c.reset}
shipyard_showroom Browse ships at station shipyard
commission_ship <class> Order a custom ship build
commission_quote <class> Get build quote
commission_status Check build progress
Expand Down
8 changes: 8 additions & 0 deletions src/version.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -630,22 +630,30 @@ describe('client.ts source integrity', () => {
const clientPath = path.join(import.meta.dir, 'client.ts');
const src = fs.readFileSync(clientPath, 'utf-8');
const removedCommands = [
// Friends system
'inspect_cargo',
'add_friend',
'remove_friend',
'get_friends',
'get_friend_requests',
'accept_friend_request',
'decline_friend_request',
// Base raiding system
'build_base',
'get_base_cost',
'attack_base',
'raid_status',
'get_base_wrecks',
'loot_base_wreck',
'salvage_base_wreck',
// Other deprecated commands
'get_drones',
'search_changelog',
'buy_ship',
'get_ships',
'get_recipes',
'shipyard_showroom',
'set_anonymous',
];
for (const cmd of removedCommands) {
expect(src).not.toContain(` ${cmd}:`);
Expand Down
Loading