feat(soak): runner.ts end-to-end with --watch=none
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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<void> {
|
||||
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=<name> 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<string, unknown>,
|
||||
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) => {
|
||||
|
||||
Reference in New Issue
Block a user