Verify explicit activation and legacy incremental migration

This commit is contained in:
Jonathan Sykes
2026-10-08 00:03:14 +08:00
parent bb446c717f
commit 5d1e5ec791
7 changed files with 96 additions and 16 deletions

View File

@@ -9,4 +9,4 @@ function environment(){
return {storage,m,skips,dispatch,request};
}
test('install does not activate or publish; CACHE_STATUS is honest; activation commits and exact requests self-heal',async()=>{const e=environment();await e.dispatch('install');assert.equal(e.skips.length,0);const cache=await e.storage.open(core.CACHE);assert.equal(await core.state(cache),null);let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,true);await e.dispatch('activate');assert.equal((await core.state(cache)).current.buildTag,'next');assert.equal((await e.request('/playlist/x','navigate')).headers.get('X-Asset-Hash'),'index');await cache.delete('/app.js?v=app');await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,false);assert.equal(reply.missing,1);assert.equal(reply.version,'next');assert.equal((await e.request('/app.js')).headers.get('X-Asset-Hash'),'app');assert.ok(await cache.match('/app.js?v=app'));assert.equal(await cache.match('/app.js?v=stale'),undefined);assert.equal((await e.request('/app.js?v=stale')).status,409);});
test('reported playback defers explicit activation until pause; install never calls skipWaiting',async()=>{const e=environment(),source={id:'p'};await e.dispatch('message',{source,data:{type:'PLAYING',value:true}});await e.dispatch('message',{source,data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:false}});assert.equal(e.skips.length,1);});
test('reported playback defers explicit activation until pause; install never calls skipWaiting',async()=>{const e=environment(),source={id:'p'};await e.dispatch('message',{data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:true}});await e.dispatch('message',{source,data:{type:'SKIP_WAITING'}});assert.equal(e.skips.length,0);await e.dispatch('message',{source,data:{type:'PLAYING',value:false}});assert.equal(e.skips.length,1);});

View File

@@ -1,7 +1,7 @@
/* ============================================================================
* sw-update — makes "Refresh UI" actually land the new build.
*
* Framework-free and dependency-free on purpose (same pattern as
* Framework-free, with a shared sync core and a legacy fallback (same pattern as
* async-guard.js):
* • Loads as a plain <script> under CSP `script-src 'self'` (browser
* global `window.SwUpdate`).
@@ -17,7 +17,7 @@
* already current (a failed install leaves a partial cache that the
* activate handler then mistook for a previous deploy).
*
* The fix no longer depends on the service-worker install lifecycle:
* Legacy fallback (retained for ASSET_SYNC=0):
* 1. refreshShellInPlace() downloads a fresh copy of every file the shell
* caches hold (cache-busted, so even an old worker's cache-first
* handler can't answer with the stale copy) — ALL of them or nothing —
@@ -25,6 +25,10 @@
* serves the next load, it serves the new build.
* 2. Only then is a waiting worker (if any) activated, and the page
* reloaded once.
* Incremental mode uses AssetSyncCore to fetch only missing verified hash URLs
* and publishes a complete blocking set. Playback guards cover activation and
* reload callbacks; PLAYING reports provide an additional worker guard.
*
* The banner itself only opens when the build the page is running differs
* from the server's (see app.js maybeShowUpdateBanner), so no lifecycle
* event can re-open it once the page is current.

View File

@@ -356,3 +356,9 @@ test('playback guard blocks activation and reload unless explicit override is ac
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);
});

View File

@@ -2,7 +2,7 @@
* sw.js — YT Player Service Worker
*
* Strategy:
* App shell (HTML/CSS/JS) → cache-first, versioned cache
* App shell (HTML/CSS/JS) → cache-first, exact per-file hash keys
* /api/* requests → network-only (never cache yt-dlp results)
* /api/download/* → network-only (streamed binary, never cache)
* YouTube thumbnails (i.ytimg.com) → stale-while-revalidate
@@ -14,9 +14,9 @@
* 2. The client (app.js) shows the "Update available" banner only when the
* build it is running differs from the server's /api/version buildTag —
* a waiting worker or a broadcast is merely a prompt to re-check.
* 3. "Refresh UI" (sw-update.js) re-downloads every shell file into every
* versioned shell cache (all-or-nothing), activates a waiting worker if
* there is one (SKIP_WAITING), and reloads once.
* 3. Install syncs missing assets into ytplayer-assets. "Refresh UI" activates
* the verified waiting worker (SKIP_WAITING) and reloads once. The legacy
* full-shell path remains available through ASSET_SYNC=0.
* 4. activate: if an older *versioned shell cache* is found (i.e. this
* activation is genuinely replacing a previous deploy, not just the
* first-ever install of a freshly (re)registered worker), broadcast
@@ -292,7 +292,7 @@ self.addEventListener('message', (e) => {
playingClients.set(e.source.id, !!e.data.value);
if (!e.data.value && deferredClients.delete(e.source.id)) self.skipWaiting();
}
if (e.data && e.data.type === 'SKIP_WAITING') {
if (e.data && e.data.type === 'SKIP_WAITING' && e.source && e.source.id) {
const id = e.source && e.source.id;
if (playingClients.get(id)) deferredClients.add(id);
else self.skipWaiting();