201 lines
6.4 KiB
JavaScript
201 lines
6.4 KiB
JavaScript
'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');
|
|
});
|