diff --git a/tests/soak/scenarios/index.ts b/tests/soak/scenarios/index.ts index 303fdf5..cca7384 100644 --- a/tests/soak/scenarios/index.ts +++ b/tests/soak/scenarios/index.ts @@ -8,9 +8,11 @@ import type { Scenario } from '../core/types'; import populate from './populate'; +import stress from './stress'; const registry: Record = { populate, + stress, }; export function getScenario(name: string): Scenario | undefined { diff --git a/tests/soak/scenarios/shared/chaos.ts b/tests/soak/scenarios/shared/chaos.ts new file mode 100644 index 0000000..59ed9dd --- /dev/null +++ b/tests/soak/scenarios/shared/chaos.ts @@ -0,0 +1,65 @@ +/** + * Chaos injector — occasionally fires unexpected UI events while a + * game is playing, to hunt race conditions and recovery bugs. + * + * Called from the stress scenario's background chaos loop. Each call + * has `probability` of firing; when it fires it picks one random + * event type and runs it against one session. + */ + +import type { Session, Logger } from '../../core/types'; + +export type ChaosEvent = 'rapid_clicks' | 'tab_blur' | 'brief_offline'; + +const ALL_EVENTS: ChaosEvent[] = ['rapid_clicks', 'tab_blur', 'brief_offline']; + +function pickEvent(): ChaosEvent { + return ALL_EVENTS[Math.floor(Math.random() * ALL_EVENTS.length)]; +} + +export async function maybeInjectChaos( + session: Session, + probability: number, + logger: Logger, + roomId: string, +): Promise { + if (Math.random() >= probability) return null; + + const event = pickEvent(); + logger.info('chaos_injected', { room: roomId, session: session.key, event }); + try { + switch (event) { + case 'rapid_clicks': { + // Fire 5 rapid clicks at the player's own cards + for (let i = 0; i < 5; i++) { + await session.page + .locator(`#player-cards .card:nth-child(${(i % 6) + 1})`) + .click({ timeout: 300 }) + .catch(() => {}); + } + break; + } + case 'tab_blur': { + // Briefly dispatch blur then focus — simulates user tabbing away + await session.page.evaluate(() => { + window.dispatchEvent(new Event('blur')); + setTimeout(() => window.dispatchEvent(new Event('focus')), 200); + }); + break; + } + case 'brief_offline': { + // 300ms network outage — should trigger client reconnect logic + await session.context.setOffline(true); + await new Promise((r) => setTimeout(r, 300)); + await session.context.setOffline(false); + break; + } + } + } catch (err) { + logger.warn('chaos_error', { + event, + error: err instanceof Error ? err.message : String(err), + }); + } + return event; +} diff --git a/tests/soak/scenarios/stress.ts b/tests/soak/scenarios/stress.ts new file mode 100644 index 0000000..c4e713d --- /dev/null +++ b/tests/soak/scenarios/stress.ts @@ -0,0 +1,171 @@ +/** + * Stress scenario — rapid short games with chaos injection. + * + * Partitions sessions into N rooms (default 4), runs gamesPerRoom + * short 1-hole games per room in parallel. While each game plays, + * a background loop injects chaos events (rapid clicks, tab blur, + * brief offline) with 5% per-turn probability to hunt race + * conditions and recovery bugs. + */ + +import type { + Scenario, + ScenarioContext, + ScenarioResult, + ScenarioError, + Session, +} from '../core/types'; +import { runOneMultiplayerGame } from './shared/multiplayer-game'; +import { maybeInjectChaos } from './shared/chaos'; + +interface StressConfig { + gamesPerRoom: number; + holes: number; + decks: number; + rooms: number; + cpusPerRoom: number; + thinkTimeMs: [number, number]; + interGamePauseMs: number; + chaosChance: number; +} + +function chunk(arr: T[], size: number): T[][] { + const out: T[][] = []; + for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); + return out; +} + +async function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +async function runStressRoom( + ctx: ScenarioContext, + cfg: StressConfig, + roomIdx: number, + sessions: Session[], +): Promise<{ completed: number; errors: ScenarioError[]; chaosFired: number }> { + const roomId = `room-${roomIdx}`; + let completed = 0; + let chaosFired = 0; + const errors: ScenarioError[] = []; + + for (let gameNum = 0; gameNum < cfg.gamesPerRoom; gameNum++) { + if (ctx.signal.aborted) break; + + ctx.dashboard.update(roomId, { game: gameNum + 1, totalGames: cfg.gamesPerRoom }); + + // Background chaos loop — runs concurrently with the game turn loop. + // Delay the first tick by 3 seconds so room creation + joiners + game + // start have time to complete without chaos interference (rapid clicks + // or brief_offline during lobby setup can prevent #create-room-btn + // from becoming stable). + let chaosActive = true; + const chaosLoop = (async () => { + await sleep(3000); + while (chaosActive && !ctx.signal.aborted) { + await sleep(500); + for (const session of sessions) { + const e = await maybeInjectChaos( + session, + cfg.chaosChance, + ctx.logger, + roomId, + ); + if (e) chaosFired++; + } + } + })(); + + const result = await runOneMultiplayerGame(ctx, sessions, { + roomId, + holes: cfg.holes, + decks: cfg.decks, + cpusPerRoom: cfg.cpusPerRoom, + thinkTimeMs: cfg.thinkTimeMs, + }); + + chaosActive = false; + await chaosLoop; + + if (result.completed) { + completed++; + ctx.logger.info('game_complete', { + room: roomId, + game: gameNum + 1, + turns: result.turns, + }); + } else { + errors.push({ + room: roomId, + reason: 'game_failed', + detail: result.error, + timestamp: Date.now(), + }); + ctx.logger.error('game_failed', { room: roomId, error: result.error }); + } + + await sleep(cfg.interGamePauseMs); + } + + return { completed, errors, chaosFired }; +} + +const stress: Scenario = { + name: 'stress', + description: 'Rapid short games for stability & race condition hunting', + needs: { accounts: 16, rooms: 4, cpusPerRoom: 2 }, + defaultConfig: { + gamesPerRoom: 50, + holes: 1, + decks: 1, + rooms: 4, + cpusPerRoom: 2, + thinkTimeMs: [50, 150], + interGamePauseMs: 200, + chaosChance: 0.05, + }, + + async run(ctx: ScenarioContext): Promise { + const start = Date.now(); + const cfg = ctx.config as unknown as StressConfig; + const perRoom = Math.floor(ctx.sessions.length / cfg.rooms); + if (perRoom * cfg.rooms !== ctx.sessions.length) { + throw new Error( + `stress: ${ctx.sessions.length} sessions does not divide evenly into ${cfg.rooms} rooms`, + ); + } + const roomSessions = chunk(ctx.sessions, perRoom); + + const results = await Promise.allSettled( + roomSessions.map((s, idx) => runStressRoom(ctx, cfg, idx, s)), + ); + + let gamesCompleted = 0; + let chaosFired = 0; + const errors: ScenarioError[] = []; + results.forEach((r, idx) => { + if (r.status === 'fulfilled') { + gamesCompleted += r.value.completed; + chaosFired += r.value.chaosFired; + errors.push(...r.value.errors); + } else { + errors.push({ + room: `room-${idx}`, + reason: 'room_threw', + detail: r.reason instanceof Error ? r.reason.message : String(r.reason), + timestamp: Date.now(), + }); + } + }); + + return { + gamesCompleted, + errors, + durationMs: Date.now() - start, + customMetrics: { chaos_fired: chaosFired }, + }; + }, +}; + +export default stress;