Loading...
Loading...
Game QA testing with Playwright — visual regression, gameplay verification, performance, and accessibility for browser games
npx skill4agent add opusgamelabs/game-creator game-qavisual-regression.mdclock-control.mdplaywright-mcp.mditerate-client.mdmobile-tests.md@playwright/testtoHaveScreenshot()@axe-core/playwrightwebServernpm install -D @playwright/test @axe-core/playwright
npx playwright install chromiumpackage.json{
"scripts": {
"test": "npx playwright test",
"test:ui": "npx playwright test --ui",
"test:headed": "npx playwright test --headed",
"test:update-snapshots": "npx playwright test --update-snapshots"
}
}tests/
├── e2e/
│ ├── game.spec.js # Core game tests (boot, scenes, input, score)
│ ├── visual.spec.js # Visual regression screenshots
│ └── perf.spec.js # Performance and FPS tests
├── fixtures/
│ ├── game-test.js # Custom test fixture with game helpers
│ └── screenshot.css # CSS to mask dynamic elements for visual tests
├── helpers/
│ └── seed-random.js # Seeded PRNG for deterministic game behavior
playwright.config.jsimport { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './tests',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: [['html', { open: 'never' }], ['list']],
use: {
baseURL: 'http://localhost:3000',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
video: 'retain-on-failure',
},
expect: {
toHaveScreenshot: {
maxDiffPixels: 200,
threshold: 0.3,
},
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'mobile-chrome', use: { ...devices['Pixel 5'] } },
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
timeout: 30000,
},
});webServerreuseExistingServerbaseURLvite.config.jswindowmain.js// Expose for Playwright QA
window.__GAME__ = game;
window.__GAME_STATE__ = gameState;
window.__EVENT_BUS__ = eventBus;
window.__EVENTS__ = Events;render_game_to_text()window.render_game_to_text = () => {
if (!game || !gameState) return JSON.stringify({ error: 'not_ready' });
const activeScenes = game.scene.getScenes(true).map(s => s.scene.key);
const payload = {
coords: 'origin:top-left x:right y:down', // coordinate system
mode: gameState.gameOver ? 'game_over' : 'playing',
scene: activeScenes[0] || null,
score: gameState.score,
bestScore: gameState.bestScore,
};
// Add player info when in gameplay
const gameScene = game.scene.getScene('GameScene');
if (gameState.started && gameScene?.player?.sprite) {
const s = gameScene.player.sprite;
const body = s.body;
payload.player = {
x: Math.round(s.x), y: Math.round(s.y),
vx: Math.round(body.velocity.x), vy: Math.round(body.velocity.y),
onGround: body.blocked.down,
};
}
// Extend with visible entities as you add them:
// payload.entities = obstacles.map(o => ({ x: o.x, y: o.y, type: o.type }));
return JSON.stringify(payload);
};render_game_to_text()advanceTime(ms)window.advanceTime = (ms) => {
return new Promise((resolve) => {
const start = performance.now();
function step() {
if (performance.now() - start >= ms) return resolve();
requestAnimationFrame(step);
}
requestAnimationFrame(step);
});
};@playwright/testpage.clock.install()page.clock.runFor()advanceTimescripts/iterate-client.jsGameimport { test as base, expect } from '@playwright/test';
export const test = base.extend({
gamePage: async ({ page }, use) => {
await page.goto('/');
// Wait for Phaser to boot and canvas to render
await page.waitForFunction(() => {
const g = window.__GAME__;
return g && g.isBooted && g.canvas;
}, null, { timeout: 10000 });
await use(page);
},
});
export { expect };import { test, expect } from '../fixtures/game-test.js';
test('game boots directly to gameplay', async ({ gamePage }) => {
const sceneKey = await gamePage.evaluate(() => {
return window.__GAME__.scene.getScenes(true)[0]?.scene?.key;
});
expect(sceneKey).toBe('GameScene');
});test('bird flaps on space press', async ({ gamePage }) => {
// Start game
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started);
// Record position before flap
const yBefore = await gamePage.evaluate(() => {
const scene = window.__GAME__.scene.getScene('GameScene');
return scene.bird.y;
});
// Flap
await gamePage.keyboard.press('Space');
await gamePage.waitForTimeout(100);
// Bird should have moved up (lower y)
const yAfter = await gamePage.evaluate(() => {
const scene = window.__GAME__.scene.getScene('GameScene');
return scene.bird.y;
});
expect(yAfter).toBeLessThan(yBefore);
});
test('game over triggers on collision', async ({ gamePage }) => {
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started);
// Don't flap — let bird fall to ground
await gamePage.waitForFunction(
() => window.__GAME_STATE__.gameOver,
null,
{ timeout: 10000 }
);
expect(await gamePage.evaluate(() => window.__GAME_STATE__.gameOver)).toBe(true);
});test('score increments when passing pipes', async ({ gamePage }) => {
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started);
// Keep flapping to survive
const flapInterval = setInterval(async () => {
await gamePage.keyboard.press('Space').catch(() => {});
}, 300);
// Wait for at least 1 score
await gamePage.waitForFunction(
() => window.__GAME_STATE__.score > 0,
null,
{ timeout: 15000 }
);
clearInterval(flapInterval);
const score = await gamePage.evaluate(() => window.__GAME_STATE__.score);
expect(score).toBeGreaterThan(0);
});test('player can score at least 1 point', async ({ gamePage }) => {
// Start the game (space/tap)
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started, null, { timeout: 5000 });
// Perform gameplay actions — keep the player alive
const actionInterval = setInterval(async () => {
await gamePage.keyboard.press('Space').catch(() => {});
}, 400);
// Wait for score > 0
await gamePage.waitForFunction(
() => window.__GAME_STATE__.score > 0,
null,
{ timeout: 20000 }
);
clearInterval(actionInterval);
const score = await gamePage.evaluate(() => window.__GAME_STATE__.score);
expect(score).toBeGreaterThan(0);
});test('game over triggers through normal gameplay', async ({ gamePage }) => {
// Start the game
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started, null, { timeout: 5000 });
// Do nothing — let the fail condition trigger naturally (fall, timer, collision)
await gamePage.waitForFunction(
() => window.__GAME_STATE__.gameOver === true,
null,
{ timeout: 15000 }
);
const isOver = await gamePage.evaluate(() => window.__GAME_STATE__.gameOver);
expect(isOver).toBe(true);
});test('game over buttons display text labels', async ({ gamePage }) => {
// Trigger game over
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started, null, { timeout: 5000 });
await gamePage.waitForFunction(() => window.__GAME_STATE__.gameOver, null, { timeout: 15000 });
// Wait for GameOverScene to render
await gamePage.waitForFunction(() => {
const scenes = window.__GAME__.scene.getScenes(true);
return scenes.some(s => s.scene.key === 'GameOverScene');
}, null, { timeout: 5000 });
await gamePage.waitForTimeout(500);
// Check that text objects exist and are visible in the scene
const hasVisibleText = await gamePage.evaluate(() => {
const scene = window.__GAME__.scene.getScene('GameOverScene');
if (!scene) return false;
const textObjects = scene.children.list.filter(
child => child.type === 'Text' && child.visible && child.alpha > 0
);
// Should have at least: title ("GAME OVER"), score, and button label ("PLAY AGAIN")
return textObjects.length >= 3;
});
expect(hasVisibleText).toBe(true);
});render_game_to_text()test('render_game_to_text returns valid game state', async ({ gamePage }) => {
const stateStr = await gamePage.evaluate(() => window.render_game_to_text());
const state = JSON.parse(stateStr);
expect(state).toHaveProperty('mode');
expect(state).toHaveProperty('score');
expect(['playing', 'game_over']).toContain(state.mode);
expect(typeof state.score).toBe('number');
});Constants.js> 0GameState.jswonresultrender_game_to_text()main.js'win''game_over'design-brief.mdtest('player loses when providing no input', async ({ gamePage }) => {
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started);
await gamePage.waitForFunction(
() => window.__GAME_STATE__.gameOver,
null,
{ timeout: 45000 }
);
const result = await gamePage.evaluate(() => window.__GAME_STATE__.result);
expect(result).toBe('lose');
});test('opponent reaches 25% within half the round duration', async ({ gamePage }) => {
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started);
const { halfDuration, maxValue } = await gamePage.evaluate(() => {
return {
halfDuration: window.Constants?.ROUND_DURATION_MS / 2 || 15000,
maxValue: window.Constants?.MAX_VALUATION || 100,
};
});
await gamePage.waitForTimeout(halfDuration);
const opponentValue = await gamePage.evaluate(() => {
return window.__GAME_STATE__.opponentScore;
});
expect(opponentValue).toBeGreaterThanOrEqual(maxValue * 0.25);
});test('player wins with active input', async ({ gamePage }) => {
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started);
const inputInterval = setInterval(async () => {
await gamePage.keyboard.press('Space').catch(() => {});
}, 100);
await gamePage.waitForFunction(
() => window.__GAME_STATE__.gameOver,
null,
{ timeout: 45000 }
);
clearInterval(inputInterval);
const result = await gamePage.evaluate(() => window.__GAME_STATE__.result);
expect(result).toBe('win');
});resultopponentScore// QA FLAG: asymmetric interactionisMutedtest('mute button exists and toggles audio state', async ({ gamePage }) => {
await gamePage.keyboard.press('Space');
await gamePage.waitForFunction(() => window.__GAME_STATE__.started, null, { timeout: 5000 });
const hasMuteState = await gamePage.evaluate(() => {
return typeof window.__GAME_STATE__.isMuted === 'boolean';
});
expect(hasMuteState).toBe(true);
const before = await gamePage.evaluate(() => window.__GAME_STATE__.isMuted);
await gamePage.keyboard.press('m');
await gamePage.waitForTimeout(100);
const after = await gamePage.evaluate(() => window.__GAME_STATE__.isMuted);
expect(after).toBe(!before);
await gamePage.keyboard.press('m');
await gamePage.waitForTimeout(100);
const restored = await gamePage.evaluate(() => window.__GAME_STATE__.isMuted);
expect(restored).toBe(before);
});npm install -D @playwright/test @axe-core/playwright && npx playwright install chromiumplaywright.config.jswindow.__GAME__window.__GAME_STATE__window.__EVENT_BUS__main.jstests/fixtures/game-test.jsgamePagetests/helpers/seed-random.jstests/e2e/game.spec.jsvisual.spec.jsperf.spec.jstesttest:uitest:headedtest:update-snapshotsnpm run test:update-snapshotspage.route()