// Issue #343: a page keeps the browser simulator in its module map across a server restart. // Exercise the real server, WebSocket client, simulator loader, route selector and build guard in Chrome. // Only a throwaway install changes; no game assets or tracked runtime files are modified. // Run: SP_E2E=1 node --test test/ui/build-runtime.browser.test.js (CHROME_PATH overrides system Chrome). import { test } from 'node:test'; import assert from 'node:assert/strict'; import { fork } from 'node:child_process'; import { once } from 'node:events'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); const CHROME = process.env.CHROME_PATH || '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; const enabled = process.env.SP_E2E === '1' && fs.existsSync(CHROME); const PAGE = `
Loading simulator
`; function fixture() { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-build-browser-')); for (const rel of ['server', 'shared', 'data', 'public/js', 'public/css', 'public/index.html', 'public/vendor/hooks.module.js', 'public/vendor/preact.module.js', 'package.json']) { fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); fs.cpSync(path.join(ROOT, rel), path.join(root, rel), { recursive: true }); } fs.symlinkSync(path.join(ROOT, 'node_modules'), path.join(root, 'node_modules'), 'junction'); fs.mkdirSync(path.join(root, 'public/dev'), { recursive: true }); fs.writeFileSync(path.join(root, 'public/dev/build-runtime.html'), PAGE); fs.writeFileSync(path.join(root, 'start.mjs'), ` import { startServer } from './server/index.js'; const srv = await startServer({ port: Number(process.env.PORT), host: '127.0.0.1', quiet: true }); process.send({ port: srv.port }); process.on('SIGTERM', async () => { await srv.close(); process.exit(0); }); `); return root; } async function start(root, port = 0) { const child = fork(path.join(root, 'start.mjs'), [], { cwd: root, env: { ...process.env, PORT: String(port) }, silent: true, }); let stderr = ''; child.stderr.on('data', (buf) => { stderr += buf; }); try { const [ready] = await once(child, 'message', { signal: AbortSignal.timeout(15000) }); return { port: ready.port, stop: async () => { if (child.exitCode !== null || child.signalCode !== null) return; const exited = once(child, 'exit'); child.kill('SIGTERM'); await exited; } }; } catch (error) { child.kill('SIGKILL'); throw new Error(`Fixture server failed to start: ${stderr}`, { cause: error }); } } test('an old simulator page reconnects safely, then reloads before the next match after a runtime-only deploy', { skip: enabled ? false : 'set SP_E2E=1 (needs Chrome)', timeout: 120000, }, async () => { const root = fixture(); let server, browser; try { server = await start(root); const port = server.port; const puppeteer = (await import('puppeteer-core')).default; browser = await puppeteer.launch({ executablePath: CHROME, headless: true, protocolTimeout: 30000, args: ['--no-first-run', '--no-sandbox'] }); const page = await browser.newPage(); const problems = []; let navigations = 0, simRequests = 0; page.on('pageerror', (error) => problems.push(error.message)); page.on('console', (message) => { // The two deliberate server outages produce browser transport errors, not application errors. if (message.type() === 'error' && !/net::ERR_CONNECTION_REFUSED|WebSocket connection to .* failed/.test(message.text())) { problems.push(message.text()); } }); page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) navigations++; }); page.on('request', (request) => { if (new URL(request.url()).pathname === '/sim/rng.js') simRequests++; }); await page.goto(`http://127.0.0.1:${port}/dev/build-runtime.html`); const ready = async () => { try { await page.waitForFunction(() => window.fixture?.guard.known() && window.fixture.net.status === 'online'); } catch (error) { throw new Error(`Fixture page failed: ${JSON.stringify(problems)}`, { cause: error }); } }; const state = () => page.evaluate(() => window.fixture.state()); const check = () => page.evaluate(() => window.fixture.guard.check()); await ready(); assert.equal(await page.title(), 'Build runtime regression'); await page.click('#battle'); const old = await state(); assert.equal(old.route, 'game'); assert.equal(simRequests, 1, 'the first match imported the actual simulator'); await server.stop(); await page.waitForFunction(() => window.fixture.net.status === 'reconnecting'); assert.equal((await check()).status, 'unknown', 'a stopped server never causes a mid-match reload'); await page.click('#lobby'); assert.equal((await check()).status, 'unknown', 'a stopped server never causes an outside-match reload either'); assert.equal(navigations, 1); await page.click('#battle'); // Change an actual simulator dependency, retaining its size to exercise the mtime portion of the build tag. const rngPath = path.join(root, 'server/sim/rng.js'); const source = fs.readFileSync(rngPath, 'utf8'); assert.match(source, /0x6d2b79f5/); const stat = fs.statSync(rngPath); fs.writeFileSync(rngPath, source.replace('0x6d2b79f5', '0x6d2b79f4')); fs.utimesSync(rngPath, stat.atime, new Date(stat.mtimeMs + 5000)); const { createRng } = await import(pathToFileURL(rngPath).href); const freshFingerprint = createRng(91)(); assert.notEqual(freshFingerprint, old.fingerprint); server = await start(root, port); await ready(); assert.equal((await state()).boots, 1, 'the original page actually reconnected'); assert.equal((await state()).fingerprint, old.fingerprint, 'the already loaded simulator still has old code'); assert.equal(simRequests, 1, 'reconnect does not refetch an imported ES module'); assert.equal((await check()).status, 'new', 'the restarted server advertises the simulator-only deploy'); assert.equal((await state()).stale, false, 'one new tag is not confirmation'); assert.equal((await check()).status, 'stale'); assert.equal((await state()).stale, true, 'confirmed update is exposed to the in-match banner'); assert.equal(navigations, 1, 'a confirmed update never interrupts combat'); await page.click('#result'); assert.equal((await check()).status, 'stale'); assert.equal(navigations, 1, 'settlement also remains on screen'); // Even an already-confirmed update must wait while the server is unavailable. await server.stop(); await page.click('#lobby'); assert.equal((await check()).status, 'unknown'); assert.equal(navigations, 1); server = await start(root, port); await ready(); assert.equal((await check()).status, 'new', 'the failed check resets consecutive confirmations'); assert.equal(navigations, 1, 'one successful check after downtime is insufficient'); await Promise.all([ page.waitForNavigation(), page.evaluate(() => { window.fixture.guard.check(); }), ]); await ready(); assert.equal(navigations, 2, 'the confirmed healthy build reloads exactly once in the lobby'); assert.equal((await state()).boots, 2); assert.notEqual((await state()).known, old.known); await page.click('#battle'); assert.equal((await state()).fingerprint, freshFingerprint, 'the next battle uses the updated simulator'); assert.equal(simRequests, 2, 'the real reload creates a fresh module map'); assert.equal((await check()).status, 'current', 'the refreshed page does not enter a reload loop'); assert.equal(navigations, 2); assert.deepEqual(problems, []); if (process.env.SP_BUILD_SCREENSHOT) await page.screenshot({ path: process.env.SP_BUILD_SCREENSHOT }); } finally { await browser?.close(); await server?.stop(); fs.rmSync(root, { recursive: true, force: true }); } });