diff --git a/server/http/buildTag.js b/server/http/buildTag.js index ebff88c0..ec693893 100644 --- a/server/http/buildTag.js +++ b/server/http/buildTag.js @@ -15,11 +15,12 @@ import { ROOT } from './config.js'; * without this signal a deployed fix could never reach a player who does not reload — a client-only battle fix * shipped exactly that way and stayed invisible on a page that had been opened before the deploy. * - * `server/`, `data/` and `shared/` are deliberately NOT in here: this process read them once at startup, so when they - * change without a restart the server still runs the old simulation and data — a page that reloaded into the new files - * would be out of step with the server that validates its battles (and DEPLOY.md restarts the server for every update). + * The browser also imports `/sim/` from `server/sim/` and `/shared/`, and caches JSON from `/data/`. Include those + * trees so a simulation-only or data-only deploy reaches old pages after the server restarts. `buildTag()` caches + * this fingerprint for the process lifetime: files changing on disk do not advertise a new build before restart. + * The rest of `server/` and downloaded media are not included. */ -export const BUILD_INPUTS = Object.freeze(['public/index.html', 'public/js', 'public/css']); +export const BUILD_INPUTS = Object.freeze(['public/index.html', 'public/js', 'public/css', 'server/sim', 'shared', 'data']); /** Names the static server never serves: dot files (`.DS_Store`, `.main.js.swp`) and editor backups (`main.js~`). */ const isIgnoredBuildName = (name) => name.startsWith('.') || name.endsWith('~'); diff --git a/test/build.test.js b/test/build.test.js index a3979c2f..44d1a82b 100644 --- a/test/build.test.js +++ b/test/build.test.js @@ -6,26 +6,56 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { computeBuildTag, buildTag, resetBuildTag, BUILD_INPUTS } from '../server/index.js'; +import { computeBuildTag, buildTag, resetBuildTag } from '../server/index.js'; +import { checkBuildOnce } from '../public/js/ui/buildGuard.js'; -/** A throwaway root with the browser runtime layout (one file per BUILD_INPUTS entry). */ +/** A throwaway browser runtime, independent of the build tag's input list. */ function fixture() { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-build-')); - for (const rel of BUILD_INPUTS) { + for (const rel of [ + 'public/index.html', 'public/js/file.js', 'public/css/file.css', + 'server/sim/content/kits/skill.js', 'shared/protocol.js', 'data/chess.json', + ]) { const abs = path.join(root, rel); - if (path.extname(rel)) { - fs.mkdirSync(path.dirname(abs), { recursive: true }); - fs.writeFileSync(abs, 'x'); - } else { - fs.mkdirSync(abs, { recursive: true }); - fs.writeFileSync(path.join(abs, 'file.js'), 'x'); - } + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, 'x'); } return root; } -test('BUILD_INPUTS: only the runtime the browser loads (server/, data/ and shared/ are not part of it)', () => { - assert.deepEqual([...BUILD_INPUTS], ['public/index.html', 'public/js', 'public/css']); +for (const rel of ['server/sim/content/kits/skill.js', 'shared/protocol.js', 'data/chess.json']) { + test(`buildTag: an update to ${rel} is visible to an old page after restart only`, async () => { + const root = fixture(); + try { + resetBuildTag(); + const known = buildTag(root); + const fetchFn = async () => ({ ok: true, json: async () => ({ build: buildTag(root) }) }); + fs.writeFileSync(path.join(root, rel), 'updated runtime'); + assert.notEqual(computeBuildTag(root), known, 'the updated file changes the startup fingerprint'); + assert.equal(buildTag(root), known, 'the running process keeps its startup tag'); + assert.equal((await checkBuildOnce({ known, fetchFn })).status, 'current'); + resetBuildTag(); // The next process computes its tag from the deployed files. + assert.equal((await checkBuildOnce({ known, fetchFn })).status, 'new'); + } finally { + resetBuildTag(); + fs.rmSync(root, { recursive: true, force: true }); + } + }); +} + +test('computeBuildTag: server-only code and downloaded media do not change the browser build', () => { + const root = fixture(); + try { + const known = computeBuildTag(root); + for (const rel of ['server/index.js', 'server/http/routes.js', 'server/data.js', 'public/assets/sprite.png']) { + const abs = path.join(root, rel); + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, 'changed'); + } + assert.equal(computeBuildTag(root), known); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } }); test('computeBuildTag: stable for one tree, different when a runtime file changes (size or mtime)', () => { @@ -39,7 +69,7 @@ test('computeBuildTag: stable for one tree, different when a runtime file change const b = computeBuildTag(root); assert.notEqual(b, a, 'a changed runtime file is a new build'); // …and so is a rewrite with the same size but a new mtime (a deploy of identical bytes keeps its timestamp) - const target = path.join(root, 'public/css/file.js'); + const target = path.join(root, 'public/css/file.css'); const st = fs.statSync(target); fs.utimesSync(target, st.atime, new Date(st.mtimeMs + 5000)); assert.notEqual(computeBuildTag(root), b); @@ -52,10 +82,12 @@ test('computeBuildTag: dot files and editor backups are ignored (the static serv const root = fixture(); try { const a = computeBuildTag(root); - fs.writeFileSync(path.join(root, 'public/js/.DS_Store'), 'x'); - fs.writeFileSync(path.join(root, 'public/css/main.css~'), 'x'); - fs.mkdirSync(path.join(root, 'public/js/.cache'), { recursive: true }); - fs.writeFileSync(path.join(root, 'public/js/.cache/leftover.js'), 'x'); + for (const rel of ['public/js', 'server/sim', 'shared', 'data']) { + fs.writeFileSync(path.join(root, rel, '.DS_Store'), 'x'); + fs.writeFileSync(path.join(root, rel, 'file.js~'), 'x'); + fs.mkdirSync(path.join(root, rel, '.cache'), { recursive: true }); + fs.writeFileSync(path.join(root, rel, '.cache/leftover.js'), 'x'); + } assert.equal(computeBuildTag(root), a, 'a .DS_Store / backup / dot directory in the tree is not a new build'); } finally { fs.rmSync(root, { recursive: true, force: true }); diff --git a/test/ui/build-runtime.browser.test.js b/test/ui/build-runtime.browser.test.js new file mode 100644 index 00000000..ec038991 --- /dev/null +++ b/test/ui/build-runtime.browser.test.js @@ -0,0 +1,197 @@ +// 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 }); + } +});