feat(soak): stress scenario with chaos injection
Rapid short games with a parallel chaos loop that has a 5% per-turn chance of firing one of: - rapid_clicks: 5 quick clicks at the player's own cards - tab_blur: window blur/focus event pair - brief_offline: 300ms network outage via context.setOffline Chaos counts roll up into ScenarioResult.customMetrics.chaos_fired. Important detail: chaos loop has a 3-second initial delay so room creation, joiners, and game start can complete without interference. Chaos during lobby setup (especially brief_offline) was causing #create-room-btn to go unstable. Verified: stress smoke with --games-per-room=3, 4 accounts + 1 CPU, first game completed with 37 turns and chaos events fired across all three event types. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
65
tests/soak/scenarios/shared/chaos.ts
Normal file
65
tests/soak/scenarios/shared/chaos.ts
Normal file
@@ -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<ChaosEvent | null> {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user