From e90e7a30fc9499756e6aee6c2096b1cb291581fd Mon Sep 17 00:00:00 2001 From: shanzhaikabi <31912061+shanzhaikabi@users.noreply.github.com> Date: Sun, 4 Oct 2026 14:19:53 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=EF=BC=9A=E9=83=A8=E7=BD=B2?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E5=88=B0=E8=BE=BE=E5=B7=B2=E6=89=93=E5=BC=80?= =?UTF-8?q?=E7=9A=84=E9=A1=B5=E9=9D=A2=EF=BC=88=E6=96=B0=E5=A2=9E=E6=9E=84?= =?UTF-8?q?=E5=BB=BA=E7=89=88=E6=9C=AC=E6=A0=87=E8=AE=B0=20+=20=E9=99=88?= =?UTF-8?q?=E6=97=A7=E9=A1=B5=E9=9D=A2=E8=87=AA=E5=8A=A8=E9=87=8D=E8=BD=BD?= =?UTF-8?q?=EF=BC=89=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- public/js/main.js | 13 ++++ public/js/store.js | 2 +- public/js/ui/buildGuard.js | 124 +++++++++++++++++++++++++++++++++ public/js/ui/connBanner.js | 27 +++++--- server/index.js | 71 +++++++++++++++++++ test/build.test.js | 82 ++++++++++++++++++++++ test/ui/buildGuard.test.js | 138 +++++++++++++++++++++++++++++++++++++ test/ui/devices.test.js | 3 +- 8 files changed, 450 insertions(+), 10 deletions(-) create mode 100644 public/js/ui/buildGuard.js create mode 100644 test/build.test.js create mode 100644 test/ui/buildGuard.test.js diff --git a/public/js/main.js b/public/js/main.js index 35ea7ac9..283c412e 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -45,6 +45,7 @@ import { GuideHost } from './ui/guide.js'; import { installDeviceSupport } from './ui/device.js'; import { LoadoutHost } from './screens/loadout.js'; import { installLoadoutSync } from './ui/loadoutSync.js'; +import { startBuildGuard } from './ui/buildGuard.js'; const RESTORE_GRACE_MS = 1500; const JOIN_DELAY_MS = 350; @@ -351,6 +352,18 @@ async function boot() { setTimeout(() => splash.remove(), 300); } globalThis.__SP__ = { store, net, data, version: 1 }; + // A page keeps the modules it imported at load time for its whole lifetime, so a deploy cannot reach an open tab + // (ui/buildGuard.js): watch `/healthz.build`. Outside a match the page reloads itself; during a match the guard says + // so instead (the connection banner offers 刷新页面) and reloads once the match — settlement screen included — is over, + // so a running game is never thrown away. + try { + startBuildGuard({ + inMatch: () => selectRoute(store.get()) === 'game', + onStale: ({ waiting }) => { if (waiting) store.patch('ui', { buildStale: true }); }, + }); + } catch (err) { + console.warn('[app] build guard failed to start', err); + } } boot().catch((err) => { diff --git a/public/js/store.js b/public/js/store.js index 8a7f095c..0f914664 100644 --- a/public/js/store.js +++ b/public/js/store.js @@ -82,7 +82,7 @@ export const initialState = Object.freeze({ ticker: [], emotes: [], clock: { offset: 0, rtt: null, synced: false }, - ui: { pendingJoin: null, restoring: false }, + ui: { pendingJoin: null, restoring: false, buildStale: false }, }); /** The app-wide store singleton. */ diff --git a/public/js/ui/buildGuard.js b/public/js/ui/buildGuard.js new file mode 100644 index 00000000..c9ed37ec --- /dev/null +++ b/public/js/ui/buildGuard.js @@ -0,0 +1,124 @@ +// public/js/ui/buildGuard.js — the "this page is running an outdated build" guard. +// +// WHY: every module a page imports lives in its module map for the whole page lifetime, so a deploy can never reach an +// already-open tab — not even a client-only one, which is served `Cache-Control: no-cache` and would be picked up by a +// reload. A client-only battle fix shipped exactly that way and stayed invisible on the reporting player's page: the +// nginx log shows the page loaded the PRE-fix module blob and made no module request at all for the rest of the session. +// +// HOW: the server stamps a short hash of the runtime it serves into `/healthz.build` (server/index.js computeBuildTag). +// The first successful check of a page records that tag as "the build this page runs" — IN MEMORY, because one page is +// one set of loaded modules: nothing must survive a reload, and a duplicated tab must not inherit the first tab's tag. +// A later check reporting a DIFFERENT tag means the server changed under this page: +// * outside a match the page reloads itself, once; +// * during a match the guard never throws the player's game away — `onStale` fires (the connection banner offers +// 刷新页面) and the reload happens by itself as soon as the match, settlement screen included, is over. +// A check that cannot read a build (`/healthz` down or restarting, no `build` field, no answer in time) is 'unknown' and +// NEVER reloads: a restarting server must not turn a playing page into the browser's error page. A new tag is acted on +// only after two checks in a row report the SAME one, so a deploy that is still copying files cannot ping-pong a page. +// +// Kept dependency-free and injectable (fetch / reload / inMatch / timers) so test/ui/buildGuard.test.js can drive it. + +/** How often a page re-asks the server for its build tag. */ +export const BUILD_CHECK_MS = 60_000; +/** Give up on a `/healthz` that does not answer: a request left hanging must not stop the guard from ever checking again. */ +export const BUILD_FETCH_TIMEOUT_MS = 5_000; +/** How many checks in a row must report the same NEW build before the page acts on it. */ +export const BUILD_CONFIRMATIONS = 2; + +/** + * Fetch `/healthz` and return its `build` tag (null when unavailable, not reported, or too slow). Never throws. + * @param {Function} fetchFn @param {{ timeoutMs?: number, setTimeout?: Function, clearTimeout?: Function }} [o] + * @returns {Promise} + */ +export async function fetchBuild(fetchFn, o = {}) { + const timeoutMs = Number.isFinite(o.timeoutMs) && o.timeoutMs > 0 ? o.timeoutMs : BUILD_FETCH_TIMEOUT_MS; + const setT = o.setTimeout || ((fn, ms) => globalThis.setTimeout(fn, ms)); + const clearT = o.clearTimeout || ((h) => globalThis.clearTimeout(h)); + const ctrl = typeof AbortController === 'function' ? new AbortController() : null; + let timer = null; + const tooSlow = new Promise((_, reject) => { + timer = setT(() => { try { ctrl?.abort(); } catch { /* ignore */ } reject(new Error('timeout')); }, timeoutMs); + }); + try { + const res = await Promise.race([fetchFn('/healthz', { cache: 'no-store', signal: ctrl ? ctrl.signal : undefined }), tooSlow]); + if (!res || !res.ok) return null; + const body = await res.json(); + return body && typeof body.build === 'string' && body.build ? body.build : null; + } catch { + return null; + } finally { + if (timer != null) clearT(timer); + } +} + +/** + * One check, against the build this page already knows. + * @param {{ fetchFn?: Function, known?: string|null, timeoutMs?: number, setTimeout?: Function, clearTimeout?: Function }} [o] + * @returns {Promise<{ status: 'first'|'current'|'new'|'unknown', build: string|null }>} + */ +export async function checkBuildOnce(o = {}) { + const fetchFn = o.fetchFn || ((url, init) => globalThis.fetch(url, init)); + const build = await fetchBuild(fetchFn, o); + if (!build) return { status: 'unknown', build: null }; // unreachable / not reported: never a reload + if (!o.known) return { status: 'first', build }; + return { status: o.known === build ? 'current' : 'new', build }; +} + +/** + * Watch for a new build. + * @param {{ fetchFn?: Function, reload?: Function, inMatch?: () => boolean, intervalMs?: number, timeoutMs?: number, + * setInterval?: Function, clearInterval?: Function, setTimeout?: Function, clearTimeout?: Function, + * onStale?: (info: { build: string, known: string|null, waiting: boolean }) => void }} [o] + * @returns {{ stop: () => void, check: () => Promise, stale: () => boolean, known: () => string|null }} + */ +export function startBuildGuard(o = {}) { + const fetchFn = o.fetchFn || ((url, init) => globalThis.fetch(url, init)); + const reload = o.reload || (() => globalThis.location.reload()); + const inMatch = typeof o.inMatch === 'function' ? o.inMatch : () => false; + const setIv = o.setInterval || ((fn, ms) => globalThis.setInterval(fn, ms)); + const clearIv = o.clearInterval || ((h) => globalThis.clearInterval(h)); + const intervalMs = Number.isFinite(o.intervalMs) && o.intervalMs > 0 ? o.intervalMs : BUILD_CHECK_MS; + let known = null; // the build this page runs — memory only (one page == one set of loaded modules) + let candidate = null; // the last new build seen; needs BUILD_CONFIRMATIONS checks in a row to be acted on + let seen = 0; // consecutive checks that reported `candidate` + let stale = false; // a new build is confirmed → reload as soon as no match is on screen + let pending = false; + let timer = null; + let stopped = false; + + const stopTimer = () => { if (timer != null) { clearIv(timer); timer = null; } }; + const reloadNow = () => { stopped = true; stopTimer(); reload(); }; + + const check = async () => { + if (stopped) return { status: 'stopped', build: null }; + if (pending) return { status: 'pending', build: null }; + pending = true; + let r; + try { r = await checkBuildOnce({ fetchFn, known, timeoutMs: o.timeoutMs, setTimeout: o.setTimeout, clearTimeout: o.clearTimeout }); } + finally { pending = false; } + if (stopped) return { status: 'stopped', build: null }; + if (r.status === 'unknown') { candidate = null; seen = 0; return r; } // review: a failed check never reloads + if (r.status === 'first') { known = r.build; return r; } + if (r.status === 'current') { candidate = null; seen = 0; return r; } + // a different build: only BUILD_CONFIRMATIONS checks in a row with the SAME tag count (a deploy that is still + // copying files exposes a tag that keeps moving, and must not ping-pong the page) + if (candidate !== r.build) { candidate = r.build; seen = 1; } else { seen++; } + if (seen < BUILD_CONFIRMATIONS) return { status: 'new', build: r.build }; + const first = !stale; + stale = true; + const waiting = inMatch(); + if (first) { try { o.onStale?.({ build: r.build, known, waiting }); } catch { /* ignore */ } } + if (!waiting) reloadNow(); + return { status: 'stale', build: r.build, known }; + }; + + const tick = () => { check().catch(() => {}); }; + check().catch(() => {}); + timer = setIv(tick, intervalMs); + return { + stop: () => { stopped = true; stopTimer(); }, + check, + stale: () => stale, + known: () => known, + }; +} diff --git a/public/js/ui/connBanner.js b/public/js/ui/connBanner.js index 73373047..dc8708a4 100644 --- a/public/js/ui/connBanner.js +++ b/public/js/ui/connBanner.js @@ -1,8 +1,9 @@ // Connection banner (global chrome, mounted once by main.js): reconnecting / closed / rejected-hello states with -// the matching action, and "正在同步同盟状态…" while a resumed session waits for its room/match state. Outside a -// match it sits at the bottom centre; in a match (html.sp-in-match, set by the match screen) it moves under the top bar -// so it never covers the combat view switcher or the shop bar. While it shows, html.sp-conn moves the toasts below it -// (classes instead of CSS :has(), which Firefox ESR / Safari < 15.4 lack). +// the matching action, "正在同步同盟状态…" while a resumed session waits for its room/match state, and "服务器已更新" +// while a match still runs on a page the server has moved past (ui/buildGuard.js sets ui.buildStale). Outside a +// match the banner sits at the bottom centre; in a match (html.sp-in-match, set by the match screen) it moves under the +// top bar so it never covers the combat view switcher or the shop bar. While it shows, html.sp-conn moves the toasts +// below it (classes instead of CSS :has(), which Firefox ESR / Safari < 15.4 lack). import { html, Button, Icon, useTicker } from './components.js'; import { net, CLIENT_ERR_TEXT } from '../net.js'; @@ -10,9 +11,9 @@ import { useStore, shallowEqual } from '../store.js'; import { useDocClass } from './device.js'; /** Whether the banner shows for this connection state (mirrors the early returns below). */ -export function bannerVisible(conn, entered, restoring) { +export function bannerVisible(conn, entered, restoring, buildStale = false) { if (!entered || !conn) return false; - if (conn.status === 'online') return !!restoring; + if (conn.status === 'online') return !!restoring || !!buildStale; if (!conn.everOnline && (conn.status === 'connecting' || conn.status === 'handshaking' || conn.status === 'idle')) return false; return true; } @@ -21,13 +22,23 @@ export function ConnectionBanner() { const conn = useStore((s) => s.connection, shallowEqual); const entered = useStore((s) => s.session.entered); const restoring = useStore((s) => s.ui.restoring); + const buildStale = useStore((s) => !!s.ui.buildStale); useTicker(conn.status === 'reconnecting' ? 500 : 0); - useDocClass('sp-conn', bannerVisible(conn, entered, restoring)); + useDocClass('sp-conn', bannerVisible(conn, entered, restoring, buildStale)); if (!entered) return null; - if (conn.status === 'online' && !restoring) return null; + if (conn.status === 'online' && !restoring && !buildStale) return null; if (conn.status === 'online' && restoring) { return html`
<${Icon} name="refresh" />正在同步同盟状态…
`; } + if (conn.status === 'online' && buildStale) { + // the server has a newer build than this page: the guard reloads by itself once the match is over, the button is + // for a player who would rather do it now + return html``; + } if (!conn.everOnline && (conn.status === 'connecting' || conn.status === 'handshaking' || conn.status === 'idle')) return null; const secs = conn.retryAt ? Math.max(0, Math.ceil((conn.retryAt - Date.now()) / 1000)) : 0; const replaced = conn.status === 'closed' && conn.lastError?.code === 'REPLACED'; diff --git a/server/index.js b/server/index.js index b3ebdbf7..7a8a8347 100644 --- a/server/index.js +++ b/server/index.js @@ -34,6 +34,7 @@ import fsp from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import zlib from 'node:zlib'; +import { createHash } from 'node:crypto'; import { promisify } from 'node:util'; import { pipeline } from 'node:stream/promises'; import { fileURLToPath } from 'node:url'; @@ -118,6 +119,70 @@ const IMMUTABLE_CACHE = 'public, max-age=31536000, immutable'; const LONG_CACHE_DIRS = ['assets', 'fonts', 'vendor']; // first path segment under public/ const MAX_URL_LENGTH = 4096; +// --------------------------------------------------------------------------------------------------- +// build tag — the "your page is stale" signal (public/js/ui/buildGuard.js) +// --------------------------------------------------------------------------------------------------- + +/** + * The files that make up the runtime the BROWSER loads. A change in any of them is a new build: an already-open page + * keeps the modules it imported at load time (ES modules live in the page's module map for its whole lifetime), so + * 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). + */ +export const BUILD_INPUTS = Object.freeze(['public/index.html', 'public/js', 'public/css']); + +/** 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('~'); + +/** @type {{ tag: string|null }|null} */ +let buildCache = null; + +/** Every file under `abs` (or `abs` itself), as `[relative path, size, mtimeMs]`, sorted by path. Missing → []. */ +function buildEntries(abs, rel, out) { + let stat; + try { stat = fs.statSync(abs); } catch { return; } + if (stat.isFile()) { out.push([rel, stat.size, stat.mtimeMs]); return; } + if (!stat.isDirectory()) return; + let names; + try { names = fs.readdirSync(abs, { withFileTypes: true }); } catch { return; } + for (const d of names.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) { + if (isIgnoredBuildName(d.name)) continue; + const child = path.join(abs, d.name); + const childRel = rel ? `${rel}/${d.name}` : d.name; + if (d.isDirectory()) buildEntries(child, childRel, out); + else if (d.isFile()) { try { const s = fs.statSync(child); out.push([childRel, s.size, s.mtimeMs]); } catch { /* ignore */ } } + } +} + +/** Short hash of the served browser runtime (size + mtime of every BUILD_INPUTS file); null when nothing is readable. */ +export function computeBuildTag(root = ROOT) { + const out = []; + for (const rel of BUILD_INPUTS) buildEntries(path.join(root, rel), rel, out); + if (!out.length) return null; + out.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + const h = createHash('sha1'); + for (const [rel, size, mtime] of out) h.update(`${rel}\0${size}\0${Math.floor(mtime)}\n`); + return h.digest('hex').slice(0, 12); +} + +/** + * The build tag of THIS process. Computed once (`startServer` warms it at startup): the tag describes the files the + * process is actually serving, every update restarts the server (DEPLOY.md), and re-reading the tree on a timer would + * let a half-finished deploy — or a file that changed while the process kept running — move the tag under a page. + * @param {string} [root] used by the first call only (tests) + */ +export function buildTag(root = ROOT) { + if (buildCache === null) buildCache = { tag: computeBuildTag(root) }; + return buildCache.tag; +} + +/** Drop the cache: the next `buildTag()` re-reads the tree (tests, and `startServer`). */ +export function resetBuildTag() { buildCache = null; } + const gzipAsync = promisify(zlib.gzip); const noopLog = { info() {}, warn() {}, error() {}, debug() {} }; @@ -572,6 +637,9 @@ export async function startServer(opts = {}) { const network = new Network({ registry, handler: lobby, log, options: netOptions }); const serveStatic = createStaticHandler({ publicDir, dataDir, sharedDir, log }); const startedAt = Date.now(); + // The tag is per process (see buildTag): read the browser runtime once, here, not on every /healthz. + resetBuildTag(); + buildTag(); const server = http.createServer((req, res) => { res.setHeader('X-Content-Type-Options', 'nosniff'); @@ -595,6 +663,9 @@ export async function startServer(opts = {}) { if (parts.rawPath === '/healthz') { sendJson(req, res, 200, { ok: true, version: PROTOCOL_VERSION, app: APP_VERSION, uptimeSec: Math.round((Date.now() - startedAt) / 1000), + // the runtime the server is serving right now (public/js/ui/buildGuard.js): a page whose own build is + // older than this reloads itself, so a deploy reaches clients that never reload + build: buildTag(), sockets: network.connectionCount, sessions: registry.size, ...lobby.stats(), }); return; diff --git a/test/build.test.js b/test/build.test.js new file mode 100644 index 00000000..2102b3ef --- /dev/null +++ b/test/build.test.js @@ -0,0 +1,82 @@ +// test/build.test.js — the served-runtime build tag (server/index.js computeBuildTag / buildTag): the signal that lets +// an already-open page notice a deploy (public/js/ui/buildGuard.js; /healthz `build`). +import { test } from 'node:test'; +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'; + +/** A throwaway root with the browser runtime layout (one file per BUILD_INPUTS entry). */ +function fixture() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'sp-build-')); + for (const rel of BUILD_INPUTS) { + 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'); + } + } + 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']); +}); + +test('computeBuildTag: stable for one tree, different when a runtime file changes (size or mtime)', () => { + const root = fixture(); + try { + const a = computeBuildTag(root); + assert.match(a, /^[0-9a-f]{12}$/); + assert.equal(computeBuildTag(root), a, 'same tree → same tag'); + // a changed file (new size) is a new build + fs.writeFileSync(path.join(root, 'public/js/file.js'), 'yy'); + 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 st = fs.statSync(target); + fs.utimesSync(target, st.atime, new Date(st.mtimeMs + 5000)); + assert.notEqual(computeBuildTag(root), b); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('computeBuildTag: dot files and editor backups are ignored (the static server never serves them)', () => { + 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'); + 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 }); + } +}); + +test('computeBuildTag: nothing readable → null (an unknown build never reloads a page)', () => { + assert.equal(computeBuildTag(path.join(os.tmpdir(), 'sp-does-not-exist-xyz')), null); +}); + +test('buildTag: computed once per process — a file that changes afterwards does not move it until the next process', () => { + const root = fixture(); + try { + resetBuildTag(); + const first = buildTag(root); + assert.match(String(first), /^[0-9a-f]{12}$/); + fs.writeFileSync(path.join(root, 'public/js/file.js'), 'changed while the process ran'); + assert.equal(buildTag(root), first, 'the tag describes what this process is serving, not whatever appears on disk'); + resetBuildTag(); // a restart recomputes it + assert.notEqual(buildTag(root), first); + } finally { + resetBuildTag(); + fs.rmSync(root, { recursive: true, force: true }); + } +}); diff --git a/test/ui/buildGuard.test.js b/test/ui/buildGuard.test.js new file mode 100644 index 00000000..7d6c180b --- /dev/null +++ b/test/ui/buildGuard.test.js @@ -0,0 +1,138 @@ +// test/ui/buildGuard.test.js — public/js/ui/buildGuard.js: the "this page runs an outdated build" guard that lets a +// deploy reach a tab that was already open (a page keeps its imported ES modules for its whole lifetime — the reason a +// client-only battle fix stayed invisible on the reporting player's page). +// +// Review-driven contract: a check that cannot read a build NEVER reloads, a new build is acted on only after two checks +// in a row agree, and a match on screen is never thrown away — the guard waits for it (and its settlement) to end. +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { checkBuildOnce, startBuildGuard, fetchBuild, BUILD_CHECK_MS, BUILD_FETCH_TIMEOUT_MS } from '../../public/js/ui/buildGuard.js'; + +/** A fake fetch answering /healthz with `build` (null → the frame carries none). */ +const fetchOf = (build) => async (url, init) => { + assert.equal(url, '/healthz'); + assert.equal(init.cache, 'no-store'); + return { ok: true, status: 200, json: async () => ({ ok: true, build }) }; +}; + +test('fetchBuild: the tag, or null when unreachable / not reported / too slow', async () => { + assert.equal(await fetchBuild(fetchOf('aaa')), 'aaa'); + assert.equal(await fetchBuild(async () => { throw new Error('offline'); }), null); + assert.equal(await fetchBuild(async () => ({ ok: false, status: 503, json: async () => ({}) })), null); + assert.equal(await fetchBuild(fetchOf(null)), null); + // a request that never answers must not keep the guard waiting forever + const t0 = Date.now(); + assert.equal(await fetchBuild(() => new Promise(() => {}), { timeoutMs: 20 }), null); + assert.ok(Date.now() - t0 < BUILD_FETCH_TIMEOUT_MS, 'gives up on its own timeout'); +}); + +test('checkBuildOnce: first sighting, unchanged, changed, and unknown when the server says nothing', async () => { + assert.deepEqual(await checkBuildOnce({ fetchFn: fetchOf('aaa') }), { status: 'first', build: 'aaa' }); + assert.deepEqual(await checkBuildOnce({ fetchFn: fetchOf('aaa'), known: 'aaa' }), { status: 'current', build: 'aaa' }); + assert.deepEqual(await checkBuildOnce({ fetchFn: fetchOf('bbb'), known: 'aaa' }), { status: 'new', build: 'bbb' }); + assert.deepEqual(await checkBuildOnce({ fetchFn: async () => { throw new Error('offline'); }, known: 'aaa' }), { status: 'unknown', build: null }); +}); + +/** A guard driven by hand: run the interval callback on demand. */ +function manual({ inMatch = false } = {}) { + const ticks = []; + const calls = { reload: 0, stale: [] }; + let build = 'aaa'; + let reachable = true; + let busy = inMatch; + const guard = startBuildGuard({ + fetchFn: async () => { + if (!reachable) throw new Error('offline'); + return { ok: true, status: 200, json: async () => ({ build }) }; + }, + reload: () => { calls.reload++; }, + inMatch: () => busy, + setInterval: (fn) => { ticks.push(fn); return ticks.length; }, + clearInterval: () => {}, + onStale: (info) => calls.stale.push(info), + }); + return { + calls, ticks, guard, + setBuild: (b) => { build = b; }, + setReachable: (b) => { reachable = b; }, + setInMatch: (b) => { busy = b; }, + /** run the interval callback and let its await settle */ + tick: async () => { for (const fn of ticks) fn(); await new Promise((r) => setTimeout(r, 0)); }, + }; +} + +test('startBuildGuard: records this page\'s build in memory, reloads once on a NEW build (after two agreeing checks)', async () => { + const m = manual(); + await new Promise((r) => setTimeout(r, 0)); // the immediate first check + assert.equal(m.guard.known(), 'aaa'); + assert.equal(m.calls.reload, 0); + await m.tick(); + assert.equal(m.calls.reload, 0, 'same build → no reload'); + m.setBuild('bbb'); + await m.tick(); + assert.equal(m.calls.reload, 0, 'one sighting is not enough: a half-copied deploy must not ping-pong the page'); + assert.equal(m.guard.stale(), false); + await m.tick(); + assert.equal(m.calls.reload, 1, 'the same new build twice → the page reloads'); + assert.deepEqual(m.calls.stale, [{ build: 'bbb', known: 'aaa', waiting: false }]); + await m.tick(); + assert.equal(m.calls.reload, 1, 'the guard stopped after reloading'); + assert.equal(m.guard.stale(), true); +}); + +test('startBuildGuard: a NEW build that is then not served twice is dropped (rolling deploy / reverts)', async () => { + const m = manual(); + await new Promise((r) => setTimeout(r, 0)); + m.setBuild('bbb'); + await m.tick(); + m.setBuild('aaa'); // the fleet rolled back to this page's build + await m.tick(); + m.setBuild('bbb'); + await m.tick(); + assert.equal(m.calls.reload, 0, 'never saw the same new build twice in a row'); +}); + +test('startBuildGuard: an unreadable /healthz never reloads — not even after a new build was seen', async () => { + const m = manual(); + await new Promise((r) => setTimeout(r, 0)); + m.setBuild('bbb'); + await m.tick(); // one sighting of the new build + m.setReachable(false); // …and now the server is restarting + await m.tick(); + await m.tick(); + assert.equal(m.calls.reload, 0, 'a failed check is unknown, never a reason to reload'); + assert.equal(m.guard.stale(), false); + m.setReachable(true); + await m.tick(); + assert.equal(m.calls.reload, 0, 'the sightings must be consecutive: the failures in between reset the count'); + await m.tick(); + assert.equal(m.calls.reload, 1, 'two checks in a row agree → reload'); +}); + +test('startBuildGuard: a match on screen is never thrown away — it waits for the match (and its settlement) to end', async () => { + const m = manual({ inMatch: true }); + await new Promise((r) => setTimeout(r, 0)); + m.setBuild('bbb'); + await m.tick(); + await m.tick(); + assert.equal(m.calls.reload, 0, 'in a match: the reload waits'); + assert.equal(m.guard.stale(), true, 'the staleness is remembered'); + assert.deepEqual(m.calls.stale, [{ build: 'bbb', known: 'aaa', waiting: true }], 'the UI is told to offer a reload'); + await m.tick(); + assert.equal(m.calls.reload, 0); + m.setInMatch(false); // back in the lobby / room: the match is over + await m.tick(); + assert.equal(m.calls.reload, 1, 'once nothing is at stake the page reloads'); +}); + +test('startBuildGuard: stop() ends the watch', async () => { + const m = manual(); + await new Promise((r) => setTimeout(r, 0)); + m.guard.stop(); + m.setBuild('bbb'); + await m.tick(); + await m.tick(); + assert.equal(m.calls.reload, 0); + assert.equal(m.guard.stale(), false); + assert.equal(BUILD_CHECK_MS, 60_000); +}); diff --git a/test/ui/devices.test.js b/test/ui/devices.test.js index 306e2727..d4ecdd31 100644 --- a/test/ui/devices.test.js +++ b/test/ui/devices.test.js @@ -272,7 +272,8 @@ describe('connection banner class (replaces CSS :has())', () => { test('bannerVisible mirrors the banner', () => { assert.equal(bannerVisible({ status: 'online' }, true, false), false); assert.equal(bannerVisible({ status: 'online' }, true, true), true, 'syncing after a resume'); - assert.equal(bannerVisible({ status: 'reconnecting', everOnline: true }, true, false), true); + assert.equal(bannerVisible({ status: 'online' }, true, false, true), true, 'a stale build during a match'); + assert.equal(bannerVisible({ status: 'reconnecting', everOnline: true }, true, false, true), true); assert.equal(bannerVisible({ status: 'connecting', everOnline: false }, true, false), false, 'first connect is silent'); assert.equal(bannerVisible({ status: 'closed' }, false, false), false, 'not before the title'); });