const {test,expect}=require('@playwright/test'); const {openClassic,showPlayingFixture}=require('./helpers/classic-fixture'); const {mkdirSync,readFileSync,existsSync}=require('node:fs'); const {join}=require('node:path'); const {homedir}=require('node:os'); const {differentPixels}=require('./helpers/png-diff'); const out=process.env.BENTO_SCREENSHOT_DIR||join(homedir(),'deliverables','ytplayer-done13');mkdirSync(out,{recursive:true}); const before=process.env.CAPTURE_BEFORE==='1'; const screens=['home','search','playlist','queue','history','saved','downloads','channel','settings','settings-detail','now-playing','lyrics','chapters','lyric-search','service','modal','sheet','stage-lyrics','batch','library','empty-queue','empty-saved','empty-downloads']; async function scene(page,layout,screen){ // Freeze the automatic Classic mini-player observer in these static scenes. // Otherwise its pending first callback races our intentionally visible mini bar. await page.addInitScript(()=>{const Observer=window.IntersectionObserver;window.IntersectionObserver=class extends Observer{observe(el){if(el.id!=='controls')super.observe(el);}};}); await openClassic(page,{populated:true,standalone:page.viewportSize().width<900});await showPlayingFixture(page); await page.evaluate(({layout,screen})=>{ data.settings.layout=layout;applyAppearance(); const videos=data.playlists[0].videos; API.cacheList=async()=>({ok:true,items:screen==='empty-saved'?[]:videos.map(v=>({...v,size:84000000})),total:screen==='empty-saved'?0:1008000000}); Downloads.configure({slots:()=>4,files:async()=>[],playlists:()=>data.playlists,jobs:()=>!['downloads','settings-detail'].includes(screen)?[]:['active','queued','paused','failed'].map((status,i)=>({...videos[i],status,error:status==='failed'?'ERROR: [youtube] Video unavailable':null,progress:status==='active'?{received:24000000,total:84000000}:null})),action:()=>{}}); channelData={name:'Preview fixtures',url:'https://www.youtube.com/@preview',key:'preview',results:videos,loading:false}; searchResults=screen==='home'?[]:videos; searchAttempted=screen!=='home'; if(screen==='empty-queue'){queue=[];data.queue=[];} view=screen==='home'?{type:'search'}:screen==='playlist'?{type:'playlist',id:'studio'}:{type:screen.replace('empty-','')}; if(screen==='home'){current=null;els.playerPane.classList.add('empty');els.placeholder.classList.remove('hidden');} if(['now-playing','lyrics','chapters','lyric-search','service','modal','sheet','stage-lyrics','batch','library'].includes(screen))view={type:'playlist',id:'studio'}; if(screen==='settings-detail')view={type:'settings'}; render();renderUpNext(); if(screen!=='home')showMiniBar(); if(['lyrics','chapters','lyric-search'].includes(screen)){ $('notesPanel').classList.remove('hidden'); const tab=screen==='lyrics'?'lyrics':screen==='chapters'?'markers':'search'; document.querySelectorAll('.notes-tab').forEach(b=>b.classList.toggle('active',b.dataset.tab===tab)); ['lyrics','markers','search'].forEach(t=>$(t+'Pane').classList.toggle('hidden',t!==tab)); $('lyricsList').innerHTML='
Because You are God
You can do anything
You can do anything
Your grace is enough
'; $('chapterList').innerHTML=''; } if(screen==='service'){data.settings.serviceLyrics=true;enterServiceMode(data.playlists[0]);$('serviceLyrics').classList.remove('hidden');$('serviceLyricsList').innerHTML='
Because You are God
You can do anything
';} if(screen==='batch'){selectMode=true;selectedIds=new Set(videos.slice(0,2).map(v=>v.id));renderList();} if(screen==='sheet')openMoreControls(); if(screen==='stage-lyrics'){ $('stageLyrics').classList.remove('hidden');$('stageLyricsList').innerHTML='
Because You are God
You can do anything
'; } if(screen==='modal')showModal('Add to playlist',document.createTextNode('Keep this song in your Sunday setlist.'),[{label:'Cancel',onClick:closeModal},{label:'Add',primary:true,onClick:closeModal}]); },{layout,screen}); if(screen==='settings-detail')await page.getByRole('button',{name:/Downloads & storage/}).click(); if(layout==='bento-hub'&&['now-playing','lyrics','chapters','lyric-search','stage-lyrics'].includes(screen))await page.evaluate(()=>document.querySelector('.body').scrollTop=0); await page.evaluate(()=>document.querySelectorAll('progress:not([value])').forEach(el=>el.value=0)); await page.addStyleTag({content:'*,*::before,*::after{animation:none!important;transition:none!important;caret-color:transparent!important;scroll-behavior:auto!important}'}); await page.evaluate(async()=>{ await document.fonts.ready; await Promise.all([...document.images].map(img=>{img.loading='eager';return img.decode().catch(()=>{});})); if(document.activeElement instanceof HTMLElement)document.activeElement.blur(); });await page.waitForTimeout(300); // Short Classic empty lists otherwise clamp navigation one pixel before // their header on WebKit. An offscreen spacer makes the static route exact. if(layout==='classic'&&screen==='empty-saved'&&page.viewportSize().width===390)await page.evaluate(()=>{const body=document.querySelector('.body');$('listPane').style.minHeight=(body.clientHeight+8)+'px';}); // render() navigates before fonts finish; settle the real list route once // after font metrics are known, so WebKit cannot capture an old scroll offset. if(!['home','now-playing','lyrics','chapters','lyric-search','stage-lyrics','service','modal','sheet','batch'].includes(screen)){await page.evaluate(()=>scrollListIntoViewPortrait());await page.waitForTimeout(100);} } for(const width of [390,1440])for(const screen of screens)for(const layout of before?['classic','glass-stage']:['classic','glass-stage','bento-hub'])test(`${layout} ${width} ${screen}`,async({page},info)=>{ await page.setViewportSize({width,height:844});await scene(page,layout,screen); const name=`${layout}-${width}-${screen}-${info.project.name}.png`; const buffer=await page.screenshot({path:`${out}/${before?'before':'after'}-${name}`}); if(before)return; if(layout!=='bento-hub'){expect(differentPixels(readFileSync(`${out}/before-${name}`),buffer)).toBeLessThanOrEqual(40);return;} expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); if(['lyrics','chapters','lyric-search'].includes(screen)){await page.locator('#notesPanel').scrollIntoViewIfNeeded();await page.screenshot({path:`${out}/panel-${name}`});} if(screen==='now-playing')await expect(page.locator('#controls')).toBeVisible(); if(screen==='downloads'){const retry=page.getByRole('button',{name:/^Retry /});await expect(retry).toBeVisible();expect(await retry.evaluate(el=>getComputedStyle(el).backgroundColor)).toBe('rgb(255, 183, 3)');} if(width===390&&!['service','modal','sheet'].includes(screen)){await expect(page.locator('#bottomNav')).toBeVisible(); for(const button of await page.locator('#bottomNav button').all()){const icon=await button.locator('.bottom-nav-icon').boundingBox(),label=await button.locator('.bottom-nav-label').boundingBox();expect(Math.abs(icon.x+icon.width/2-label.x-label.width/2)).toBeLessThan(1);}} expect(await page.locator('#sectionRail').evaluate(el=>el.parentElement===document.body)).toBe(true); if(!['service','modal','sheet','stage-lyrics','batch','library'].includes(screen)&&width===390){const tabs=page.locator('#bottomNav button');for(const b of await tabs.all()){if(await b.isVisible()){const r=await b.boundingBox();expect(r.height).toBeGreaterThanOrEqual(44);}}} }); for(const width of [390,1440])test(`reference ${width}`,async({page},info)=>{ test.skip(!existsSync('docs/mockups/bento-hub/index.html'),'The design reference is an optional local deliverable.'); await page.setViewportSize({width,height:844});await page.route('**/__bento-reference',r=>r.fulfill({contentType:'text/html',body:readFileSync('docs/mockups/bento-hub/index.html','utf8')}));await page.goto('/__bento-reference');await page.evaluate(()=>document.fonts.ready); await page.screenshot({path:`${out}/reference-${width}-${info.project.name}.png`,fullPage:true}); for(const section of await page.locator('.screen-section').all()){ await section.evaluate(el=>{document.querySelectorAll('.screen-section').forEach(s=>s.classList.remove('active'));el.classList.add('active');}); const id=await section.getAttribute('id'); const frame=section.locator(width===390?'.frame-phone':'.frame-desktop'); if(await frame.count())await frame.screenshot({path:`${out}/reference-${id}-${width}-${info.project.name}.png`}); } }); if(!before){ for(const width of [390,960])test(`Bento ${width} navigation and floating clearance`,async({page})=>{ await page.setViewportSize({width,height:844});await scene(page,'bento-hub','downloads'); await page.locator('#sidebarToggle').click();await expect(page.locator('.app')).toHaveClass(/sidebar-open/);await page.locator('#sidebarBackdrop').click({position:{x:width-8,y:20}}); const chrome=await page.evaluate(()=>{const body=document.querySelector('.body').getBoundingClientRect(),mini=$('miniBar').getBoundingClientRect();return {bodyBottom:body.bottom,miniTop:mini.top};}); expect(chrome.bodyBottom).toBeLessThan(chrome.miniTop); if(width===390){await page.locator('#miniTitle').click();await expect(page.locator('#controls')).toBeInViewport();} }); for(const layout of ['classic','glass-stage'])test(`Bento restores ${layout} after switching`,async({page})=>{ await page.setViewportSize({width:390,height:844});await scene(page,layout,'home');await page.evaluate(()=>document.querySelector('.body').scrollTop=0);const original=await page.screenshot(); await page.evaluate(()=>{data.settings.layout='bento-hub';applyAppearance();});await page.waitForTimeout(100); await page.evaluate(layout=>{data.settings.layout=layout;applyAppearance();},layout);await page.waitForTimeout(100);await page.evaluate(()=>document.querySelector('.body').scrollTop=0); // Chromium may rerasterize composited corners by one RGB level after a theme switch. expect(differentPixels(original,await page.screenshot(),1)).toBeLessThanOrEqual(40); }); for(const theme of ['light','contrast'])test(`Bento ${theme} appearance`,async({page},info)=>{ await page.setViewportSize({width:390,height:844});await scene(page,'bento-hub','saved');await page.evaluate(theme=>{data.settings.theme=theme;applyAppearance();},theme); await page.screenshot({path:`${out}/after-bento-hub-390-${theme}-${info.project.name}.png`}); await expect(page.locator('.saved-file-action').first()).toBeVisible(); }); }