129 lines
5.6 KiB
JavaScript
129 lines
5.6 KiB
JavaScript
// 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); }); |