Verify worker recovery and retained offline feature fallback

This commit is contained in:
Jonathan Sykes
2026-10-09 09:50:31 +08:00
parent 009a155568
commit 6b8d590638
3 changed files with 53 additions and 17 deletions

View File

@@ -21,16 +21,18 @@ async function run(engine){
const result={engine,legacy,failures:[],events:[],layouts:[],conditions:[]};results.push(result);
try{
if(!await poll(async()=>{try{return(await fetch(origin+'/api/version')).ok;}catch{return false;}}))throw Error(log);
proxy=createThrottleProxy({targetPort:port,profile:'unthrottled'});origin='http://127.0.0.1:'+await proxy.listen();
proxy=createThrottleProxy({targetPort:port,profile:'unthrottled'});proxy.startTracking();origin='http://127.0.0.1:'+await proxy.listen();
const manifest=await(await fetch(origin+'/api/manifest')).json();result.build=manifest.buildTag;
const type={chromium,webkit}[engine];context=await type.launchPersistentContext(join(cwd,'profile'),{headless:true});browser=context.browser();
const type={chromium,webkit}[engine];
const openContext=async()=>{if(engine==='webkit'){browser ||= await type.launch();return browser.newContext();}return type.launchPersistentContext(join(cwd,'profile'),{headless:true});};
context=await openContext();
const configure=async()=>{await context.route('**/*',r=>r.continue());return context.addInitScript(()=>{Object.defineProperty(navigator,'onLine',{configurable:true,get:()=>localStorage.getItem('perf-offline')!=='1'});Object.defineProperty(navigator,'connection',{configurable:true,value:{get saveData(){return localStorage.getItem('perf-save-data')==='1';}}});});};
await configure();if(args.includes('--save-data'))await context.addInitScript(()=>localStorage.setItem('perf-save-data','1'));
let offline=false;
const attach=page=>{
page.on('response',r=>{if(offline&&r.status()>=400&&!new URL(r.url()).pathname.startsWith('/api/'))result.failures.push({kind:'http',url:r.url(),status:r.status()});});
page.on('requestfailed',r=>{if(offline)result.events.push({kind:'requestfailed',url:r.url(),error:r.failure()?.errorText});});
page.on('console',m=>{if(offline&&m.type()==='error')result.events.push({kind:'console',message:m.text()});});
page.on('console',m=>{if(!offline&&['warning','error'].includes(m.type()))console.log(engine,'install console:',m.text());if(offline&&m.type()==='error')result.events.push({kind:'console',message:m.text()});});
page.on('pageerror',e=>{if(offline)result.events.push({kind:'pageerror',message:e.message,stack:e.stack});});
};
let page=await context.newPage();attach(page);
@@ -40,9 +42,9 @@ async function run(engine){
const missing=[];for(const [path,file]of Object.entries(manifest.files)){const r=await cache.match(legacy?path:path+'?v='+file.h);if(!r||(!legacy&&r.headers.get('X-Asset-Hash')!==file.h))missing.push(path);}
return{total:Object.keys(manifest.files).length,missing};
},{manifest,legacy});
const started=Date.now();await boot();await page.evaluate(()=>navigator.serviceWorker.ready);await page.evaluate(()=>window.Lazy?.warm());
const started=Date.now();console.log(engine,'install boot');await boot();console.log(engine,'waiting for worker activation');if(!await poll(()=>page.evaluate(async()=>!!(await navigator.serviceWorker.getRegistration())?.active),30000))throw Error('Worker did not activate: '+JSON.stringify(await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();return {active:r?.active?.state,waiting:r?.waiting?.state,installing:r?.installing?.state,caches:await caches.keys(),keys:(await(await caches.open('ytplayer-assets')).keys()).map(r=>new URL(r.url).pathname)};})));await page.evaluate(()=>navigator.serviceWorker.ready);console.log(engine,'worker active');await page.evaluate(()=>window.Lazy?.warm());
await poll(async()=>!(await inventory()).missing.length,Number(option('--warm-timeout','15000')));
result.installMs=Date.now()-started;result.inventory=await inventory();result.installWire=proxy.getStats();result.saveData=await page.evaluate(()=>navigator.connection?.saveData);
console.log(engine,'inventory complete');result.installMs=Date.now()-started;result.inventory=await inventory();result.installWire=proxy.getStats();result.saveData=await page.evaluate(()=>navigator.connection?.saveData);
if(result.inventory.missing.length)result.failures.push({kind:'incomplete',paths:result.inventory.missing});
// Offline navigation always starts from a controlled document.
await page.reload();await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('[aria-label="Car mode"]'));
@@ -56,7 +58,7 @@ async function run(engine){
await attempt('all lazy groups',()=>page.evaluate(async()=>{if(window.Lazy)for(const [name,group] of Object.entries(Lazy.manifest.groups))if(group.background)await Lazy.load(name);}));
for(const section of ['piano','midi','floating-lyrics','vocal-reducer','equalizer','setlist-import','broadcast','party-dj','direct-transfer'])await attempt(section,async()=>{await page.evaluate(async()=>{view={type:'settings'};await renderSettings();});await page.waitForSelector('[data-section="'+section+'"]',{timeout:5000,state:'attached'});await page.evaluate(id=>document.querySelector('[data-section="'+id+'"]').click(),section);await page.waitForTimeout(100);});
await attempt('presenter',async()=>{await page.evaluate(async()=>{history.replaceState(null,'','/?present=');await Presenter.boot();});await page.waitForSelector('#presenterView:not(.hidden)',{timeout:5000});await page.locator('#pvCode').fill('x');await page.locator('#pvPairBtn').click();if(!/6-digit/.test(await page.locator('#pvMsg').textContent()))throw Error('Presenter validation did not run');await page.evaluate(()=>{document.querySelector('#presenterView').classList.add('hidden');document.body.classList.remove('presenter-open');history.replaceState(null,'','/');});});
await attempt('video editor',async()=>{await page.evaluate(()=>openVideoEditor({id:'upl_0123456789ab',title:'Offline edit',duration:120}));await page.waitForSelector('.video-editor',{timeout:5000});await page.locator('.ve-from').fill('0:10');await page.locator('.ve-to').fill('0:20');await page.locator('.ve-add').click();if(!/10/.test(await page.locator('.ve-cuts').textContent()))throw Error('Cut was not added');await page.evaluate(()=>closeModal());});
await attempt('video editor',async()=>{await page.evaluate(()=>openVideoEditor({id:'upl_0123456789ab',title:'Offline edit',duration:120}));if(!await page.evaluate(()=>window.OPFS?.isSupported())){if(!/Editing needs offline storage/.test(await page.locator('#toastContainer').textContent()))throw Error('Missing unsupported-storage explanation');result.unsupportedFeatures ||= [];if(!result.unsupportedFeatures.includes('video editor: OPFS unavailable in Linux WebKit'))result.unsupportedFeatures.push('video editor: OPFS unavailable in Linux WebKit');return;}await page.waitForSelector('.video-editor',{timeout:5000});await page.locator('.ve-from').fill('0:10');await page.locator('.ve-to').fill('0:20');await page.locator('.ve-add').click();if(!/10/.test(await page.locator('.ve-cuts').textContent()))throw Error('Cut was not added');await page.evaluate(()=>closeModal());});
await attempt('lyrics editor',async()=>{await page.evaluate(async()=>{data.profile={name:'offline-test'};await Notes.load({id:'abcdefghijk',title:'Offline lyrics'});document.querySelector('#lyricsEditBtn').click();});await page.waitForSelector('.lyr-editor-text',{timeout:5000});await page.locator('.lyr-editor-text').fill('[0:10] Offline lyric');await page.evaluate(()=>closeModal());});
await attempt('car',async()=>{await page.evaluate(()=>document.querySelector('[aria-label="Car mode"]').click());await page.waitForSelector('.car-mode',{timeout:5000});await page.getByRole('button',{name:'Exit Car mode'}).click();});
await attempt('remote',async()=>{await page.evaluate(()=>document.querySelector('#navRemoteBtn').click());await page.waitForSelector('#modal:not(.hidden)',{timeout:5000});await page.evaluate(()=>closeModal());});
@@ -81,18 +83,36 @@ async function run(engine){
const interrupted=await inventory();
if(!interrupted.missing.length)throw Error('Interruption did not leave missing assets');
if(engine==='chromium'){const cdp=await context.newCDPSession(page);await cdp.send('ServiceWorker.enable');await cdp.send('ServiceWorker.stopAllWorkers');await cdp.detach();}
await page.close();await context.close();
await proxy.setOffline(false);
context=await type.launchPersistentContext(join(cwd,'profile'),{headless:true});browser=context.browser();await configure();
page=await context.newPage();attach(page);await boot();
if(engine==='chromium'){
await page.close();await context.close();await proxy.setOffline(false);
context=await openContext();await configure();page=await context.newPage();attach(page);await boot();
}else{
// Linux WebKit persistent contexts do not share page/worker CacheStorage.
// Exercise a real worker re-registration in the same browser context.
await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();await r.unregister();});
await page.close();await proxy.setOffline(false);page=await context.newPage();attach(page);await boot();
}
const other=await context.newPage();await other.goto(origin);await other.waitForSelector('#carModeBtn');
const complete=await poll(async()=>!(await inventory()).missing.length);
result.conditions.push({name:'interrupted job, page closed, browser restarted with same profile, Save-Data, two tabs',interruptedMissing:interrupted.missing.length,complete});
if(!complete)result.failures.push({kind:'resume',inventory:await inventory()});
result.conditions.push({name:engine==='webkit'?'page closed, worker re-registration, Save-Data, two tabs':'interrupted job, page closed, browser restarted with same profile, Save-Data, two tabs',interruptedMissing:interrupted.missing.length,storageMode:engine==='webkit'?'same-context worker re-registration; Linux WebKit persistent CacheStorage is broken':'persistent disk profile',complete});
if(!complete)result.failures.push({kind:'resume',inventory:await inventory(),status:await page.evaluate(async()=>({registration:!!(await navigator.serviceWorker.getRegistration())?.active}))});
// Evict one optional file and require the online event alone to restart work.
const path='/piano-engine.mjs';await page.evaluate(async({path,manifest})=>{await(await caches.open('ytplayer-assets')).delete(path+'?v='+manifest.files[path].h);localStorage.removeItem('perf-save-data');window.dispatchEvent(new Event('online'));},{path,manifest});
const onlineResume=await poll(async()=>!(await inventory()).missing.length);result.conditions.push({name:'online event, Save-Data off',complete:onlineResume});if(!onlineResume)result.failures.push({kind:'online-resume'});
await other.close();
if(engine==='chromium'){
await sleep(100);await proxy.setOffline(true);
await context.route('**/admin.html?offline-inspector=1',route=>route.fulfill({contentType:'text/html',body:'<!doctype html><title>Cache inspector</title>'}));
const inspector=await context.newPage();await inspector.goto(origin+'/admin.html?offline-inspector=1');const cdp=await context.newCDPSession(inspector);
const {caches:names}=await cdp.send('CacheStorage.requestCacheNames',{securityOrigin:origin});
const cacheId=names.find(c=>c.cacheName==='ytplayer-assets').cacheId;
await page.evaluate(async manifest=>{const c=await caches.open('ytplayer-assets');for(const path of Object.keys(manifest.files).filter(p=>manifest.files[p].g!=='core'))await c.delete(path+'?v='+manifest.files[path].h);navigator.serviceWorker.controller.postMessage({type:'COMPLETE_ASSETS'});},manifest);
await poll(()=>page.evaluate(async()=>{const c=await caches.open('ytplayer-assets'),s=await(await c.match('/__ytp_asset_state')).json(),r=await c.match('/__ytp_completion/'+s.current.buildTag);return r&&(await r.json()).running;}),5000);
await page.close();await other.close();await proxy.setOffline(false);
const withoutPage=await poll(async()=>{const {cacheDataEntries:entries}=await cdp.send('CacheStorage.requestEntries',{cacheId,skipCount:0,pageSize:1000});return Object.entries(manifest.files).every(([path,file])=>entries.some(e=>e.requestURL===origin+path+'?v='+file.h&&e.responseHeaders.some(h=>h.name.toLowerCase()==='x-asset-hash'&&h.value===file.h)));});
result.conditions.push({name:'worker completes with every application page closed; passive same-origin inspector only',complete:withoutPage});
if(!withoutPage)result.failures.push({kind:'completion-without-page'});
await cdp.detach();await inspector.close();
}else await other.close();
}
console.log(engine,JSON.stringify({missing:result.inventory.missing,failures:result.failures,conditions:result.conditions}));
}catch(e){result.failures.push({kind:'harness',error:e.stack});console.log(engine,e.message);}finally{await context?.close();await browser?.close();await proxy?.close();child.kill('SIGTERM');if(child.exitCode===null)await new Promise(r=>child.once('exit',r));}