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:
1 parent
063408e47b
commit
8597b66df3
3 files changed
+251
-21
No files matched your search
@@ -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
@@ -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 });
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user