From 6df81e6f8d8a9ac8e11cae4a218cb11a05b372d7 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 17:25:04 -0400 Subject: [PATCH] feat(soak): CLI parsing + config precedence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit parseArgs pulls --scenario/--rooms/--watch/etc from argv, mergeConfig layers scenarioDefaults → env → CLI so CLI flags always win. 12 Vitest unit tests cover both parse happy/edge paths and the 4-way merge precedence matrix. Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/soak/config.ts | 125 ++++++++++++++++++++++++++++++++ tests/soak/tests/config.test.ts | 102 ++++++++++++++++++++++++++ 2 files changed, 227 insertions(+) create mode 100644 tests/soak/config.ts create mode 100644 tests/soak/tests/config.test.ts diff --git a/tests/soak/config.ts b/tests/soak/config.ts new file mode 100644 index 0000000..5c85b20 --- /dev/null +++ b/tests/soak/config.ts @@ -0,0 +1,125 @@ +/** + * CLI flag parsing and config precedence for the soak runner. + * + * Precedence (later wins): + * runner defaults → scenario.defaultConfig → env vars → CLI flags + */ + +export type WatchMode = 'none' | 'dashboard' | 'tiled'; + +export interface CliArgs { + scenario?: string; + accounts?: number; + rooms?: number; + cpusPerRoom?: number; + gamesPerRoom?: number; + holes?: number; + watch?: WatchMode; + dashboardPort?: number; + target?: string; + runId?: string; + dryRun?: boolean; + listOnly?: boolean; +} + +const VALID_WATCH: WatchMode[] = ['none', 'dashboard', 'tiled']; + +function parseInt10(s: string, name: string): number { + const n = parseInt(s, 10); + if (Number.isNaN(n)) throw new Error(`Invalid integer for ${name}: ${s}`); + return n; +} + +export function parseArgs(argv: string[]): CliArgs { + const out: CliArgs = {}; + for (const arg of argv) { + if (arg === '--list') { + out.listOnly = true; + continue; + } + if (arg === '--dry-run') { + out.dryRun = true; + continue; + } + const m = arg.match(/^--([a-z][a-z0-9-]*)=(.*)$/); + if (!m) continue; + const [, key, value] = m; + switch (key) { + case 'scenario': + out.scenario = value; + break; + case 'accounts': + out.accounts = parseInt10(value, '--accounts'); + break; + case 'rooms': + out.rooms = parseInt10(value, '--rooms'); + break; + case 'cpus-per-room': + out.cpusPerRoom = parseInt10(value, '--cpus-per-room'); + break; + case 'games-per-room': + out.gamesPerRoom = parseInt10(value, '--games-per-room'); + break; + case 'holes': + out.holes = parseInt10(value, '--holes'); + break; + case 'watch': + if (!VALID_WATCH.includes(value as WatchMode)) { + throw new Error( + `Invalid --watch value: ${value} (expected ${VALID_WATCH.join('|')})`, + ); + } + out.watch = value as WatchMode; + break; + case 'dashboard-port': + out.dashboardPort = parseInt10(value, '--dashboard-port'); + break; + case 'target': + out.target = value; + break; + case 'run-id': + out.runId = value; + break; + default: + // Unknown flag — ignore so scenario-specific flags can slot in later + break; + } + } + return out; +} + +/** + * Merge layers in precedence order: defaults → env → cli (later wins). + */ +export function mergeConfig( + cli: Record, + env: Record, + defaults: Record, +): Record { + const merged: Record = { ...defaults }; + + // Env overlay — SOAK_UPPER_SNAKE → lowerCamel in cli space. + const envMap: Record = { + SOAK_HOLES: 'holes', + SOAK_ROOMS: 'rooms', + SOAK_ACCOUNTS: 'accounts', + SOAK_CPUS_PER_ROOM: 'cpusPerRoom', + SOAK_GAMES_PER_ROOM: 'gamesPerRoom', + SOAK_WATCH: 'watch', + SOAK_DASHBOARD_PORT: 'dashboardPort', + }; + const numericKeys = /^(holes|rooms|accounts|cpusPerRoom|gamesPerRoom|dashboardPort)$/; + for (const [envKey, cfgKey] of Object.entries(envMap)) { + const v = env[envKey]; + if (v !== undefined) { + merged[cfgKey] = numericKeys.test(cfgKey) ? parseInt(v, 10) : v; + } + } + + // CLI overlay — wins over env and defaults. + for (const [k, v] of Object.entries(cli)) { + if (v !== undefined) merged[k] = v; + } + + return merged; +} diff --git a/tests/soak/tests/config.test.ts b/tests/soak/tests/config.test.ts new file mode 100644 index 0000000..bc40dcf --- /dev/null +++ b/tests/soak/tests/config.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect } from 'vitest'; +import { parseArgs, mergeConfig } from '../config'; + +describe('parseArgs', () => { + it('parses --scenario and numeric flags', () => { + const r = parseArgs(['--scenario=populate', '--rooms=4', '--games-per-room=10']); + expect(r.scenario).toBe('populate'); + expect(r.rooms).toBe(4); + expect(r.gamesPerRoom).toBe(10); + }); + + it('parses watch mode', () => { + const r = parseArgs(['--scenario=populate', '--watch=none']); + expect(r.watch).toBe('none'); + }); + + it('rejects unknown watch mode', () => { + expect(() => parseArgs(['--scenario=populate', '--watch=bogus'])).toThrow(); + }); + + it('--list sets listOnly', () => { + const r = parseArgs(['--list']); + expect(r.listOnly).toBe(true); + }); + + it('--dry-run sets dryRun', () => { + const r = parseArgs(['--scenario=populate', '--dry-run']); + expect(r.dryRun).toBe(true); + }); + + it('parses --accounts, --cpus-per-room, --dashboard-port, --target, --run-id, --holes', () => { + const r = parseArgs([ + '--scenario=stress', + '--accounts=8', + '--cpus-per-room=2', + '--dashboard-port=7777', + '--target=http://localhost:8000', + '--run-id=test-1', + '--holes=1', + ]); + expect(r.accounts).toBe(8); + expect(r.cpusPerRoom).toBe(2); + expect(r.dashboardPort).toBe(7777); + expect(r.target).toBe('http://localhost:8000'); + expect(r.runId).toBe('test-1'); + expect(r.holes).toBe(1); + }); + + it('throws on non-numeric integer flag', () => { + expect(() => parseArgs(['--rooms=four'])).toThrow(/Invalid integer/); + }); +}); + +describe('mergeConfig', () => { + it('CLI flags override scenario defaults', () => { + const cfg = mergeConfig( + { gamesPerRoom: 20 }, + {}, + { gamesPerRoom: 5, holes: 9 }, + ); + expect(cfg.gamesPerRoom).toBe(20); + expect(cfg.holes).toBe(9); + }); + + it('env overrides scenario defaults but CLI overrides env', () => { + const cfg = mergeConfig( + { holes: 5 }, // CLI + { SOAK_HOLES: '3' }, // env + { holes: 9 }, // defaults + ); + expect(cfg.holes).toBe(5); // CLI wins + }); + + it('env overrides scenario defaults when CLI is absent', () => { + const cfg = mergeConfig( + {}, // no CLI + { SOAK_HOLES: '3' }, // env + { holes: 9 }, // defaults + ); + expect(cfg.holes).toBe(3); // env wins over defaults + }); + + it('scenario defaults fill in unset values', () => { + const cfg = mergeConfig( + {}, + {}, + { gamesPerRoom: 3, holes: 9 }, + ); + expect(cfg.gamesPerRoom).toBe(3); + expect(cfg.holes).toBe(9); + }); + + it('env numeric keys are parsed to integers', () => { + const cfg = mergeConfig( + {}, + { SOAK_ROOMS: '4', SOAK_ACCOUNTS: '16' }, + {}, + ); + expect(cfg.rooms).toBe(4); + expect(cfg.accounts).toBe(16); + }); +});