#!/usr/bin/env node // Existing-install migration and profile/import settings gate; all servers and media stay local. import assert from 'node:assert/strict'; import {spawn,execFileSync} from 'node:child_process'; import {mkdtempSync,mkdirSync,writeFileSync,readFileSync,symlinkSync,unlinkSync,rmSync,statSync} from 'node:fs'; import {join,resolve} from 'node:path'; import {createServer} from 'node:net'; import {createServer as createHttpServer, request as httpRequest} from 'node:http'; import {createRequire} from 'node:module'; import {chromium,webkit} from 'playwright'; const repo=resolve(import.meta.dirname,'..'),args=process.argv.slice(2); const opt=(k,d)=>args.includes(k)?args[args.indexOf(k)+1]:d; const oldTag='1175f1a1d2c1',target=opt('--target','main'),engine=opt('--browser','all'); assert.ok(['all','chromium','webkit'].includes(engine)); execFileSync('bash',[join(repo,'perf/make-shell-fixture.sh'),'b77938a',oldTag],{cwd:repo,stdio:'inherit'}); const scratch=mkdtempSync(join(repo,'perf/.tmp/settings-')); function archive(rev,dest){mkdirSync(dest,{recursive:true});const tar=join(dest,'source.tar');writeFileSync(tar,execFileSync('git',['archive',rev,'frontend','server'],{cwd:repo,maxBuffer:40*1024*1024}));execFileSync('tar',['-xf',tar,'-C',dest]);symlinkSync(join(repo,'server/node_modules'),join(dest,'server/node_modules'));} const revision=execFileSync('git',['rev-parse',target],{cwd:repo,encoding:'utf8'}).trim(); archive('b77938a',join(scratch,'old'));archive(revision,join(scratch,'new')); symlinkSync(join(repo,'perf/fixtures/shell-'+oldTag),join(scratch,'old/server/public')); symlinkSync(join(scratch,'new/frontend'),join(scratch,'new/server/public')); const results={revision,oldTag,results:[],failures:[],unsupported:[]}; const sleep=ms=>new Promise(r=>setTimeout(r,ms)); async function poll(fn,timeout=90000){const until=Date.now()+timeout;while(Date.now()sock.listen(0,'127.0.0.1',r));const port=sock.address().port;await new Promise(r=>sock.close(r));let origin='http://127.0.0.1:'+port; let child,browser,proxy,profileReply=null,userDataGets=0,log=''; const result={engine:name,errors:[],profileChecks:[],behaviour:[],diff:[]};results.results.push(result); async function stop(){if(!child)return;const c=child;child=null;if(c.exitCode===null){c.kill('SIGTERM');await new Promise(r=>c.once('exit',r));}} async function start(old){symlinkSync(old?join(repo,'perf/fixtures/shell-'+oldTag):join(scratch,'new/frontend'),join(cwd,'public'));child=spawn('bun',[join(scratch,old?'old':'new','server/server.js')],{cwd,env:{...process.env,PORT:String(port),DB_PATH:join(cwd,'db.sqlite'),MEDIA_DIR:join(cwd,'media'),UPLOAD_DIR:join(cwd,'uploads'),DATA_DIR:join(cwd,'data'),ASSET_HASHING:old?'0':'1',ASSET_SYNC:old?'0':'1',BUILD_TAG:''},stdio:['ignore','pipe','pipe']});for(const s of [child.stdout,child.stderr])s.on('data',c=>log=(log+c).slice(-5000));await poll(async()=>{if(child.exitCode!==null)throw Error(log);try{return(await fetch(origin+'/api/version')).ok;}catch{return false;}});} try{ await start(true); const clip={id:'benchmedia01',title:'Persisted settings local clip',channel:'Settings probe',duration:3,thumbnail:''}; const mp4=join(cwd,'media',clip.id+'.1.mp4');execFileSync('ffmpeg',['-y','-f','lavfi','-i','color=c=blue:s=160x90:r=10:d=3','-f','lavfi','-i','sine=frequency=440:duration=3','-c:v','libx264','-pix_fmt','yuv420p','-c:a','aac','-movflags','+faststart','-shortest',mp4],{stdio:'ignore'}); execFileSync('ffmpeg',['-y','-i',mp4,'-vn','-c:a','copy',join(cwd,'media',clip.id+'.1.m4a')],{stdio:'ignore'}); const require=createRequire(import.meta.url),{createClient}=require(join(repo,'server/node_modules/@libsql/client')); const db=createClient({url:'file:'+join(cwd,'db.sqlite')});await db.execute({sql:'INSERT OR REPLACE INTO media_cache (video_id,status,gen,size,height,vcodec,acodec,duration,meta,last_access) VALUES (?,?,?,?,?,?,?,?,?,?)',args:[clip.id,'ready',1,statSync(mp4).size,90,'h264','aac',3,JSON.stringify(clip),Date.now()]});db.close(); browser=await({chromium,webkit}[name]).launch({headless:true});const context=await browser.newContext({viewport:{width:414,height:896}}); // Server-side mocks also cover WebKit worker-owned fetches, which bypass page routing. proxy=createHttpServer((req,res)=>{ const pathname=new URL(req.url,'http://local').pathname; if(pathname==='/api/user/data'){userDataGets++;res.setHeader('Content-Type','application/json');res.end(JSON.stringify({ok:true,data:{settings:{saveBeforePlay:false}}}));return;} if(pathname==='/api/profile/load'&&profileReply){res.setHeader('Cache-Control','no-store');res.setHeader('Content-Type','application/json');res.end(JSON.stringify(profileReply));return;} if(pathname==='/api/profile/save'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({ok:false,error:'probe blocks writes'}));return;} const upstream=httpRequest({hostname:'127.0.0.1',port,path:req.url,method:req.method,headers:req.headers},r=>{res.writeHead(r.statusCode,r.headers);r.pipe(res);});upstream.on('error',()=>{res.statusCode=503;res.end();});req.pipe(upstream); });await new Promise(r=>proxy.listen(0,'127.0.0.1',r));origin='http://127.0.0.1:'+proxy.address().port; await context.route('**/*',route=>route.request().url().startsWith(origin)?route.continue():route.abort()); const page=await context.newPage();page.on('pageerror',e=>result.errors.push(e.message)); const boot=async()=>{await page.goto(origin);await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#sidebarToggle'));await page.waitForTimeout(800);}; const settings=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('_ytpdata')).settings); const openSettings=async()=>{await page.locator('[data-view="settings"]').first().evaluate(e=>e.click());await page.waitForSelector('#setSaveBeforePlay',{state:'attached'});}; const readUI=()=>page.evaluate(ids=>Object.fromEntries(ids.map(id=>{const e=document.getElementById(id);return[id,e.type==='checkbox'?e.checked:e.value];})),Object.values(ui).map(x=>x[0])); await boot();await page.waitForFunction(()=>!!navigator.serviceWorker.controller); await openSettings(); for(const [key,[id,value]]of Object.entries(ui)){await page.locator('#'+id).evaluate((e,value)=>{if(e.type==='checkbox')e.checked=value;else e.value=value;e.dispatchEvent(new Event('change',{bubbles:true}));},value);} await page.evaluate(()=>EQ.open());await page.waitForSelector('[data-preset="bass"]',{state:'attached'});await page.locator('[data-preset="bass"]').click();await page.evaluate(()=>closeModal());await sleep(600); await boot();await openSettings();result.old=await settings();result.oldUI=await readUI();result.oldRaw=await page.evaluate(()=>localStorage.getItem('_ytpdata')); async function behaviour(stage){const supported=await page.evaluate(()=>OPFS.isSupported());if(!supported){result.behaviour.push({stage,supported:false,note:'Native OPFS unavailable; no shim'});results.unsupported.push(name);return;} await page.evaluate(async id=>{await OPFS.deleteVideo(id);cachedIds.delete(id);Player.pause();},clip.id); await page.evaluate(clip=>Player.loadVideo(clip),clip);await page.waitForFunction(id=>cachedIds.has(id)&&!!current?.localUrl,clip.id,{timeout:20000}); const state=await page.evaluate(async id=>({cached:cachedIds.has(id),files:await OPFS.listVideos(),localUrl:current.localUrl,src:Player.master?.src}),clip.id);assert.equal(state.files.find(f=>f.id===clip.id)?.size,statSync(mp4).size);assert.ok(state.src?.startsWith('blob:'),'player must use OPFS blob');await page.waitForFunction(()=>Player.master?.readyState>=3);await page.evaluate(()=>Player.play());await page.waitForFunction(()=>Player.master?.currentTime>0);await page.evaluate(()=>Player.pause());result.behaviour.push({stage,supported:true,bytes:state.files.find(f=>f.id===clip.id).size,cached:true,localPlayback:true});} await behaviour('old');console.log(name,'old settings and save verified');await page.evaluate(()=>Player.pause()); await stop();unlinkSync(join(cwd,'public'));await start(false);result.newTag=(await(await fetch(origin+'/api/version')).json()).buildTag; await page.evaluate(async()=>{await checkBuildTag();await(await navigator.serviceWorker.getRegistration()).update();});await poll(()=>page.evaluate(async()=>!!(await navigator.serviceWorker.getRegistration())?.waiting)); await page.waitForFunction(()=>!document.getElementById('modal').classList.contains('hidden')&&/Update available/.test(document.getElementById('modalTitle').textContent)); await page.locator('#modal').getByRole('button',{name:'Refresh UI',exact:true}).evaluate(e=>e.click());await page.waitForFunction(tag=>document.querySelector('meta[name="ytp-build"]')?.content===tag,result.newTag,{timeout:90000});await sleep(1000);await openSettings(); console.log(name,'real Refresh UI completed');result.upgraded=await settings();result.newRaw=await page.evaluate(()=>localStorage.getItem('_ytpdata'));result.newUI=await readUI();assert.deepEqual(result.newUI,result.oldUI,'checkbox/select values preserved'); const {migrate}=require(join(scratch,'new/frontend/eq-parameters.js'));const expected=structuredClone(result.old);if(expected.eq?.g?.length===5)expected.eq.g=migrate(expected.eq.g); for(const key of new Set([...Object.keys(result.old),...Object.keys(result.upgraded)]))result.diff.push({key,before:result.old[key],after:result.upgraded[key],equal:JSON.stringify(result.old[key])===JSON.stringify(result.upgraded[key]),expectedMigration:key==='eq'&&result.old.eq?.g?.length===5}); assert.deepEqual(result.upgraded,expected,'all persisted settings preserved except deliberate 5→10 EQ migration');await behaviour('upgraded'); const local=await settings(),remote={...local,saveBeforePlay:false,autoPreload:false,layout:'classic',quality:'auto'}; async function profileCheck(updatedAt,expectedSave,stage){profileReply={ok:true,name:'probe',updatedAt,data:{settings:remote,playlists:[]}};await page.evaluate(s=>{data.settings=s;data.profile={name:'probe',syncedAt:200};localStorage.setItem('_ytpdata',JSON.stringify(data));},local);await boot();await page.waitForFunction(()=>_syncBusy===0);await openSettings();const s=await settings();assert.equal(s.saveBeforePlay,expectedSave);assert.equal(await page.locator('#setSaveBeforePlay').isChecked(),expectedSave);if(!expectedSave)assert.equal(s.layout,'classic');else assert.deepEqual(s,local);result.profileChecks.push({stage,serverUpdatedAt:updatedAt,localSyncedAt:200,saveBeforePlay:s.saveBeforePlay});} await profileCheck(100,true,'older timestamp ignored');await profileCheck(300,false,'older settings with newer timestamp adopted'); profileReply=null;await page.evaluate(s=>{data.profile=null;data.settings=s;localStorage.setItem('_ytpdata',JSON.stringify(data));},local);await boot();await openSettings(); await page.locator('#backupFileInput').setInputFiles({name:'settings-probe.json',mimeType:'application/json',buffer:Buffer.from(JSON.stringify({playlists:[],settings:remote}))});await page.waitForFunction(()=>document.getElementById('modalTitle').textContent==='Import backup?');await page.locator('#modal').getByRole('button',{name:'Import',exact:true}).evaluate(e=>e.click());await sleep(600);assert.deepEqual(await settings(),local,'backup import does not replace settings');result.importPreserved=true;result.userDataGets=userDataGets;assert.equal(userDataGets,0,'boot does not pull settings from fingerprint user-data endpoint'); console.log(name,JSON.stringify({keys:result.diff.length,changed:result.diff.filter(x=>!x.equal),behaviour:result.behaviour,profileChecks:result.profileChecks,importPreserved:result.importPreserved,errors:result.errors})); }finally{await browser?.close();await stop();if(proxy){proxy.closeAllConnections();await new Promise(r=>proxy.close(r));}} } try{for(const name of ['chromium','webkit'])if(engine==='all'||engine===name){try{await run(name);}catch(e){results.failures.push({engine:name,error:e.stack});console.error(name,e.message);}}}finally{const out=resolve(opt('--out','perf/results/settings-persist.json'));mkdirSync(resolve(out,'..'),{recursive:true});writeFileSync(out,JSON.stringify(results,null,2)+'\n');rmSync(scratch,{recursive:true,force:true});} if(results.failures.length)process.exitCode=1;else if(results.unsupported.length&&!args.includes('--allow-unsupported-webkit')){console.error('Native WebKit OPFS unverified; use --allow-unsupported-webkit for persistence/UI coverage');process.exitCode=2;}