feat: update ready keeps showing even after updating

Task #61 completed by ClaudeQueue

ClaudeQueue
This commit is contained in:
Claude Worker
2026-07-01 17:40:50 +00:00
parent 42ff13b135
commit 8018060d26
6 changed files with 237 additions and 15 deletions

130
frontend/sw-update.test.js Normal file
View File

@@ -0,0 +1,130 @@
'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('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');
});