'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 delete(name) { return store.delete(name); }, 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); }, async match(url) { return m.has(url) ? res(m.get(url)) : undefined; }, }; }, }; } 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, resumed: 0 }); }); test('refreshShellInPlace resumes: files that arrived before a failure are not fetched again', async () => { const cachesApi = fakeCaches({ 'ytplayer-old': { [`${ORIGIN}/`]: 'old-index', [`${ORIGIN}/app.js`]: 'old-app', [`${ORIGIN}/styles.css`]: 'old-css' }, }); let cssUp = false; const fetched = []; const fetchFn = async (url) => { fetched.push(new URL(url).pathname); return url.includes('/styles.css') && !cssUp ? res('gateway timeout', 504) : res('NEW ' + new URL(url).pathname); }; await assert.rejects(refreshShellInPlace({ cachesApi, isShellCache, fetchFn, stagingKey: 'B1' }), /styles\.css/); assert.deepStrictEqual([...cachesApi.store.get('ytplayer-old').values()], ['old-index', 'old-app', 'old-css'], 'live shell untouched'); fetched.length = 0; cssUp = true; const r = await refreshShellInPlace({ cachesApi, isShellCache, fetchFn, stagingKey: 'B1' }); assert.deepStrictEqual(fetched, ['/styles.css'], 'only the missing file is fetched on the retry'); assert.strictEqual(r.resumed, 2); assert.strictEqual(cachesApi.store.get('ytplayer-old').get(`${ORIGIN}/app.js`), 'NEW /app.js'); assert.strictEqual(cachesApi.store.get('ytplayer-old').get(`${ORIGIN}/styles.css`), 'NEW /styles.css'); assert.ok(![...cachesApi.store.keys()].some((k) => k.startsWith('ytp-staging-')), 'staging removed after success'); }); test('refreshShellInPlace never reuses files staged for a different build', async () => { const cachesApi = fakeCaches({ 'ytplayer-old': { [`${ORIGIN}/app.js`]: 'old-app' }, 'ytp-staging-OLDBUILD': { [`${ORIGIN}/app.js`]: 'STALE' }, }); const r = await refreshShellInPlace({ cachesApi, isShellCache, stagingKey: 'NEWBUILD', fetchFn: async () => res('FRESH') }); assert.strictEqual(r.resumed, 0); assert.strictEqual(cachesApi.store.get('ytplayer-old').get(`${ORIGIN}/app.js`), 'FRESH'); assert.ok(!cachesApi.store.has('ytp-staging-OLDBUILD')); }); test('refreshShellInPlace keeps at most `concurrency` downloads in flight', async () => { const files = {}; for (let i = 0; i < 12; i++) files[`${ORIGIN}/f${i}.js`] = 'old'; const cachesApi = fakeCaches({ 'ytplayer-old': files }); let inFlight = 0, peak = 0; await refreshShellInPlace({ cachesApi, isShellCache, concurrency: 4, fetchFn: async () => { inFlight++; peak = Math.max(peak, inFlight); await new Promise((r) => setImmediate(r)); inFlight--; return res('NEW'); }, }); assert.strictEqual(peak, 4); assert.ok([...cachesApi.store.get('ytplayer-old').values()].every((v) => v === 'NEW')); }); 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' }); }); test('playback guard blocks activation and reload unless explicit override is accepted',async()=>{ let messages=[],reloads=0;const reg={waiting:{postMessage:m=>messages.push(m)}}; await assert.rejects(applyUpdate({reg,container:fakeContainer(),reload:()=>reloads++,isPlaying:()=>true,confirmOverride:()=>false}),/Playing/); assert.equal(messages.length,0);assert.equal(reloads,0); let playing=false;const container=fakeContainer();await applyUpdate({reg,container,reload:()=>reloads++,isPlaying:()=>playing,askStatus:async()=>({ready:true}),setTimeout:()=>{}});playing=true;container.fireControllerChange();assert.equal(reloads,0); }); test('explicit playback override clears the worker guard and reloads once',async()=>{ const messages=[];let reloads=0;const container=fakeContainer(); await applyUpdate({reg:{waiting:{postMessage:m=>messages.push(m)}},container,reload:()=>reloads++,isPlaying:()=>true,confirmOverride:()=>true,askStatus:async()=>({ready:true}),setTimeout:()=>{}}); assert.deepStrictEqual(messages,[{type:'PLAYING',value:false},{type:'SKIP_WAITING'}]); container.fireControllerChange();container.fireControllerChange();assert.equal(reloads,1); }); test('an unanswered worker status cannot fall through to an empty legacy refresh',async()=>{ const vm=require('node:vm'),fs=require('node:fs'); const sandbox={AssetSyncCore:{CACHE:'ytplayer-assets'},navigator:{serviceWorker:{getRegistration:async()=>({active:{postMessage(){}}})}}}; vm.runInNewContext(fs.readFileSync(__dirname+'/sw-update.js','utf8'),sandbox); await assert.rejects(sandbox.SwUpdate.refreshShellInPlace({cachesApi:{open:async()=>({}),keys:async()=>[]},fetchFn:async()=>({ok:true,json:async()=>({buildTag:'new'})}),isShellCache:()=>true}),/Worker update status unavailable/); });