From d1688aae0bbc7ad02e80f149188a8ac0d131f0c8 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sat, 11 Apr 2026 18:52:08 -0400 Subject: [PATCH] feat(soak): runner.ts end-to-end with --watch=none MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First full end-to-end milestone: parses CLI, builds SessionPool + RoomCoordinator, loads a scenario by name, runs it, reports results, cleans up. Watch modes other than "none" log a warning and fall back until Tasks 19-24 implement them. Smoke test passed against local dev: bun run soak -- --scenario=populate --accounts=2 --rooms=1 --cpus-per-room=0 --games-per-room=1 --holes=1 --watch=none → Games completed: 1, Errors: 0, Duration: 78.2s, exit 0 Co-Authored-By: Claude Opus 4.6 (1M context) --- tests/soak/runner.ts | 163 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 160 insertions(+), 3 deletions(-) diff --git a/tests/soak/runner.ts b/tests/soak/runner.ts index 7017c92..e1b419f 100644 --- a/tests/soak/runner.ts +++ b/tests/soak/runner.ts @@ -2,12 +2,169 @@ /** * Golf Soak Harness — entry point. * - * Placeholder. Full runner lands in Task 18. + * Usage: + * TEST_URL=http://localhost:8000 \ + * SOAK_INVITE_CODE=SOAKTEST \ + * bun run soak -- --scenario=populate --rooms=1 --accounts=2 \ + * --cpus-per-room=0 --games-per-room=1 --holes=1 --watch=none */ +import * as path from 'path'; +import { parseArgs, mergeConfig, CliArgs } from './config'; +import { createLogger } from './core/logger'; +import { SessionPool } from './core/session-pool'; +import { RoomCoordinator } from './core/room-coordinator'; +import { getScenario, listScenarios } from './scenarios'; +import type { DashboardReporter, ScenarioContext } from './core/types'; + +function noopDashboard(): DashboardReporter { + return { + update: () => {}, + log: () => {}, + incrementMetric: () => {}, + }; +} + +function printScenarioList(): void { + console.log('Available scenarios:'); + for (const s of listScenarios()) { + console.log(` ${s.name.padEnd(12)} ${s.description}`); + console.log( + ` needs: accounts=${s.needs.accounts}, rooms=${s.needs.rooms ?? 1}, cpus=${s.needs.cpusPerRoom ?? 0}`, + ); + } +} + async function main(): Promise { - console.log('golf-soak runner (placeholder)'); - console.log('Full implementation lands in Task 18 of the plan.'); + const cli: CliArgs = parseArgs(process.argv.slice(2)); + + if (cli.listOnly) { + printScenarioList(); + return; + } + + if (!cli.scenario) { + console.error('Error: --scenario= is required. Use --list to see scenarios.'); + process.exit(2); + } + + const scenario = getScenario(cli.scenario); + if (!scenario) { + console.error(`Error: unknown scenario "${cli.scenario}". Use --list to see scenarios.`); + process.exit(2); + } + + const runId = + cli.runId ?? `${cli.scenario}-${new Date().toISOString().replace(/[:.]/g, '-')}`; + const targetUrl = cli.target ?? process.env.TEST_URL ?? 'http://localhost:8000'; + const inviteCode = process.env.SOAK_INVITE_CODE ?? 'SOAKTEST'; + const watch = cli.watch ?? 'dashboard'; + + const logger = createLogger({ runId }); + logger.info('run_start', { + scenario: scenario.name, + targetUrl, + watch, + cli, + }); + + // Resolve final config: scenarioDefaults → env → CLI (later wins) + const config = mergeConfig( + cli as Record, + process.env, + scenario.defaultConfig, + ); + + // Ensure core knobs exist, falling back to scenario.needs + const accounts = Number(config.accounts ?? scenario.needs.accounts); + const rooms = Number(config.rooms ?? scenario.needs.rooms ?? 1); + const cpusPerRoom = Number(config.cpusPerRoom ?? scenario.needs.cpusPerRoom ?? 0); + if (accounts % rooms !== 0) { + console.error( + `Error: --accounts=${accounts} does not divide evenly into --rooms=${rooms}`, + ); + process.exit(2); + } + config.accounts = accounts; + config.rooms = rooms; + config.cpusPerRoom = cpusPerRoom; + + if (cli.dryRun) { + logger.info('dry_run', { config }); + console.log('Dry run OK. Resolved config:'); + console.log(JSON.stringify(config, null, 2)); + return; + } + + if (watch !== 'none') { + logger.warn('watch_mode_not_yet_implemented', { watch }); + console.warn(`Watch mode "${watch}" not yet implemented — falling back to "none".`); + } + + // Build dependencies + const credFile = path.resolve(__dirname, '.env.stresstest'); + const pool = new SessionPool({ + targetUrl, + inviteCode, + credFile, + logger, + }); + const coordinator = new RoomCoordinator(); + const dashboard = noopDashboard(); + const abortController = new AbortController(); + + const onSignal = (sig: string) => { + logger.warn('signal_received', { signal: sig }); + abortController.abort(); + }; + process.on('SIGINT', () => onSignal('SIGINT')); + process.on('SIGTERM', () => onSignal('SIGTERM')); + + let exitCode = 0; + try { + const sessions = await pool.acquire(accounts); + logger.info('sessions_acquired', { count: sessions.length }); + + const ctx: ScenarioContext = { + config, + sessions, + coordinator, + dashboard, + logger, + signal: abortController.signal, + heartbeat: () => { + // Task 26 wires per-room watchdogs. No-op until then. + }, + }; + + const result = await scenario.run(ctx); + logger.info('run_complete', { + gamesCompleted: result.gamesCompleted, + errors: result.errors.length, + durationMs: result.durationMs, + }); + console.log(`Games completed: ${result.gamesCompleted}`); + console.log(`Errors: ${result.errors.length}`); + console.log(`Duration: ${(result.durationMs / 1000).toFixed(1)}s`); + if (result.errors.length > 0) { + console.log('Errors:'); + for (const e of result.errors) { + console.log(` ${e.room}: ${e.reason}${e.detail ? ' — ' + e.detail : ''}`); + } + exitCode = 1; + } + } catch (err) { + logger.error('run_failed', { + error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, + }); + exitCode = 1; + } finally { + await pool.release(); + } + + if (abortController.signal.aborted && exitCode === 0) exitCode = 2; + process.exit(exitCode); } main().catch((err) => {