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:
231
tests/e2e/health/animation-tracker.ts
Normal file
231
tests/e2e/health/animation-tracker.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
209
tests/e2e/health/freeze-detector.ts
Normal file
209
tests/e2e/health/freeze-detector.ts
Normal 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];
|
||||
}
|
||||
}
|
||||
2
tests/e2e/health/index.ts
Normal file
2
tests/e2e/health/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { FreezeDetector, HealthCheck, HealthIssue } from './freeze-detector';
|
||||
export { AnimationTracker, AnimationEvent } from './animation-tracker';
|
||||
Reference in New Issue
Block a user