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

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

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

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

343 lines
7.8 KiB
TypeScript

/**
* 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 };
}
}