Show the update banner only when the running build differs from the server and make Refresh UI land the new shell on flaky links

This commit is contained in:
Jonathan Sykes
2026-09-13 17:42:50 +08:00
parent 5fb863ba25
commit 91dab289c3
8 changed files with 526 additions and 266 deletions

View File

@@ -1,22 +1,24 @@
'use strict';
/**
* Unit tests for sw-update.js (Task #61).
* Unit tests for sw-update.js — the "Update available keeps showing after
* Refresh UI" loop.
*
* Bug: "Update ready / Reload now" reappeared immediately after the user
* clicked "Reload now". Root cause — the old flow unregistered the service
* worker and wiped every cache before navigating, forcing a brand-new
* install on the next load; that fresh install's registration briefly has
* `reg.waiting` truthy again (a normal but transient SW lifecycle state),
* which was misread as a new pending update and re-showed the banner.
* On the real deployment (slow homelab link that drops requests) the new
* service worker's all-or-nothing install kept failing, so "Refresh UI"
* reloaded into the old cached shell and the banner came back. The fix
* refreshes the cached shell in place (all-or-nothing, cache-busted) before
* activating any waiting worker and reloading once.
*
* These tests drive applyUpdate() directly against mocked registration /
* container / reload objects — no real service worker or browser needed.
* No real service worker or browser needed: fake CacheStorage / fetch /
* registration / container objects drive the functions directly.
*/
const { test } = require('node:test');
const assert = require('node:assert');
const { applyUpdate } = require('./sw-update');
const { applyUpdate, refreshShellInPlace, BUST_PARAM } = require('./sw-update');
const ORIGIN = 'https://worship.example';
// A minimal fake ServiceWorkerContainer supporting addEventListener/once.
function fakeContainer() {
@@ -29,172 +31,183 @@ function fakeContainer() {
const fns = (listeners.controllerchange || []).slice();
for (const { fn, once } of fns) {
fn();
if (once) {
listeners.controllerchange = listeners.controllerchange.filter((l) => l.fn !== fn);
}
if (once) listeners.controllerchange = listeners.controllerchange.filter((l) => l.fn !== fn);
}
},
};
}
test('messages the waiting worker to skipWaiting and reloads only after controllerchange', async () => {
const messages = [];
const waiting = { postMessage: (m) => messages.push(m) };
const container = fakeContainer();
let reloadCount = 0;
const done = applyUpdate({
reg: { waiting },
container,
reload: () => { reloadCount++; },
setTimeout: () => {}, // no-op — we drive controllerchange manually
});
// SKIP_WAITING should be sent immediately, before any reload.
await Promise.resolve();
assert.deepStrictEqual(messages, [{ type: 'SKIP_WAITING' }]);
assert.strictEqual(reloadCount, 0, 'must not reload before the new SW has taken control');
container.fireControllerChange();
await done;
assert.strictEqual(reloadCount, 1, 'reloads exactly once after controllerchange');
});
test('reloads only once even if controllerchange fires more than once (no reload loop)', async () => {
const waiting = { postMessage: () => {} };
const container = fakeContainer();
let reloadCount = 0;
const done = applyUpdate({
reg: { waiting },
container,
reload: () => { reloadCount++; },
setTimeout: () => {},
});
container.fireControllerChange();
container.fireControllerChange(); // simulate a spurious second event
await done;
assert.strictEqual(reloadCount, 1, 'reload must be idempotent — no loop');
});
test('falls back to a plain reload when there is no waiting worker', async () => {
const container = fakeContainer();
let reloadCount = 0;
await applyUpdate({
reg: { waiting: null },
container,
reload: () => { reloadCount++; },
setTimeout: () => { throw new Error('timeout should not be scheduled without a waiting worker'); },
});
assert.strictEqual(reloadCount, 1);
});
test('with no waiting worker, fetches the SW update and activates the newly installed worker (buildTag-poll path)', async () => {
// Simulates: server redeployed (banner shown by the /api/version poll) but
// the browser hasn't fetched the new sw.js yet — reg.waiting is null until
// reg.update() is called and the new worker finishes installing.
const messages = [];
const container = fakeContainer();
let reloadCount = 0;
const stateListeners = [];
const installing = {
state: 'installing',
addEventListener: (type, fn) => { if (type === 'statechange') stateListeners.push(fn); },
postMessage: (m) => messages.push(m),
};
const reg = {
waiting: null,
installing: null,
update() {
// Browser found a byte-different sw.js → a new worker starts installing.
this.installing = installing;
return Promise.resolve();
// Fake CacheStorage: { name: { url: body } }.
function fakeCaches(initial) {
const store = new Map(Object.entries(initial).map(([k, v]) => [k, new Map(Object.entries(v))]));
return {
store,
async keys() { return [...store.keys()]; },
async open(name) {
if (!store.has(name)) store.set(name, new Map());
const m = store.get(name);
return {
async keys() { return [...m.keys()].map((url) => ({ url })); },
async put(url, res) { m.set(url, res.body); },
async delete(req) { return m.delete(req.url); },
};
},
};
}
const done = applyUpdate({
reg,
container,
reload: () => { reloadCount++; },
setTimeout: () => {}, // no-op — we drive state transitions manually
const res = (body, status = 200) => ({ ok: status >= 200 && status < 300, status, body, clone() { return res(body, status); } });
const isShellCache = (n) => /^ytplayer-(?!thumbs$|fonts$)/.test(n);
test('refreshShellInPlace replaces every cached shell file in every shell cache, cache-busted', async () => {
const cachesApi = fakeCaches({
'ytplayer-old': { [`${ORIGIN}/`]: 'old-index', [`${ORIGIN}/app.js`]: 'old-app' },
'ytplayer-new': { [`${ORIGIN}/app.js`]: 'old-app', [`${ORIGIN}/video-edit.js`]: 'old-edit' },
'ytplayer-thumbs': { [`${ORIGIN}/thumb.jpg`]: 'thumb' },
});
// Let applyUpdate reach the waitForInstalled stage, then finish the install.
await Promise.resolve(); await Promise.resolve();
installing.state = 'installed';
reg.waiting = installing;
stateListeners.forEach((fn) => fn());
await Promise.resolve(); await Promise.resolve();
assert.deepStrictEqual(messages, [{ type: 'SKIP_WAITING' }], 'skip-waiting sent to the freshly installed worker');
assert.strictEqual(reloadCount, 0, 'must not reload before the new SW takes control');
container.fireControllerChange();
await done;
assert.strictEqual(reloadCount, 1);
const fetched = [];
const r = await refreshShellInPlace({
cachesApi,
isShellCache,
bust: 'T1',
fetchFn: async (url, init) => {
fetched.push({ url, init });
return res('NEW ' + new URL(url).pathname);
},
});
assert.strictEqual(r.refreshed, 3);
// Busted so an old worker's cache-first handler can't answer from its cache.
for (const f of fetched) {
assert.strictEqual(new URL(f.url).searchParams.get(BUST_PARAM), 'T1-0');
assert.strictEqual(f.init.cache, 'reload');
}
const old = cachesApi.store.get('ytplayer-old');
assert.strictEqual(old.get(`${ORIGIN}/`), 'NEW /');
assert.strictEqual(old.get(`${ORIGIN}/app.js`), 'NEW /app.js');
assert.strictEqual(old.get(`${ORIGIN}/video-edit.js`), 'NEW /video-edit.js');
assert.strictEqual(cachesApi.store.get('ytplayer-new').get(`${ORIGIN}/`), 'NEW /');
assert.strictEqual(cachesApi.store.get('ytplayer-thumbs').get(`${ORIGIN}/thumb.jpg`), 'thumb', 'utility caches untouched');
});
test('with no waiting worker and no update found, reloads once after the bounded wait', async () => {
const container = fakeContainer();
let reloadCount = 0;
const timeouts = [];
test('refreshShellInPlace is all-or-nothing: one failed download writes nothing and throws', async () => {
const cachesApi = fakeCaches({
'ytplayer-old': { [`${ORIGIN}/`]: 'old-index', [`${ORIGIN}/app.js`]: 'old-app', [`${ORIGIN}/styles.css`]: 'old-css' },
});
await assert.rejects(refreshShellInPlace({
cachesApi,
isShellCache,
fetchFn: async (url) => (url.includes('/styles.css') ? res('gateway timeout', 504) : res('NEW')),
}), /styles\.css/, 'fails after its retries are used up');
const old = cachesApi.store.get('ytplayer-old');
assert.deepStrictEqual([...old.values()], ['old-index', 'old-app', 'old-css'], 'no partial shell');
});
const reg = {
waiting: null,
installing: null,
update: () => Promise.resolve(), // update check ran; nothing new
test('refreshShellInPlace retries a dropped download with a fresh cache-bust token', async () => {
const cachesApi = fakeCaches({ 'ytplayer-old': { [`${ORIGIN}/app.js`]: 'old-app', [`${ORIGIN}/`]: 'old-index' } });
const tries = [];
await refreshShellInPlace({
cachesApi, isShellCache, bust: 'T3',
fetchFn: async (url) => {
tries.push(url);
if (url.includes('/app.js') && tries.filter((t) => t.includes('/app.js')).length === 1) throw new TypeError('network error');
return res('NEW');
},
});
const appTries = tries.filter((t) => t.includes('/app.js')).map((t) => new URL(t).searchParams.get(BUST_PARAM));
assert.deepStrictEqual(appTries, ['T3-0', 'T3-1']);
assert.strictEqual(cachesApi.store.get('ytplayer-old').get(`${ORIGIN}/app.js`), 'NEW');
});
test('refreshShellInPlace drops busted URLs an old cache-first worker stored on the way through', async () => {
const cachesApi = fakeCaches({ 'ytplayer-old': { [`${ORIGIN}/app.js`]: 'old-app' } });
const fetchFn = async (url) => {
// Simulate the old worker's cacheFirst caching the busted request.
(await cachesApi.open('ytplayer-old')).put(url, res('NEW'));
return res('NEW');
};
const done = applyUpdate({
reg,
container,
reload: () => { reloadCount++; },
setTimeout: (fn) => { timeouts.push(fn); },
});
await Promise.resolve(); await Promise.resolve();
// reg.installing is null → waitForInstalled resolves immediately with null.
await done;
assert.strictEqual(reloadCount, 1, 'plain reload when the update check finds nothing');
await refreshShellInPlace({ cachesApi, isShellCache, fetchFn, bust: 'T2' });
assert.deepStrictEqual([...cachesApi.store.get('ytplayer-old').keys()], [`${ORIGIN}/app.js`]);
});
test('falls back to a plain reload when there is no registration at all', async () => {
const container = fakeContainer();
let reloadCount = 0;
await applyUpdate({
reg: null,
container,
reload: () => { reloadCount++; },
test('refreshShellInPlace times out instead of hanging on a stalled request', async () => {
const cachesApi = fakeCaches({ 'ytplayer-old': { [`${ORIGIN}/app.js`]: 'old-app' } });
let fire = null;
const p = refreshShellInPlace({
cachesApi, isShellCache,
fetchFn: () => new Promise(() => {}), // never settles
timeoutMs: 1000,
setTimeout: (fn, ms) => { if (ms === 1000) fire = fn; },
});
await new Promise((r) => setImmediate(r));
fire();
await assert.rejects(p, /timed out/);
assert.strictEqual(cachesApi.store.get('ytplayer-old').get(`${ORIGIN}/app.js`), 'old-app');
});
assert.strictEqual(reloadCount, 1);
test('refreshShellInPlace with no shell caches (uncontrolled page) is a no-op', async () => {
const r = await refreshShellInPlace({ cachesApi: fakeCaches({}), isShellCache, fetchFn: async () => { throw new Error('no fetch expected'); } });
assert.deepStrictEqual(r, { refreshed: 0, caches: 0 });
});
test('applyUpdate refreshes first, then activates the waiting worker, then reloads once on controllerchange', async () => {
const order = [];
const waiting = { postMessage: (m) => order.push('msg:' + m.type) };
const container = fakeContainer();
let reloads = 0;
await applyUpdate({
reg: { waiting },
container,
reload: () => { reloads++; order.push('reload'); },
refreshShell: async () => { order.push('refresh'); },
setTimeout: () => {},
});
assert.deepStrictEqual(order, ['refresh', 'msg:SKIP_WAITING']);
assert.strictEqual(reloads, 0, 'must not reload before the new worker has taken control');
container.fireControllerChange();
container.fireControllerChange();
assert.strictEqual(reloads, 1, 'reloads exactly once');
});
test('applyUpdate with no waiting worker reloads right after the refresh', async () => {
const order = [];
await applyUpdate({
reg: { waiting: null },
container: fakeContainer(),
reload: () => order.push('reload'),
refreshShell: async () => { order.push('refresh'); },
});
assert.deepStrictEqual(order, ['refresh', 'reload']);
});
test('applyUpdate does NOT reload or activate anything when the refresh fails', async () => {
const messages = [];
let reloads = 0;
await assert.rejects(applyUpdate({
reg: { waiting: { postMessage: (m) => messages.push(m) } },
container: fakeContainer(),
reload: () => { reloads++; },
refreshShell: async () => { throw new Error('/app.js → 504'); },
}), /504/);
assert.strictEqual(reloads, 0, 'reloading now would just serve the old shell again (the loop)');
assert.deepStrictEqual(messages, []);
});
test('the timeout safety net reloads once if controllerchange never fires', async () => {
const waiting = { postMessage: () => {} };
let fire = null;
let reloads = 0;
const container = fakeContainer();
let reloadCount = 0;
let scheduledFn = null;
await applyUpdate({
reg: { waiting },
reg: { waiting: { postMessage() {} } },
container,
reload: () => { reloadCount++; },
setTimeout: (fn) => { scheduledFn = fn; }, // capture instead of real timer
reload: () => { reloads++; },
setTimeout: (fn) => { fire = fn; },
});
assert.strictEqual(reloadCount, 0, 'not reloaded yet — timeout not fired');
scheduledFn(); // simulate the timeout elapsing
assert.strictEqual(reloadCount, 1);
// A late controllerchange after the timeout already reloaded must not
// trigger a second reload.
fire();
container.fireControllerChange();
assert.strictEqual(reloadCount, 1, 'no double reload once the timeout fallback has fired');
assert.strictEqual(reloads, 1);
});
test('applyUpdate with no registration at all still reloads', async () => {
let reloads = 0;
await applyUpdate({ reg: null, container: fakeContainer(), reload: () => { reloads++; } });
assert.strictEqual(reloads, 1);
});