'use strict'; /** * Unit tests for sw-update.js (Task #61). * * 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. * * These tests drive applyUpdate() directly against mocked registration / * container / reload objects — no real service worker or browser needed. */ const { test } = require('node:test'); const assert = require('node:assert'); const { applyUpdate } = require('./sw-update'); // 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); } } }, }; } 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(); }, }; const done = applyUpdate({ reg, container, reload: () => { reloadCount++; }, setTimeout: () => {}, // no-op — we drive state transitions manually }); // 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); }); test('with no waiting worker and no update found, reloads once after the bounded wait', async () => { const container = fakeContainer(); let reloadCount = 0; const timeouts = []; const reg = { waiting: null, installing: null, update: () => Promise.resolve(), // update check ran; nothing 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'); }); 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++; }, }); assert.strictEqual(reloadCount, 1); }); test('the timeout safety net reloads once if controllerchange never fires', async () => { const waiting = { postMessage: () => {} }; const container = fakeContainer(); let reloadCount = 0; let scheduledFn = null; await applyUpdate({ reg: { waiting }, container, reload: () => { reloadCount++; }, setTimeout: (fn) => { scheduledFn = fn; }, // capture instead of real timer }); 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. container.fireControllerChange(); assert.strictEqual(reloadCount, 1, 'no double reload once the timeout fallback has fired'); });