Files
ark-stronghold-protocol/test/ui/buildGuard.test.js
T

139 lines
6.5 KiB
JavaScript

// 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);
});