At 200% zoom on a 390px viewport the compact conversation row held a decision status on one line, so Needs your input pushed the page 12px sideways. The status now takes its own line under the icon and text and wraps. The Overview column margin and split were scoped to Overview, so Requirements keeps its base geometry. Tests cover a real decision status and the Requirements position.
112 lines
10 KiB
JavaScript
112 lines
10 KiB
JavaScript
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.left<data.action.right && data.title.right>data.action.left && data.title.top<data.action.bottom && data.title.bottom>data.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)}`);
|
|
});
|