136 lines
15 KiB
JavaScript
136 lines
15 KiB
JavaScript
#!/usr/bin/env node
|
|
// Phase 3 checks against the real Bun server, a same-origin restart and real workers.
|
|
import assert from 'node:assert/strict';
|
|
import { spawn } from 'node:child_process';
|
|
import { mkdtempSync, cpSync, mkdirSync, symlinkSync, readFileSync, 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), scratch=mkdtempSync(join(repo,'perf/.tmp/lazy-'));
|
|
const sleep=ms=>new Promise(r=>setTimeout(r,ms));
|
|
async function wait(page,fn,arg,timeout=90000){const end=Date.now()+timeout;while(Date.now()<end){if(await page.evaluate(fn,arg))return;await sleep(100);}throw Error('Timed out: '+fn.toString());}
|
|
async function checkCssOrder(page){
|
|
const changes=await page.evaluate(async()=>{
|
|
await Lazy.load('layout:'+Lazy.layout);
|
|
const settle=()=>new Promise(resolve=>setTimeout(resolve,300));await settle();
|
|
const nodes=[...document.querySelectorAll('body,body *')],props=['display','position','color','background-color','font-family','font-size','padding','margin','border-radius','--bg','--text','--radius'];
|
|
const snapshot=()=>nodes.map(n=>props.map(p=>getComputedStyle(n).getPropertyValue(p)));
|
|
const links=[...document.querySelectorAll('link[data-lazy-css]')],positions=links.map(n=>[n,n.nextSibling]),before=snapshot();
|
|
const loaded=()=>new Promise((resolve,reject)=>{let tries=0;function poll(){if(links.every(n=>n.sheet))return resolve();if(tries++>120)return reject(Error('Reordered CSS did not load'));requestAnimationFrame(poll);}poll();});
|
|
links.forEach(n=>document.head.append(n));await loaded();await settle();const after=snapshot();
|
|
for(const [n,next] of positions.reverse())document.head.insertBefore(n,next);
|
|
await loaded();await settle();
|
|
return before.flatMap((values,i)=>values.flatMap((v,j)=>v===after[i][j]?[]:[{element:nodes[i].id||nodes[i].className,property:props[j],before:v,after:after[i][j]}]));
|
|
});
|
|
assert.deepEqual(changes,[],'requesting scoped layout CSS earlier preserves its computed appearance');
|
|
}
|
|
const args=process.argv.slice(2), requested=args.includes('--browser')?args[args.indexOf('--browser')+1]:'all';
|
|
const results=[];
|
|
async function run(name){
|
|
const cwd=join(scratch,name);mkdirSync(cwd);cpSync(join(repo,'frontend'),join(cwd,'public'),{recursive:true});
|
|
const portServer=createServer();await new Promise(r=>portServer.listen(0,'127.0.0.1',r));const port=portServer.address().port;await new Promise(r=>portServer.close(r));
|
|
let child,browser,proxy,log='';
|
|
async function stop(){if(!child)return;const p=child;child=null;if(p.exitCode===null){p.kill('SIGTERM');await new Promise(r=>p.once('exit',r));}}
|
|
async function start(){log='';child=spawn('bun',[join(repo,'server/server.js')],{cwd,env:{...process.env,PORT:String(port),ASSET_HASHING:'1',ASSET_SYNC:'1',BUILD_TAG:'',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(-15000));for(let i=0;i<200;i++){if(child.exitCode!==null)throw Error(log);try{if((await fetch('http://127.0.0.1:'+port+'/api/version')).ok)return;}catch{}await sleep(100);}throw Error(log);}
|
|
try {
|
|
await start();proxy=createThrottleProxy({targetPort:port,profile:'unthrottled'});const origin='http://127.0.0.1:'+await proxy.listen();proxy.startTracking();
|
|
browser=await ({chromium,webkit}[name]).launch({headless:true});const context=await browser.newContext();let disconnected=false;
|
|
await context.route('**/*',r=>r.request().url().startsWith(origin)?r.continue():r.abort());
|
|
await context.addInitScript(()=>{window.__saveData=localStorage.getItem('perf-save-data')==='1';const observer=new MutationObserver(()=>{if(document.body){window.__firstBodyLayout=document.documentElement.dataset.layout;observer.disconnect();}});observer.observe(document,{childList:true,subtree:true});Object.defineProperty(navigator,'onLine',{configurable:true,get:()=>localStorage.getItem('perf-offline')!=='1'});Object.defineProperty(navigator,'connection',{configurable:true,value:{get saveData(){return window.__saveData;}}});});
|
|
const page=await context.newPage(),errors=[],failedRequests=[];page.on('requestfailed',request=>{if(disconnected)failedRequests.push(request.url());});page.on('pageerror',e=>errors.push({message:e.message,disconnected}));page.on('console',m=>{if(m.type()==='error')console.log(name,'console:',m.text());});
|
|
console.log(name,'initial boot');await page.goto(origin);await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#carModeBtn'));
|
|
await wait(page,async()=>!!(await navigator.serviceWorker.getRegistration())?.active);
|
|
await page.evaluate(()=>Lazy.warm());
|
|
await wait(page,async()=>{const c=await caches.open('ytplayer-assets'),r=await c.match('/__ytp_asset_state');if(!r)return false;const s=await r.json();return !(await Promise.all(Object.values(s.current.groups).flatMap(g=>g.files).map(async p=>!!await c.match(p+'?v='+s.current.files[p].h)))).includes(false);});
|
|
console.log(name,'warm complete');assert.equal(await page.evaluate(()=>Lazy.loaded('feature:car-mode')),false);
|
|
assert.equal(await page.evaluate(()=>[...document.querySelectorAll('.stg-overlay,.deck-chips,.vol-pct,.mode-name,.stg-bot,.ch-badge,.upnext-clear,.list-sub,.p2p-strip,.sleep-status .sl-ic,.sleep-status .sl-sub,.mc-tune')].every(n=>getComputedStyle(n).display==='none')),true,'classic retains shared visibility defaults without loading Glass CSS');
|
|
await checkCssOrder(page);
|
|
assert.equal(await page.evaluate(()=>Lazy.loaded('feature:piano')),false);
|
|
assert.equal(await page.evaluate(()=>[typeof newPlaylist,typeof renderSettings,typeof enterServiceMode,typeof Notes.load,typeof LyricsCore.fromText].every(type=>type==='function')),true,'playlist, settings, service mode and lyric core remain in RAM');
|
|
// Profile/imported settings use applyAppearance, not the Settings select handler.
|
|
await page.evaluate(()=>{data.settings.layout='glass-stage';applyAppearance();});
|
|
await wait(page,()=>document.documentElement.dataset.layout==='glass-stage'&&Lazy.layout==='glass-stage');
|
|
await page.evaluate(()=>{data.settings.layout='classic';applyAppearance();});
|
|
await wait(page,()=>document.documentElement.dataset.layout==='classic'&&Lazy.layout==='classic');
|
|
// Eager application panels stay usable without executing optional settings modules.
|
|
await page.click('[data-view="settings"]');await page.click('[data-section="appearance"]');await page.waitForSelector('#setLayout');
|
|
assert.equal(await page.evaluate(()=>Lazy.loaded('feature:piano')),false);
|
|
await page.selectOption('#setLayout','glass-stage');await page.waitForFunction(()=>document.documentElement.dataset.layout==='glass-stage');
|
|
await page.evaluate(()=>{window.__saveData=true;localStorage.setItem('perf-save-data','1');localStorage.setItem('perf-offline','1');});
|
|
await stop();disconnected=true;await proxy.setOffline(true);
|
|
console.log(name,'offline reload');await page.reload();await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#carModeBtn'));
|
|
assert.equal(await page.evaluate(()=>document.documentElement.dataset.layout),'glass-stage');
|
|
assert.equal(await page.evaluate(()=>window.__firstBodyLayout),'glass-stage','selected layout is established before body/first paint');
|
|
await checkCssOrder(page);
|
|
const before=proxy.getStats().requestCount;
|
|
await page.getByRole('button',{name:'Car mode',exact:true}).click();await page.waitForSelector('.car-mode');
|
|
assert.equal(await page.locator('.car-controls button').count(),4,'all transport controls survive lazy setup');
|
|
assert.equal(await page.locator('.car-controls #voiceSearchBtn').count(),0,'voice search stays outside transport');
|
|
await page.getByRole('button',{name:'Exit Car mode'}).click();
|
|
assert.equal(proxy.getStats().requestCount,before,'warmed feature first use reaches no network');
|
|
await page.click('[data-view="settings"]');await page.click('[data-section="appearance"]');await page.selectOption('#setLayout','bento-hub');await page.waitForFunction(()=>document.documentElement.dataset.layout==='bento-hub');
|
|
await page.selectOption('#setLayout','f7-swipe');await page.waitForFunction(()=>document.documentElement.dataset.layout==='f7-swipe'&&!!window.Framework7);
|
|
await page.selectOption('#setLayout','classic');await page.waitForFunction(()=>document.documentElement.dataset.layout==='classic');
|
|
assert.equal(proxy.getStats().requestCount,before,'offline layout changes reach no network');
|
|
await page.getByRole('searchbox',{name:'Search settings and controls'}).fill('Enable piano transcription');
|
|
await page.locator('.settings-results button').first().waitFor();
|
|
await page.getByRole('searchbox',{name:'Search settings and controls'}).fill('');
|
|
// Every extension panel renders from disk without executing other panels eagerly.
|
|
for(const id of ['piano','midi','floating-lyrics','vocal-reducer','equalizer','setlist-import','broadcast']) {
|
|
await page.evaluate(id=>document.querySelector('[data-section="'+id+'"]').click(),id);
|
|
await wait(page,async id=>{const section=SettingsSections.get(id);return !section.lazyGroup;},id);
|
|
}
|
|
await page.evaluate(async()=>{await Lazy.load('feature:export');await Lazy.load('feature:video-edit');});
|
|
assert.equal(proxy.getStats().requestCount,before,'all warmed extension assets stay offline');
|
|
await start();disconnected=false;await proxy.setOffline(false);await page.evaluate(()=>localStorage.removeItem('perf-offline'));
|
|
// Keep an old tab whose core and group contracts remain those of build N.
|
|
const old=await context.newPage();old.on('pageerror',e=>errors.push({message:e.message,disconnected}));await old.goto(origin);await old.waitForSelector('#carModeBtn');
|
|
await old.evaluate(async()=>{window.__saveData=true;await Lazy.load('feature:setlist-import');window.__pinned=window.SetlistImport;});
|
|
await page.evaluate(()=>window.__saveData=true);
|
|
const previous=await page.evaluate(()=>Lazy.manifest);
|
|
await stop();
|
|
for(const file of ['video-edit.js','setlist-import.js','car-mode.js'])writeFileSync(join(cwd,'public',file),readFileSync(join(cwd,'public',file),'utf8')+'\n// phase3 new file\n');
|
|
const defs=JSON.parse(readFileSync(join(cwd,'public/assets.json')));defs.groups['feature:car-mode'].contract=2;writeFileSync(join(cwd,'public/assets.json'),JSON.stringify(defs));
|
|
// Production stamping replaces the source-only native map with these updated groups.
|
|
await start();const target=await(await fetch(origin+'/api/manifest')).json();
|
|
await page.evaluate(async()=>{window.__saveData=true;const r=await navigator.serviceWorker.getRegistration();await r.update();});
|
|
await wait(page,async target=>{const r=await navigator.serviceWorker.getRegistration();if(!r?.waiting)return false;return(await SwUpdate.askCacheStatus(r.waiting))?.version===target;},target.buildTag);
|
|
// No automatic navigation; a real playing flag blocks the real apply flow.
|
|
let navs=0;page.on('framenavigated',f=>{if(f===page.mainFrame())navs++;});
|
|
await sleep(1000);assert.equal(navs,0);
|
|
await page.evaluate(()=>Player._wantsPlaying=true);
|
|
page.once('dialog',d=>d.dismiss());
|
|
const guard=await page.evaluate(async()=>{try{await SwUpdate.applyUpdate({getRegistration:()=>navigator.serviceWorker.getRegistration(),askStatus:SwUpdate.askCacheStatus,reload:()=>location.reload()});return false;}catch{return true;}});
|
|
assert.equal(guard,true);assert.equal(navs,0);
|
|
await page.evaluate(()=>Player._wantsPlaying=false);
|
|
// Explicit worker message, then explicit navigation into new stamped core.
|
|
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.
|
|
const cacheKey='/video-edit.js?v='+target.files['/video-edit.js'].h;
|
|
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'));
|
|
await page.evaluate(()=>Lazy.load('feature:video-edit'));const pinned=await page.evaluate(()=>{window.__videoPinned=VideoEdit;return Lazy.loaded('feature:video-edit');});assert.equal(pinned,true);
|
|
// Different contract is already downloaded before commit, but the old core must keep N-1.
|
|
const oldRequests=[];old.on('request',r=>oldRequests.push(r.url()));
|
|
await old.evaluate(()=>Lazy.load('feature:car-mode'));assert.equal(await old.evaluate(()=>Lazy.reloadRequired),true);
|
|
await old.getByRole('button',{name:'Refresh UI',exact:true}).waitFor();
|
|
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 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)));
|
|
await page.evaluate(()=>Lazy.load('feature:video-edit'));assert.equal(await page.evaluate(()=>window.VideoEdit===window.__videoPinned),true);
|
|
const offlineTransportErrors=errors.filter(error=>name==='webkit'&&error.disconnected&&(error.message==='TypeError: Load failed'||/\/api\/(?:recommendations\?[^\s]*|version)\.$/.test(error.message)));
|
|
assert.deepEqual(errors.filter(error=>!offlineTransportErrors.includes(error)),[]);
|
|
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();}
|
|
}
|
|
try{for(const name of requested==='all'?['chromium','webkit']:[requested])await run(name);writeFileSync(join(repo,'perf/results/phase3-lazy-2026-10-08.json'),JSON.stringify({results},null,2)+'\n');}finally{rmSync(scratch,{recursive:true,force:true});}
|