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

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';