feedback7 G-TOOLS: the boot build tag includes sim, shared and data changes (PR #351 by @siruimei07)

Applied as-is. BUILD_INPUTS now covers server/sim, shared and data as well as the page, so a restart after a simulation-only or data-only change is visible to an already-open page. The tag is still computed once per process. A page still waits until the match, including settlement, is over before it reloads, and a failed check still does not reload.

Tests: node --test test/build.test.js test/ui/build-runtime.browser.test.js — 8 pass, 1 skipped (the Chrome restart test; SP_E2E unset), 0 fail.
This commit is contained in:
siruimei07 authored and sganggs committed 2026-10-08 18:42:46 +08:00
1 parent 063408e47b
commit 8597b66df3
3 files changed
+251 -21

No files matched your search

+5 -4
View File
@@ -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('~');
+49 -17
View File
@@ -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 });
+197
View File
@@ -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 = `<!doctype html><title>Build runtime regression</title><link rel="icon" href="data:,">
<h1>Build runtime regression</h1><p id="state">Loading simulator</p>
<button id="battle">Start battle</button><button id="result">Show settlement</button><button id="lobby">Return to lobby</button>
<script type="module">
import { startBuildGuard } from '/js/ui/buildGuard.js';
import { Net } from '/js/net.js';
import { store, selectRoute, emptyMatch } from '/js/store.js';
import { PHASE } from '/shared/constants.js';
import { loadBrowserSim } from '/js/battle/runner.js';
const boots = Number(sessionStorage.getItem('boots') || 0) + 1;
sessionStorage.setItem('boots', String(boots));
store.patch('session', { entered: true });
const sim = await loadBrowserSim();
let fingerprint = null;
const render = () => {
document.querySelector('#state').textContent = JSON.stringify({ boots, route: selectRoute(store.get()),
phase: store.get().match.public?.phase, stale: store.get().ui.buildStale, fingerprint });
};
store.subscribe(render);
document.querySelector('#battle').onclick = () => {
const spec = sim.spec.buildBattleSpec({ seed: 91, content: 'none' });
const battle = sim.spec.createBattleFromSpec(spec, sim.ds, { quiet: true });
fingerprint = battle.rng();
store.patch('match', { public: { phase: PHASE.COMBAT } });
};
document.querySelector('#result').onclick = () => store.patch('match', { public: { phase: PHASE.RESULT } });
document.querySelector('#lobby').onclick = () => store.set({ match: emptyMatch(), room: null });
const net = new Net();
net.setName('Build test');
const guard = startBuildGuard({
intervalMs: 3600000,
inMatch: () => selectRoute(store.get()) === 'game',
onStale: ({ waiting }) => { if (waiting) store.patch('ui', { buildStale: true }); },
});
window.fixture = { guard, net, state: () => ({ boots, fingerprint, route: selectRoute(store.get()),
stale: store.get().ui.buildStale, known: guard.known(), connection: net.status }) };
render();
</script>`;
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 });
}
});