Files
stack/packages/webui/tests/s9-reflow-browser.test.mjs
jason.woltje 266f3a45ff fix(webui): E2E6 review round, apply-phase refusals, held sections, tab fade, header pins (#1873)
Review finding 1: a hire start refusal the apply step can also raise after the decision is
consumed (decision-stale, organization-instance-*, organization-refused, definition-missing,
invalid-request and the rest) no longer says "Nothing was applied". It says that part may be
applied when the inbox shows the proposal as answered, and to check Agents first. Only codes
that prepare() or the route checks alone raise keep "Nothing was applied".
Review P3: a failed workflows or autonomy read keeps the last good read, so an open editor or
a dirty form stays through it. Focus holds a section only in a field being typed in; a focused
button is rebuilt and gets focus back by data-focus-key. Autonomy has Discard changes. The
project tab fade is 0px when every tab fits.
s8 and s9 header pins at 1440 now measure from the content edge: 1418 was 100vw-based, ran
under the page scrollbar and is the P2-11 clipping itself.

Correction to the previous commit's message: P1-2 is the unknown project, agent or harness
wording; P2-4 names the refused field; P2-6 is the Queue refusal reason; P2-8 is "bus stopped".
2026-11-13 19:52:26 -06:00

115 lines
11 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 {sb:innerWidth-document.documentElement.clientWidth,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));
// E2E6 P2-11 (#1873): at 1440 the header actions end 22px inside the content edge, so the design's
// x values move left by the page scrollbar when the page scrolls (1418 was 100vw-based and clipped Settings).
const sb=width===1440?data.sb:0;
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]]).map(([x0,y0,x1,y1])=>[x0-sb,y0,x1-sb,y1]);
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)}`);
});