diff --git a/perf/lazy.mjs b/perf/lazy.mjs index b1afee7..4d42e3f 100644 --- a/perf/lazy.mjs +++ b/perf/lazy.mjs @@ -108,8 +108,12 @@ async function run(name){ await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.waiting.postMessage({type:'SKIP_WAITING'});}); await wait(page,async tag=>{const c=await caches.open('ytplayer-assets');return(await(await c.match('/__ytp_asset_state')).json()).current.buildTag===tag;},target.buildTag); await page.reload();await page.waitForSelector('#carModeBtn');assert.equal(await page.evaluate(()=>Lazy.manifest.buildTag),target.buildTag); - // No warm: the new page gets a same-contract N-1 file immediately from disk. + // Completion now runs even with Save-Data. Evict a real completed entry to + // exercise the same-contract N-1 fallback, rather than suppressing the job. + await wait(page,async()=>{const r=await navigator.serviceWorker.getRegistration();return (await SwUpdate.askCacheStatus(r.active))?.offlineReady;}); + disconnected=true;await proxy.setOffline(true);await stop(); const cacheKey='/video-edit.js?v='+target.files['/video-edit.js'].h; + await page.evaluate(async key=>{localStorage.setItem('perf-offline','1');await(await caches.open('ytplayer-assets')).delete(key);},cacheKey); assert.equal(await page.evaluate(async key=>!!await(await caches.open('ytplayer-assets')).match(key),cacheKey),false); const stale=await page.evaluate(async key=>{const r=await fetch(key);return {h:r.headers.get('X-Asset-Hash'),body:await r.text()};},cacheKey); assert.equal(stale.h,previous.files['/video-edit.js'].h);assert.ok(!stale.body.includes('// phase3 new file')); @@ -121,6 +125,7 @@ async function run(name){ assert.ok(oldRequests.some(u=>u.includes('/car-mode.js?v='+previous.files['/car-mode.js'].h))); await old.evaluate(()=>Lazy.load('feature:setlist-import'));assert.equal(await old.evaluate(()=>window.SetlistImport===window.__pinned),true); // Warm new files explicitly; compatible, unexecuted old-page group selects new cached URL. + await start();disconnected=false;await proxy.setOffline(false);await page.evaluate(()=>localStorage.removeItem('perf-offline')); await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();r.active.postMessage({type:'WARM_ASSETS',saveData:false});}); await wait(page,async key=>!!await(await caches.open('ytplayer-assets')).match(key),cacheKey); await old.evaluate(()=>Lazy.load('feature:video-edit'));assert.ok(oldRequests.some(u=>u.includes(cacheKey))); @@ -130,6 +135,6 @@ async function run(name){ if(offlineTransportErrors.length)assert.ok(failedRequests.some(url=>/\/api\/(?:recommendations|version)/.test(url))); results.push({browser:name,sourceBuildTag:previous.buildTag,classicSharedDefaults:true,cssOrderPreserved:true,settingsSearch:true,carControls:4,offlineFeature:true,firstUseNetwork:0,layoutsOffline:['glass-stage','bento-hub','f7-swipe','classic'],pinned:true,compatibleUnexecutedNewURL:true,incompatibleN1ReloadRequired:true,stagedN1:true,playbackGuard:true,prePaintLayout:true,offlineSettingsPanels:true,scriptErrors:[],offlineTransportErrors}); console.log(name,'PASS'); - } finally {await browser?.close();await proxy?.close();await stop();} + } catch(error){console.error(name,error.stack);throw error;} finally {await browser?.close();proxy?.server.closeAllConnections();await proxy?.close();await stop();} } try{for(const name of requested==='all'?['chromium','webkit']:[requested])await run(name);writeFileSync(args.includes('--out')?resolve(args[args.indexOf('--out')+1]):join(repo,'perf/results/phase3-lazy-2026-10-08.json'),JSON.stringify({results},null,2)+'\n');}finally{rmSync(scratch,{recursive:true,force:true});} diff --git a/perf/migration.mjs b/perf/migration.mjs index dbe97d7..06d4942 100644 --- a/perf/migration.mjs +++ b/perf/migration.mjs @@ -102,7 +102,9 @@ async function run(name) { await page.waitForFunction(() => !!navigator.serviceWorker.controller, { timeout: 90000 }); assert.equal(await page.locator('meta[name="ytp-build"]').getAttribute('content'), oldTag); assert.equal(banners, 0, 'no first-install banner'); + console.log(name,'old shell installed'); await stop(); unlinkSync(join(cwd, 'public')); await start(false); + console.log(name,'new server started'); const backend = `http://127.0.0.1:${port}`; const manifest = await (await fetch(`${backend}/api/manifest`)).json(); assert.notEqual(manifest.buildTag, oldTag); @@ -119,10 +121,12 @@ async function run(name) { proxy.resetStats(); proxy.startTracking(); const started = Date.now(); // Use the old application's public functions; no simulated update helper. + console.log(name,'requesting old-client update'); await page.evaluate(async () => { await checkBuildTag(); await (await navigator.serviceWorker.getRegistration()).update(); }); + console.log(name,'old-client update check returned'); await waitAsync(page,async () => !!(await navigator.serviceWorker.getRegistration())?.waiting); await page.waitForFunction(() => /Update available/.test(document.querySelector('#modalTitle')?.textContent || '') && !document.querySelector('#modal').classList.contains('hidden')); assert.equal(banners, 1, 'exactly one offer before refresh'); @@ -137,17 +141,24 @@ async function run(name) { return !reg.installing && reg.waiting?.state==='installed' && !!await cache.match('/__ytp_candidate/'+tag); }, manifest.buildTag); + console.log(name,'candidate installed'); const status = await page.evaluate(async()=>SwUpdate.askCacheStatus((await navigator.serviceWorker.getRegistration()).waiting,{timeoutMs:4000})); assert.equal(status?.ready,true,'new worker confirms persistent cache to old helper'); assert.equal(status.version,manifest.buildTag,'status belongs to the target build'); + console.log(name,'ready status received'); const oldTab = await context.newPage(); 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()); - await page.locator('#modal').getByRole('button', { name: 'Refresh UI', exact: true }).click(); + 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'); await page.waitForFunction(tag => document.querySelector('meta[name="ytp-build"]')?.content === tag && typeof checkBuildTag === 'function', manifest.buildTag, { timeout: 90000 }); + console.log(name,'new page loaded'); await page.waitForFunction(() => !sessionStorage.getItem('ytpUpdateAttempt')); + console.log(name,'update outcome cleared'); await page.evaluate(async () => { await checkBuildTag(); await checkBuildTag(); await checkBuildTag(); }); + console.log(name,'banner rechecks complete'); await sleep(2000); assert.equal(navigations - before, 1, 'one reload, no update loop'); assert.equal(banners, 1, 'no banner after applying the current build'); @@ -225,7 +236,7 @@ async function run(name) { const result = { browser: name, oldTag, newTag: manifest.buildTag, banners, updateReloads: 1, cachedFiles: cacheResult.count, durationMs, wireBytes: stats.totalWireBytes, offline: true, legacyURLs: 21, profile, userInitiatedOnly: true, playbackGuard: true, evictionRepair: true, incrementalRefresh: true, offlineTransportErrors: errors.length }; console.log(JSON.stringify(result)); return result; - } finally { + } catch(error) { console.error(name,error.stack); throw error; } finally { await browser?.close(); if (proxy) { proxy.server.closeAllConnections(); await proxy.close(); } await stop(); diff --git a/perf/offline-complete.mjs b/perf/offline-complete.mjs index 5dbfe05..cd0e86e 100644 --- a/perf/offline-complete.mjs +++ b/perf/offline-complete.mjs @@ -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:'