#!/usr/bin/env node // Le prove nel browser a sezioni (idea di Andrea, 1 novembre 2026): ogni prova ha i suoi ingressi, i file da cui dipende; // si rifà solo se la loro impronta (i blob di git, più i file modificati nella cartella di lavoro) è cambiata dall'ultimo // verde, ricordato in .verify-ok.json (fuori da git). Se cambia la briscola si riprova la briscola; se cambia la storia, il // motore, l'impaginazione o le risorse si riprova la partita intera (e le prove delle scene speciali), non la briscola. // node scripts/verify.mjs le prove da rifare, in parallelo (JOBS=2: con quattro Chromium swiftshader insieme le schermate vanno in timeout), e aggiorna i verdi // node scripts/verify.mjs --plan dice solo cosa rifarebbe e perché // node scripts/verify.mjs --all tutte, senza guardare i verdi // node scripts/verify.mjs briscola solo quelle nominate (anche se verdi) // I test unitari e check-public non stanno qui: costano pochi secondi e girano sempre (make verify). import { execFileSync, spawn } from 'node:child_process'; import { createHash } from 'node:crypto'; import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { cpus } from 'node:os'; // Il motore comune a tutto: cambia questo e si riprova ogni minigioco. export const ENGINE = ['index.html', 'style.css', 'src/main.js', 'src/engine/game.js', 'src/engine/render.js', 'tests/helpers.mjs', 'package-lock.json']; // Non toccano il gioco nel browser: documenti, strumenti, pubblicazione, versione (make bump la cambia prima di verificare), // test unitari (girano sempre, a parte), e questo stesso script (lo prova verify.test.mjs). export const IGNORED = [/^docs\//, /^[^/]*\.md$/, /^src\/version\.js$/, /^package\.json$/, /^Makefile$/, /^scripts\//, /^tools\//, /^video\//, /^\.gitea\//, /^prompts\//, /^LICENSE$/, /^Dockerfile$/, /^nginx\.conf$/, /^docker-compose/, /^requirements\.txt$/, /^\.(gitignore|dockerignore|verify-ok\.json)$/, /^tests\/[^/]*\.test\.mjs$/, /^tests\/test_[^/]*\.py$/, /^deploy\.local\.mk$/, /(^|\/)__pycache__\//]; // Le prove. `core: true` = dipende da tutto il gioco tranne i file dei minigiochi a sé (che hanno la loro prova); altrimenti // solo dal motore e dai propri file: quelli che si chiamano come la prova (src/engine/flipper.js, flipper-render.js, // assets/closeups/flipper-*.png, docs/BRISCOLA.md…) più gli `extra`. `run` sono gli argomenti di node. export const SUITES = { principale: { core: true, run: ['tests/browser.mjs', '--parte=principale'], note: 'la partita intera, pausa e salvataggi' }, minigiochi: { core: true, run: ['tests/browser.mjs', '--parte=minigiochi'], note: 'libro, bocce, tiri, pesca e biliardo dalla partita' }, telefoni: { core: true, run: ['tests/browser.mjs', '--parte=telefoni'], note: 'multimedia, telefoni, la finestra di Andrea, debug' }, ambient: { core: true, run: ['tests/ambient.browser.mjs'], note: 'tutte le scene nelle tre fasi e gli effetti' }, veicoli: { core: true, run: ['tests/veicoli.browser.mjs'], note: 'i veicoli nelle scene' }, ingorgo: { core: true, run: ['tests/ingorgo.browser.mjs'], note: "l'Ingorgo Sonoro" }, smarronata: { core: true, run: ['tests/smarronata.browser.mjs'], note: 'la Smarronata del 1993' }, briscola: { run: ['tests/briscola.browser.mjs'] }, tressette: { run: ['tests/tressette.browser.mjs'] }, scopa: { run: ['tests/scopa.browser.mjs'] }, biliardo: { run: ['tests/biliardo.browser.mjs'] }, flipper: { run: ['tests/flipper.browser.mjs'] }, micro: { run: ['tests/micro.browser.mjs'] }, cerbottana: { run: ['tests/cerbottana.browser.mjs'] }, condor: { run: ['tests/condor.browser.mjs'] }, campana: { run: ['tests/campana.browser.mjs'] }, bruciate: { run: ['tests/bruciate.browser.mjs'] }, gara: { run: ['tests/gara.browser.mjs'], extra: ['src/engine/corsa.js', 'src/story/piloti.js', /^assets\/[^/]*\/(gara|gp|pilot)/] }, telefono: { run: ['tests/telefono.browser.mjs'], extra: [/^assets\/muro\//] }, risveglio: { run: ['tests/risveglio.browser.mjs'] }, }; const ownRe = name => new RegExp(`(^|/)${name}([-_.][^/]*)?$`, 'i'); const matches = (path, rule) => rule instanceof RegExp ? rule.test(path) : rule === path; export const ignored = path => IGNORED.some(r => r.test(path)); export function ownFiles(name, files) { const s = SUITES[name], re = ownRe(name); return files.filter(p => re.test(p) || (s.extra || []).some(r => matches(p, r))); } // Gli ingressi di una prova fra i file dati (tutti quelli di git più quelli nuovi nella cartella). export function inputsOf(name, files) { const s = SUITES[name], live = files.filter(p => !ignored(p)); if (!s.core) return [...new Set([...ENGINE.filter(p => files.includes(p)), ...ownFiles(name, live)])].sort(); const others = Object.keys(SUITES).filter(n => !SUITES[n].core); const foreign = new Set(others.flatMap(n => ownFiles(n, live))); return live.filter(p => !foreign.has(p)).sort(); } export const fingerprint = (paths, hashOf) => createHash('sha1').update(paths.map(p => `${p} ${hashOf(p)}`).join('\n')).digest('hex'); const ROOT = fileURLToPath(new URL('..', import.meta.url)); const git = (...a) => execFileSync('git', a, { cwd: ROOT, encoding: 'utf8', maxBuffer: 1 << 26 }); // I file del progetto con l'impronta di ognuno: il blob dell'indice di git, o il contenuto attuale se modificato o nuovo. export function snapshot() { const hashes = new Map(); for (const line of git('ls-files', '-s', '-z').split('\0')) if (line) { const [meta, path] = line.split('\t'); hashes.set(path, meta.split(' ')[1]); } const dirty = []; for (const line of git('status', '--porcelain', '-z', '--untracked-files=all').split('\0')) { if (!line) continue; const code = line.slice(0, 2), path = line.slice(3); if (code.includes('D')) hashes.delete(path); else dirty.push(path); } for (const path of dirty) if (existsSync(`${ROOT}/${path}`)) hashes.set(path, git('hash-object', '--', path).trim()); return hashes; } const OK_FILE = `${ROOT}/.verify-ok.json`; const readOk = () => { try { return JSON.parse(readFileSync(OK_FILE, 'utf8')); } catch { return {}; } }; export function plan(hashes, ok, names = Object.keys(SUITES)) { const files = [...hashes.keys()], out = []; for (const name of names) { const inputs = inputsOf(name, files), fp = fingerprint(inputs, p => hashes.get(p)); const was = ok[name], changed = was?.fingerprint === fp ? [] : inputs.filter(p => !was?.hashes || was.hashes[p] !== hashes.get(p)); out.push({ name, fp, inputs, changed, green: was?.fingerprint === fp, seconds: was?.seconds }); } return out; } function run(name) { return new Promise(resolve => { const t0 = Date.now(), lines = []; const p = spawn(process.execPath, SUITES[name].run, { cwd: ROOT, stdio: ['ignore', 'pipe', 'pipe'] }); let rest = ''; const onData = chunk => { const parts = (rest + chunk).split('\n'); rest = parts.pop(); for (const l of parts) { lines.push(l); console.log(`[${name}] ${l}`); } }; p.stdout.on('data', onData); p.stderr.on('data', onData); p.on('close', code => { if (rest) console.log(`[${name}] ${rest}`); resolve({ name, code, seconds: Math.round((Date.now() - t0) / 1000) }); }); }); } async function main() { const args = process.argv.slice(2), all = args.includes('--all'), onlyPlan = args.includes('--plan'); const named = args.filter(a => !a.startsWith('--')); for (const n of named) if (!SUITES[n]) { console.error(`prova sconosciuta: ${n} (${Object.keys(SUITES).join(', ')})`); process.exit(2); } const hashes = snapshot(), ok = readOk(); const todo = plan(hashes, ok, named.length ? named : undefined).filter(p => all || named.length || !p.green); const green = Object.keys(SUITES).filter(n => !todo.some(t => t.name === n)); if (!todo.length) { console.log(`Prove nel browser: tutte verdi dall'ultimo giro (${green.length}); niente da rifare.`); return; } for (const t of todo) { const why = all ? 'tutte' : named.includes(t.name) ? 'chiesta' : !ok[t.name] ? 'mai provata' : !t.changed.length ? "cambiato l'elenco dei file da cui dipende" : `cambiati ${t.changed.slice(0, 4).join(', ')}${t.changed.length > 4 ? ` e altri ${t.changed.length - 4}` : ''}`; console.log(`da rifare: ${t.name}${t.seconds ? ` (~${t.seconds} s)` : ''}: ${why}`); } if (green.length) console.log(`già verdi: ${green.join(', ')}`); if (onlyPlan) return; const jobs = Math.max(1, Number(process.env.JOBS) || Math.min(2, Math.floor(cpus().length / 4))); const queue = todo.sort((a, b) => (b.seconds || 999) - (a.seconds || 999)), results = []; const worker = async () => { for (let t; (t = queue.shift());) { const r = await run(t.name); results.push(r); if (r.code === 0) { const now = readOk(); now[t.name] = { fingerprint: t.fp, seconds: r.seconds, when: new Date().toISOString(), hashes: Object.fromEntries(t.inputs.map(p => [p, hashes.get(p)])) }; writeFileSync(OK_FILE, JSON.stringify(now, null, 1) + '\n'); } } }; await Promise.all(Array.from({ length: Math.min(jobs, queue.length) }, worker)); for (const r of results.sort((a, b) => a.name.localeCompare(b.name))) console.log(`${r.code === 0 ? 'OK ' : 'FAIL'} ${r.name} (${r.seconds} s)`); const failed = results.filter(r => r.code !== 0).map(r => r.name); if (failed.length) { console.log(`Prove fallite: ${failed.join(', ')}`); process.exit(1); } console.log(`Prove nel browser OK: ${results.length} rifatte, ${green.length} già verdi.`); } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) main();