299 lines
12 KiB
JavaScript
299 lines
12 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Unit tests for sw-update.js — the "Update available keeps showing after
|
|
* Refresh UI" loop.
|
|
*
|
|
* 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.
|
|
*
|
|
* 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, refreshShellInPlace, askCacheStatus, BUST_PARAM } = require('./sw-update');
|
|
|
|
// Minimal MessageChannel stand-in: port2 is handed to the "worker", which
|
|
// delivers a reply back through port1.onmessage.
|
|
function FakeChannel() {
|
|
const p1 = { onmessage: null };
|
|
const p2 = { __deliver: (data) => { if (p1.onmessage) p1.onmessage({ data }); } };
|
|
this.port1 = p1;
|
|
this.port2 = p2;
|
|
}
|
|
|
|
const ORIGIN = 'https://worship.example';
|
|
|
|
// A minimal fake ServiceWorkerContainer supporting addEventListener/once.
|
|
function fakeContainer() {
|
|
const listeners = {};
|
|
return {
|
|
addEventListener(type, fn, opts) {
|
|
(listeners[type] = listeners[type] || []).push({ fn, once: !!(opts && opts.once) });
|
|
},
|
|
fireControllerChange() {
|
|
const fns = (listeners.controllerchange || []).slice();
|
|
for (const { fn, once } of fns) {
|
|
fn();
|
|
if (once) listeners.controllerchange = listeners.controllerchange.filter((l) => l.fn !== fn);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
// 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 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' },
|
|
});
|
|
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('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');
|
|
});
|
|
|
|
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');
|
|
};
|
|
await refreshShellInPlace({ cachesApi, isShellCache, fetchFn, bust: 'T2' });
|
|
assert.deepStrictEqual([...cachesApi.store.get('ytplayer-old').keys()], [`${ORIGIN}/app.js`]);
|
|
});
|
|
|
|
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');
|
|
});
|
|
|
|
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 () => {
|
|
let fire = null;
|
|
let reloads = 0;
|
|
const container = fakeContainer();
|
|
await applyUpdate({
|
|
reg: { waiting: { postMessage() {} } },
|
|
container,
|
|
reload: () => { reloads++; },
|
|
setTimeout: (fn) => { fire = fn; },
|
|
});
|
|
fire();
|
|
container.fireControllerChange();
|
|
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);
|
|
});
|
|
|
|
// ---- background download + instant swap ------------------------------------
|
|
|
|
test('applyUpdate skips the download when the waiting worker already precached the build', async () => {
|
|
const order = [];
|
|
const container = fakeContainer();
|
|
await applyUpdate({
|
|
reg: { waiting: { postMessage: (m) => order.push('msg:' + m.type) } },
|
|
container,
|
|
reload: () => order.push('reload'),
|
|
refreshShell: async () => { order.push('refresh'); },
|
|
askStatus: async () => ({ ready: true, version: 'new' }),
|
|
onPhase: (p) => order.push('phase:' + p),
|
|
setTimeout: () => {},
|
|
});
|
|
assert.deepStrictEqual(order, ['phase:ready', 'msg:SKIP_WAITING'],
|
|
'the whole point: no re-download when the shell is already on disk');
|
|
container.fireControllerChange();
|
|
assert.ok(order.includes('reload'));
|
|
});
|
|
|
|
test('applyUpdate still downloads when the waiting worker reports an incomplete cache', async () => {
|
|
const order = [];
|
|
await applyUpdate({
|
|
reg: { waiting: { postMessage: (m) => order.push('msg:' + m.type) } },
|
|
container: fakeContainer(),
|
|
reload: () => order.push('reload'),
|
|
refreshShell: async () => { order.push('refresh'); },
|
|
askStatus: async () => ({ ready: false, missing: 3 }),
|
|
onPhase: (p) => order.push('phase:' + p),
|
|
setTimeout: () => {},
|
|
});
|
|
assert.deepStrictEqual(order, ['phase:downloading', 'refresh', 'msg:SKIP_WAITING']);
|
|
});
|
|
|
|
test('a worker that never answers falls back to downloading, it does not hang', async () => {
|
|
const order = [];
|
|
await applyUpdate({
|
|
reg: { waiting: { postMessage: () => {} } },
|
|
container: fakeContainer(),
|
|
reload: () => order.push('reload'),
|
|
refreshShell: async () => { order.push('refresh'); },
|
|
askStatus: async () => null, // no reply / timed out
|
|
setTimeout: () => {},
|
|
});
|
|
assert.deepStrictEqual(order, ['refresh']);
|
|
});
|
|
|
|
test('a failing status probe is not fatal — the download path still runs', async () => {
|
|
const order = [];
|
|
await applyUpdate({
|
|
reg: { waiting: { postMessage: () => {} } },
|
|
container: fakeContainer(),
|
|
reload: () => order.push('reload'),
|
|
refreshShell: async () => { order.push('refresh'); },
|
|
askStatus: async () => { throw new Error('port closed'); },
|
|
setTimeout: () => {},
|
|
});
|
|
assert.deepStrictEqual(order, ['refresh']);
|
|
});
|
|
|
|
test('askCacheStatus resolves null when the worker never replies', async () => {
|
|
let fire = null;
|
|
const p = askCacheStatus(
|
|
{ postMessage() {} },
|
|
{ timeoutMs: 10, setTimeout: (fn) => { fire = fn; }, Channel: FakeChannel },
|
|
);
|
|
fire();
|
|
assert.strictEqual(await p, null);
|
|
});
|
|
|
|
test('askCacheStatus returns the worker reply', async () => {
|
|
const worker = { postMessage(_msg, ports) { ports[0].__deliver({ ready: true, version: 'abc' }); } };
|
|
const got = await askCacheStatus(worker, { setTimeout: () => {}, Channel: FakeChannel });
|
|
assert.deepStrictEqual(got, { ready: true, version: 'abc' });
|
|
});
|