#!/usr/bin/env node // Settings layout regression checks across responsive widths, layouts and themes. import assert from 'node:assert/strict'; import { createServer } from 'node:http'; import { readFile, mkdir } from 'node:fs/promises'; import { resolve, extname } from 'node:path'; import { chromium, webkit } from 'playwright'; const root = resolve('frontend'), shots = resolve('plans/settings-shots'); await mkdir(shots, { 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 url = new URL(req.url, 'http://localhost'); const path = resolve(root, '.' + (url.pathname === '/' ? '/index.html' : url.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 layouts = ['classic','f7-swipe','glass-stage','bento-hub'], themes = ['dark','light','contrast'], widths = [390,768,1280,1440,1920]; const failures = [], results = []; try { for (const [engineName, engine] of Object.entries({ chromium, webkit })) { const browser = await engine.launch(); try { const context = await browser.newContext({ viewport:{width:1280,height:1000}, serviceWorkers:'block' }); await context.route('**/*', route => route.request().url().startsWith(origin) ? route.continue() : route.abort()); await context.addInitScript(() => localStorage.setItem('_ytpdata', JSON.stringify({ playlists:[], history:[], queue:[], settings:{layout:'glass-stage',theme:'dark',quality:'auto',jamesDsp:{enabled:true,downloadForOffline:false,preset:'warm',params:{'convolver.enabled':1,'spatial.enabled':1},irRef:{name:'Small room'}}} }))); const page = await context.newPage(); page.on('pageerror', error => failures.push(`${engineName}: pageerror ${error.message}`)); await page.goto(origin); await page.waitForFunction(() => typeof data !== 'undefined' && typeof Lazy !== 'undefined'); await page.waitForFunction(() => performance.getEntriesByName('ytp:boot').length > 0); for (const css of ['theme-classic.css','theme-f7.css','theme-glass.css','theme-glass-panel.css','theme-glass-controls.css','theme-bento-hub.css']) await page.addStyleTag({ path:resolve(root,css) }); for (const layout of layouts) { for (const theme of themes) for (const width of widths) { await page.setViewportSize({ width, height:1000 }); await page.evaluate(({theme,layout}) => { data.settings.theme=theme; data.settings.layout=layout; data.settings.jamesDsp.enabled=true; document.documentElement.dataset.theme=theme; document.documentElement.dataset.layout=layout; if(typeof EQ!=='undefined')EQ.listDspIRs=async()=>[]; view={type:'settings'}; render(); }, {theme,layout}); await page.waitForSelector('.settings-browser .settings-section-row[data-section="playback"]',{state:'attached'}); await page.evaluate(()=>{const host=document.querySelector('.settings-browser');host.dataset.detail='1';document.querySelectorAll('.settings-section').forEach(panel=>panel.hidden=panel.id!=='settings-section-playback');document.getElementById('jamesDspControls').hidden=false;}); await page.waitForSelector('#setDspSpatialMode',{state:'attached'}); const measured = await page.evaluate(() => { const rect = e => { const r=e.getBoundingClientRect(); return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right}; }; const selectors=['.settings-browser','.settings-grid','.settings-detail','#listPane .cards','#jamesDspSettings','.dsp-subsection']; const boxes=Object.fromEntries(selectors.map(s=>{const e=document.querySelector(s);return [s,e?{clientWidth:e.clientWidth,scrollWidth:e.scrollWidth,rect:rect(e)}:null];})); const rows=[...document.querySelectorAll('#jamesDspSettings .set-row')].map(row=>{ const label=row.querySelector(':scope > span:first-child'),control=row.querySelector(':scope > input,:scope > select,:scope > .set-control-wrap,:scope > .dsp-file-control'); return {text:row.textContent.trim().slice(0,90),label:label?rect(label):null,control:control?{...rect(control),scrollWidth:control.scrollWidth,clientWidth:control.clientWidth,overflow:getComputedStyle(control).overflow,select:control.querySelector('select')?{...rect(control.querySelector('select')),scrollWidth:control.querySelector('select').scrollWidth,clientWidth:control.querySelector('select').clientWidth}:null}:null,row:rect(row)}; }); const file=document.querySelector('#dspIrFile'),checkbox=document.querySelector('#setDspConvolver'),buttons=[...document.querySelectorAll('.dsp-subsection > button')]; const selects=[...document.querySelectorAll('.settings-browser select')].map(s=>({id:s.id,appearance:getComputedStyle(s).appearance,backgroundImage:getComputedStyle(s).backgroundImage,backgroundColor:getComputedStyle(s).backgroundColor})); const overflows=[...document.querySelectorAll('#jamesDspSettings *')].filter(e=>e.scrollWidth>e.clientWidth+1).map(e=>({tag:e.tagName,id:e.id,cls:e.className,text:e.textContent.trim().slice(0,100),scroll:e.scrollWidth,client:e.clientWidth,rect:rect(e)})); return {boxes,rows,file:{clipPath:getComputedStyle(file).clipPath,rect:rect(file),parent:rect(file.parentElement)},checkbox:{appearance:getComputedStyle(checkbox).appearance,width:rect(checkbox).width},selects,buttons:buttons.map(b=>({whiteSpace:getComputedStyle(b).whiteSpace,scrollWidth:b.scrollWidth,clientWidth:b.clientWidth,rect:rect(b)})),overflows,page:{scrollWidth:document.documentElement.scrollWidth,clientWidth:document.documentElement.clientWidth}}; }); const key=`${engineName}/${layout}/${theme}/${width}`; for (const [selector,box] of Object.entries(measured.boxes)) if(box) { if(box.scrollWidth>box.clientWidth+1) failures.push(`${key}: ${selector} horizontal overflow ${box.scrollWidth}/${box.clientWidth}`); } if(measured.page.scrollWidth>measured.page.clientWidth+1) failures.push(`${key}: document horizontal overflow ${measured.page.scrollWidth}/${measured.page.clientWidth}`); for(const row of measured.rows) if(row.label&&row.control&&row.control.x1||measured.file.clipPath==='none') failures.push(`${key}: native file picker is visible`); for(const b of measured.buttons) if(b.whiteSpace!=='nowrap'||b.rect.height>50) failures.push(`${key}: DSP action button wraps vertically`); results.push({engine:engineName,layout,theme,width,...measured}); if(width===1280) await page.screenshot({path:resolve(shots,`${engineName}-${layout}-${theme}.png`),fullPage:true}); if(width===1280&&layout==='glass-stage'&&theme==='dark') { await page.locator('#dspConvolverTitle').scrollIntoViewIfNeeded(); await page.screenshot({path:resolve(shots,`${engineName}-glass-stage-dark-dsp.png`),fullPage:true}); await page.locator('#dspSpatialTitle').scrollIntoViewIfNeeded(); await page.screenshot({path:resolve(shots,`${engineName}-glass-stage-dark-spatial.png`),fullPage:true}); } } } await context.close(); } finally { await browser.close(); } } } finally { await new Promise(r=>server.close(r)); } await (await import('node:fs/promises')).writeFile(resolve(shots,'measurements.json'),JSON.stringify({cases:results.length,failures,screenshots:results.filter(r=>r.width===1280).map(r=>`${r.engine}-${r.layout}-${r.theme}.png`)},null,2)); console.log(`Settings layout: ${results.length} cases, ${failures.length} failures; screenshots in plans/settings-shots/`); if(failures.length) { console.error(failures.slice(0,30).join('\n')); process.exitCode=1; }