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:
125
tests/soak/config.ts
Normal file
125
tests/soak/config.ts
Normal 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;
|
||||||
|
}
|
||||||
102
tests/soak/tests/config.test.ts
Normal file
102
tests/soak/tests/config.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user