import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp, mkdir, writeFile, chmod, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fixture } from './fixture.mjs'; import { browser } from './browser.mjs'; import { P, T, stub } from './helpers/projects-review-fixture.mjs'; // Use Chromium tab zoom, not CSS zoom or device-scale emulation. The profile's // default zoom and extension both set the same 200% level for fixture pages. async function zoomLauncher() { const dir = await mkdtemp(join(tmpdir(), 's9-zoom-')); const ext = join(dir, 'extension'); await mkdir(ext); await writeFile(join(ext, 'manifest.json'), JSON.stringify({manifest_version:3,name:'S9 fixture zoom',version:'1.0',permissions:['tabs'],background:{service_worker:'zoom.js'}})); await writeFile(join(ext, 'zoom.js'), "chrome.tabs.onUpdated.addListener((id,change,tab)=>{if(tab.url?.startsWith('http://127.0.0.1:')) chrome.tabs.setZoom(id,2)});\n"); const launcher = join(dir, 'chromium'); await writeFile(launcher, `#!/usr/bin/env python3\nimport json, math, os, pathlib, sys\np = pathlib.Path(next(a.split('=',1)[1] for a in sys.argv[1:] if a.startswith('--user-data-dir='))) / 'Default'\np.mkdir(exist_ok=True)\n(p / 'Preferences').write_text(json.dumps({'partition': {'default_zoom_level': math.log(2)/math.log(1.2)}}))\nos.execv('/usr/bin/chromium', ['/usr/bin/chromium', '--disable-extensions-except=${ext}', '--load-extension=${ext}', *sys.argv[1:]])\n`); await chmod(launcher, 0o755); return {dir,launcher}; } async function setup(t, width, zoom = false, source = stub) { const f = await fixture(); const previous = process.env.CHROMIUM; const z = zoom ? await zoomLauncher() : null; if (z) process.env.CHROMIUM = z.launcher; let b; try { b = await browser({machine:false}); } finally { if (previous === undefined) delete process.env.CHROMIUM; else process.env.CHROMIUM = previous; } t.after(async () => { await b.close(); await f.close(); if(z) await rm(z.dir,{recursive:true,force:true}); }); await b.call('Fetch.enable',{patterns:[{urlPattern:'*'}]}); b.on('Fetch.requestPaused',p=>void b.call(p.request.url.startsWith(f.base+'/')?'Fetch.continueRequest':'Fetch.failRequest',p.request.url.startsWith(f.base+'/')?{requestId:p.requestId}:{requestId:p.requestId,errorReason:'BlockedByClient'}).catch(()=>{})); await b.call('Page.addScriptToEvaluateOnNewDocument',{source}); await b.viewport(width,width===390?900:1000); await b.navigate(f.base+'/#/projects/'+P); const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return;await new Promise(r=>setTimeout(r,30))}throw Error('Timeout '+${JSON.stringify(expr)})})()`); if(zoom) await wait('devicePixelRatio===2 && innerWidth===195'); await wait("document.querySelector('[data-project-content] [data-project-room-status]')"); return {b,wait}; } const routes = [['overview','/projects/'+P], ...['conversations','tasks','requirements','files','settings'].map(n=>[n,'/projects/'+P+'/'+n]), ['room','/projects/rooms/'+P], ['conversation','/projects/threads/'+T]]; for (const zoom of [true,false]) test(`S9 project routes reflow at ${zoom ? 'actual 200% tab zoom' : '320 CSS pixels'}`, async t => { const {b,wait}=await setup(t,zoom ? 390 : 320,zoom), problems=[]; for(const [name,route] of routes) { await b.evaluate(`location.hash=${JSON.stringify('#'+route)}`); await wait(`document.querySelector('.project-header')?.dataset.projectSection===${JSON.stringify(name==='conversation'?'conversations':name)} && document.querySelector('.project-header')?.dataset.projectConversation===${JSON.stringify(String(name==='conversation'))}`); await wait("!document.querySelector('[data-project-content]')?.textContent.includes('Reading…')"); await b.evaluate('scrollTo(0,0)'); await b.evaluate('document.fonts.ready.then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))'); const data=await b.evaluate(`(()=>{const d=document.documentElement,rect=e=>{const r=e.getBoundingClientRect();return {left:r.left,right:r.right,top:r.top,bottom:r.bottom}};const h=document.querySelector('.project-heading h1'),a=document.querySelector('[data-project-dialog="conversation"]');return {client:d.clientWidth,scroll:d.scrollWidth,title:rect(h),action:a?rect(a):null,header:rect(document.querySelector('.project-header')),footer:document.querySelector('footer.foot')?rect(document.querySelector('footer.foot')):null,rows:[...document.querySelectorAll('.project-conversation-row')].map(e=>rect(e.lastElementChild))}})()`); if(process.env.S9_MEASURE) console.log('S9 reflow',zoom ? '200%' : '320px',name,JSON.stringify(data)); if(data.scroll>data.client) problems.push(`${name}: scroll ${data.scroll} > ${data.client}`); if(data.action && data.title.leftdata.action.left && data.title.topdata.action.top) problems.push(`${name}: title overlaps Details`); if(name==='conversation' && data.footer && data.header.bottom>data.footer.top) problems.push(`${name}: footer overlaps header`); for(const row of data.rows) if(row.right>data.client || row.left<0) problems.push(`${name}: chevron outside viewport ${JSON.stringify(row)}`); if(zoom && process.env.S9_CAPTURE && ['conversations','conversation'].includes(name)) { await b.screenshot(`${process.env.S9_CAPTURE}/${process.env.S9_PHASE}-${name}-390-200.png`); if(name==='conversations') { await b.evaluate("document.querySelector('.project-conversation-row').scrollIntoView({block:'center'})"); await b.screenshot(`${process.env.S9_CAPTURE}/${process.env.S9_PHASE}-conversations-rows-390-200.png`); } } } assert.deepEqual(problems,[]); }); for(const width of [1440,390]) test(`S9 Overview action and S8 header boxes at ${width}`,async t=>{ const {b}=await setup(t,width); const data=await b.evaluate(`(()=>{const rect=e=>{const r=e.getBoundingClientRect();return [r.left,r.top,r.right,r.bottom]};return {button:rect(document.querySelector('[data-project-content] .project-section-title .btn')),section:rect(document.querySelector('.project-columns > section')),headers:[...document.querySelectorAll('.project-primary-action')].map(rect)}})()`); if(process.env.S9_CAPTURE) await b.screenshot(`${process.env.S9_CAPTURE}/${process.env.S9_PHASE}-overview-${width}.png`); if(process.env.S9_MEASURE) console.log('S9 geometry',width,JSON.stringify(data)); const expected=width===1440?[[1060,130,1190,174],[1202,130,1290,174],[1302,130,1418,174]]:[[12,198,132,242],[140,198,220,242],[228,198,340,242]]; for(let i=0;i<3;i++) for(let j=0;j<4;j++) assert.ok(Math.abs(data.headers[i][j]-expected[i][j])<=4,`header ${i}: ${JSON.stringify(data.headers[i])}`); // The accepted shell reserves a scrollbar and narrows the content; the // action must align to its section when that puts the target right edge out of reach. const [left,top,right,bottom]=data.button, target=width===1440?[792,446,968,490]:[206,538,378,582]; const rightLimit=width===1440 ? target[2] : Math.min(target[2],data.section[2]); assert.ok(Math.abs(right-rightLimit)<=4,`button right/section: ${JSON.stringify(data)}`); assert.ok(Math.abs((right-left)-(target[2]-target[0]))<=4,`button width: ${JSON.stringify(data)}`); assert.ok(Math.abs(top-target[1])<=4 && Math.abs(bottom-target[3])<=4,`button vertical: ${JSON.stringify(data)}`); if(width===390 && process.env.S9_CAPTURE) { await b.evaluate(`location.hash=${JSON.stringify('#/projects/threads/'+T)}`); await b.evaluate("(async()=>{for(let i=0;i<200;i++){if(document.querySelector('.project-header')?.dataset.projectConversation==='true')return;await new Promise(r=>setTimeout(r,30))}throw Error('conversation timeout')})()"); await b.screenshot(`${process.env.S9_CAPTURE}/${process.env.S9_PHASE}-conversation-390-100.png`); } }); // A decision wait gives the widest status label; it must wrap, not push the page sideways. const decision = stub.replace("wait:null}", "wait:id==='request-1'?{kind:'decision',code:'fixture-decision'}:null}"); test('S9 conversation rows keep a wide status inside the page at actual 200% tab zoom', async t => { assert.notEqual(decision, stub); const {b,wait}=await setup(t,390,true,decision); await b.evaluate(`location.hash=${JSON.stringify('#/projects/'+P+'/conversations')}`); await wait("[...document.querySelectorAll('.project-conversation-row .project-chip')].some(e=>e.textContent==='Needs your input')"); await b.evaluate('document.fonts.ready.then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))'); const data=await b.evaluate(`(()=>{const d=document.documentElement;return {client:d.clientWidth,scroll:d.scrollWidth,chips:[...document.querySelectorAll('.project-conversation-row .project-chip')].map(e=>{const r=e.getBoundingClientRect();return {text:e.textContent,left:r.left,right:r.right}})}})()`); if(process.env.S9_MEASURE) console.log('S9 decision',JSON.stringify(data)); if(process.env.S9_CAPTURE) { await b.evaluate("document.querySelector('.project-conversation-row').scrollIntoView({block:'center'})"); await b.screenshot(`${process.env.S9_CAPTURE}/${process.env.S9_PHASE}-conversations-decision-390-200.png`); } assert.ok(data.scroll<=data.client,`scroll ${data.scroll} > ${data.client}`); for(const chip of data.chips) assert.ok(chip.left>=0 && chip.right<=data.client,`chip outside: ${JSON.stringify(chip)}`); }); // The Overview column geometry must not move Requirements (S9 review finding 3). for(const [width,expected] of [[390,{top:512.125}],[1440,{top:438.516,right:960.109}]]) test(`S9 Requirements columns keep their base geometry at ${width}`, async t => { const {b,wait}=await setup(t,width); await b.evaluate(`location.hash=${JSON.stringify('#/projects/'+P+'/requirements')}`); await wait("document.querySelector('.project-header')?.dataset.projectSection==='requirements' && document.querySelector('.project-columns > section')"); await wait("!document.querySelector('[data-project-content]')?.textContent.includes('Reading…')"); await b.evaluate('document.fonts.ready.then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))'); const r=await b.evaluate("(()=>{scrollTo(0,0);const r=document.querySelector('.project-columns > section').getBoundingClientRect();return {top:r.top,right:r.right}})()"); if(process.env.S9_MEASURE) console.log('S9 requirements',width,JSON.stringify(r)); for(const k of Object.keys(expected)) assert.ok(Math.abs(r[k]-expected[k])<=1,`${k}: ${JSON.stringify(r)}`); });