diff --git a/frontend/asset-worker.test.js b/frontend/asset-worker.test.js index 7efcb6f..77186ee 100644 --- a/frontend/asset-worker.test.js +++ b/frontend/asset-worker.test.js @@ -1,14 +1,14 @@ const {test}=require('node:test');const assert=require('node:assert/strict');const vm=require('node:vm');const fs=require('node:fs');const core=require('./asset-sync-core'); function environment(){ - const stores=new Map(),listeners={},messages=[],skips=[],fetches=[];const m={buildTag:'next',files:{'/index.html':{h:'index'},'/app.js':{h:'app'}},groups:{core:{files:['/index.html','/app.js']}}}; + const stores=new Map(),listeners={},messages=[],skips=[],fetches=[],selfMessages=[];const m={buildTag:'next',files:{'/index.html':{h:'index'},'/app.js':{h:'app'}},groups:{core:{files:['/index.html','/app.js']}}}; const storage={async keys(){return [...stores.keys()]},async delete(n){return stores.delete(n)},async open(n){if(!stores.has(n)){const map=new Map();stores.set(n,{async match(k){return map.get(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)?.clone()},async put(k,r){map.set(k,r.clone())},async keys(){return [...map.keys()].map(k=>({url:'https://local'+k}))},async delete(k){return map.delete(typeof k==='string'?k:new URL(k.url).pathname+new URL(k.url).search)}})}return stores.get(n)}}; - const sandbox={__BUILD_TAG__:'next',__ASSET_SYNC__:true,AssetSyncCore:core,importScripts:()=>{},caches:storage,URL,Response,Request,Headers,setTimeout,clearTimeout,crypto:require('node:crypto').webcrypto,console,fetch:async u=>{fetches.push(u);return u==='/api/manifest'?Response.json(m):new Response(u,{headers:{'X-Asset-Hash':m.files[u.split('?')[0]]?.h || ''}})},self:{location:{origin:'https://local'},addEventListener:(t,f)=>listeners[t]=f,skipWaiting:()=>skips.push(1),clients:{claim:async()=>{},matchAll:async()=>[{id:'old',postMessage:x=>messages.push(x)}]}}}; + const sandbox={__BUILD_TAG__:'next',__ASSET_SYNC__:true,AssetSyncCore:core,importScripts:()=>{},caches:storage,URL,Response,Request,Headers,setTimeout,clearTimeout,crypto:require('node:crypto').webcrypto,console,fetch:async u=>{fetches.push(u);return u==='/api/manifest'?Response.json(m):new Response(u,{headers:{'X-Asset-Hash':m.files[u.split('?')[0]]?.h || ''}})},self:{registration:{active:{postMessage:data=>selfMessages.push(data)}},location:{origin:'https://local'},addEventListener:(t,f)=>listeners[t]=f,skipWaiting:()=>skips.push(1),clients:{claim:async()=>{},matchAll:async()=>[{id:'old',postMessage:x=>messages.push(x)}]}}}; vm.runInNewContext(fs.readFileSync(__dirname+'/sw.js','utf8'),sandbox); async function dispatch(t,e={}){const tasks=[];listeners[t]({...e,waitUntil:v=>tasks.push(v)});await Promise.all(tasks)} - async function request(url,mode='cors',clientId='new'){let p;listeners.fetch({request:{url:'https://local'+url,method:'GET',mode},clientId,waitUntil:()=>{},respondWith:v=>p=v});return p} - return {storage,m,skips,fetches,dispatch,request}; + async function request(url,mode='cors',clientId='new'){let p;const tasks=[];listeners.fetch({request:{url:'https://local'+url,method:'GET',mode},clientId,waitUntil:v=>tasks.push(v),respondWith:v=>p=v});const response=await p;await Promise.all(tasks);return response;} + return {storage,m,skips,fetches,dispatch,request,sandbox,selfMessages,async complete(){for(const data of selfMessages.splice(0))await dispatch('message',{data});}}; } -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');assert.equal((await core.status(e.m,cache)).ready,false);await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal((await core.status(e.m,cache)).offlineReady,true);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('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');await e.complete();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');assert.equal((await core.status(e.m,cache)).ready,false);await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal((await core.status(e.m,cache)).offlineReady,true);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',{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);}); test('staged same-contract responses use verified N-1 without poisoning the new URL',async()=>{ @@ -16,7 +16,7 @@ test('staged same-contract responses use verified N-1 without poisoning the new const old={buildTag:'old',files:{'/extra.js':{h:'old'}},groups:{core:{files:[],contract:1},'feature:extra':{files:['/extra.js'],contract:1,background:true}}}; e.m.groups.core.contract=1; e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true}; e.m.files['/extra.js']={h:'new'}; await cache.put(core.STATE,Response.json({current:old})); await cache.put('/extra.js?v=old',new Response('old body',{headers:{'X-Asset-Hash':'old'}})); - await e.dispatch('install'); await e.dispatch('activate'); + await e.dispatch('install'); await e.dispatch('activate');await e.complete(); assert.ok(await cache.match('/extra.js?v=new'));await cache.delete('/extra.js?v=new'); assert.equal(await cache.match('/extra.js?v=new'),undefined); const stale=await e.request('/extra.js?v=new'); assert.equal(await stale.text(),'old body');assert.equal(stale.headers.get('X-Asset-Hash'),'old');assert.equal(await cache.match('/extra.js?v=new'),undefined); @@ -33,13 +33,26 @@ test('a changed background contract is downloaded before ready and cannot fall b await e.dispatch('install');assert.equal((await core.state(cache)).current.buildTag,'old'); assert.equal((await cache.match('/extra.js?v=new')).headers.get('X-Asset-Hash'),'new'); let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]});assert.equal(reply.ready,true); - await e.dispatch('activate');await cache.delete('/extra.js?v=new'); + await e.dispatch('activate');await e.complete();await cache.delete('/extra.js?v=new'); const response=await e.request('/extra.js?v=new');assert.equal(response.headers.get('X-Asset-Hash'),'new');assert.notEqual(await response.text(),'old body'); }); test('overlapping idle warm messages share one download pool',async()=>{ const e=environment();e.m.files['/extra.js']={h:'extra'};e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true}; - await e.dispatch('install');await e.dispatch('activate'); + await e.dispatch('install');await e.dispatch('activate');await e.complete(); await Promise.all([e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}}),e.dispatch('message',{data:{type:'WARM_ASSETS',saveData:false}})]); assert.equal(e.fetches.filter(url=>url==='/extra.js?v=extra').length,1); }); + +test('waiting status does not start completion and activation delegates it to its own event',async()=>{ + const e=environment();e.m.files['/extra.js']={h:'extra'};e.m.groups['feature:extra']={files:['/extra.js'],contract:1,background:true}; + await e.dispatch('install');let reply;await e.dispatch('message',{data:{type:'CACHE_STATUS'},ports:[{postMessage:r=>reply=r}]}); + assert.equal(reply.ready,true);assert.equal(reply.offlineReady,false);assert.ok(!e.fetches.includes('/extra.js?v=extra')); + let release,started;const gate=new Promise(r=>release=r),begin=new Promise(r=>started=r),fetch=e.sandbox.fetch; + e.sandbox.fetch=async url=>{if(url==='/extra.js?v=extra'){started();await gate;}return fetch(url);}; + await e.dispatch('activate');const cache=await e.storage.open(core.CACHE); + assert.equal((await core.state(cache)).current.buildTag,'next');assert.equal((await core.status(e.m,cache)).ready,true); + assert.equal((await core.status(e.m,cache)).offlineReady,false);assert.equal(e.selfMessages[0].type,'COMPLETE_ASSETS'); + const job=e.complete();await begin;assert.equal(await cache.match('/extra.js?v=extra'),undefined);release();await job; + assert.equal((await core.status(e.m,cache)).offlineReady,true); +}); diff --git a/frontend/sw.js b/frontend/sw.js index 64a8461..4b85abd 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -308,7 +308,7 @@ function completeAssets(force = false) { if (!ASSET_SYNC) return Promise.resolve(); completionTask ||= (async () => { const cache = await caches.open(AssetSyncCore.CACHE); - const m = await candidate(cache) || (await AssetSyncCore.state(cache))?.current; + const m = (await AssetSyncCore.state(cache))?.current; if (!m || m.buildTag !== VERSION) return; const job = await AssetSyncCore.complete(m, { cache, fetchFn: fetch, force, notify: async status => { @@ -573,7 +573,9 @@ async function activateAssets() { for (const req of await cache.keys()) if(new URL(req.url).pathname.startsWith('/__ytp_candidate/')) await cache.delete(req); await self.clients.claim(); if(old && old.current.buildTag !== m.buildTag) for(const c of await self.clients.matchAll({type:'window',includeUncontrolled:true})) c.postMessage({type:'SW_UPDATE_AVAILABLE',version:VERSION}); - await completeAssets(true); + // Completion owns a message-event lifetime, not the activation barrier. + // In particular, a legacy client's four-second reload must not wait for it. + self.registration.active.postMessage({ type: 'COMPLETE_ASSETS' }); try { console.info('[asset-sync] storage', await self.navigator.storage.estimate()); } catch {} } async function assetFetch(request, clientId) { diff --git a/perf/migration.mjs b/perf/migration.mjs index 06d4942..9ff197e 100644 --- a/perf/migration.mjs +++ b/perf/migration.mjs @@ -12,6 +12,7 @@ import { chromium, webkit } from 'playwright'; const repo = resolve(fileURLToPath(new URL('..', import.meta.url))); const commit = 'b77938a', oldTag = '1175f1a1d2c1'; const args = process.argv.slice(2); +const targetFrontend = args.includes('--frontend-source') ? resolve(args[args.indexOf('--frontend-source')+1]) : join(repo,'frontend'); const profile = args.includes('--profile') ? args[args.indexOf('--profile')+1] : 'lossy'; const browserName = args.includes('--browser') ? args[args.indexOf('--browser') + 1] : 'all'; assert.ok(['all', 'chromium', 'webkit'].includes(browserName)); @@ -55,7 +56,7 @@ async function run(name) { }); } async function start(old) { - symlinkSync(old ? join(repo, `perf/fixtures/shell-${oldTag}`) : join(repo, 'frontend'), join(cwd, 'public')); + symlinkSync(old ? join(repo, `perf/fixtures/shell-${oldTag}`) : targetFrontend, join(cwd, 'public')); output = ''; child = spawn('bun', [old ? join(scratch, 'server/server.js') : join(repo, 'server/server.js')], { cwd, env: { ...process.env, BUILD_TAG: '', ASSET_HASHING: old ? '0' : '1', PORT: String(port), @@ -114,7 +115,7 @@ async function run(name) { const response = await fetch(`${backend}${path}${query}`); assert.equal(response.status, 200); assert.equal(response.headers.get('x-asset-hash'), manifest.files[path].h); - assert.deepEqual(Buffer.from(await response.arrayBuffer()), readFileSync(join(repo, 'frontend', path.slice(1)))); + assert.deepEqual(Buffer.from(await response.arrayBuffer()), readFileSync(join(targetFrontend, path.slice(1)))); if (query.includes('?v=')) assert.equal(response.headers.get('cache-control'), 'no-store'); } } @@ -150,6 +151,8 @@ async function run(name) { await oldTab.goto(origin,{waitUntil:'load'}); assert.equal(await oldTab.locator('meta[name="ytp-build"]').getAttribute('content'),oldTag); const oldBytes = await oldTab.evaluate(async()=>await (await fetch('/app.js')).text()); + // Bring the updating page forward after checking the retained old tab. + await page.bringToFront(); console.log(name,'clicking legacy refresh'); await page.locator('#modal').getByRole('button', { name: 'Refresh UI', exact: true }).click({noWaitAfter:true,timeout:15000}); console.log(name,'legacy refresh clicked');