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>
This commit is contained in:
Aaron D. Lee
2026-01-29 18:33:28 -05:00
parent 724bf87c43
commit 6950769bc3
29 changed files with 5153 additions and 348 deletions

5
tests/e2e/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
playwright-report/
test-results/
*.db
*.db-journal

255
tests/e2e/bot/actions.ts Normal file
View File

@@ -0,0 +1,255 @@
/**
* Game action executors with proper animation timing
*/
import { Page } from '@playwright/test';
import { SELECTORS } from '../utils/selectors';
import { TIMING, waitForAnimations } from '../utils/timing';
/**
* Result of a game action
*/
export interface ActionResult {
success: boolean;
error?: string;
}
/**
* Executes game actions on the page
*/
export class Actions {
constructor(private page: Page) {}
/**
* Draw a card from the deck
*/
async drawFromDeck(): Promise<ActionResult> {
try {
// Wait for any ongoing animations first
await waitForAnimations(this.page);
const deck = this.page.locator(SELECTORS.game.deck);
await deck.waitFor({ state: 'visible', timeout: 5000 });
// Wait for deck to become clickable (may take a moment after turn starts)
let isClickable = false;
for (let i = 0; i < 20; i++) {
isClickable = await deck.evaluate(el => el.classList.contains('clickable'));
if (isClickable) break;
await this.page.waitForTimeout(100);
}
if (!isClickable) {
return { success: false, error: 'Deck is not clickable' };
}
// Use force:true because deck-area has a pulsing animation that makes it "unstable"
await deck.click({ force: true, timeout: 5000 });
await this.page.waitForTimeout(TIMING.drawComplete);
await waitForAnimations(this.page);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Draw a card from the discard pile
*/
async drawFromDiscard(): Promise<ActionResult> {
try {
// Wait for any ongoing animations first
await waitForAnimations(this.page);
const discard = this.page.locator(SELECTORS.game.discard);
await discard.waitFor({ state: 'visible', timeout: 5000 });
// Use force:true because deck-area has a pulsing animation
await discard.click({ force: true, timeout: 5000 });
await this.page.waitForTimeout(TIMING.drawComplete);
await waitForAnimations(this.page);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Swap drawn card with a card at position
*/
async swapCard(position: number): Promise<ActionResult> {
try {
const cardSelector = SELECTORS.cards.playerCard(position);
const card = this.page.locator(cardSelector);
await card.waitFor({ state: 'visible', timeout: 5000 });
// Use force:true to handle any CSS animations
await card.click({ force: true, timeout: 5000 });
await this.page.waitForTimeout(TIMING.swapComplete);
await waitForAnimations(this.page);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Discard the drawn card
*/
async discardDrawn(): Promise<ActionResult> {
try {
const discardBtn = this.page.locator(SELECTORS.game.discardBtn);
await discardBtn.click();
await this.page.waitForTimeout(TIMING.pauseAfterDiscard);
await waitForAnimations(this.page);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Flip a card at position
*/
async flipCard(position: number): Promise<ActionResult> {
try {
// Wait for animations before clicking
await waitForAnimations(this.page);
const cardSelector = SELECTORS.cards.playerCard(position);
const card = this.page.locator(cardSelector);
await card.waitFor({ state: 'visible', timeout: 5000 });
// Use force:true to handle any CSS animations
await card.click({ force: true, timeout: 5000 });
await this.page.waitForTimeout(TIMING.flipComplete);
await waitForAnimations(this.page);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Skip the optional flip (endgame mode)
*/
async skipFlip(): Promise<ActionResult> {
try {
const skipBtn = this.page.locator(SELECTORS.game.skipFlipBtn);
await skipBtn.click();
await this.page.waitForTimeout(TIMING.turnTransition);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Knock early (flip all remaining cards)
*/
async knockEarly(): Promise<ActionResult> {
try {
const knockBtn = this.page.locator(SELECTORS.game.knockEarlyBtn);
await knockBtn.click();
await this.page.waitForTimeout(TIMING.swapComplete);
await waitForAnimations(this.page);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Wait for turn to start
*/
async waitForMyTurn(timeout: number = 30000): Promise<boolean> {
try {
await this.page.waitForFunction(
(sel) => {
const deckArea = document.querySelector(sel);
return deckArea?.classList.contains('your-turn-to-draw');
},
SELECTORS.game.deckArea,
{ timeout }
);
return true;
} catch {
return false;
}
}
/**
* Wait for game phase change
*/
async waitForPhase(
expectedPhases: string[],
timeout: number = 30000
): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeout) {
// Check for round over
const nextRoundBtn = this.page.locator(SELECTORS.game.nextRoundBtn);
if (await nextRoundBtn.isVisible().catch(() => false)) {
if (expectedPhases.includes('round_over')) return true;
}
// Check for game over
const newGameBtn = this.page.locator(SELECTORS.game.newGameBtn);
if (await newGameBtn.isVisible().catch(() => false)) {
if (expectedPhases.includes('game_over')) return true;
}
// Check for final turn
const finalTurnBadge = this.page.locator(SELECTORS.game.finalTurnBadge);
if (await finalTurnBadge.isVisible().catch(() => false)) {
if (expectedPhases.includes('final_turn')) return true;
}
// Check for my turn (playing phase)
const deckArea = this.page.locator(SELECTORS.game.deckArea);
const isMyTurn = await deckArea.evaluate(el =>
el.classList.contains('your-turn-to-draw')
).catch(() => false);
if (isMyTurn && expectedPhases.includes('playing')) return true;
await this.page.waitForTimeout(100);
}
return false;
}
/**
* Click the "Next Hole" button to start next round
*/
async nextRound(): Promise<ActionResult> {
try {
const btn = this.page.locator(SELECTORS.game.nextRoundBtn);
await btn.waitFor({ state: 'visible', timeout: 5000 });
await btn.click();
await this.page.waitForTimeout(TIMING.roundOverDelay);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Click the "New Game" button to return to waiting room
*/
async newGame(): Promise<ActionResult> {
try {
const btn = this.page.locator(SELECTORS.game.newGameBtn);
await btn.waitFor({ state: 'visible', timeout: 5000 });
await btn.click();
await this.page.waitForTimeout(TIMING.turnTransition);
return { success: true };
} catch (error) {
return { success: false, error: String(error) };
}
}
/**
* Wait for animations to complete
*/
async waitForAnimationComplete(timeout: number = 5000): Promise<void> {
await waitForAnimations(this.page, timeout);
}
}

334
tests/e2e/bot/ai-brain.ts Normal file
View File

@@ -0,0 +1,334 @@
/**
* AI decision-making logic for the test bot
* Simplified port of server/ai.py for client-side decision making
*/
import { CardState, PlayerState } from './state-parser';
/**
* Card value mapping (standard rules)
*/
const CARD_VALUES: Record<string, number> = {
'★': -2, // Joker
'2': -2,
'A': 1,
'K': 0,
'3': 3,
'4': 4,
'5': 5,
'6': 6,
'7': 7,
'8': 8,
'9': 9,
'10': 10,
'J': 10,
'Q': 10,
};
/**
* Game options that affect card values
*/
export interface GameOptions {
superKings?: boolean; // K = -2 instead of 0
tenPenny?: boolean; // 10 = 1 instead of 10
oneEyedJacks?: boolean; // J♥/J♠ = 0
eagleEye?: boolean; // Jokers +2 unpaired, -4 paired
}
/**
* Get the point value of a card
*/
export function getCardValue(card: { rank: string | null; suit?: string | null }, options: GameOptions = {}): number {
if (!card.rank) return 5; // Unknown card estimated at average
let value = CARD_VALUES[card.rank] ?? 5;
// Super Kings rule
if (options.superKings && card.rank === 'K') {
value = -2;
}
// Ten Penny rule
if (options.tenPenny && card.rank === '10') {
value = 1;
}
// One-Eyed Jacks rule
if (options.oneEyedJacks && card.rank === 'J') {
if (card.suit === 'hearts' || card.suit === 'spades') {
value = 0;
}
}
// Eagle Eye rule (Jokers are +2 when unpaired)
// Note: We can't know pairing status from just one card, so this is informational
if (options.eagleEye && card.rank === '★') {
value = 2; // Default to unpaired value
}
return value;
}
/**
* Get column partner position (cards that can form pairs)
* Column pairs: (0,3), (1,4), (2,5)
*/
export function getColumnPartner(position: number): number {
return position < 3 ? position + 3 : position - 3;
}
/**
* AI Brain - makes decisions for the test bot
*/
export class AIBrain {
constructor(private options: GameOptions = {}) {}
/**
* Choose 2 cards for initial flip
* Prefer different columns for better pair information
*/
chooseInitialFlips(cards: CardState[]): number[] {
const faceDown = cards.filter(c => !c.faceUp);
if (faceDown.length === 0) return [];
if (faceDown.length === 1) return [faceDown[0].position];
// Good initial flip patterns (different columns)
const patterns = [
[0, 4], [2, 4], [3, 1], [5, 1],
[0, 5], [2, 3],
];
// Find a valid pattern
for (const pattern of patterns) {
const valid = pattern.every(p =>
faceDown.some(c => c.position === p)
);
if (valid) return pattern;
}
// Fallback: pick any two face-down cards in different columns
const result: number[] = [];
const usedColumns = new Set<number>();
for (const card of faceDown) {
const col = card.position % 3;
if (!usedColumns.has(col)) {
result.push(card.position);
usedColumns.add(col);
if (result.length === 2) break;
}
}
// If we couldn't get different columns, just take first two
if (result.length < 2) {
for (const card of faceDown) {
if (!result.includes(card.position)) {
result.push(card.position);
if (result.length === 2) break;
}
}
}
return result;
}
/**
* Decide whether to take from discard pile
*/
shouldTakeDiscard(
discardCard: { rank: string; suit: string } | null,
myCards: CardState[]
): boolean {
if (!discardCard) return false;
const value = getCardValue(discardCard, this.options);
// Always take Jokers and Kings (excellent cards)
if (discardCard.rank === '★' || discardCard.rank === 'K') {
return true;
}
// Always take negative/low value cards
if (value <= 2) {
return true;
}
// Check if discard can form a pair with a visible card
for (const card of myCards) {
if (card.faceUp && card.rank === discardCard.rank) {
const partnerPos = getColumnPartner(card.position);
const partnerCard = myCards.find(c => c.position === partnerPos);
// Only pair if partner is face-down (unknown) - pairing negative cards is wasteful
if (partnerCard && !partnerCard.faceUp && value > 0) {
return true;
}
}
}
// Take medium cards if we have visible bad cards to replace
if (value <= 5) {
for (const card of myCards) {
if (card.faceUp && card.rank) {
const cardValue = getCardValue(card, this.options);
if (cardValue > value + 1) {
return true;
}
}
}
}
// Default: draw from deck
return false;
}
/**
* Choose position to swap drawn card, or null to discard
*/
chooseSwapPosition(
drawnCard: { rank: string; suit?: string | null },
myCards: CardState[],
mustSwap: boolean = false // True if drawn from discard
): number | null {
const drawnValue = getCardValue(drawnCard, this.options);
// Calculate score for each position
const scores: { pos: number; score: number }[] = [];
for (let pos = 0; pos < 6; pos++) {
const card = myCards.find(c => c.position === pos);
if (!card) continue;
let score = 0;
const partnerPos = getColumnPartner(pos);
const partnerCard = myCards.find(c => c.position === partnerPos);
// Check for pair creation
if (partnerCard?.faceUp && partnerCard.rank === drawnCard.rank) {
const partnerValue = getCardValue(partnerCard, this.options);
if (drawnValue >= 0) {
// Good pair! Both cards become 0
score += drawnValue + partnerValue;
} else {
// Pairing negative cards is wasteful (unless special rules)
score -= Math.abs(drawnValue) * 2;
}
}
// Point improvement
if (card.faceUp && card.rank) {
const currentValue = getCardValue(card, this.options);
score += currentValue - drawnValue;
} else {
// Face-down card - expected value ~4.5
const expectedHidden = 4.5;
score += (expectedHidden - drawnValue) * 0.7; // Discount for uncertainty
}
// Bonus for revealing hidden cards with good drawn cards
if (!card.faceUp && drawnValue <= 3) {
score += 2;
}
scores.push({ pos, score });
}
// Sort by score descending
scores.sort((a, b) => b.score - a.score);
// If best score is positive, swap there
if (scores.length > 0 && scores[0].score > 0) {
return scores[0].pos;
}
// Must swap if drawn from discard
if (mustSwap && scores.length > 0) {
// Find a face-down position if possible
const faceDownScores = scores.filter(s => {
const card = myCards.find(c => c.position === s.pos);
return card && !card.faceUp;
});
if (faceDownScores.length > 0) {
return faceDownScores[0].pos;
}
// Otherwise take the best score even if negative
return scores[0].pos;
}
// Discard the drawn card
return null;
}
/**
* Choose which card to flip after discarding
*/
chooseFlipPosition(myCards: CardState[]): number {
const faceDown = myCards.filter(c => !c.faceUp);
if (faceDown.length === 0) return 0;
// Prefer flipping cards where the partner is visible (pair info)
for (const card of faceDown) {
const partnerPos = getColumnPartner(card.position);
const partner = myCards.find(c => c.position === partnerPos);
if (partner?.faceUp) {
return card.position;
}
}
// Random face-down card
return faceDown[Math.floor(Math.random() * faceDown.length)].position;
}
/**
* Decide whether to skip optional flip (endgame mode)
*/
shouldSkipFlip(myCards: CardState[]): boolean {
const faceDown = myCards.filter(c => !c.faceUp);
// Always flip if we have many hidden cards
if (faceDown.length >= 3) {
return false;
}
// Small chance to skip with 1-2 hidden cards
return faceDown.length <= 2 && Math.random() < 0.15;
}
/**
* Calculate estimated hand score
*/
estimateScore(cards: CardState[]): number {
let score = 0;
// Group cards by column for pair detection
const columns: (CardState | undefined)[][] = [
[cards.find(c => c.position === 0), cards.find(c => c.position === 3)],
[cards.find(c => c.position === 1), cards.find(c => c.position === 4)],
[cards.find(c => c.position === 2), cards.find(c => c.position === 5)],
];
for (const [top, bottom] of columns) {
if (top?.faceUp && bottom?.faceUp) {
if (top.rank === bottom.rank) {
// Pair - contributes 0
continue;
}
score += getCardValue(top, this.options);
score += getCardValue(bottom, this.options);
} else if (top?.faceUp) {
score += getCardValue(top, this.options);
score += 4.5; // Estimate for hidden bottom
} else if (bottom?.faceUp) {
score += 4.5; // Estimate for hidden top
score += getCardValue(bottom, this.options);
} else {
score += 9; // Both hidden, estimate 4.5 each
}
}
return Math.round(score);
}
}

599
tests/e2e/bot/golf-bot.ts Normal file
View File

@@ -0,0 +1,599 @@
/**
* GolfBot - Main orchestrator for the test bot
* Controls browser and coordinates game actions
*/
import { Page, expect } from '@playwright/test';
import { StateParser, GamePhase, ParsedGameState } from './state-parser';
import { AIBrain, GameOptions } from './ai-brain';
import { Actions, ActionResult } from './actions';
import { SELECTORS } from '../utils/selectors';
import { TIMING, waitForAnimations } from '../utils/timing';
/**
* Options for starting a game
*/
export interface StartGameOptions {
holes?: number;
decks?: number;
initialFlips?: number;
flipMode?: 'never' | 'always' | 'endgame';
knockPenalty?: boolean;
jokerMode?: 'none' | 'standard' | 'lucky-swing' | 'eagle-eye';
}
/**
* Result of a turn
*/
export interface TurnResult {
success: boolean;
action: string;
details?: Record<string, unknown>;
error?: string;
}
/**
* GolfBot - automated game player for testing
*/
export class GolfBot {
private stateParser: StateParser;
private actions: Actions;
private brain: AIBrain;
private screenshots: { label: string; buffer: Buffer }[] = [];
private consoleErrors: string[] = [];
private turnCount = 0;
constructor(
private page: Page,
aiOptions: GameOptions = {}
) {
this.stateParser = new StateParser(page);
this.actions = new Actions(page);
this.brain = new AIBrain(aiOptions);
// Capture console errors
page.on('console', msg => {
if (msg.type() === 'error') {
this.consoleErrors.push(msg.text());
}
});
page.on('pageerror', err => {
this.consoleErrors.push(err.message);
});
}
/**
* Navigate to the game
*/
async goto(url?: string): Promise<void> {
await this.page.goto(url || '/');
await this.page.waitForLoadState('networkidle');
}
/**
* Create a new game room
*/
async createGame(playerName: string): Promise<string> {
// Enter name
const nameInput = this.page.locator(SELECTORS.lobby.playerNameInput);
await nameInput.fill(playerName);
// Click create room
const createBtn = this.page.locator(SELECTORS.lobby.createRoomBtn);
await createBtn.click();
// Wait for waiting room
await this.page.waitForSelector(SELECTORS.screens.waiting, {
state: 'visible',
timeout: 10000,
});
// Get room code
const roomCodeEl = this.page.locator(SELECTORS.waiting.roomCode);
const roomCode = await roomCodeEl.textContent() || '';
return roomCode.trim();
}
/**
* Join an existing game room
*/
async joinGame(roomCode: string, playerName: string): Promise<void> {
// Enter name
const nameInput = this.page.locator(SELECTORS.lobby.playerNameInput);
await nameInput.fill(playerName);
// Enter room code
const codeInput = this.page.locator(SELECTORS.lobby.roomCodeInput);
await codeInput.fill(roomCode);
// Click join
const joinBtn = this.page.locator(SELECTORS.lobby.joinRoomBtn);
await joinBtn.click();
// Wait for waiting room
await this.page.waitForSelector(SELECTORS.screens.waiting, {
state: 'visible',
timeout: 10000,
});
}
/**
* Add a CPU player
*/
async addCPU(profileName?: string): Promise<void> {
// Click add CPU button
const addBtn = this.page.locator(SELECTORS.waiting.addCpuBtn);
await addBtn.click();
// Wait for modal
await this.page.waitForSelector(SELECTORS.waiting.cpuModal, {
state: 'visible',
timeout: 5000,
});
// Select profile if specified
if (profileName) {
const profileCard = this.page.locator(
`${SELECTORS.waiting.cpuProfilesGrid} .profile-card:has-text("${profileName}")`
);
await profileCard.click();
} else {
// Select first available profile
const firstProfile = this.page.locator(
`${SELECTORS.waiting.cpuProfilesGrid} .profile-card:not(.unavailable)`
).first();
await firstProfile.click();
}
// Click add button
const addSelectedBtn = this.page.locator(SELECTORS.waiting.addSelectedCpusBtn);
await addSelectedBtn.click();
// Wait for modal to close
await this.page.waitForSelector(SELECTORS.waiting.cpuModal, {
state: 'hidden',
timeout: 5000,
});
await this.page.waitForTimeout(500);
}
/**
* Start the game
*/
async startGame(options: StartGameOptions = {}): Promise<void> {
// Set game options if host
const hostSettings = this.page.locator(SELECTORS.waiting.hostSettings);
if (await hostSettings.isVisible()) {
if (options.holes) {
await this.page.selectOption(SELECTORS.waiting.numRounds, String(options.holes));
}
if (options.decks) {
await this.page.selectOption(SELECTORS.waiting.numDecks, String(options.decks));
}
if (options.initialFlips !== undefined) {
await this.page.selectOption(SELECTORS.waiting.initialFlips, String(options.initialFlips));
}
// Advanced options require opening the details section first
if (options.flipMode) {
const advancedSection = this.page.locator('.advanced-options-section');
if (await advancedSection.isVisible()) {
// Check if it's already open
const isOpen = await advancedSection.evaluate(el => el.hasAttribute('open'));
if (!isOpen) {
await advancedSection.locator('summary').click();
await this.page.waitForTimeout(300);
}
}
await this.page.selectOption(SELECTORS.waiting.flipMode, options.flipMode);
}
}
// Click start game
const startBtn = this.page.locator(SELECTORS.waiting.startGameBtn);
await startBtn.click();
// Wait for game screen
await this.page.waitForSelector(SELECTORS.screens.game, {
state: 'visible',
timeout: 10000,
});
await waitForAnimations(this.page);
}
/**
* Get current game phase
*/
async getGamePhase(): Promise<GamePhase> {
return this.stateParser.getPhase();
}
/**
* Get full game state
*/
async getGameState(): Promise<ParsedGameState> {
return this.stateParser.getState();
}
/**
* Check if it's bot's turn
*/
async isMyTurn(): Promise<boolean> {
return this.stateParser.isMyTurn();
}
/**
* Wait for bot's turn
*/
async waitForMyTurn(timeout: number = 30000): Promise<boolean> {
return this.actions.waitForMyTurn(timeout);
}
/**
* Wait for any animation to complete
*/
async waitForAnimation(): Promise<void> {
await waitForAnimations(this.page);
}
/**
* Play a complete turn
*/
async playTurn(): Promise<TurnResult> {
this.turnCount++;
const state = await this.getGameState();
// Handle initial flip phase
if (state.phase === 'initial_flip') {
return this.handleInitialFlip(state);
}
// Handle waiting for flip after discard
if (state.phase === 'waiting_for_flip') {
return this.handleWaitingForFlip(state);
}
// Regular turn
if (!state.heldCard.visible) {
// Need to draw
return this.handleDraw(state);
} else {
// Have a card, need to swap or discard
return this.handleSwapOrDiscard(state);
}
}
/**
* Handle initial flip phase
*/
private async handleInitialFlip(state: ParsedGameState): Promise<TurnResult> {
const myCards = state.myPlayer?.cards || [];
const faceDownPositions = myCards.filter(c => !c.faceUp).map(c => c.position);
if (faceDownPositions.length === 0) {
return { success: true, action: 'initial_flip_complete' };
}
// Choose cards to flip
const toFlip = this.brain.chooseInitialFlips(myCards);
for (const pos of toFlip) {
if (faceDownPositions.includes(pos)) {
const result = await this.actions.flipCard(pos);
if (!result.success) {
return { success: false, action: 'initial_flip', error: result.error };
}
}
}
return {
success: true,
action: 'initial_flip',
details: { positions: toFlip },
};
}
/**
* Handle draw phase
*/
private async handleDraw(state: ParsedGameState): Promise<TurnResult> {
const myCards = state.myPlayer?.cards || [];
const discardTop = state.discard.topCard;
// Decide: discard or deck
const takeDiscard = this.brain.shouldTakeDiscard(discardTop, myCards);
let result: ActionResult;
let source: string;
if (takeDiscard && state.discard.clickable) {
result = await this.actions.drawFromDiscard();
source = 'discard';
} else {
result = await this.actions.drawFromDeck();
source = 'deck';
}
if (!result.success) {
return { success: false, action: 'draw', error: result.error };
}
// Wait for held card to be visible
await this.page.waitForTimeout(500);
// Now handle swap or discard
const newState = await this.getGameState();
// If held card still not visible, wait a bit more and retry
if (!newState.heldCard.visible) {
await this.page.waitForTimeout(500);
const retryState = await this.getGameState();
return this.handleSwapOrDiscard(retryState, source === 'discard');
}
return this.handleSwapOrDiscard(newState, source === 'discard');
}
/**
* Handle swap or discard decision
*/
private async handleSwapOrDiscard(
state: ParsedGameState,
mustSwap: boolean = false
): Promise<TurnResult> {
const myCards = state.myPlayer?.cards || [];
const heldCard = state.heldCard.card;
if (!heldCard) {
return { success: false, action: 'swap_or_discard', error: 'No held card' };
}
// Decide: swap position or discard
const swapPos = this.brain.chooseSwapPosition(heldCard, myCards, mustSwap);
if (swapPos !== null) {
// Swap
const result = await this.actions.swapCard(swapPos);
return {
success: result.success,
action: 'swap',
details: { position: swapPos, card: heldCard },
error: result.error,
};
} else {
// Discard
const result = await this.actions.discardDrawn();
if (!result.success) {
return { success: false, action: 'discard', error: result.error };
}
// Check if we need to flip
await this.page.waitForTimeout(200);
const afterState = await this.getGameState();
if (afterState.phase === 'waiting_for_flip') {
return this.handleWaitingForFlip(afterState);
}
return {
success: true,
action: 'discard',
details: { card: heldCard },
};
}
}
/**
* Handle waiting for flip after discard
*/
private async handleWaitingForFlip(state: ParsedGameState): Promise<TurnResult> {
const myCards = state.myPlayer?.cards || [];
// Check if flip is optional
if (state.canSkipFlip) {
if (this.brain.shouldSkipFlip(myCards)) {
const result = await this.actions.skipFlip();
return {
success: result.success,
action: 'skip_flip',
error: result.error,
};
}
}
// Choose a card to flip
const pos = this.brain.chooseFlipPosition(myCards);
const result = await this.actions.flipCard(pos);
return {
success: result.success,
action: 'flip',
details: { position: pos },
error: result.error,
};
}
/**
* Take a screenshot with label
*/
async takeScreenshot(label: string): Promise<Buffer> {
const buffer = await this.page.screenshot();
this.screenshots.push({ label, buffer });
return buffer;
}
/**
* Get all collected screenshots
*/
getScreenshots(): { label: string; buffer: Buffer }[] {
return this.screenshots;
}
/**
* Get console errors collected
*/
getConsoleErrors(): string[] {
return this.consoleErrors;
}
/**
* Clear console errors
*/
clearConsoleErrors(): void {
this.consoleErrors = [];
}
/**
* Check if the UI appears frozen (animation stuck)
*/
async isFrozen(timeout: number = 3000): Promise<boolean> {
try {
await waitForAnimations(this.page, timeout);
return false;
} catch {
return true;
}
}
/**
* Get turn count
*/
getTurnCount(): number {
return this.turnCount;
}
/**
* Play through initial flip phase completely
*/
async completeInitialFlips(): Promise<void> {
let phase = await this.getGamePhase();
let attempts = 0;
const maxAttempts = 10;
while (phase === 'initial_flip' && attempts < maxAttempts) {
if (await this.isMyTurn()) {
await this.playTurn();
}
await this.page.waitForTimeout(500);
phase = await this.getGamePhase();
attempts++;
}
}
/**
* Play through entire round
*/
async playRound(maxTurns: number = 100): Promise<{ success: boolean; turns: number }> {
let turns = 0;
while (turns < maxTurns) {
const phase = await this.getGamePhase();
if (phase === 'round_over' || phase === 'game_over') {
return { success: true, turns };
}
if (await this.isMyTurn()) {
const result = await this.playTurn();
if (!result.success) {
console.warn(`Turn ${turns} failed:`, result.error);
}
turns++;
}
await this.page.waitForTimeout(200);
// Check for frozen state
if (await this.isFrozen()) {
return { success: false, turns };
}
}
return { success: false, turns };
}
/**
* Play through entire game (all rounds)
*/
async playGame(maxRounds: number = 18): Promise<{
success: boolean;
rounds: number;
totalTurns: number;
}> {
let rounds = 0;
let totalTurns = 0;
while (rounds < maxRounds) {
const phase = await this.getGamePhase();
if (phase === 'game_over') {
return { success: true, rounds, totalTurns };
}
// Complete initial flips first
await this.completeInitialFlips();
// Play the round
const roundResult = await this.playRound();
totalTurns += roundResult.turns;
rounds++;
if (!roundResult.success) {
return { success: false, rounds, totalTurns };
}
// Check for game over
let newPhase = await this.getGamePhase();
if (newPhase === 'game_over') {
return { success: true, rounds, totalTurns };
}
// Check if this was the final round
const state = await this.getGameState();
const isLastRound = state.currentRound >= state.totalRounds;
// If last round just ended, wait for game_over or trigger it
if (newPhase === 'round_over' && isLastRound) {
// Wait a few seconds for auto-transition or countdown
for (let i = 0; i < 10; i++) {
await this.page.waitForTimeout(1000);
newPhase = await this.getGamePhase();
if (newPhase === 'game_over') {
return { success: true, rounds, totalTurns };
}
}
// Game might require clicking Next Hole to show Final Results
// Try clicking the button to trigger the transition
const nextResult = await this.actions.nextRound();
// Wait for Final Results modal to appear
for (let i = 0; i < 10; i++) {
await this.page.waitForTimeout(1000);
newPhase = await this.getGamePhase();
if (newPhase === 'game_over') {
return { success: true, rounds, totalTurns };
}
}
}
// Start next round if available
if (newPhase === 'round_over') {
await this.page.waitForTimeout(1000);
const nextResult = await this.actions.nextRound();
if (!nextResult.success) {
// Maybe we're not the host, wait for host to start
await this.page.waitForTimeout(5000);
}
}
}
return { success: true, rounds, totalTurns };
}
}

10
tests/e2e/bot/index.ts Normal file
View File

@@ -0,0 +1,10 @@
export { GolfBot, StartGameOptions, TurnResult } from './golf-bot';
export { AIBrain, GameOptions, getCardValue, getColumnPartner } from './ai-brain';
export { Actions, ActionResult } from './actions';
export {
StateParser,
CardState,
PlayerState,
ParsedGameState,
GamePhase,
} from './state-parser';

View File

@@ -0,0 +1,524 @@
import { Page, Locator } from '@playwright/test';
import { SELECTORS } from '../utils/selectors';
/**
* Represents a card's state as extracted from the DOM
*/
export interface CardState {
position: number;
faceUp: boolean;
rank: string | null;
suit: string | null;
clickable: boolean;
selected: boolean;
}
/**
* Represents a player's state as extracted from the DOM
*/
export interface PlayerState {
name: string;
cards: CardState[];
isCurrentTurn: boolean;
score: number | null;
}
/**
* Represents the overall game state as extracted from the DOM
*/
export interface ParsedGameState {
phase: GamePhase;
currentRound: number;
totalRounds: number;
statusMessage: string;
isFinalTurn: boolean;
myPlayer: PlayerState | null;
opponents: PlayerState[];
deck: {
clickable: boolean;
};
discard: {
hasCard: boolean;
clickable: boolean;
pickedUp: boolean;
topCard: { rank: string; suit: string } | null;
};
heldCard: {
visible: boolean;
card: { rank: string; suit: string } | null;
};
canDiscard: boolean;
canSkipFlip: boolean;
canKnockEarly: boolean;
}
export type GamePhase =
| 'lobby'
| 'waiting'
| 'initial_flip'
| 'playing'
| 'waiting_for_flip'
| 'final_turn'
| 'round_over'
| 'game_over';
/**
* Parses game state from the DOM
* This allows visual validation - the DOM should reflect the internal game state
*/
export class StateParser {
constructor(private page: Page) {}
/**
* Get the current screen/phase
*/
async getPhase(): Promise<GamePhase> {
// Check which screen is active
const lobbyVisible = await this.isVisible(SELECTORS.screens.lobby);
if (lobbyVisible) return 'lobby';
const waitingVisible = await this.isVisible(SELECTORS.screens.waiting);
if (waitingVisible) return 'waiting';
const gameVisible = await this.isVisible(SELECTORS.screens.game);
if (!gameVisible) return 'lobby';
// We're in the game screen - determine game phase
const statusText = await this.getStatusMessage();
const gameButtons = await this.isVisible(SELECTORS.game.gameButtons);
// Check for game over - Final Results modal or "New Game" button visible
const finalResultsModal = this.page.locator('#final-results-modal');
if (await finalResultsModal.isVisible().catch(() => false)) {
return 'game_over';
}
const newGameBtn = this.page.locator(SELECTORS.game.newGameBtn);
if (await newGameBtn.isVisible().catch(() => false)) {
return 'game_over';
}
// Check for round over (Next Hole button visible)
const nextRoundBtn = this.page.locator(SELECTORS.game.nextRoundBtn);
if (await nextRoundBtn.isVisible().catch(() => false)) {
// Check if this is the last round - if so, might be transitioning to game_over
const currentRound = await this.getCurrentRound();
const totalRounds = await this.getTotalRounds();
// If on last round and all cards revealed, this is effectively game_over
if (currentRound >= totalRounds) {
// Check the button text - if it doesn't mention "Next", might be game over
const btnText = await nextRoundBtn.textContent().catch(() => '');
if (btnText && !btnText.toLowerCase().includes('next')) {
return 'game_over';
}
// Still round_over but will transition to game_over soon
}
return 'round_over';
}
// Check for final turn badge
const finalTurnBadge = this.page.locator(SELECTORS.game.finalTurnBadge);
if (await finalTurnBadge.isVisible().catch(() => false)) {
return 'final_turn';
}
// Check if waiting for initial flip
if (statusText.toLowerCase().includes('flip') &&
statusText.toLowerCase().includes('card')) {
// Could be initial flip or flip after discard
const skipFlipBtn = this.page.locator(SELECTORS.game.skipFlipBtn);
if (await skipFlipBtn.isVisible().catch(() => false)) {
return 'waiting_for_flip';
}
// Check if we're in initial flip phase (multiple cards to flip)
const myCards = await this.getMyCards();
const faceUpCount = myCards.filter(c => c.faceUp).length;
if (faceUpCount < 2) {
return 'initial_flip';
}
return 'waiting_for_flip';
}
return 'playing';
}
/**
* Get full parsed game state
*/
async getState(): Promise<ParsedGameState> {
const phase = await this.getPhase();
return {
phase,
currentRound: await this.getCurrentRound(),
totalRounds: await this.getTotalRounds(),
statusMessage: await this.getStatusMessage(),
isFinalTurn: await this.isFinalTurn(),
myPlayer: await this.getMyPlayer(),
opponents: await this.getOpponents(),
deck: {
clickable: await this.isDeckClickable(),
},
discard: {
hasCard: await this.discardHasCard(),
clickable: await this.isDiscardClickable(),
pickedUp: await this.isDiscardPickedUp(),
topCard: await this.getDiscardTop(),
},
heldCard: {
visible: await this.isHeldCardVisible(),
card: await this.getHeldCard(),
},
canDiscard: await this.isVisible(SELECTORS.game.discardBtn),
canSkipFlip: await this.isVisible(SELECTORS.game.skipFlipBtn),
canKnockEarly: await this.isVisible(SELECTORS.game.knockEarlyBtn),
};
}
/**
* Get current round number
*/
async getCurrentRound(): Promise<number> {
const text = await this.getText(SELECTORS.game.currentRound);
return parseInt(text) || 1;
}
/**
* Get total rounds
*/
async getTotalRounds(): Promise<number> {
const text = await this.getText(SELECTORS.game.totalRounds);
return parseInt(text) || 9;
}
/**
* Get status message text
*/
async getStatusMessage(): Promise<string> {
return this.getText(SELECTORS.game.statusMessage);
}
/**
* Check if final turn badge is visible
*/
async isFinalTurn(): Promise<boolean> {
return this.isVisible(SELECTORS.game.finalTurnBadge);
}
/**
* Get local player's state
*/
async getMyPlayer(): Promise<PlayerState | null> {
const playerArea = this.page.locator(SELECTORS.game.playerArea).first();
if (!await playerArea.isVisible().catch(() => false)) {
return null;
}
const nameEl = playerArea.locator('.player-name');
const name = await nameEl.textContent().catch(() => 'You') || 'You';
const scoreEl = playerArea.locator(SELECTORS.game.yourScore);
const scoreText = await scoreEl.textContent().catch(() => '0') || '0';
const score = parseInt(scoreText) || 0;
const cards = await this.getMyCards();
const isCurrentTurn = await this.isMyTurn();
return { name, cards, isCurrentTurn, score };
}
/**
* Get cards for local player
*/
async getMyCards(): Promise<CardState[]> {
const cards: CardState[] = [];
const cardContainer = this.page.locator(SELECTORS.game.playerCards);
const cardEls = cardContainer.locator('.card, .card-slot .card');
const count = await cardEls.count();
for (let i = 0; i < Math.min(count, 6); i++) {
const cardEl = cardEls.nth(i);
cards.push(await this.parseCard(cardEl, i));
}
return cards;
}
/**
* Get opponent players' states
*/
async getOpponents(): Promise<PlayerState[]> {
const opponents: PlayerState[] = [];
const opponentAreas = this.page.locator('.opponent-area');
const count = await opponentAreas.count();
for (let i = 0; i < count; i++) {
const area = opponentAreas.nth(i);
const nameEl = area.locator('.opponent-name');
const name = await nameEl.textContent().catch(() => `Opponent ${i + 1}`) || `Opponent ${i + 1}`;
const scoreEl = area.locator('.opponent-showing');
const scoreText = await scoreEl.textContent().catch(() => null);
const score = scoreText ? parseInt(scoreText) : null;
const isCurrentTurn = await area.evaluate(el =>
el.classList.contains('current-turn')
);
const cards: CardState[] = [];
const cardEls = area.locator('.card-grid .card');
const cardCount = await cardEls.count();
for (let j = 0; j < Math.min(cardCount, 6); j++) {
cards.push(await this.parseCard(cardEls.nth(j), j));
}
opponents.push({ name, cards, isCurrentTurn, score });
}
return opponents;
}
/**
* Parse a single card element
*/
private async parseCard(cardEl: Locator, position: number): Promise<CardState> {
const classList = await cardEl.evaluate(el => Array.from(el.classList));
// Face-down cards have 'card-back' class, face-up have 'card-front' class
const faceUp = classList.includes('card-front');
const clickable = classList.includes('clickable');
const selected = classList.includes('selected');
let rank: string | null = null;
let suit: string | null = null;
if (faceUp) {
const content = await cardEl.textContent().catch(() => '') || '';
// Check for joker
if (classList.includes('joker') || content.toLowerCase().includes('joker')) {
rank = '★';
// Determine suit from icon
if (content.includes('🐉')) {
suit = 'hearts';
} else if (content.includes('👹')) {
suit = 'spades';
}
} else {
// Parse rank and suit from text
const lines = content.split('\n').map(l => l.trim()).filter(l => l);
if (lines.length >= 2) {
rank = lines[0];
suit = this.parseSuitSymbol(lines[1]);
} else if (lines.length === 1) {
// Try to extract rank from combined text
const text = lines[0];
const rankMatch = text.match(/^([AKQJ]|10|[2-9])/);
if (rankMatch) {
rank = rankMatch[1];
const suitPart = text.slice(rank.length);
suit = this.parseSuitSymbol(suitPart);
}
}
}
}
return { position, faceUp, rank, suit, clickable, selected };
}
/**
* Parse suit symbol to suit name
*/
private parseSuitSymbol(symbol: string): string | null {
const cleaned = symbol.trim();
if (cleaned.includes('♥') || cleaned.includes('hearts')) return 'hearts';
if (cleaned.includes('♦') || cleaned.includes('diamonds')) return 'diamonds';
if (cleaned.includes('♣') || cleaned.includes('clubs')) return 'clubs';
if (cleaned.includes('♠') || cleaned.includes('spades')) return 'spades';
return null;
}
/**
* Check if it's the local player's turn
*/
async isMyTurn(): Promise<boolean> {
// Check if deck area has your-turn-to-draw class
const deckArea = this.page.locator(SELECTORS.game.deckArea);
const hasClass = await deckArea.evaluate(el =>
el.classList.contains('your-turn-to-draw')
).catch(() => false);
if (hasClass) return true;
// Check status message
const status = await this.getStatusMessage();
const statusLower = status.toLowerCase();
// Various indicators that it's our turn
if (statusLower.includes('your turn')) return true;
if (statusLower.includes('select') && statusLower.includes('card')) return true; // Initial flip
if (statusLower.includes('flip a card')) return true;
if (statusLower.includes('choose a card')) return true;
// Check if our cards are clickable (another indicator)
const clickableCards = await this.getClickablePositions();
if (clickableCards.length > 0) return true;
return false;
}
/**
* Check if deck is clickable
*/
async isDeckClickable(): Promise<boolean> {
const deck = this.page.locator(SELECTORS.game.deck);
return deck.evaluate(el => el.classList.contains('clickable')).catch(() => false);
}
/**
* Check if discard pile has a card
*/
async discardHasCard(): Promise<boolean> {
const discard = this.page.locator(SELECTORS.game.discard);
return discard.evaluate(el =>
el.classList.contains('has-card') || el.classList.contains('card-front')
).catch(() => false);
}
/**
* Check if discard is clickable
*/
async isDiscardClickable(): Promise<boolean> {
const discard = this.page.locator(SELECTORS.game.discard);
return discard.evaluate(el =>
el.classList.contains('clickable') && !el.classList.contains('disabled')
).catch(() => false);
}
/**
* Check if discard card is picked up (floating)
*/
async isDiscardPickedUp(): Promise<boolean> {
const discard = this.page.locator(SELECTORS.game.discard);
return discard.evaluate(el => el.classList.contains('picked-up')).catch(() => false);
}
/**
* Get the top card of the discard pile
*/
async getDiscardTop(): Promise<{ rank: string; suit: string } | null> {
const hasCard = await this.discardHasCard();
if (!hasCard) return null;
const content = await this.page.locator(SELECTORS.game.discardContent).textContent()
.catch(() => null);
if (!content) return null;
return this.parseCardContent(content);
}
/**
* Check if held card is visible
*/
async isHeldCardVisible(): Promise<boolean> {
const floating = this.page.locator(SELECTORS.game.heldCardFloating);
return floating.isVisible().catch(() => false);
}
/**
* Get held card details
*/
async getHeldCard(): Promise<{ rank: string; suit: string } | null> {
const visible = await this.isHeldCardVisible();
if (!visible) return null;
const content = await this.page.locator(SELECTORS.game.heldCardFloatingContent)
.textContent().catch(() => null);
if (!content) return null;
return this.parseCardContent(content);
}
/**
* Parse card content text (from held card, discard, etc.)
*/
private parseCardContent(content: string): { rank: string; suit: string } | null {
// Handle jokers
if (content.toLowerCase().includes('joker')) {
const suit = content.includes('🐉') ? 'hearts' : 'spades';
return { rank: '★', suit };
}
// Try to parse rank and suit
// Content may be "7\n♥" (with newline) or "7♥" (combined)
const lines = content.split('\n').map(l => l.trim()).filter(l => l);
if (lines.length >= 2) {
// Two separate lines
return {
rank: lines[0],
suit: this.parseSuitSymbol(lines[1]) || 'unknown',
};
} else if (lines.length === 1) {
const text = lines[0];
// Try to extract rank (A, K, Q, J, 10, or 2-9)
const rankMatch = text.match(/^(10|[AKQJ2-9])/);
if (rankMatch) {
const rank = rankMatch[1];
const suitPart = text.slice(rank.length);
const suit = this.parseSuitSymbol(suitPart);
if (suit) {
return { rank, suit };
}
}
}
return null;
}
/**
* Count face-up cards for local player
*/
async countFaceUpCards(): Promise<number> {
const cards = await this.getMyCards();
return cards.filter(c => c.faceUp).length;
}
/**
* Count face-down cards for local player
*/
async countFaceDownCards(): Promise<number> {
const cards = await this.getMyCards();
return cards.filter(c => !c.faceUp).length;
}
/**
* Get positions of clickable cards
*/
async getClickablePositions(): Promise<number[]> {
const cards = await this.getMyCards();
return cards.filter(c => c.clickable).map(c => c.position);
}
/**
* Get positions of face-down cards
*/
async getFaceDownPositions(): Promise<number[]> {
const cards = await this.getMyCards();
return cards.filter(c => !c.faceUp).map(c => c.position);
}
// Helper methods
private async isVisible(selector: string): Promise<boolean> {
const el = this.page.locator(selector);
return el.isVisible().catch(() => false);
}
private async getText(selector: string): Promise<string> {
const el = this.page.locator(selector);
return (await el.textContent().catch(() => '')) || '';
}
}

View File

@@ -0,0 +1,231 @@
/**
* Animation tracker - monitors animation completion and timing
*/
import { Page } from '@playwright/test';
import { TIMING } from '../utils/timing';
/**
* Animation event
*/
export interface AnimationEvent {
type: 'start' | 'complete' | 'stall';
animationType?: string;
duration?: number;
timestamp: number;
}
/**
* AnimationTracker - tracks animation states
*/
export class AnimationTracker {
private events: AnimationEvent[] = [];
private animationStartTime: number | null = null;
constructor(private page: Page) {}
/**
* Record animation start
*/
recordStart(type?: string): void {
this.animationStartTime = Date.now();
this.events.push({
type: 'start',
animationType: type,
timestamp: this.animationStartTime,
});
}
/**
* Record animation complete
*/
recordComplete(type?: string): void {
const now = Date.now();
const duration = this.animationStartTime
? now - this.animationStartTime
: undefined;
this.events.push({
type: 'complete',
animationType: type,
duration,
timestamp: now,
});
this.animationStartTime = null;
}
/**
* Record animation stall
*/
recordStall(type?: string): void {
const now = Date.now();
const duration = this.animationStartTime
? now - this.animationStartTime
: undefined;
this.events.push({
type: 'stall',
animationType: type,
duration,
timestamp: now,
});
}
/**
* Check if animation queue is animating
*/
async isAnimating(): Promise<boolean> {
try {
return await this.page.evaluate(() => {
const game = (window as any).game;
return game?.animationQueue?.isAnimating() ?? false;
});
} catch {
return false;
}
}
/**
* Get animation queue length
*/
async getQueueLength(): Promise<number> {
try {
return await this.page.evaluate(() => {
const game = (window as any).game;
return game?.animationQueue?.queue?.length ?? 0;
});
} catch {
return 0;
}
}
/**
* Wait for animation to complete with tracking
*/
async waitForAnimation(
type: string,
timeoutMs: number = 5000
): Promise<{ completed: boolean; duration: number }> {
this.recordStart(type);
const startTime = Date.now();
try {
await this.page.waitForFunction(
() => {
const game = (window as any).game;
if (!game?.animationQueue) return true;
return !game.animationQueue.isAnimating();
},
{ timeout: timeoutMs }
);
const duration = Date.now() - startTime;
this.recordComplete(type);
return { completed: true, duration };
} catch {
const duration = Date.now() - startTime;
this.recordStall(type);
return { completed: false, duration };
}
}
/**
* Wait for specific animation type by watching DOM changes
*/
async waitForFlipAnimation(timeoutMs: number = 2000): Promise<boolean> {
return this.waitForAnimationClass('flipping', timeoutMs);
}
async waitForSwapAnimation(timeoutMs: number = 3000): Promise<boolean> {
return this.waitForAnimationClass('swap-animation', timeoutMs);
}
/**
* Wait for animation class to appear and disappear
*/
private async waitForAnimationClass(
className: string,
timeoutMs: number
): Promise<boolean> {
try {
// Wait for class to appear
await this.page.waitForSelector(`.${className}`, {
state: 'attached',
timeout: timeoutMs / 2,
});
// Wait for class to disappear (animation complete)
await this.page.waitForSelector(`.${className}`, {
state: 'detached',
timeout: timeoutMs / 2,
});
return true;
} catch {
return false;
}
}
/**
* Get animation events
*/
getEvents(): AnimationEvent[] {
return [...this.events];
}
/**
* Get stall events
*/
getStalls(): AnimationEvent[] {
return this.events.filter(e => e.type === 'stall');
}
/**
* Get average animation duration by type
*/
getAverageDuration(type?: string): number | null {
const completed = this.events.filter(e =>
e.type === 'complete' &&
e.duration !== undefined &&
(!type || e.animationType === type)
);
if (completed.length === 0) return null;
const total = completed.reduce((sum, e) => sum + (e.duration || 0), 0);
return total / completed.length;
}
/**
* Check if animations are within expected timing
*/
validateTiming(
type: string,
expectedMs: number,
tolerancePercent: number = 50
): { valid: boolean; actual: number | null } {
const avgDuration = this.getAverageDuration(type);
if (avgDuration === null) {
return { valid: true, actual: null };
}
const tolerance = expectedMs * (tolerancePercent / 100);
const minOk = expectedMs - tolerance;
const maxOk = expectedMs + tolerance;
return {
valid: avgDuration >= minOk && avgDuration <= maxOk,
actual: avgDuration,
};
}
/**
* Clear tracked events
*/
clear(): void {
this.events = [];
this.animationStartTime = null;
}
}

View File

@@ -0,0 +1,209 @@
/**
* Freeze detector - monitors for UI responsiveness issues
*/
import { Page } from '@playwright/test';
import { SELECTORS } from '../utils/selectors';
import { TIMING } from '../utils/timing';
/**
* Health check result
*/
export interface HealthCheck {
healthy: boolean;
issues: HealthIssue[];
}
export interface HealthIssue {
type: 'animation_stall' | 'websocket_closed' | 'console_error' | 'unresponsive';
message: string;
timestamp: number;
}
/**
* FreezeDetector - monitors UI health
*/
export class FreezeDetector {
private issues: HealthIssue[] = [];
private consoleErrors: string[] = [];
private wsState: number | null = null;
constructor(private page: Page) {
// Monitor console errors
page.on('console', msg => {
if (msg.type() === 'error') {
const text = msg.text();
this.consoleErrors.push(text);
this.addIssue('console_error', text);
}
});
page.on('pageerror', err => {
this.consoleErrors.push(err.message);
this.addIssue('console_error', err.message);
});
}
/**
* Add a health issue
*/
private addIssue(type: HealthIssue['type'], message: string): void {
this.issues.push({
type,
message,
timestamp: Date.now(),
});
}
/**
* Clear recorded issues
*/
clearIssues(): void {
this.issues = [];
this.consoleErrors = [];
}
/**
* Get all recorded issues
*/
getIssues(): HealthIssue[] {
return [...this.issues];
}
/**
* Get recent issues (within timeframe)
*/
getRecentIssues(withinMs: number = 10000): HealthIssue[] {
const cutoff = Date.now() - withinMs;
return this.issues.filter(i => i.timestamp > cutoff);
}
/**
* Check for animation stall
*/
async checkAnimationStall(timeoutMs: number = 5000): Promise<boolean> {
try {
await this.page.waitForFunction(
() => {
const game = (window as any).game;
if (!game?.animationQueue) return true;
return !game.animationQueue.isAnimating();
},
{ timeout: timeoutMs }
);
return false; // No stall
} catch {
this.addIssue('animation_stall', `Animation did not complete within ${timeoutMs}ms`);
return true; // Stalled
}
}
/**
* Check WebSocket health
*/
async checkWebSocket(): Promise<boolean> {
try {
const state = await this.page.evaluate(() => {
const game = (window as any).game;
return game?.ws?.readyState;
});
this.wsState = state;
// WebSocket.OPEN = 1
if (state !== 1) {
const stateNames: Record<number, string> = {
0: 'CONNECTING',
1: 'OPEN',
2: 'CLOSING',
3: 'CLOSED',
};
this.addIssue('websocket_closed', `WebSocket is ${stateNames[state] || 'UNKNOWN'}`);
return false;
}
return true;
} catch (error) {
this.addIssue('websocket_closed', `Failed to check WebSocket: ${error}`);
return false;
}
}
/**
* Check if element is responsive to clicks
*/
async checkClickResponsiveness(
selector: string,
timeoutMs: number = 2000
): Promise<boolean> {
try {
const el = this.page.locator(selector);
if (!await el.isVisible()) {
return true; // Element not visible is not necessarily an issue
}
// Check if element is clickable
await el.click({ timeout: timeoutMs, trial: true });
return true;
} catch {
this.addIssue('unresponsive', `Element ${selector} not responsive`);
return false;
}
}
/**
* Run full health check
*/
async runHealthCheck(): Promise<HealthCheck> {
const animationOk = !(await this.checkAnimationStall());
const wsOk = await this.checkWebSocket();
const healthy = animationOk && wsOk && this.consoleErrors.length === 0;
return {
healthy,
issues: this.getRecentIssues(),
};
}
/**
* Monitor game loop for issues
* Returns when an issue is detected or timeout
*/
async monitorUntilIssue(
timeoutMs: number = 60000,
checkIntervalMs: number = 500
): Promise<HealthIssue | null> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
// Check animation
const animStall = await this.checkAnimationStall(3000);
if (animStall) {
return this.issues[this.issues.length - 1];
}
// Check WebSocket
const wsOk = await this.checkWebSocket();
if (!wsOk) {
return this.issues[this.issues.length - 1];
}
// Check for new console errors
if (this.consoleErrors.length > 0) {
return this.issues[this.issues.length - 1];
}
await this.page.waitForTimeout(checkIntervalMs);
}
return null;
}
/**
* Get console errors
*/
getConsoleErrors(): string[] {
return [...this.consoleErrors];
}
}

View File

@@ -0,0 +1,2 @@
export { FreezeDetector, HealthCheck, HealthIssue } from './freeze-detector';
export { AnimationTracker, AnimationEvent } from './animation-tracker';

111
tests/e2e/package-lock.json generated Normal file
View File

@@ -0,0 +1,111 @@
{
"name": "golf-e2e-tests",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "golf-e2e-tests",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^20.10.0",
"typescript": "^5.3.0"
}
},
"node_modules/@playwright/test": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz",
"integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.58.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@types/node": {
"version": "20.19.30",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
"integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz",
"integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.58.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.58.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz",
"integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

22
tests/e2e/package.json Normal file
View File

@@ -0,0 +1,22 @@
{
"name": "golf-e2e-tests",
"version": "1.0.0",
"description": "End-to-end tests for Golf Card Game",
"private": true,
"scripts": {
"test": "playwright test",
"test:headed": "playwright test --headed",
"test:debug": "playwright test --debug",
"test:ui": "playwright test --ui",
"test:full-game": "playwright test specs/full-game.spec.ts",
"test:visual": "playwright test specs/visual.spec.ts",
"test:stress": "playwright test specs/stress.spec.ts",
"test:report": "playwright show-report",
"install:browsers": "playwright install chromium"
},
"devDependencies": {
"@playwright/test": "^1.40.0",
"@types/node": "^20.10.0",
"typescript": "^5.3.0"
}
}

View File

@@ -0,0 +1,36 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './specs',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [
['html', { outputFolder: 'playwright-report' }],
['list'],
],
use: {
baseURL: process.env.TEST_URL || 'http://localhost:8000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
{
name: 'mobile',
use: { ...devices['iPhone 13'] },
},
{
name: 'tablet',
use: { ...devices['iPad Pro 11'] },
},
],
// Note: The server must be running before tests
// Start it with: cd ../.. && python server/main.py
// webServer config removed because it requires PostgreSQL which may not be available
});

View File

@@ -0,0 +1,253 @@
/**
* Full game playthrough tests
* Tests complete game sessions with the bot
*/
import { test, expect } from '@playwright/test';
import { GolfBot } from '../bot/golf-bot';
import { FreezeDetector } from '../health/freeze-detector';
import { ScreenshotValidator } from '../visual/screenshot-validator';
test.describe('Full Game Playthrough', () => {
test('bot completes 3-hole game against CPU', async ({ page }) => {
test.setTimeout(180000); // 3 minutes for 3-hole game
const bot = new GolfBot(page);
const freezeDetector = new FreezeDetector(page);
const validator = new ScreenshotValidator(page);
// Navigate to game
await bot.goto();
// Create game and add CPU
const roomCode = await bot.createGame('TestBot');
expect(roomCode).toHaveLength(4);
await bot.addCPU('Sofia');
// Take screenshot of waiting room
await validator.capture('waiting-room');
// Start game with 3 holes
await bot.startGame({ holes: 3 });
// Verify game started
const phase = await bot.getGamePhase();
expect(['initial_flip', 'playing']).toContain(phase);
// Take screenshot of game start
await validator.capture('game-start', phase);
// Play through the entire game
const result = await bot.playGame(3);
// Take final screenshot
await validator.capture('game-over', 'game_over');
// Verify game completed
expect(result.success).toBe(true);
expect(result.rounds).toBeGreaterThanOrEqual(1);
// Check for errors
const errors = bot.getConsoleErrors();
expect(errors).toHaveLength(0);
// Verify no freezes occurred
const health = await freezeDetector.runHealthCheck();
expect(health.healthy).toBe(true);
});
test('bot completes 9-hole game against CPU', async ({ page }) => {
test.setTimeout(900000); // 15 minutes for 9-hole game
const bot = new GolfBot(page);
const freezeDetector = new FreezeDetector(page);
await bot.goto();
const roomCode = await bot.createGame('TestBot');
await bot.addCPU('Marcus');
await bot.startGame({ holes: 9 });
// Play full game
const result = await bot.playGame(9);
expect(result.success).toBe(true);
expect(result.rounds).toBe(9);
// Verify game ended properly
const finalPhase = await bot.getGamePhase();
expect(finalPhase).toBe('game_over');
// Check health
const health = await freezeDetector.runHealthCheck();
expect(health.healthy).toBe(true);
});
test('bot handles initial flip phase correctly', async ({ page }) => {
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1, initialFlips: 2 });
// Wait for initial flip phase
await page.waitForTimeout(500);
// Take screenshot before flips
await validator.capture('before-initial-flip');
// Complete initial flips
await bot.completeInitialFlips();
// Take screenshot after flips
await validator.capture('after-initial-flip');
// Verify 2 cards are face-up
const state = await bot.getGameState();
const faceUpCount = state.myPlayer?.cards.filter(c => c.faceUp).length || 0;
expect(faceUpCount).toBeGreaterThanOrEqual(2);
});
test('bot recovers from rapid turn changes', async ({ page }) => {
test.setTimeout(90000); // 90 seconds
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('TestBot');
// Add multiple fast CPUs
await bot.addCPU('Maya'); // Aggressive
await bot.addCPU('Sage'); // Sneaky finisher
await bot.startGame({ holes: 1 });
// Play with health monitoring
let frozenCount = 0;
let turnCount = 0;
while (await bot.getGamePhase() !== 'round_over' && turnCount < 50) {
if (await bot.isMyTurn()) {
const result = await bot.playTurn();
expect(result.success).toBe(true);
turnCount++;
}
// Check for freeze
if (await bot.isFrozen(2000)) {
frozenCount++;
}
await page.waitForTimeout(100);
}
// Should not have frozen
expect(frozenCount).toBe(0);
});
test('game handles all players finishing', async ({ page }) => {
test.setTimeout(90000); // 90 seconds for single round
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
// Play until round over
const roundResult = await bot.playRound(100);
expect(roundResult.success).toBe(true);
// Take screenshot of round end
await validator.capture('round-end');
// Verify all player cards are revealed
const state = await bot.getGameState();
const allRevealed = state.myPlayer?.cards.every(c => c.faceUp) ?? false;
expect(allRevealed).toBe(true);
// Verify scoreboard is visible
const scoreboardVisible = await validator.expectVisible('#game-buttons');
expect(scoreboardVisible.passed).toBe(true);
});
});
test.describe('Game Settings', () => {
test('Speed Golf mode (flip on discard)', async ({ page }) => {
test.setTimeout(90000); // 90 seconds
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
// Start with Speed Golf (always flip)
await bot.startGame({
holes: 1,
flipMode: 'always',
});
// Play through
const result = await bot.playRound(50);
expect(result.success).toBe(true);
// No errors should occur
expect(bot.getConsoleErrors()).toHaveLength(0);
});
test('Endgame mode (optional flip)', async ({ page }) => {
test.setTimeout(90000); // 90 seconds
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
// Start with Endgame mode
await bot.startGame({
holes: 1,
flipMode: 'endgame',
});
// Play through
const result = await bot.playRound(50);
expect(result.success).toBe(true);
expect(bot.getConsoleErrors()).toHaveLength(0);
});
test('Multiple decks with many players', async ({ page }) => {
test.setTimeout(90000);
const bot = new GolfBot(page);
await bot.goto();
await bot.createGame('TestBot');
// Add 4 CPUs (5 total players)
await bot.addCPU('Sofia');
await bot.addCPU('Marcus');
await bot.addCPU('Maya');
await bot.addCPU('Kenji');
// Start with 2 decks
await bot.startGame({
holes: 1,
decks: 2,
});
// Play through
const result = await bot.playRound(100);
expect(result.success).toBe(true);
expect(bot.getConsoleErrors()).toHaveLength(0);
});
});

View File

@@ -0,0 +1,401 @@
/**
* 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);
}
});
});

View File

@@ -0,0 +1,348 @@
/**
* Visual regression tests
* Validates visual correctness at key game moments
*/
import { test, expect, devices } from '@playwright/test';
import { GolfBot } from '../bot/golf-bot';
import { ScreenshotValidator } from '../visual/screenshot-validator';
import {
validateGameStart,
validateAfterInitialFlip,
validateDrawPhase,
validateAfterDraw,
validateRoundOver,
validateFinalTurn,
validateResponsiveLayout,
} from '../visual/visual-rules';
test.describe('Visual Validation', () => {
test('game start visual state', async ({ page }) => {
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
// Wait for game to fully render
await page.waitForTimeout(1000);
// Capture game start
await validator.capture('game-start-visual');
// Validate visual state
const result = await validateGameStart(validator);
expect(result.passed).toBe(true);
if (!result.passed) {
console.log('Failures:', result.failures);
}
});
test('initial flip visual state', async ({ page }) => {
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1, initialFlips: 2 });
// Complete initial flips
await bot.completeInitialFlips();
await page.waitForTimeout(500);
// Capture after flips
await validator.capture('after-initial-flip-visual');
// Validate
const result = await validateAfterInitialFlip(validator, 2);
expect(result.passed).toBe(true);
if (!result.passed) {
console.log('Failures:', result.failures);
}
});
test('draw phase visual state', async ({ page }) => {
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
// Complete initial phase and wait for our turn
await bot.completeInitialFlips();
// Wait for our turn
await bot.waitForMyTurn(10000);
// Capture draw phase
await validator.capture('draw-phase-visual');
// Validate
const result = await validateDrawPhase(validator);
expect(result.passed).toBe(true);
if (!result.passed) {
console.log('Failures:', result.failures);
}
});
test('held card visual state', async ({ page }) => {
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
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();
// Wait for draw animation
await page.waitForTimeout(500);
// Capture held card state
await validator.capture('held-card-visual');
// Validate held card is visible
const heldResult = await validator.expectHeldCardVisible();
expect(heldResult.passed).toBe(true);
// Validate cards are clickable
const clickableResult = await validator.expectCount(
'#player-cards .card.clickable',
6
);
expect(clickableResult.passed).toBe(true);
});
test('round over visual state', async ({ page }) => {
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
// Play until round over
await bot.playRound(50);
// Wait for animations
await page.waitForTimeout(1000);
// Capture round over
await validator.capture('round-over-visual');
// Validate
const result = await validateRoundOver(validator);
expect(result.passed).toBe(true);
if (!result.passed) {
console.log('Failures:', result.failures);
}
});
test('card flip animation renders correctly', async ({ page }) => {
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1, initialFlips: 0 }); // No initial flips
// Wait for our turn
await bot.waitForMyTurn(10000);
// Capture before flip
await validator.capture('before-flip');
// Get a face-down card position
const state = await bot.getGameState();
const faceDownPos = state.myPlayer?.cards.find(c => !c.faceUp)?.position ?? 0;
// Draw and swap to trigger flip
const deck = page.locator('#deck');
await deck.click();
await page.waitForTimeout(300);
// Click the face-down card to swap
const card = page.locator(`#player-cards .card:nth-child(${faceDownPos + 1})`);
await card.click();
// Wait for animation to complete
await page.waitForTimeout(1500);
// Capture after flip
await validator.capture('after-flip');
// Verify card is now face-up
const afterState = await bot.getGameState();
const cardAfter = afterState.myPlayer?.cards.find(c => c.position === faceDownPos);
expect(cardAfter?.faceUp).toBe(true);
});
test('opponent highlighting on their turn', async ({ page }) => {
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
await bot.completeInitialFlips();
// Play our turn and then wait for opponent's turn
if (await bot.isMyTurn()) {
await bot.playTurn();
}
// Wait for opponent turn indicator
await page.waitForTimeout(1000);
// Check if it's opponent's turn
const state = await bot.getGameState();
const opponentPlaying = state.opponents.some(o => o.isCurrentTurn);
if (opponentPlaying) {
// Capture opponent turn
await validator.capture('opponent-turn-visual');
// Find which opponent has current turn
const currentOpponentIndex = state.opponents.findIndex(o => o.isCurrentTurn);
if (currentOpponentIndex >= 0) {
const result = await validator.expectOpponentCurrentTurn(currentOpponentIndex);
expect(result.passed).toBe(true);
}
}
});
test('discard pile updates correctly', async ({ page }) => {
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
await bot.completeInitialFlips();
await bot.waitForMyTurn(10000);
// Get initial discard state
const beforeState = await bot.getGameState();
const beforeDiscard = beforeState.discard.topCard;
// Draw from deck and discard
const deck = page.locator('#deck');
await deck.click();
await page.waitForTimeout(500);
// Get the held card
const heldCard = (await bot.getGameState()).heldCard.card;
// Discard the drawn card
const discardBtn = page.locator('#discard-btn');
await discardBtn.click();
await page.waitForTimeout(800);
// Capture after discard
await validator.capture('after-discard-visual');
// Verify discard pile has the card we discarded
const afterState = await bot.getGameState();
expect(afterState.discard.hasCard).toBe(true);
});
});
test.describe('Responsive Layout', () => {
test('mobile layout (375px)', async ({ browser }) => {
const context = await browser.newContext({
...devices['iPhone 13'],
});
const page = await context.newPage();
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
await page.waitForTimeout(1000);
// Capture mobile layout
await validator.capture('mobile-375-layout');
// Validate responsive elements
const result = await validateResponsiveLayout(validator, 375);
expect(result.passed).toBe(true);
if (!result.passed) {
console.log('Mobile failures:', result.failures);
}
await context.close();
});
test('tablet layout (768px)', async ({ browser }) => {
const context = await browser.newContext({
viewport: { width: 768, height: 1024 },
});
const page = await context.newPage();
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
await page.waitForTimeout(1000);
// Capture tablet layout
await validator.capture('tablet-768-layout');
// Validate responsive elements
const result = await validateResponsiveLayout(validator, 768);
expect(result.passed).toBe(true);
await context.close();
});
test('desktop layout (1920px)', async ({ browser }) => {
const context = await browser.newContext({
viewport: { width: 1920, height: 1080 },
});
const page = await context.newPage();
const bot = new GolfBot(page);
const validator = new ScreenshotValidator(page);
await bot.goto();
await bot.createGame('TestBot');
await bot.addCPU('Sofia');
await bot.startGame({ holes: 1 });
await page.waitForTimeout(1000);
// Capture desktop layout
await validator.capture('desktop-1920-layout');
// Validate responsive elements
const result = await validateResponsiveLayout(validator, 1920);
expect(result.passed).toBe(true);
await context.close();
});
});

32
tests/e2e/tsconfig.json Normal file
View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": false,
"declarationMap": false,
"sourceMap": true,
"outDir": "./dist",
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@bot/*": ["bot/*"],
"@health/*": ["health/*"],
"@visual/*": ["visual/*"],
"@utils/*": ["utils/*"]
}
},
"include": [
"**/*.ts"
],
"exclude": [
"node_modules",
"dist",
"playwright-report"
]
}

12
tests/e2e/utils/index.ts Normal file
View File

@@ -0,0 +1,12 @@
export {
TIMING,
waitForAnimations,
waitForWebSocket,
safeWait,
} from './timing';
export {
SELECTORS,
playerCardSelector,
clickableCardSelector,
opponentCardSelector,
} from './selectors';

View File

@@ -0,0 +1,157 @@
/**
* DOM selector constants for the Golf game
* Extracted from client/index.html and client/app.js
*/
export const SELECTORS = {
// Screens
screens: {
lobby: '#lobby-screen',
waiting: '#waiting-screen',
game: '#game-screen',
rules: '#rules-screen',
},
// Lobby elements
lobby: {
playerNameInput: '#player-name',
roomCodeInput: '#room-code',
createRoomBtn: '#create-room-btn',
joinRoomBtn: '#join-room-btn',
error: '#lobby-error',
},
// Waiting room elements
waiting: {
roomCode: '#display-room-code',
copyCodeBtn: '#copy-room-code',
shareBtn: '#share-room-link',
playersList: '#players-list',
hostSettings: '#host-settings',
startGameBtn: '#start-game-btn',
leaveRoomBtn: '#leave-room-btn',
addCpuBtn: '#add-cpu-btn',
removeCpuBtn: '#remove-cpu-btn',
cpuModal: '#cpu-select-modal',
cpuProfilesGrid: '#cpu-profiles-grid',
cancelCpuBtn: '#cancel-cpu-btn',
addSelectedCpusBtn: '#add-selected-cpus-btn',
// Settings
numDecks: '#num-decks',
numRounds: '#num-rounds',
initialFlips: '#initial-flips',
flipMode: '#flip-mode',
knockPenalty: '#knock-penalty',
},
// Game screen elements
game: {
// Header
currentRound: '#current-round',
totalRounds: '#total-rounds',
statusMessage: '#status-message',
finalTurnBadge: '#final-turn-badge',
muteBtn: '#mute-btn',
leaveGameBtn: '#leave-game-btn',
activeRulesBar: '#active-rules-bar',
// Table
opponentsRow: '#opponents-row',
playerArea: '.player-area',
playerCards: '#player-cards',
playerHeader: '#player-header',
yourScore: '#your-score',
// Deck and discard
deckArea: '.deck-area',
deck: '#deck',
discard: '#discard',
discardContent: '#discard-content',
discardBtn: '#discard-btn',
skipFlipBtn: '#skip-flip-btn',
knockEarlyBtn: '#knock-early-btn',
// Held card
heldCardSlot: '#held-card-slot',
heldCardDisplay: '#held-card-display',
heldCardFloating: '#held-card-floating',
heldCardFloatingContent: '#held-card-floating-content',
// Scoreboard
scoreboard: '#scoreboard',
scoreTable: '#score-table tbody',
standingsList: '#standings-list',
nextRoundBtn: '#next-round-btn',
newGameBtn: '#new-game-btn',
gameButtons: '#game-buttons',
// Card layer for animations
cardLayer: '#card-layer',
},
// Card-related selectors
cards: {
// Player's own cards (0-5)
playerCard: (index: number) => `#player-cards .card:nth-child(${index + 1})`,
playerCardSlot: (index: number) => `#player-cards .card-slot:nth-child(${index + 1})`,
// Opponent cards
opponentArea: (index: number) => `.opponent-area:nth-child(${index + 1})`,
opponentCard: (oppIndex: number, cardIndex: number) =>
`.opponent-area:nth-child(${oppIndex + 1}) .card-grid .card:nth-child(${cardIndex + 1})`,
// Card states
faceUp: '.card-front',
faceDown: '.card-back',
clickable: '.clickable',
selected: '.selected',
},
// CSS classes for state detection
classes: {
active: 'active',
hidden: 'hidden',
clickable: 'clickable',
selected: 'selected',
faceUp: 'card-front',
faceDown: 'card-back',
red: 'red',
black: 'black',
joker: 'joker',
currentTurn: 'current-turn',
roundWinner: 'round-winner',
yourTurnToDraw: 'your-turn-to-draw',
hasCard: 'has-card',
pickedUp: 'picked-up',
disabled: 'disabled',
},
// Animation-related
animations: {
swapAnimation: '#swap-animation',
swapCardFromHand: '#swap-card-from-hand',
animCard: '.anim-card',
realCard: '.real-card',
},
};
/**
* Build a selector for a card in the player's grid
*/
export function playerCardSelector(position: number): string {
return SELECTORS.cards.playerCard(position);
}
/**
* Build a selector for a clickable card
*/
export function clickableCardSelector(position: number): string {
return `${SELECTORS.cards.playerCard(position)}.${SELECTORS.classes.clickable}`;
}
/**
* Build a selector for an opponent's card
*/
export function opponentCardSelector(opponentIndex: number, cardPosition: number): string {
return SELECTORS.cards.opponentCard(opponentIndex, cardPosition);
}

71
tests/e2e/utils/timing.ts Normal file
View File

@@ -0,0 +1,71 @@
/**
* Animation timing constants from animation-queue.js
* Used to wait for animations to complete before asserting state
*/
export const TIMING = {
// Core animation durations (from CSS/animation-queue.js)
flipDuration: 540,
moveDuration: 270,
pauseAfterFlip: 144,
pauseAfterDiscard: 550,
pauseBeforeNewCard: 150,
pauseAfterSwapComplete: 400,
pauseBetweenAnimations: 90,
// Derived waits for test actions
get flipComplete() {
return this.flipDuration + this.pauseAfterFlip + 100;
},
get swapComplete() {
return this.flipDuration + this.pauseAfterFlip + this.moveDuration +
this.pauseAfterDiscard + this.pauseBeforeNewCard +
this.moveDuration + this.pauseAfterSwapComplete + 200;
},
get drawComplete() {
return this.moveDuration + this.pauseBeforeNewCard + 100;
},
// Safety margins for network/processing
networkBuffer: 200,
safetyMargin: 300,
// Longer waits
turnTransition: 500,
cpuThinkingMin: 400,
cpuThinkingMax: 1200,
roundOverDelay: 1000,
};
/**
* Wait for animation queue to drain
*/
export async function waitForAnimations(page: import('@playwright/test').Page, timeout = 5000): Promise<void> {
await page.waitForFunction(
() => {
const game = (window as any).game;
if (!game?.animationQueue) return true;
return !game.animationQueue.isAnimating();
},
{ timeout }
);
}
/**
* Wait for WebSocket to be ready
*/
export async function waitForWebSocket(page: import('@playwright/test').Page, timeout = 5000): Promise<void> {
await page.waitForFunction(
() => {
const game = (window as any).game;
return game?.ws?.readyState === WebSocket.OPEN;
},
{ timeout }
);
}
/**
* Wait a fixed time plus safety margin
*/
export function safeWait(duration: number): number {
return duration + TIMING.safetyMargin;
}

15
tests/e2e/visual/index.ts Normal file
View File

@@ -0,0 +1,15 @@
export {
ScreenshotValidator,
VisualExpectation,
CaptureResult,
} from './screenshot-validator';
export {
validateGameStart,
validateAfterInitialFlip,
validateDrawPhase,
validateAfterDraw,
validateRoundOver,
validateFinalTurn,
validateOpponentTurn,
validateResponsiveLayout,
} from './visual-rules';

View File

@@ -0,0 +1,342 @@
/**
* Screenshot validator - captures screenshots and validates visual states
*/
import { Page, expect } from '@playwright/test';
import { SELECTORS } from '../utils/selectors';
/**
* Visual expectation result
*/
export interface VisualExpectation {
passed: boolean;
selector: string;
expected: string;
actual?: string;
error?: string;
}
/**
* Screenshot capture result
*/
export interface CaptureResult {
label: string;
buffer: Buffer;
timestamp: number;
phase?: string;
}
/**
* ScreenshotValidator - semantic visual validation
*/
export class ScreenshotValidator {
private captures: CaptureResult[] = [];
constructor(private page: Page) {}
/**
* Capture a screenshot with metadata
*/
async capture(label: string, phase?: string): Promise<CaptureResult> {
const buffer = await this.page.screenshot({ fullPage: true });
const result: CaptureResult = {
label,
buffer,
timestamp: Date.now(),
phase,
};
this.captures.push(result);
return result;
}
/**
* Capture element-specific screenshot
*/
async captureElement(
selector: string,
label: string
): Promise<CaptureResult | null> {
try {
const element = this.page.locator(selector);
const buffer = await element.screenshot();
const result: CaptureResult = {
label,
buffer,
timestamp: Date.now(),
};
this.captures.push(result);
return result;
} catch {
return null;
}
}
/**
* Get all captures
*/
getCaptures(): CaptureResult[] {
return [...this.captures];
}
/**
* Clear captures
*/
clearCaptures(): void {
this.captures = [];
}
// ============== Semantic Validators ==============
/**
* Expect element to be visible
*/
async expectVisible(selector: string): Promise<VisualExpectation> {
try {
const el = this.page.locator(selector);
await expect(el).toBeVisible({ timeout: 2000 });
return { passed: true, selector, expected: 'visible' };
} catch (error) {
return {
passed: false,
selector,
expected: 'visible',
actual: 'not visible',
error: String(error),
};
}
}
/**
* Expect element to be hidden
*/
async expectNotVisible(selector: string): Promise<VisualExpectation> {
try {
const el = this.page.locator(selector);
await expect(el).toBeHidden({ timeout: 2000 });
return { passed: true, selector, expected: 'hidden' };
} catch (error) {
return {
passed: false,
selector,
expected: 'hidden',
actual: 'visible',
error: String(error),
};
}
}
/**
* Expect element to have specific CSS class
*/
async expectHasClass(
selector: string,
className: string
): Promise<VisualExpectation> {
try {
const el = this.page.locator(selector);
const hasClass = await el.evaluate(
(node, cls) => node.classList.contains(cls),
className
);
return {
passed: hasClass,
selector,
expected: `has class "${className}"`,
actual: hasClass ? `has class "${className}"` : `missing class "${className}"`,
};
} catch (error) {
return {
passed: false,
selector,
expected: `has class "${className}"`,
error: String(error),
};
}
}
/**
* Expect element to NOT have specific CSS class
*/
async expectNoClass(
selector: string,
className: string
): Promise<VisualExpectation> {
try {
const el = this.page.locator(selector);
const hasClass = await el.evaluate(
(node, cls) => node.classList.contains(cls),
className
);
return {
passed: !hasClass,
selector,
expected: `no class "${className}"`,
actual: hasClass ? `has class "${className}"` : `no class "${className}"`,
};
} catch (error) {
return {
passed: false,
selector,
expected: `no class "${className}"`,
error: String(error),
};
}
}
/**
* Expect text content to match
*/
async expectText(
selector: string,
expected: string | RegExp
): Promise<VisualExpectation> {
try {
const el = this.page.locator(selector);
const text = await el.textContent() || '';
const matches = expected instanceof RegExp
? expected.test(text)
: text.includes(expected);
return {
passed: matches,
selector,
expected: String(expected),
actual: text,
};
} catch (error) {
return {
passed: false,
selector,
expected: String(expected),
error: String(error),
};
}
}
/**
* Expect specific number of elements
*/
async expectCount(
selector: string,
count: number
): Promise<VisualExpectation> {
try {
const els = this.page.locator(selector);
const actual = await els.count();
return {
passed: actual === count,
selector,
expected: `count=${count}`,
actual: `count=${actual}`,
};
} catch (error) {
return {
passed: false,
selector,
expected: `count=${count}`,
error: String(error),
};
}
}
/**
* Expect card at position to be face-up
*/
async expectCardFaceUp(position: number): Promise<VisualExpectation> {
const selector = SELECTORS.cards.playerCard(position);
return this.expectHasClass(selector, 'card-front');
}
/**
* Expect card at position to be face-down
*/
async expectCardFaceDown(position: number): Promise<VisualExpectation> {
const selector = SELECTORS.cards.playerCard(position);
return this.expectHasClass(selector, 'card-back');
}
/**
* Expect card at position to be clickable
*/
async expectCardClickable(position: number): Promise<VisualExpectation> {
const selector = SELECTORS.cards.playerCard(position);
return this.expectHasClass(selector, 'clickable');
}
/**
* Expect deck to be clickable
*/
async expectDeckClickable(): Promise<VisualExpectation> {
return this.expectHasClass(SELECTORS.game.deck, 'clickable');
}
/**
* Expect discard pile to have a card
*/
async expectDiscardHasCard(): Promise<VisualExpectation> {
return this.expectHasClass(SELECTORS.game.discard, 'has-card');
}
/**
* Expect final turn badge visible
*/
async expectFinalTurnBadge(): Promise<VisualExpectation> {
return this.expectVisible(SELECTORS.game.finalTurnBadge);
}
/**
* Expect held card floating visible
*/
async expectHeldCardVisible(): Promise<VisualExpectation> {
return this.expectVisible(SELECTORS.game.heldCardFloating);
}
/**
* Expect held card floating hidden
*/
async expectHeldCardHidden(): Promise<VisualExpectation> {
return this.expectNotVisible(SELECTORS.game.heldCardFloating);
}
/**
* Expect opponent to have current-turn class
*/
async expectOpponentCurrentTurn(opponentIndex: number): Promise<VisualExpectation> {
const selector = SELECTORS.cards.opponentArea(opponentIndex);
return this.expectHasClass(selector, 'current-turn');
}
/**
* Expect status message to contain text
*/
async expectStatusMessage(text: string | RegExp): Promise<VisualExpectation> {
return this.expectText(SELECTORS.game.statusMessage, text);
}
/**
* Run a batch of visual checks
*/
async runChecks(
checks: Array<() => Promise<VisualExpectation>>
): Promise<{ passed: number; failed: number; results: VisualExpectation[] }> {
const results: VisualExpectation[] = [];
let passed = 0;
let failed = 0;
for (const check of checks) {
const result = await check();
results.push(result);
if (result.passed) {
passed++;
} else {
failed++;
}
}
return { passed, failed, results };
}
}

View File

@@ -0,0 +1,232 @@
/**
* Visual rules - expected visual states for different game phases
*/
import { ScreenshotValidator } from './screenshot-validator';
/**
* Expected visual states for game start
*/
export async function validateGameStart(
validator: ScreenshotValidator
): Promise<{ passed: boolean; failures: string[] }> {
const failures: string[] = [];
// All cards should be visible (face up or down)
for (let i = 0; i < 6; i++) {
const result = await validator.expectCount(
`#player-cards .card:nth-child(${i + 1})`,
1
);
if (!result.passed) {
failures.push(`Card ${i} not present`);
}
}
// Status message should indicate game phase
const statusResult = await validator.expectVisible('#status-message');
if (!statusResult.passed) {
failures.push('Status message not visible');
}
// Deck should be visible
const deckResult = await validator.expectVisible('#deck');
if (!deckResult.passed) {
failures.push('Deck not visible');
}
// Discard should be visible
const discardResult = await validator.expectVisible('#discard');
if (!discardResult.passed) {
failures.push('Discard not visible');
}
return { passed: failures.length === 0, failures };
}
/**
* Expected visual states after initial flip
*/
export async function validateAfterInitialFlip(
validator: ScreenshotValidator,
expectedFaceUp: number = 2
): Promise<{ passed: boolean; failures: string[] }> {
const failures: string[] = [];
// Count face-up cards
const faceUpResult = await validator.expectCount(
'#player-cards .card.card-front',
expectedFaceUp
);
if (!faceUpResult.passed) {
failures.push(`Expected ${expectedFaceUp} face-up cards, got ${faceUpResult.actual}`);
}
// Count face-down cards
const faceDownResult = await validator.expectCount(
'#player-cards .card.card-back',
6 - expectedFaceUp
);
if (!faceDownResult.passed) {
failures.push(`Expected ${6 - expectedFaceUp} face-down cards, got ${faceDownResult.actual}`);
}
return { passed: failures.length === 0, failures };
}
/**
* Expected visual states during player's turn (draw phase)
*/
export async function validateDrawPhase(
validator: ScreenshotValidator
): Promise<{ passed: boolean; failures: string[] }> {
const failures: string[] = [];
// Deck should be clickable
const deckResult = await validator.expectDeckClickable();
if (!deckResult.passed) {
failures.push('Deck should be clickable');
}
// Held card should NOT be visible yet
const heldResult = await validator.expectHeldCardHidden();
if (!heldResult.passed) {
failures.push('Held card should not be visible before draw');
}
// Discard button should be hidden
const discardBtnResult = await validator.expectNotVisible('#discard-btn');
if (!discardBtnResult.passed) {
failures.push('Discard button should be hidden before draw');
}
return { passed: failures.length === 0, failures };
}
/**
* Expected visual states after drawing a card
*/
export async function validateAfterDraw(
validator: ScreenshotValidator
): Promise<{ passed: boolean; failures: string[] }> {
const failures: string[] = [];
// Held card should be visible (floating)
const heldResult = await validator.expectHeldCardVisible();
if (!heldResult.passed) {
failures.push('Held card should be visible after draw');
}
// Player cards should be clickable
const clickableResult = await validator.expectCount(
'#player-cards .card.clickable',
6
);
if (!clickableResult.passed) {
failures.push('All player cards should be clickable');
}
return { passed: failures.length === 0, failures };
}
/**
* Expected visual states for round over
*/
export async function validateRoundOver(
validator: ScreenshotValidator
): Promise<{ passed: boolean; failures: string[] }> {
const failures: string[] = [];
// All player cards should be face-up
const faceUpResult = await validator.expectCount(
'#player-cards .card.card-front',
6
);
if (!faceUpResult.passed) {
failures.push('All cards should be face-up at round end');
}
// Next round button OR new game button should be visible
const nextRoundResult = await validator.expectVisible('#next-round-btn');
const newGameResult = await validator.expectVisible('#new-game-btn');
if (!nextRoundResult.passed && !newGameResult.passed) {
failures.push('Neither next round nor new game button visible');
}
// Game buttons container should be visible
const gameButtonsResult = await validator.expectVisible('#game-buttons');
if (!gameButtonsResult.passed) {
failures.push('Game buttons should be visible');
}
return { passed: failures.length === 0, failures };
}
/**
* Expected visual states for final turn
*/
export async function validateFinalTurn(
validator: ScreenshotValidator
): Promise<{ passed: boolean; failures: string[] }> {
const failures: string[] = [];
// Final turn badge should be visible
const badgeResult = await validator.expectFinalTurnBadge();
if (!badgeResult.passed) {
failures.push('Final turn badge should be visible');
}
return { passed: failures.length === 0, failures };
}
/**
* Expected visual states during opponent's turn
*/
export async function validateOpponentTurn(
validator: ScreenshotValidator,
opponentIndex: number
): Promise<{ passed: boolean; failures: string[] }> {
const failures: string[] = [];
// Opponent should have current-turn highlight
const turnResult = await validator.expectOpponentCurrentTurn(opponentIndex);
if (!turnResult.passed) {
failures.push(`Opponent ${opponentIndex} should have current-turn class`);
}
// Deck should NOT be clickable (not our turn)
const deckResult = await validator.expectNoClass('#deck', 'clickable');
if (!deckResult.passed) {
failures.push('Deck should not be clickable during opponent turn');
}
return { passed: failures.length === 0, failures };
}
/**
* Validate responsive layout at specific width
*/
export async function validateResponsiveLayout(
validator: ScreenshotValidator,
width: number
): Promise<{ passed: boolean; failures: string[] }> {
const failures: string[] = [];
// Core elements should still be visible
const elements = [
'#deck',
'#discard',
'#player-cards',
'#status-message',
];
for (const selector of elements) {
const result = await validator.expectVisible(selector);
if (!result.passed) {
failures.push(`${selector} not visible at ${width}px width`);
}
}
return { passed: failures.length === 0, failures };
}