From 14716e648b9c91c587d6bd5ae7fe1499cf951490 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Fri, 9 Oct 2026 09:15:29 +0800 Subject: [PATCH] Add offline completeness and feature regression harness --- perf/offline-complete.mjs | 84 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 perf/offline-complete.mjs diff --git a/perf/offline-complete.mjs b/perf/offline-complete.mjs new file mode 100644 index 0000000..6b47f4f --- /dev/null +++ b/perf/offline-complete.mjs @@ -0,0 +1,84 @@ +#!/usr/bin/env node +// Real-server completeness and offline UI regression gate. All scratch stays in this worktree. +import {spawn} from 'node:child_process'; +import {mkdtempSync,cpSync,mkdirSync,writeFileSync,rmSync} from 'node:fs'; +import {resolve,join} from 'node:path'; +import {createServer} from 'node:net'; +import {chromium,webkit} from 'playwright'; +import {createThrottleProxy} from './proxy.mjs'; +const repo=resolve(new URL('..',import.meta.url).pathname), args=process.argv.slice(2); +const option=(key,fallback)=>args.includes(key)?args[args.indexOf(key)+1]:fallback; +const source=resolve(option('--source',join(repo,'frontend'))),legacy=args.includes('--legacy'); +const scratch=mkdtempSync(join(repo,'perf/.tmp/offline-')),results=[]; +const sleep=ms=>new Promise(r=>setTimeout(r,ms)); +async function poll(fn,timeout=60000){const end=Date.now()+timeout;while(Date.now()socket.listen(0,'127.0.0.1',r));const port=socket.address().port;await new Promise(r=>socket.close(r)); + let origin='http://127.0.0.1:'+port, log='',browser,proxy; + const child=spawn('bun',[join(repo,'server/server.js')],{cwd,env:{...process.env,PORT:String(port),ASSET_HASHING:legacy?'0':'1',ASSET_SYNC:legacy?'0':'1',BUILD_TAG:legacy?'1175f1a1d2c1':'',DB_PATH:join(cwd,'db.sqlite'),DATA_DIR:join(cwd,'data'),MEDIA_DIR:join(cwd,'media'),UPLOAD_DIR:join(cwd,'uploads')},stdio:['ignore','pipe','pipe']}); + for(const stream of [child.stdout,child.stderr])stream.on('data',c=>log=(log+c).slice(-6000)); + 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(); + const manifest=await(await fetch(origin+'/api/manifest')).json();result.build=manifest.buildTag; + browser=await({chromium,webkit}[engine]).launch();const context=await browser.newContext(); + await context.addInitScript(()=>{Object.defineProperty(navigator,'onLine',{configurable:true,get:()=>localStorage.getItem('perf-offline')!=='1'});Object.defineProperty(navigator,'connection',{configurable:true,value:{saveData:localStorage.getItem('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('pageerror',e=>{if(offline)result.events.push({kind:'pageerror',message:e.message,stack:e.stack});}); + }; + let page=await context.newPage();attach(page); + const boot=async()=>{await page.goto(origin);await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('[aria-label="Car mode"]'));}; + const inventory=()=>page.evaluate(async({manifest,legacy})=>{ + const names=await caches.keys(),cache=await caches.open(legacy?names.find(n=>n.startsWith('ytplayer-')&&!['ytplayer-assets','ytplayer-fonts','ytplayer-thumbs'].includes(n)):'ytplayer-assets'); + 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()); + await poll(async()=>!(await inventory()).missing.length,Number(option('--warm-timeout','15000'))); + result.installMs=Date.now()-started;result.inventory=await inventory(); + 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"]')); + await page.evaluate(()=>localStorage.setItem('perf-offline','1'));if(engine==='chromium')await context.setOffline(true);await proxy.setOffline(true);offline=true;result.offlineTransport=engine==='chromium'?'context.setOffline + proxy':'proxy (Linux WebKit setOffline breaks cached navigation)'; + for(const layout of ['classic','f7-swipe','glass-stage','bento-hub']){ + const check={layout,opened:[],errors:[]};result.layouts.push(check); + const attempt=async(name,fn)=>{console.log(engine,layout,name);try{await Promise.race([fn(),new Promise((_,reject)=>{const t=setTimeout(()=>reject(Error('Action timed out')),10000);t.unref();})]);check.opened.push(name);}catch(e){check.errors.push({name,error:e.message});result.failures.push({layout,name,error:e.message});}}; + await attempt('reload',async()=>{await page.evaluate(layout=>{data.settings.layout=layout;localStorage.setItem('_ytpdata',JSON.stringify(data));},layout);await page.reload();await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('[aria-label="Car mode"]'),null,{timeout:10000});}); + for(const view of ['search','queue','history','saved','downloads','settings','nowPlaying'])await attempt(view,()=>page.evaluate(async type=>{view={type};if(type==='settings')await renderSettings();else render();await new Promise(r=>setTimeout(r,150));},view)); + await attempt('sidebar',()=>page.evaluate(()=>{document.querySelector('#menuBtn')?.click();document.querySelector('#sidebar')?.classList.remove('open');})); + 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'])await attempt(section,async()=>{await page.evaluate(async()=>{view={type:'settings'};await renderSettings();});await page.waitForSelector('[data-section="'+section+'"]',{timeout:5000});await page.evaluate(id=>document.querySelector('[data-section="'+id+'"]').click(),section);await page.waitForTimeout(100);}); + 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());}); + await attempt('party',async()=>{await page.evaluate(()=>document.querySelector('#navPartyBtn').click());await page.waitForSelector('#partyPanel:not(.hidden)',{timeout:5000});await page.evaluate(()=>document.querySelector('#partyPanel').classList.add('hidden'));}); + await attempt('share/external',()=>page.evaluate(async()=>{current={meta:{id:'benchmedia01',title:'Offline fixture'}};await Share.open();closeModal();await External.open();closeModal();})); + await attempt('stats',async()=>{await page.evaluate(()=>{view={type:'stats'};render();});await page.waitForSelector('.stats-empty, .stats',{timeout:5000});}); + await attempt('service',()=>page.evaluate(()=>{enterServiceMode();exitServiceMode();})); + await attempt('runtime URLs',async()=>{const failures=await page.evaluate(async manifest=>{const paths=Object.keys(manifest.files).filter(p=>/\.(?:woff2|png|webmanifest)$/.test(p)||/\/(?:opfs-worker|hash-worker|direct-recv-worker|p2p-recv-worker|sha256-wasm)\.js$/.test(p)||p==='/piano-engine.mjs'||p==='/vendor/framework7-swipe.min.js');const bad=[];for(const path of paths){const r=await fetch(path);await r.arrayBuffer();if(!r.ok)bad.push(path+': '+r.status);}return bad;},manifest);if(failures.length)throw Error(failures.join(', '));}); + } + result.staticFailures=result.events.filter(e=>e.kind==='requestfailed'&&new URL(e.url).origin===origin&&!new URL(e.url).pathname.startsWith('/api/')); + result.scriptErrors=result.events.filter(e=>e.kind==='pageerror'); + result.failures.push(...result.staticFailures,...result.scriptErrors); + // Resumption and Save-Data/two-tab checks use the same persisted CacheStorage. + offline=false;if(engine==='chromium')await context.setOffline(false);await proxy.setOffline(false);await page.evaluate(()=>localStorage.removeItem('perf-offline')); + if(!legacy&&args.includes('--conditions')){ + await page.evaluate(async manifest=>{const c=await caches.open('ytplayer-assets');for(const p of Object.keys(manifest.files).filter(p=>manifest.files[p].g!=='core'))await c.delete(p+'?v='+manifest.files[p].h);localStorage.setItem('perf-save-data','1');},manifest); + await page.close();page=await context.newPage();attach(page);await boot(); + const other=await context.newPage();await other.goto(origin);await other.waitForSelector('#carModeBtn'); + if(engine==='chromium'){const cdp=await context.newCDPSession(page);await cdp.send('ServiceWorker.enable');await cdp.send('ServiceWorker.stopAllWorkers');await cdp.detach();} + await page.reload();await page.waitForSelector('#carModeBtn'); + const complete=await poll(async()=>!(await inventory()).missing.length);result.conditions.push({name:'close/reopen, Save-Data, two tabs, worker restart',complete});if(!complete)result.failures.push({kind:'resume',inventory:await inventory()}); + await page.evaluate(()=>{localStorage.removeItem('perf-save-data');window.dispatchEvent(new Event('online'));}); + 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 browser?.close();await proxy?.close();child.kill('SIGTERM');if(child.exitCode===null)await new Promise(r=>child.once('exit',r));} +} +try{for(const engine of option('--browser','all')==='all'?['chromium','webkit']:[option('--browser')])await run(engine);}finally{writeFileSync(resolve(option('--out',join(repo,'perf/results/offline-complete.json'))),JSON.stringify({results},null,2)+'\n');rmSync(scratch,{recursive:true,force:true});} +if(results.some(r=>r.failures.length))process.exitCode=1;