golfgame/tests/e2e/specs/stress.spec.ts
Aaron D. Lee 6950769bc3 Version 2.0.0: Animation fixes, timing improvements, and E2E test suite
Animation fixes:
- Fix held card positioning bug (was appearing at bottom of page)
- Fix discard pile blank/white flash on turn transitions
- Fix blank card at round end by skipping animations during round_over/game_over
- Set card content before triggering flip animation to prevent flash
- Center suit symbol on 10 cards

Timing improvements:
- Reduce post-discard delay from 700ms to 500ms
- Reduce post-swap delay from 1800ms to 1000ms
- Speed up swap flip animation from 1150ms to 550ms
- Reduce CPU initial thinking delay from 150-250ms to 80-150ms
- Pause now happens after swap completes (showing result) instead of before

E2E test suite:
- Add Playwright-based test bot that plays full games
- State parser extracts game state from DOM for validation
- AI brain ports decision logic for automated play
- Freeze detector monitors for UI hangs
- Visual validator checks CSS states
- Full game, stress, and visual test specs

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-29 18:33:28 -05:00

402 lines
11 KiB
TypeScript

/**
* Stress tests
* Tests for race conditions, memory leaks, and edge cases
*/
import { test, expect } from '@playwright/test';
import { GolfBot } from '../bot/golf-bot';
import { FreezeDetector } from '../health/freeze-detector';
import { AnimationTracker } from '../health/animation-tracker';
test.describe('Stress Tests', () => {
test('rapid action sequence (race condition detection)', async ({ page }) => {
const bot = new GolfBot(page);
const freezeDetector = new FreezeDetector(page);
await bot.goto();
await bot.createGame('StressBot');
await bot.addCPU('Maya'); // Aggressive, fast player
await bot.startGame({ holes: 1 });
await bot.completeInitialFlips();
let actionCount = 0;
let errorCount = 0;
// Rapid turns with minimal delays
while (await bot.getGamePhase() !== 'round_over' && actionCount < 100) {
if (await bot.isMyTurn()) {
// Reduce normal waits
const state = await bot.getGameState();
if (!state.heldCard.visible) {
// Quick draw
const deck = page.locator('#deck');
await deck.click({ timeout: 1000 }).catch(() => { errorCount++; });
await page.waitForTimeout(100);
} else {
// Quick swap or discard
const faceDown = state.myPlayer?.cards.find(c => !c.faceUp);
if (faceDown) {
const card = page.locator(`#player-cards .card:nth-child(${faceDown.position + 1})`);
await card.click({ timeout: 1000 }).catch(() => { errorCount++; });
} else {
const discardBtn = page.locator('#discard-btn');
await discardBtn.click({ timeout: 1000 }).catch(() => { errorCount++; });
}
await page.waitForTimeout(100);
}
actionCount++;
} else {
await page.waitForTimeout(50);
}
// Check for freezes
if (await bot.isFrozen(2000)) {
console.warn(`Freeze detected at action ${actionCount}`);
break;
}
}
// Verify no critical errors
const health = await freezeDetector.runHealthCheck();
expect(health.issues.filter(i => i.type === 'websocket_closed')).toHaveLength(0);
// Some click errors are acceptable (timing issues), but not too many
expect(errorCount).toBeLessThan(10);
console.log(`Completed ${actionCount} rapid actions with ${errorCount} minor errors`);
});
test('multiple games in succession (memory leak detection)', async ({ page }) => {
test.setTimeout(300000); // 5 minutes
const bot = new GolfBot(page);
const gamesCompleted: number[] = [];
// Get initial memory if available
const getMemory = async () => {
try {
return await page.evaluate(() => {
if ('memory' in performance) {
return (performance as any).memory.usedJSHeapSize;
}
return null;
});
} catch {
return null;
}
};
const initialMemory = await getMemory();
console.log(`Initial memory: ${initialMemory ? Math.round(initialMemory / 1024 / 1024) + 'MB' : 'N/A'}`);
// Play 10 quick games
for (let game = 0; game < 10; game++) {
await bot.goto();
await bot.createGame(`MemBot${game}`);
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
const result = await bot.playRound(50);
if (result.success) {
gamesCompleted.push(game);
}
// Check memory every few games
if (game % 3 === 2) {
const currentMemory = await getMemory();
if (currentMemory) {
console.log(`Game ${game + 1}: memory = ${Math.round(currentMemory / 1024 / 1024)}MB`);
}
}
// Clear any accumulated errors
bot.clearConsoleErrors();
}
// Should complete most games
expect(gamesCompleted.length).toBeGreaterThanOrEqual(8);
// Final memory check
const finalMemory = await getMemory();
if (initialMemory && finalMemory) {
const memoryGrowth = finalMemory - initialMemory;
console.log(`Memory growth: ${Math.round(memoryGrowth / 1024 / 1024)}MB`);
// Memory shouldn't grow excessively (allow 50MB growth for 10 games)
expect(memoryGrowth).toBeLessThan(50 * 1024 * 1024);
}
});
test('6-player game with 5 CPUs (max players)', async ({ page }) => {
test.setTimeout(180000); // 3 minutes
const bot = new GolfBot(page);
const freezeDetector = new FreezeDetector(page);
await bot.goto();
await bot.createGame('TestBot');
// Add 5 CPU players (max typical setup)
await bot.addCPU('Sofia');
await bot.addCPU('Maya');
await bot.addCPU('Priya');
await bot.addCPU('Marcus');
await bot.addCPU('Kenji');
// Start with 2 decks (recommended for 6 players)
await bot.startGame({
holes: 3,
decks: 2,
});
// Play through all rounds
const result = await bot.playGame(3);
expect(result.success).toBe(true);
expect(result.rounds).toBe(3);
// Check for issues
const health = await freezeDetector.runHealthCheck();
expect(health.healthy).toBe(true);
console.log(`6-player game completed in ${result.totalTurns} turns`);
});
test('animation queue under load', async ({ page }) => {
const bot = new GolfBot(page);
const animTracker = new AnimationTracker(page);
await bot.goto();
await bot.createGame('AnimBot');
await bot.addCPU('Maya'); // Fast player
await bot.addCPU('Sage'); // Sneaky finisher
await bot.startGame({ holes: 1 });
await bot.completeInitialFlips();
let animationCount = 0;
let stallCount = 0;
while (await bot.getGamePhase() !== 'round_over' && animationCount < 50) {
if (await bot.isMyTurn()) {
// Track animation timing
animTracker.recordStart('turn');
await bot.playTurn();
const result = await animTracker.waitForAnimation('turn', 5000);
if (!result.completed) {
stallCount++;
}
animationCount++;
}
await page.waitForTimeout(100);
}
// Check animation timing is reasonable
const avgDuration = animTracker.getAverageDuration('turn');
console.log(`Average turn animation: ${avgDuration?.toFixed(0) || 'N/A'}ms`);
// Stalls should be rare
expect(stallCount).toBeLessThan(3);
// Check stall events
const stalls = animTracker.getStalls();
if (stalls.length > 0) {
console.log(`Animation stalls:`, stalls);
}
});
test('websocket reconnection handling', async ({ page }) => {
const bot = new GolfBot(page);
const freezeDetector = new FreezeDetector(page);
await bot.goto();
await bot.createGame('ReconnectBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
await bot.completeInitialFlips();
// Play a few turns
for (let i = 0; i < 3; i++) {
await bot.waitForMyTurn(10000);
if (await bot.isMyTurn()) {
await bot.playTurn();
}
}
// Check WebSocket is healthy
const wsHealthy = await freezeDetector.checkWebSocket();
expect(wsHealthy).toBe(true);
// Note: Actually closing/reopening websocket would require
// server cooperation or network manipulation
});
test('concurrent clicks during animation', async ({ page }) => {
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('ClickBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
await bot.completeInitialFlips();
await bot.waitForMyTurn(10000);
// Draw a card
const deck = page.locator('#deck');
await deck.click();
await page.waitForTimeout(200);
// Try rapid clicks on multiple elements while animation might be running
const clickPromises: Promise<void>[] = [];
for (let i = 0; i < 6; i++) {
const card = page.locator(`#player-cards .card:nth-child(${i + 1})`);
clickPromises.push(
card.click({ timeout: 500 }).catch(() => {})
);
}
// Wait for all clicks to complete or timeout
await Promise.all(clickPromises);
// Wait for any animations
await page.waitForTimeout(2000);
// Game should still be in a valid state
const phase = await bot.getGamePhase();
expect(['playing', 'waiting_for_flip', 'round_over']).toContain(phase);
// No console errors
const errors = bot.getConsoleErrors();
expect(errors.filter(e => e.includes('undefined') || e.includes('null'))).toHaveLength(0);
});
});
test.describe('Edge Cases', () => {
test('all cards revealed simultaneously', async ({ page }) => {
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('EdgeBot');
await bot.addCPU('Sofia');
// Start with Speed Golf (flip on discard) to reveal cards faster
await bot.startGame({
holes: 1,
flipMode: 'always',
initialFlips: 2,
});
// Play until we trigger round end
const result = await bot.playRound(100);
expect(result.success).toBe(true);
// Verify game handled the transition
const phase = await bot.getGamePhase();
expect(phase).toBe('round_over');
});
test('deck reshuffle scenario', async ({ page }) => {
test.setTimeout(180000); // 3 minutes for longer game
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('ShuffleBot');
// Add many players to deplete deck faster
await bot.addCPU('Sofia');
await bot.addCPU('Maya');
await bot.addCPU('Marcus');
await bot.addCPU('Kenji');
// Use only 1 deck to force reshuffle
await bot.startGame({
holes: 1,
decks: 1,
});
// Play through - deck should reshuffle during game
const result = await bot.playRound(200);
expect(result.success).toBe(true);
});
test('empty discard pile handling', async ({ page }) => {
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('EmptyBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
// At game start, discard might be empty briefly
const initialState = await bot.getGameState();
// Game should still function
await bot.completeInitialFlips();
await bot.waitForMyTurn(10000);
// Should be able to draw from deck even if discard is empty
if (await bot.isMyTurn()) {
const state = await bot.getGameState();
if (!state.discard.hasCard) {
// Draw from deck should work
const deck = page.locator('#deck');
await deck.click();
await page.waitForTimeout(500);
// Should have a held card now
const afterState = await bot.getGameState();
expect(afterState.heldCard.visible).toBe(true);
}
}
});
test('final turn badge timing', async ({ page }) => {
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('BadgeBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
// Monitor for final turn badge
let sawFinalTurnBadge = false;
let turnsAfterBadge = 0;
while (await bot.getGamePhase() !== 'round_over') {
const state = await bot.getGameState();
if (state.isFinalTurn) {
sawFinalTurnBadge = true;
}
if (sawFinalTurnBadge && await bot.isMyTurn()) {
turnsAfterBadge++;
}
if (await bot.isMyTurn()) {
await bot.playTurn();
}
await page.waitForTimeout(100);
}
// If final turn happened, we should have had at most 1 turn after badge appeared
// (this depends on whether we're the one who triggered final turn)
if (sawFinalTurnBadge) {
expect(turnsAfterBadge).toBeLessThanOrEqual(2);
}
});
});