Files
stack/packages/webui/tests/s15-polish-browser.test.mjs
jason.woltje d00eb351e6 fix(webui): keep long breadcrumbs, tabs and speaker names inside narrow layouts
S15 review fixes on top of a584c08ad.

- Breadcrumb: from 700px the project name stops at 40% and a long current
  label ellipsizes after it, so a long Conversation title no longer pushes
  the page wider than the window. Under 700px the current label is hidden
  and the project name shrinks as before.
- Details keeps its size beside a long Conversation title instead of
  squeezing to a vertical label (also present on 7c664fac9).
- The active tab is brought fully into the strip after counts load, after
  a refresh repaint and when a tab receives keyboard focus.
- Long speaker names wrap between words at narrow zoom; the time stays
  whole.

Tests: three new S15 browser tests, red on a584c08ad sources and green
here. S10-S15 each alone 3 times green; webui 952/0/0.
2026-11-07 09:13:12 -06:00

141 lines
17 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, height = width===390?900:1000) {
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,height);
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===${width / 2}`);
await wait("document.querySelector('[data-project-content] [data-project-room-status]')");
return {b,wait};
}
const shotDir='/mnt/storage/scratch/mosaic-lead/uidev/s15-shots';
const settle=b=>b.evaluate('(async()=>{for(let i=0;i<12;i++)await new Promise(requestAnimationFrame)})()');
async function open(t,kind,width=1440,height=1000,zoom=false,source=stub){
const {b,wait}=await setup(t,width,zoom,source,height);
await b.evaluate(`location.hash='#/projects/${kind==='room'?'rooms/'+P:'threads/'+T}'`);
await wait("document.querySelector('.project-chat .chat-log > li')"); await settle(b);
return {b,wait};
}
async function shot(b,name){if(process.env.S15_PHASE) await b.screenshot(`${shotDir}/${process.env.S15_PHASE}-${name}.png`);}
const rect=`e=>{const r=e.getBoundingClientRect();return {x:r.x,y:r.y,width:r.width,height:r.height,right:r.right,bottom:r.bottom}}`;
test('S15 Room details has the specified tall desktop geometry and icon',async t=>{
const {b}=await open(t,'room'); await shot(b,'1-room');
const m=await b.evaluate(`(()=>{const r=${rect},b=document.querySelector('[data-project-dialog=room]'),h=document.querySelector('.project-section-title h2');return {button:r(b),heading:r(h),icon:!!b.querySelector('svg'),name:b.getAttribute('aria-label')}})()`); console.log('S15 item1',JSON.stringify(m));
assert.equal(m.icon,true); assert.equal(m.name,'Room details'); assert.ok(Math.abs(m.button.width-148)<1&&Math.abs(m.button.right-1418)<1&&Math.abs(m.button.y-262)<2&&m.button.height===44,JSON.stringify(m)); assert.ok(Math.abs(m.heading.y-268)<1 && Math.abs(m.heading.bottom-298)<1,JSON.stringify(m));
for(const height of [700,600]){const x=await open(t,'room',1440,height); const v=await x.b.evaluate(`(()=>{const b=document.querySelector('[data-project-dialog=room]'),r=b.getBoundingClientRect();return {width:r.width,height:r.height,icon:!!b.querySelector('svg'),name:b.getAttribute('aria-label')}})()`); assert.ok(v.icon&&v.name==='Room details'&&v.height===44&&Math.abs(v.width-(height===700?128:44))<1,JSON.stringify(v));}
});
test('S15 Conversation Details icon precedes its label',async t=>{
const {b}=await open(t,'conversation'); await shot(b,'2-conversation');
const m=await b.evaluate(`(()=>{const r=${rect},b=document.querySelector('[data-project-dialog=conversation]');return {button:r(b),icon:r(b.querySelector('svg')),label:r(b.querySelector('.project-action-label'))}})()`); console.log('S15 item2',JSON.stringify(m));
assert.ok(m.icon.right<=m.label.x && m.button.height===44&&Math.abs(m.button.right-1418)<1,JSON.stringify(m));
});
test('S15 narrow zoom tabs remain whole and reachable',async t=>{
const {b}=await open(t,'room',390,900,true); await b.evaluate('scrollTo(0,0)'); await settle(b); await shot(b,'3-tabs-zoom');
const m=await b.evaluate(`(()=>{const nav=document.querySelector('.project-tabs'),active=nav.querySelector('[aria-current]'),r=${rect};return {dpr:devicePixelRatio,innerWidth,client:document.documentElement.clientWidth,docWidth:document.documentElement.scrollWidth,nav:r(nav),active:r(active),scrollLeft:nav.scrollLeft,mask:getComputedStyle(nav).maskImage}})()`); console.log('S15 item3',JSON.stringify(m));
assert.equal(m.dpr,2); assert.equal(m.innerWidth,195); assert.equal(m.docWidth,m.client); assert.equal(m.mask,'none'); assert.ok(m.active.x>=m.nav.x-1&&m.active.right<=m.nav.right+1,JSON.stringify(m));
const all=await b.evaluate(`(async()=>{const nav=document.querySelector('.project-tabs'),result=[];for(const a of nav.querySelectorAll('a')){a.scrollIntoView({block:'nearest',inline:'nearest'});await new Promise(requestAnimationFrame);const n=nav.getBoundingClientRect(),r=a.getBoundingClientRect();result.push({name:a.textContent,left:r.left,right:r.right,nleft:n.left,nright:n.right})}return result})()`); assert.ok(all.every(x=>x.left>=x.nleft-1&&x.right<=x.nright+1),JSON.stringify(all));
});
test('S15 narrow zoom message metadata keeps words and time intact',async t=>{
const source=stub+`;fixtureRoom[0].to=['assistant'];`;
const {b}=await open(t,'room',390,900,true,source); await b.evaluate("document.querySelector('.chat-message-human').scrollIntoView({block:'center'})"); await settle(b); await shot(b,'4-meta-zoom');
const m=await b.evaluate(`(()=>{const meta=document.querySelector('.chat-message-human .chat-message-meta'),walk=document.createTreeWalker(meta,NodeFilter.SHOW_TEXT),words=[];let n;while(n=walk.nextNode()){for(const match of n.textContent.matchAll(/[A-Za-z]+|\\d+:\\d+/g)){const r=document.createRange();r.setStart(n,match.index);r.setEnd(n,match.index+match[0].length);words.push({word:match[0],rects:r.getClientRects().length})}}const time=meta.querySelector('time');return {dpr:devicePixelRatio,innerWidth,words,timeRects:(()=>{const r=document.createRange();r.selectNodeContents(time);return r.getClientRects().length})()}})()`); console.log('S15 item4',JSON.stringify(m));
assert.equal(m.dpr,2); assert.equal(m.innerWidth,195); assert.ok(m.words.some(x=>x.word==='assistant')); assert.ok(m.words.every(x=>x.rects===1)&&m.timeRects===1,JSON.stringify(m));
});
test('S15 long project breadcrumb is single-line and preserves its name',async t=>{
const title='A very long project title that must stay available to everyone while the window is short';
const {b}=await open(t,'room',700,541,false,stub+`;fixtureProjects[0].name=${JSON.stringify(title)}`); await shot(b,'5-breadcrumb');
const m=await b.evaluate(`(()=>{const r=${rect},box=document.querySelector('.breadcrumbs'),a=box.querySelectorAll('a')[1];return {height:document.documentElement.scrollHeight,viewport:innerHeight,box:r(box),link:r(a),title:a.title,text:a.textContent,scrollWidth:a.firstElementChild.scrollWidth,clientWidth:a.firstElementChild.clientWidth,overflow:getComputedStyle(a.firstElementChild).textOverflow}})()`); console.log('S15 item5',JSON.stringify(m));
assert.equal(m.height,m.viewport); assert.equal(m.title,title); assert.equal(m.text,title); assert.equal(m.overflow,'ellipsis'); assert.ok(m.scrollWidth>m.clientWidth,JSON.stringify(m));
});
test('S15 truncated room member chips expose full names on hover',async t=>{
const name='A member with a much longer display name that cannot fit in the compact picker';
const source=stub+`;{const old=fetch;window.fetch=(u,i)=>String(u)==='/api/chat/instances'?Promise.resolve(new Response(JSON.stringify({rows:[{instance:'assistant',name:${JSON.stringify(name)}},{instance:'reviewer',name:'reviewer'}]}),{headers:{'content-type':'application/json'}})):old(u,i)}`;
const {b}=await open(t,'room',700,541,false,source); await shot(b,'6-chips');
const m=await b.evaluate(`(()=>{const c=document.querySelector('[data-room-chip=assistant]');return {title:c.title,text:c.textContent,scroll:c.scrollWidth,client:c.clientWidth}})()`);console.log('S15 item6',JSON.stringify(m)); assert.ok(m.scroll>m.client,JSON.stringify(m)); assert.equal(m.title,name); assert.equal(m.text,name);
});
test('S15 channel-disabled conversation follows message end instead of disabled composer',async t=>{
const history=stub+`;for(let i=0;i<42;i++)fixtureMessages.push({id:'40000000-0000-4000-8000-'+String(i+10).padStart(12,'0'),from_role:'human',body:'History '+i+' '+('message text '.repeat(8)),at:'2026-11-06T10:26:00Z'});{const old=fetch;window.fetch=(u,i)=>String(u).includes('/messages')&&i?.method==='POST'?Promise.resolve(new Response(JSON.stringify({error:'channel-disabled',sent:false}),{status:409,headers:{'content-type':'application/json'}})):old(u,i)}`;
const {b,wait}=await open(t,'conversation',390,900,true,history);
await b.evaluate("document.querySelector('#chat-body').value='hello';document.querySelector('#chat-body').dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#chat-send-form').requestSubmit()");
await wait("document.querySelector('#chat-receipt').textContent.includes('disabled')"); await settle(b);
await b.evaluate("document.querySelector('#chat-new').click()"); await settle(b);
const m=await b.evaluate(`(()=>{const l=document.querySelector('.chat-log'),f=document.querySelector('#chat-send-form'),last=l.lastElementChild.getBoundingClientRect(),form=f.getBoundingClientRect();return {dpr:devicePixelRatio,width:innerWidth,send:f.querySelector('button[type=submit]').disabled,lastBottom:last.bottom,formBottom:form.bottom,height:innerHeight}})()`); console.log('S15 fallback',JSON.stringify(m)); assert.equal(m.dpr,2); assert.equal(m.send,true); assert.ok(Math.abs(m.lastBottom-m.height)<2&&m.formBottom>m.height+20,JSON.stringify(m));
});
test('S15 preserves the S13 compact Room control at 700x760',async t=>{
const {b}=await open(t,'room',700,760);
const m=await b.evaluate(`(()=>{const b=document.querySelector('[data-project-dialog=room]'),r=b.getBoundingClientRect();return {width:r.width,height:r.height,icon:!!b.querySelector('svg')}})()`);
console.log('S15 boundary',JSON.stringify(m)); assert.equal(m.width,128); assert.equal(m.height,44); assert.equal(m.icon,true);
});
test('S15 long Conversation title keeps the breadcrumb inside the page at 700x760',async t=>{
const title='Review the Pomodoro timer implementation and its keyboard accessibility before delivery';
const {b}=await open(t,'conversation',700,760,false,stub+`;fixtureThreads[0].name=${JSON.stringify(title)}`); await shot(b,'7-long-conversation-crumb');
const m=await b.evaluate(`(()=>{const r=${rect},c=document.querySelector('.project-crumb-current'),label=c.querySelector('[aria-current]');const n=document.querySelector('.project-crumb-name span'),nr=document.createRange();nr.selectNodeContents(n);return {client:document.documentElement.clientWidth,doc:document.documentElement.scrollWidth,crumb:r(c),label:label.textContent,heading:document.querySelector('.project-header h1').textContent,name:[nr.getBoundingClientRect().width,n.getBoundingClientRect().width],details:r(document.querySelector('[data-project-dialog=conversation]'))}})()`); console.log('S15 F1',JSON.stringify(m));
assert.ok(m.name[0]<=m.name[1]+0.01,'short project name stays whole '+JSON.stringify(m)); assert.ok(m.details.height===44&&m.details.width>=90&&m.details.right<=m.client,JSON.stringify(m));
assert.ok(m.doc<=m.client,JSON.stringify(m)); assert.ok(m.crumb.right<=m.client+1,JSON.stringify(m)); assert.ok(m.crumb.width>=150,JSON.stringify(m)); assert.equal(m.label,title); assert.equal(m.heading,title);
const wide=await open(t,'conversation',1440,1000);
const w=await wide.b.evaluate(`(()=>{const c=document.querySelector('.project-crumb-current'),n=document.querySelector('.project-crumb-name span');return {current:[c.scrollWidth,c.clientWidth],name:[n.scrollWidth,n.clientWidth]}})()`);
assert.ok(w.current[0]<=w.current[1]&&w.name[0]<=w.name[1],JSON.stringify(w));
const long=await open(t,'conversation',1440,1000,false,stub+`;fixtureThreads[0].name=${JSON.stringify(title)}`);
const d=await long.b.evaluate(`(${rect})(document.querySelector('[data-project-dialog=conversation]'))`); assert.ok(d.height===44&&Math.abs(d.right-1418)<1,JSON.stringify(d));
});
test('S15 narrow zoom keeps the active tab whole after counts load, refresh and Tab focus',async t=>{
const {b,wait}=await open(t,'room',390,900,true);
const whole=`(()=>{const n=document.querySelector('.project-tabs'),a=n.querySelector('[aria-current]'),f=document.activeElement?.closest?.('.project-tabs a'),nr=n.getBoundingClientRect(),in_=e=>{const r=e.getBoundingClientRect();return r.left>=nr.left-1&&r.right<=nr.right+1};return {dpr:devicePixelRatio,text:a.textContent,active:in_(a),focused:f?{text:f.textContent,whole:in_(f)}:null,page:scrollY,doc:document.documentElement.scrollWidth,client:document.documentElement.clientWidth}})()`;
await b.evaluate(`document.querySelector('.project-tabs a[href="#/projects/${P}/files"]').click()`); await wait("document.querySelector('.project-tabs [aria-current]')?.textContent.startsWith('Files')"); await settle(b);
await b.evaluate("document.querySelector('#refresh').click()"); await settle(b); await settle(b);
const m=await b.evaluate(whole); console.log('S15 F2 refresh',JSON.stringify(m)); assert.equal(m.dpr,2); assert.ok(m.active&&m.doc===m.client,JSON.stringify(m));
await b.evaluate("document.querySelector('.project-tabs').scrollLeft=0;document.querySelector('.project-tabs a').focus()"); await settle(b);
const seen=[];
for(let i=0;i<8;i++){await b.key('Tab'); await settle(b); const f=await b.evaluate(whole); if(!f.focused) break; seen.push(f.focused.text); assert.ok(f.focused.whole,JSON.stringify(f));}
console.log('S15 F2 tab',JSON.stringify(seen)); assert.ok(seen.length>=4,JSON.stringify(seen));
});
test('S15 narrow zoom long speaker name wraps between words beside an intact time',async t=>{
const member='Assistant with a longer display name';
const source=stub+`;{const old=fetch;window.fetch=(u,i)=>String(u)==='/api/chat/instances'?Promise.resolve(new Response(JSON.stringify({rows:[{instance:'assistant',name:${JSON.stringify(member)}},{instance:'reviewer',name:'reviewer'}]}),{headers:{'content-type':'application/json'}})):old(u,i)}`;
const {b}=await open(t,'room',390,900,true,source); await b.evaluate("document.querySelector('.chat-message-agent').scrollIntoView({block:'center'})"); await settle(b); await shot(b,'8-speaker-zoom');
const m=await b.evaluate(`(()=>{const s=document.querySelector('.chat-message-agent .chat-message-meta strong'),time=s.parentElement.querySelector('time'),l=document.querySelector('.chat-log').getBoundingClientRect(),a=s.getBoundingClientRect(),words=[];const w=document.createTreeWalker(s,NodeFilter.SHOW_TEXT);let n;while(n=w.nextNode())for(const x of n.textContent.matchAll(/\\S+/g)){const r=document.createRange();r.setStart(n,x.index);r.setEnd(n,x.index+x[0].length);words.push(r.getClientRects().length)}const tr=document.createRange();tr.selectNodeContents(time);return {name:s.textContent,left:a.left,right:a.right,logLeft:l.left,logRight:l.right,words,timeRects:tr.getClientRects().length}})()`); console.log('S15 F3',JSON.stringify(m));
assert.equal(m.name,member); assert.ok(m.left>=m.logLeft-1&&m.right<=m.logRight+1,JSON.stringify(m)); assert.ok(m.words.every(x=>x===1)&&m.timeRects===1,JSON.stringify(m));
});