// Puppeteer validation: load each page, play, screenshot, assert. const puppeteer = require('puppeteer'); const path = require('path'); const fs = require('fs'); const BASE = process.env.BASE || 'http://127.0.0.1:8099'; const SHOTS = path.join(__dirname, 'shots'); fs.mkdirSync(SHOTS, { recursive: true }); const GAMES = [ { name: 'asteroid', url: '/games/asteroid.html', keys: ['ArrowLeft','ArrowRight','ArrowUp','Space','KeyX'] }, { name: 'breakout', url: '/games/breakout.html', keys: ['ArrowLeft','ArrowRight','Space'] }, { name: 'snake', url: '/games/snake.html', keys: ['ArrowUp','ArrowDown','ArrowLeft','ArrowRight'] }, { name: 'invader', url: '/games/spaceinvaders.html', keys: ['ArrowLeft','ArrowRight','Space'] }, ]; function canvasStats(page) { return page.evaluate(() => { const c = document.getElementById('game'); const ctx = c.getContext('2d'); const d = ctx.getImageData(0, 0, c.width, c.height).data; const colors = new Set(); let nonBlack = 0; for (let i = 0; i < d.length; i += 4) { const key = (d[i] >> 4) + ',' + (d[i+1] >> 4) + ',' + (d[i+2] >> 4); colors.add(key); if (d[i] + d[i+1] + d[i+2] > 30) nonBlack++; } return { colors: colors.size, nonBlack: nonBlack / (d.length / 4), w: c.width, h: c.height }; }); } async function validateGame(browser, g) { const page = await browser.newPage(); const errors = []; page.on('pageerror', e => errors.push('pageerror: ' + e.message)); page.on('console', m => { if (m.type() === 'error') errors.push('console: ' + m.text()); }); await page.setViewport({ width: 1000, height: 800 }); await page.goto(BASE + g.url, { waitUntil: 'networkidle0', timeout: 30000 }); await new Promise(r => setTimeout(r, 1200)); await page.screenshot({ path: path.join(SHOTS, g.name + '-1-load.png') }); await new Promise(r => setTimeout(r, 2500)); await page.screenshot({ path: path.join(SHOTS, g.name + '-2-title.png') }); const state1 = await page.evaluate(() => window.DSP_ACTIVE_GAME.state); if (state1 !== 1) throw new Error(g.name + ': expected TITLE state, got ' + state1); await page.keyboard.press('Space'); await new Promise(r => setTimeout(r, 800)); const state2 = await page.evaluate(() => window.DSP_ACTIVE_GAME.state); if (state2 !== 2) throw new Error(g.name + ': expected PLAYING state, got ' + state2); const seq = g.keys; for (let i = 0; i < 40; i++) { const k = seq[i % seq.length]; if (k === 'Space') await page.keyboard.press('Space'); else { await page.keyboard.down(k); await new Promise(r => setTimeout(r, 180)); await page.keyboard.up(k); } } await page.screenshot({ path: path.join(SHOTS, g.name + '-3-play.png') }); const stats = await canvasStats(page); if (stats.colors < 8) throw new Error(g.name + ': canvas looks blank (colors=' + stats.colors + ')'); const score = await page.evaluate(() => window.DSP_ACTIVE_GAME.score); const state3 = await page.evaluate(() => window.DSP_ACTIVE_GAME.state); await page.keyboard.press('KeyP'); await new Promise(r => setTimeout(r, 300)); const stateP = await page.evaluate(() => window.DSP_ACTIVE_GAME.state); await page.screenshot({ path: path.join(SHOTS, g.name + '-4-pause.png') }); await page.keyboard.press('KeyP'); await new Promise(r => setTimeout(r, 300)); await page.evaluate(() => { const g = window.DSP_ACTIVE_GAME; g.lives = 1; g.gameOver(); }); await new Promise(r => setTimeout(r, 600)); await page.screenshot({ path: path.join(SHOTS, g.name + '-5-gameover.png') }); const stateGO = await page.evaluate(() => window.DSP_ACTIVE_GAME.state); if (stateGO !== 4) throw new Error(g.name + ': expected GAMEOVER state, got ' + stateGO); const realErrors = errors.filter(e => !e.includes('AudioContext') && !e.includes('autoplay')); if (realErrors.length) throw new Error(g.name + ': JS errors: ' + realErrors.join(' | ')); await page.close(); return { name: g.name, score, state: state3, colors: stats.colors, nonBlack: stats.nonBlack.toFixed(2) }; } async function validateLanding(browser) { const page = await browser.newPage(); const errors = []; page.on('pageerror', e => errors.push(e.message)); await page.setViewport({ width: 1400, height: 1000 }); await page.goto(BASE + '/', { waitUntil: 'networkidle0', timeout: 30000 }); await new Promise(r => setTimeout(r, 1500)); await page.screenshot({ path: path.join(SHOTS, 'landing-full.png'), fullPage: true }); const cards = await page.evaluate(() => document.querySelectorAll('.card').length); const imgs = await page.evaluate(() => Array.from(document.querySelectorAll('.thumb img')).map(i => i.naturalWidth > 0)); if (cards !== 4) throw new Error('landing: expected 4 cards, got ' + cards); if (!imgs.every(Boolean)) throw new Error('landing: some thumbnails failed to load'); const realErrors = errors.filter(e => !e.includes('AudioContext')); if (realErrors.length) throw new Error('landing: JS errors: ' + realErrors.join(' | ')); await page.close(); return { name: 'landing', cards, thumbs: imgs }; } (async () => { const browser = await puppeteer.launch({ headless: 'new', args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-gpu', '--use-gl=swiftshader', '--enable-unsafe-swiftshader'], }); const results = []; try { results.push(await validateLanding(browser)); for (const g of GAMES) { results.push(await validateGame(browser, g)); console.log('PASS', g.name); } } finally { await browser.close(); } console.log(JSON.stringify(results, null, 2)); console.log('ALL VALIDATIONS PASSED'); })().catch(e => { console.error('VALIDATION FAILED:', e.message); process.exit(1); });