From e9ced4937adff254abad8ac1d13a696ada80339f Mon Sep 17 00:00:00 2001 From: michael <1+michael@noreply.git.casaderoll.de> Date: Thu, 24 Sep 2026 10:15:44 +0200 Subject: [PATCH] Add Puppeteer validation tool --- tools/validate.js | 129 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 tools/validate.js diff --git a/tools/validate.js b/tools/validate.js new file mode 100644 index 0000000..bb80cfa --- /dev/null +++ b/tools/validate.js @@ -0,0 +1,129 @@ +// 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); }); \ No newline at end of file