Tambah harness uji headless (Playwright)

This commit is contained in:
rizkyaz0 2026-10-02 21:55:45 +07:00
parent e50ead2cd8
commit 7038918998
1 changed files with 81 additions and 0 deletions

81
tests/harness.mjs Normal file
View File

@ -0,0 +1,81 @@
// Headless test harness for Konoha Fighting (Playwright + Chromium). No game code is changed by running it.
// node tests/harness.mjs smoke all 7 characters vs CPU on several levels, collects page errors
// node tests/harness.mjs <file.mjs> runs a scenario module: export default async ({ page, game, errors }) => result
// Env: PW_CORE (path to playwright-core index.mjs), PORT (default random), HEADED=1.
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const PW = process.env.PW_CORE || '/Users/cifo/Documents/Projects/erp/node_modules/playwright-core/index.mjs';
const { chromium } = await import(PW);
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.webp': 'image/webp', '.png': 'image/png', '.svg': 'image/svg+xml', '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.ttf': 'font/ttf', '.jpg': 'image/jpeg' };
export function serve(dir = root) {
const server = http.createServer((req, res) => {
let p = decodeURIComponent(new URL(req.url, 'http://x').pathname); if (p.endsWith('/')) p += 'index.html';
const f = path.join(dir, p);
if (!f.startsWith(dir) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { res.writeHead(404); res.end('nf'); return; }
res.writeHead(200, { 'Content-Type': MIME[path.extname(f)] || 'application/octet-stream', 'Cache-Control': 'no-store' }); fs.createReadStream(f).pipe(res);
});
return new Promise(r => server.listen(Number(process.env.PORT) || 0, '127.0.0.1', () => r({ server, url: `http://127.0.0.1:${server.address().port}/` })));
}
export async function open({ dir = root, query = '?touch=0', viewport = { width: 1280, height: 720 } } = {}) {
const { server, url } = await serve(dir);
const exe = process.env.CHROME_BIN || [`${process.env.HOME}/Library/Caches/ms-playwright/chromium_headless_shell-1217/chrome-headless-shell-mac-x64/chrome-headless-shell`, '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge'].find(f => fs.existsSync(f));
const browser = await chromium.launch({ executablePath: exe, headless: !process.env.HEADED, args: ['--mute-audio', '--autoplay-policy=no-user-gesture-required'] });
const context = await browser.newContext({ viewport, serviceWorkers: 'block' });
const page = await context.newPage();
const errors = [];
page.on('pageerror', e => errors.push('pageerror: ' + e.message));
page.on('console', m => { if (m.type() === 'error' && !/Failed to load resource|favicon|net::ERR/.test(m.text())) errors.push('console: ' + m.text()); });
await page.goto(url + query);
await page.waitForFunction(() => window.__game && window.__game.snapshot().ready, null, { timeout: 60000 });
await page.evaluate(() => { document.querySelectorAll('audio,video').forEach(a => { a.muted = true; a.pause(); }); const s = document.createElement('style'); s.textContent = '#pause-panel{display:none!important}'; document.head.append(s); });
const game = (fn, arg) => page.evaluate(fn, arg);
const close = async () => { await browser.close(); server.close(); };
return { page, game, errors, close, url };
}
const CHARS = ['browo', 'megachan', 'sonofsolo', 'ethanolman', 'anice', 'soloman', 'gufron'];
export const characters = CHARS;
async function smoke() {
const t = await open();
const out = [];
for (const level of ['easy', 'hard']) for (const [i, p] of CHARS.entries()) {
const e = CHARS[(i + 2) % CHARS.length];
const r = await t.game(async ({ p, e, level }) => {
const g = window.__game; g.startMatch({ player: p, enemy: e, stage: 'bellora', level, mode: 'versus' }); g.setAI(true);
for (let i = 0; i < 70; i++) {
g.advance(.25);
if (i % 5 === 0) { g.press('d'); g.press(' '); g.release(' '); g.release('d'); }
if (i % 13 === 0) { g.press('p'); g.release('p'); }
if (i % 8 === 0) { g.press('i'); g.release('i'); g.press('o'); g.release('o'); }
if (i % 9 === 0) { g.press('l'); } if (i % 9 === 4) g.release('l');
if (i % 11 === 0) { g.press('s'); g.release('s'); g.press('s'); g.release('s'); g.press(' '); g.release(' '); }
}
const s = g.snapshot(); return { hp: Math.round(g.hero.hp), dmg: Math.round(g.dummy.damage), phase: s.match?.phase };
}, { p, e, level });
out.push(`${level} ${p}>${e} hero ${r.hp} rival-dmg ${r.dmg} ${r.phase}`);
}
await t.close();
const bad = t.errors;
console.log(out.join('\n')); console.log(bad.length ? 'ERRORS:\n' + bad.join('\n') : 'no page errors');
process.exit(bad.length ? 1 : 0);
}
const arg = process.argv[2];
if (arg === 'smoke') await smoke();
else if (arg) {
const mod = await import(pathToFileURL(path.resolve(arg)).href);
const t = await open(mod.options || {});
let result, failed = false;
try { result = await mod.default({ ...t, characters: CHARS }); } catch (e) { failed = true; result = 'THROW ' + (e.stack || e); }
await t.close();
console.log(typeof result === 'string' ? result : JSON.stringify(result, null, 1));
if (t.errors.length) { console.log('ERRORS:\n' + t.errors.join('\n')); failed = true; }
process.exit(failed ? 1 : 0);
}