修复:部署无法到达已打开的页面(新增构建版本标记 + 陈旧页面自动重载) (#39)
This commit is contained in:
1 parent
9d404199df
commit
e90e7a30fc
8 files changed
+450
-10
No files matched your search
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user