#!/usr/bin/env node // Local static shell, real renderSaved(), deterministic offline-file metadata. import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { readFile, mkdir, writeFile } from 'node:fs/promises'; import { resolve, extname } from 'node:path'; import { chromium, webkit } from 'playwright'; const args = process.argv.slice(2); const option = (key, fallback) => args.includes(key) ? args[args.indexOf(key) + 1] : fallback; const root = resolve(option('--frontend', 'frontend')); const label = option('--label', 'after'); const measureOnly = args.includes('--measure-only'); const shotDir = resolve('plans/ui-shots'); await mkdir(shotDir, { recursive: true }); const mime = { '.html':'text/html', '.js':'text/javascript', '.mjs':'text/javascript', '.css':'text/css', '.json':'application/json', '.woff2':'font/woff2', '.svg':'image/svg+xml', '.png':'image/png' }; const server = createServer(async (req, res) => { const path = resolve(root, '.' + (new URL(req.url, 'http://localhost').pathname === '/' ? '/index.html' : new URL(req.url, 'http://localhost').pathname)); if (!path.startsWith(root + '/')) { res.writeHead(403).end(); return; } try { const body = await readFile(path); res.writeHead(200, { 'Content-Type':mime[extname(path)] || 'application/octet-stream' }).end(body); } catch { res.writeHead(404).end(); } }); await new Promise(r => server.listen(0, '127.0.0.1', r)); const origin = `http://127.0.0.1:${server.address().port}`; const widths = args.includes('--quick') ? [414] : [320, 360, 375, 390, 414, 430]; const layouts = ['classic', 'f7-swipe', 'glass-stage', 'bento-hub']; const results = [], failures = []; const check = (ok, message) => { if (!ok) failures.push(message); }; try { for (const [name, engine] of Object.entries({ chromium, webkit }).filter(([name])=>option('--browser','all')==='all'||option('--browser','all')===name)) { const browser = await engine.launch(); try { for (const layout of layouts) for (const speech of (args.includes('--quick') ? [true] : [true, false])) for (const entry of ['cold', 'switched']) { const context = await browser.newContext({ viewport:{width:414,height:896}, deviceScaleFactor:2, isMobile:true, hasTouch:true, serviceWorkers:'block' }); await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort()); await context.addInitScript(({ layout, speech }) => { localStorage.setItem('_ytpdata', JSON.stringify({ playlists:[], history:[], queue:[], settings:{ layout } })); window.SpeechRecognition = window.webkitSpeechRecognition = speech ? function() {} : undefined; window.addEventListener('unhandledrejection', e=>console.error('rejection:', String(e.reason))); // Prevent idle warming from concealing the eager/lazy CSS regression. Object.defineProperty(navigator.connection || navigator, 'saveData', { value:true, configurable:true }); }, { layout:entry==='cold'?layout:(layout==='classic'?'f7-swipe':'classic'), speech }); const page = await context.newPage(); page.on("pageerror", e => console.error(name, layout, e.message)); page.on("console", m=>{if(m.text().startsWith("rejection:")) console.error(m.text());}); await page.goto(origin); await page.waitForFunction(() => document.activeElement.id === 'searchInput'); // Historical shell's optional integrations can delay toolbar setup on // a static server; run its original initializer with inert callbacks. await page.evaluate(() => { if (!document.querySelector('[aria-label="Car mode"]')) CarMode.configure({ input:document.getElementById('searchInput'), search(){}, toast(){}, state:()=>({paused:true}), action(){} }); }); await page.evaluate(() => { document.querySelector('[aria-label="Car mode"]').id='carModeBtn'; const mic=document.querySelector('[aria-label="Voice search"]'); if(mic)mic.id='voiceSearchBtn'; }); await page.waitForSelector('#carModeBtn'); if (speech) await page.waitForSelector('#voiceSearchBtn'); await page.evaluate(async layout => { if(window.Lazy) await Lazy.load('layout:' + layout); data.settings.layout=layout; applyAppearance(); await document.fonts.ready; }, layout); // Seed the actual Saved renderer, without media downloads or server access. await page.evaluate(async () => { const videos = ['Revealing Lilith (origins of abortion, ancient goddesses, & the final chapter)', 'How my Trouble Has Always Been a Divine Setup | Perry Stone', 'Official live video ' + 'UnbrokenLongTitle'.repeat(12)].map((title,i) => ({id:'geometry00'+i,title,thumbnail:'/icons/icon-192.png',channel:'Geometry fixture'})); data.history = videos; API.cacheList = async () => ({ok:true,items:videos.map((v,i)=>({id:v.id,size:(3-i)*100000000})),total:600000000}); view = {type:'saved'}; render(); }); await page.waitForSelector('.saved-card .card-title'); await page.waitForTimeout(550); // card entrance animation for (const width of widths) { await page.setViewportSize({width,height:896}); for (const update of [false, true]) { await page.evaluate(show => document.querySelector('#updateIconBtn')?.classList.toggle('hidden', !show), update); const geometry = await page.evaluate(() => { const rect = n => { const r=n.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,cy:r.y+r.height/2}; }; const ids=['#sidebarToggle','#searchInput','#voiceSearchBtn','.search-btn','#carModeBtn','#updateIconBtn']; const top = Object.fromEntries(ids.map(id=>[id,document.querySelector(id)]).filter(([,n])=>n&&n.getBoundingClientRect().width).map(([id,n])=>[id,rect(n)])); const rows=[...document.querySelectorAll('.saved-card')].map(n=>({thumb:rect(n.querySelector('.thumb')),image:rect(n.querySelector('.thumb img')),info:rect(n.querySelector('.card-info')),title:rect(n.querySelector('.card-title'))})); return {top,rows,scrollWidth:document.documentElement.scrollWidth}; }); const key=`${name}/${layout}/${entry}/${width}/speech=${speech}/update=${update}`; const centers=Object.values(geometry.top).map(r=>r.cy); geometry.centerSpread=Math.max(...centers)-Math.min(...centers); geometry.gaps=geometry.rows.map(r=>r.info.x-Math.max(r.thumb.right,r.image.right)); check(geometry.centerSpread<=1,`${key}: center spread ${geometry.centerSpread}`); for (const id of ['#voiceSearchBtn','#carModeBtn']) if(geometry.top[id]) check(geometry.top[id].width>=40&&geometry.top[id].height>=40,`${key}: cramped ${id}`); for (const gap of geometry.gaps) check(gap>=8,`${key}: thumbnail/title gap ${gap}`); check(geometry.top['#searchInput'].width>=24,`${key}: search input collapsed`); check(geometry.top['#carModeBtn'].right<=width-8,`${key}: car clipped at edge`); check(geometry.scrollWidth<=width,`${key}: horizontal page overflow`); results.push({browser:name,layout,entry,width,speech,update,...geometry}); if(width===414&&speech&&!update) await page.screenshot({path:resolve(shotDir,`${label}-${name}-${layout}-${entry}.png`)}); } } // Loading the optional feature must not resize or move the toolbar. await context.close(); } } finally { await browser.close(); } } } finally { await new Promise(r=>server.close(r)); } await writeFile(resolve(shotDir,`${label}-measurements.json`),JSON.stringify({results,failures},null,2)); console.log(`${label}: ${results.length} cases; ${failures.length} failed checks; measurements/screenshots in plans/ui-shots/`); if(failures.length) console.log(failures.slice(0,12).join('\n')); if(!measureOnly) assert.equal(failures.length,0,'UI geometry checks failed');