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:
15
tests/e2e/visual/index.ts
Normal file
15
tests/e2e/visual/index.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export {
|
||||
ScreenshotValidator,
|
||||
VisualExpectation,
|
||||
CaptureResult,
|
||||
} from './screenshot-validator';
|
||||
export {
|
||||
validateGameStart,
|
||||
validateAfterInitialFlip,
|
||||
validateDrawPhase,
|
||||
validateAfterDraw,
|
||||
validateRoundOver,
|
||||
validateFinalTurn,
|
||||
validateOpponentTurn,
|
||||
validateResponsiveLayout,
|
||||
} from './visual-rules';
|
||||
342
tests/e2e/visual/screenshot-validator.ts
Normal file
342
tests/e2e/visual/screenshot-validator.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
232
tests/e2e/visual/visual-rules.ts
Normal file
232
tests/e2e/visual/visual-rules.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user