/** * Headless browser verification for PolyCity. * Every await is wrapped: the harness can never die silently. */ import { chromium } from 'playwright-core'; import { createServer } from 'node:http'; import { readFileSync, existsSync, mkdirSync, readdirSync, writeSync } from 'node:fs'; import { join, extname } from 'node:path'; const LOG = (m) => { try { writeSync(1, m + '\n'); } catch {} }; const DIST = new URL('../dist', import.meta.url).pathname; const SHOTS = new URL('../shots', import.meta.url).pathname; if (!existsSync(SHOTS)) mkdirSync(SHOTS); let last = Date.now(); setInterval(() => { if (Date.now() - last > 70000) { LOG('IDLE WATCHDOG — aborting'); process.exit(3); } }, 5000).unref(); setTimeout(() => { LOG('GLOBAL CAP — aborting'); process.exit(4); }, 360000); process.on('unhandledRejection', (r) => LOG('UNHANDLED REJECTION: ' + (r?.message || r))); process.on('uncaughtException', (e) => LOG('UNCAUGHT: ' + (e?.message || e))); function findChromium() { const root = '/root/.cache/ms-playwright'; const pats = ['chrome-headless-shell-linux64/chrome-headless-shell', 'chrome-linux64/chrome', 'chrome-linux/headless_shell', 'chrome-linux/chrome']; try { for (const d of readdirSync(root)) { for (const p of pats) { const c = join(root, d, p); if (existsSync(c)) return c; } } } catch {} return null; } // static file server const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.json': 'application/json', '.webmanifest': 'application/manifest+json' }; const server = createServer((req, res) => { let p = req.url.split('?')[0]; if (p === '/') p = '/index.html'; try { res.writeHead(200, { 'content-type': MIME[extname(p)] || 'application/octet-stream' }); res.end(readFileSync(join(DIST, p))); } catch { res.writeHead(404); res.end(); } }); await new Promise(r => server.listen(4173, r)); LOG('static server on :4173'); const errors = []; let closing = false; const exe = findChromium(); LOG('browser: ' + exe); const browser = await chromium.launch({ executablePath: exe, args: ['--no-sandbox', '--enable-unsafe-swiftshader'] }); browser.on('disconnected', () => { if (!closing) errors.push('BROWSER DISCONNECTED'); }); const page = await browser.newPage({ viewport: { width: 800, height: 520 }, deviceScaleFactor: 1 }); page.setDefaultTimeout(9000); page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message)); page.on('console', m => { if (m.type() === 'error') errors.push('CONSOLE: ' + m.text()); }); page.on('crash', () => errors.push('PAGE CRASHED')); /** race-guarded runner: never hangs, never dies silently */ async function safe(label, fn, ms = 15000) { last = Date.now(); let timer; const timeout = new Promise((_, rej) => { timer = setTimeout(() => rej(new Error('TIMEOUT ' + label)), ms); }); try { const v = await Promise.race([Promise.resolve().then(fn), timeout]); clearTimeout(timer); return v; } catch (e) { clearTimeout(timer); LOG(' [safe] ' + label + ' → ' + String(e.message || e).split('\n')[0]); return null; } } const sleep = (ms) => safe(`sleep ${ms}`, () => new Promise(r => setTimeout(r, ms)), ms + 3000); await safe('goto', () => page.addInitScript(() => { localStorage.setItem('polycity.settings.v1', JSON.stringify({ sound: false, shadows: false, autosave: true, minimap: true })); })); await safe('load', () => page.goto('http://127.0.0.1:4173/', { waitUntil: 'load', timeout: 20000 })); LOG('page loaded'); await sleep(3200); await safe('shot-boot', () => page.screenshot({ path: join(SHOTS, '01-boot.png'), timeout: 12000 })); await safe('help-close', async () => { const b = await page.$('#helpClose'); if (b) await b.click(); }); // ---- build the town through the real game API (deterministic) ---- const built = await safe('build-town', () => page.evaluate(() => { const g = window.POLYCITY, city = g.city, grid = city.grid; const free4 = (x, z) => { for (let dz = 0; dz < 2; dz++) for (let dx = 0; dx < 2; dx++) { if (!grid.inB(x + dx, z + dz)) return false; const i = grid.idx(x + dx, z + dz); if (grid.terrain[i] !== 0 || grid.struct[i] || grid.zone[i]) return false; } return true; }; const spot = (cx, cz) => { for (let r = 0; r < 40; r++) for (let z = cz - r; z <= cz + r; z++) for (let x = cx - r; x <= cx + r; x++) { if (free4(x, z)) return [x, z]; } return null; }; // road cross for (let x = 18; x < 46; x++) city.placeStruct(1, x, 32); for (let z = 20; z < 44; z++) city.placeStruct(1, 32, z); // districts const rect = (x0, z0, x1, z1, zid) => { const t = []; for (let z = z0; z <= z1; z++) for (let x = x0; x <= x1; x++) t.push([x, z]); city.placeZone(zid, t); }; rect(19, 28, 30, 31, 1); // residential north-west rect(34, 28, 45, 31, 2); // commercial north-east rect(34, 34, 45, 38, 3); // industrial south-east rect(19, 34, 26, 37, 2); // small commercial SW // coal plant + services const plant = spot(22, 41); if (plant) city.placeStruct(2, plant[0], plant[1]); const put = (sid, cx, cz) => { const p = spot(cx, cz); if (p) city.placeStruct(sid, p[0], p[1]); }; put(5, 21, 33); // police put(6, 24, 33); // fire put(7, 27, 33); // hospital put(8, 36, 33); // school put(9, 29, 30); // park put(10, 35, 32); // plaza return { roads: [...grid.struct].filter(v => v === 1).length, powerCap: city.stats.powerCap, money: Math.round(city.money) }; }, 25000)); LOG('built town: ' + JSON.stringify(built)); await safe('shot-built', () => page.screenshot({ path: join(SHOTS, '02-built.png'), timeout: 12000 })); // ---- simulate ~14 months at fast speed ---- await safe('speed2', () => page.click('#speedControls [data-speed="2"]')); for (let k = 0; k < 7; k++) { await sleep(2000); const s = await safe('probe' + k, () => page.evaluate(() => ({ pop: window.POLYCITY.city.stats.pop, dev: [...window.POLYCITY.city.grid.level].filter(v => v > 0).length })), 8000); LOG(`t+${(k + 1) * 2}s pop=${s?.pop ?? '?'} dev=${s?.dev ?? '?'}`); } const state = await safe('state', () => page.evaluate(() => { const g = window.POLYCITY, c = g.city; return { money: c.money, pop: c.stats.pop, jobs: c.stats.jobs, happy: c.stats.happiness, powerCap: c.stats.powerCap, powerUse: c.stats.powerUse, brownouts: c.stats.brownouts, monthIndex: c.monthIndex, developed: [...c.grid.level].filter(v => v > 0).length, levels123: [1, 2, 3].map(L => [...c.grid.level].filter(v => v === L).length), cars: g._parts.renderer.traffic.agents.length }; }), 10000); LOG('STATE: ' + JSON.stringify(state)); await safe('shot-grown', () => page.screenshot({ path: join(SHOTS, '03-grown.png'), timeout: 12000 })); // query popup via API await safe('query', () => page.evaluate(() => window.POLYCITY.queryTile({ x: 25, z: 30 }, 60, 60))); // panels for (const [btn, name] of [['#btnBudget', 'budget'], ['#btnStats', 'stats'], ['#btnMenu', 'menu']]) { await safe('panel-' + name, async () => { await page.click(btn); await sleep(350); await page.screenshot({ path: join(SHOTS, `08-${name}.png`), timeout: 12000 }); if (name !== 'menu') await page.keyboard.press('Escape'); }); } await safe('final-shot', () => page.screenshot({ path: join(SHOTS, '09-final.png'), timeout: 12000 })); closing = true; await safe('close-browser', () => browser.close()); server.close(); let fail = Boolean(!state || !built); if (!built?.roads || built.roads < 50) { LOG('FAIL: roads missing'); fail = true; } if (!state) fail = true; else { if (!(state.pop > 50)) { LOG('FAIL: population did not grow: ' + state.pop); fail = true; } if (!(state.developed > 10)) { LOG('FAIL: too few developments: ' + state.developed); fail = true; } if (!(state.powerCap > 0 && state.powerUse > 0)) { LOG('FAIL: power not flowing'); fail = true; } if (!(state.cars > 0)) { LOG('FAIL: traffic dead'); fail = true; } } for (const e of errors) { LOG('ERR: ' + e); if (!e.includes('favicon')) fail = true; } LOG(fail ? 'SMOKE TEST FAILED' : 'SMOKE TEST PASSED'); process.exit(fail ? 1 : 0);