feat(soak): CLI parsing + config precedence

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) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-11 17:25:04 -04:00
parent 2c20b6c7b5
commit 6df81e6f8d
2 changed files with 227 additions and 0 deletions

125
tests/soak/config.ts Normal file
View File

@@ -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<string, unknown>,
env: Record<string, string | undefined>,
defaults: Record<string, unknown>,
): Record<string, unknown> {
const merged: Record<string, unknown> = { ...defaults };
// Env overlay — SOAK_UPPER_SNAKE → lowerCamel in cli space.
const envMap: Record<string, string> = {
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;
}

View File

@@ -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);
});
});