Files
stack/packages/webui/tests/s13-room-short-browser.test.mjs
jason.woltje 94c9f04f5b fix(webui): keep Room details at the right edge in short windows
In the short desktop Room layout the section title sat in the first grid column, so Room details ended mid-row. The title now spans the row and the members line sits right-aligned before the button, capped so long names ellipsize.
2026-11-07 06:37:13 -06:00

88 lines
11 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { P, T, stub } from './helpers/projects-review-fixture.mjs';
// Same 45-message Room history used for S12's composer checks.
const history = stub + `;for(let i=0;i<42;i++){const n=String(i+10).padStart(12,'0'),body='History '+i+' '+('message text '.repeat(8)),at='2026-11-06T10:26:00Z',id='50000000-0000-4000-8000-'+n;fixtureRoom.push({id,ids:[id],from_role:'human',to:['assistant'],body,at})}`;
const controls = {
'conversation-1440x700': {log:{top:263.390625,bottom:478,height:214.609375},form:{top:508,bottom:658,height:150}},
'conversation-1366x650': {log:{top:263.390625,bottom:428,height:164.609375},form:{top:458,bottom:608,height:150}},
'conversation-1280x720': {log:{top:263.390625,bottom:498,height:234.609375},form:{top:528,bottom:678,height:150}},
'conversation-1440x760': {log:{top:263.390625,bottom:538,height:274.609375},form:{top:568,bottom:718,height:150}},
'room-1440x1000': {log:{top:366.171875,bottom:766,height:399.828125},form:{top:796,bottom:958,height:162}},
'conversation-1440x1000': {log:{top:263.390625,bottom:778,height:514.609375},form:{top:808,bottom:958,height:150}},
'room-390x900': {log:{top:394.609375,bottom:663,height:268.390625},form:{top:693,bottom:855,height:162}},
'conversation-390x900': {log:{top:237.1875,bottom:697,height:459.8125},form:{top:727,bottom:855,height:128}},
};
async function setup(t, width, height, source, dark, scale) {
const f = await fixture();
const b = await browser({machine:false});
t.after(async () => { await b.close(); await f.close(); });
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: source + (dark ? `;localStorage.setItem('mosaic-console-appearance',JSON.stringify({palette:'harbor',mode:'dark'}));` : '')});
await b.viewport(width,height);
// A 2x display at 100% zoom has the same CSS viewport as 1x and must get the same layout.
if (scale !== 1) await b.call('Emulation.setDeviceMetricsOverride',{width,height,deviceScaleFactor:scale,mobile:false});
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)})})()`);
await wait("document.querySelector('[data-project-content] [data-project-room-status]')");
return {b,wait};
}
const measure = b => b.evaluate(`(()=>{const log=document.querySelector('.project-chat .chat-log'),form=document.querySelector('.project-chat form[id$="send-form"]');const rect=e=>{const r=e.getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:r.height}};return {height:innerHeight,documentHeight:document.documentElement.scrollHeight,log:rect(log),last:rect(log.lastElementChild),form:rect(form),field:rect(form.querySelector('textarea')),send:rect(form.querySelector('button[type=submit]')),paging:rect(document.querySelector('.project-chat .chat-paging')),footer:rect(document.querySelector('footer.foot')),fieldShare:(()=>{const f=form.querySelector('textarea').getBoundingClientRect(),r=form.getBoundingClientRect(),k=form.querySelector('.chat-room-picker,.chat-audience')?.getBoundingClientRect();return {share:f.width/r.width,width:f.width,afterPicker:!k||f.left>=k.right-0.5||f.top>=k.bottom-0.5,chipsWhole:[...form.querySelectorAll('.room-chip')].every(c=>c.scrollWidth<=c.clientWidth+1)}})(),children:[...document.querySelector('.project-chat').children].map(e=>({tag:e.tagName,cls:e.className,rect:rect(e)}))}})()`);
const within = (r,h) => r.top>=-0.5 && r.bottom<=h+0.5;
const sizes = [[1440,700],[1366,650],[1280,720],[1440,760],[1440,1000],[390,900],[700,541],[700,650],[700,699],[700,700],[700,759],[700,760]];
const envScale = Number(process.env.S13_SCALE || 1);
const cases = [];
for (const [width,height] of sizes) for (const name of (width===700 ? ['room'] : ['room','conversation'])) for (const long of (name==='room' && height!==1000 && width!==390 ? [false,true] : [false])) cases.push([width,height,name,long,envScale]);
if (envScale === 1) for (const [width,height] of [[1440,700],[1366,650],[700,541]]) cases.push([width,height,'room',false,2]);
for (const [width,height,name,long,scale] of cases) {
test(`S13 ${name} ${width}x${height} ${long?'long history':'normal'}${scale!==1?' at device scale '+scale:''} keeps its composer and newest message in view`,async t=>{
const {b,wait}=await setup(t,width,height,long?history:stub,width===1366,scale);
await b.evaluate(`location.hash=${JSON.stringify('#/projects/'+(name==='room'?'rooms/'+P:'threads/'+T))}`);
await wait(`document.querySelectorAll('.project-chat .chat-log > li').length >= ${long?45:1}`);
if(name==='conversation') await wait("document.querySelector('.project-request-item .request-panel')");
await b.evaluate('document.fonts.ready.then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))');
await b.evaluate('(async()=>{for(let i=0;i<12;i++)await new Promise(requestAnimationFrame)})()');
const m=await measure(b);
console.log('S13',JSON.stringify({width,height,name,long,scale,...m}));
if(process.env.S13_CAPTURE && name==='room' && !long && (height===700 || height===650 || height===720 || (height===541 && width===700) || (height===1000 && process.env.S13_PHASE==='after'))) await b.screenshot(`${process.env.S13_CAPTURE}/${process.env.S13_PHASE}-room-${width}x${height}.png`);
assert.equal(m.documentHeight,height,`document scrolls: ${JSON.stringify(m)}`);
for(const part of ['form','field','send']) assert.ok(within(m[part],height),`${part} outside viewport: ${JSON.stringify(m)}`);
assert.ok(m.log.bottom<=m.paging.top+0.5 && m.paging.bottom<=m.form.top+0.5 && m.form.bottom<=m.footer.top+0.5,`layout overlaps: ${JSON.stringify(m)}`);
assert.ok(m.last.bottom<=m.form.top+0.5 && m.last.bottom<=m.log.bottom+0.5 && m.last.bottom>=m.log.top-0.5 && m.last.bottom<=height+0.5,`newest message end is not visible in the log: ${JSON.stringify(m)}`);
if(m.last.height<=m.log.height) assert.ok(m.last.top>=m.log.top-0.5,`newest message is clipped: ${JSON.stringify(m)}`);
assert.ok(m.fieldShare.share>=0.4 && m.fieldShare.afterPicker && m.fieldShare.chipsWhole,`message field is squeezed: ${JSON.stringify(m.fieldShare)}`);
const expected=controls[`${name}-${width}x${height}`];
if(expected) assert.deepEqual({log:m.log,form:m.form},expected);
});
}
// Room states that add content to the short layout: eight long member names, and a closed room's recovery notice.
const roomStates = {
'long members': stub + `;fixtureThreads=fixtureThreads.filter(t=>t.name!==t.instance);for(let i=0;i<8;i++)fixtureThreads.push({id:'20000000-0000-4000-8000-'+String(i+20).padStart(12,'0'),project:'${P}',name:'member'+i,instance:'member'+i});{const old=fetch;window.fetch=(u,i)=>String(u)==='/api/chat/instances'?Promise.resolve(new Response(JSON.stringify({rows:Array.from({length:8},(_,i)=>({instance:'member'+i,name:'Member '+i+' with a longer display name'}))}),{headers:{'content-type':'application/json'}})):old(u,i)}`,
'closed room': stub + ';window.reviewClosed=true;',
'one member': stub + `;fixtureThreads=fixtureThreads.filter(t=>t.name!==t.instance||t.instance==='assistant');`,
};
for (const [state,source] of Object.entries(roomStates)) for (const [width,height] of [[1440,700],[700,541]]) {
test(`S13 room ${width}x${height} ${state} keeps the notice, members line and composer in order and in view`,async t=>{
const {b,wait}=await setup(t,width,height,source,false,1);
await b.evaluate(`location.hash=${JSON.stringify('#/projects/rooms/'+P)}`);
await wait(`document.querySelectorAll('.project-chat .chat-log > li').length >= 1 && ${{'closed room':"document.querySelector('.project-room-warning')",'long members':"document.querySelectorAll('.project-chat .room-chip').length === 8",'one member':"document.querySelectorAll('.project-chat .room-chip').length === 1"}[state]}`);
await b.evaluate('document.fonts.ready.then(()=>new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(r))))');
const m=await b.evaluate(`(()=>{const s=document.querySelector('.project-chat'),rect=e=>{if(!e)return null;const r=e.getBoundingClientRect();return {top:r.top,bottom:r.bottom,left:r.left,right:r.right,width:r.width}},members=[...s.querySelectorAll(':scope > p')].find(e=>e.textContent.startsWith('Room members:')),form=s.querySelector('#chat-room-send-form');return {documentHeight:document.documentElement.scrollHeight,members:rect(members),membersLines:members.scrollHeight<=members.clientHeight+1,log:rect(s.querySelector('.chat-log')),warning:rect(s.querySelector('.project-room-warning')),open:rect(s.querySelector('[data-room-open-inline]')),form:rect(form),picker:rect(s.querySelector('.chat-room-picker')),send:rect(form.querySelector('button[type=submit]')),details:rect(s.querySelector('[data-project-dialog=room]')),field:rect(form.querySelector('textarea')),chipsWhole:[...form.querySelectorAll('.room-chip')].every(c=>c.scrollWidth<=c.clientWidth+1),footer:rect(document.querySelector('footer.foot'))}})()`);
console.log('S13 state',JSON.stringify({width,height,state,...m}));
assert.equal(m.documentHeight,height,`document scrolls: ${JSON.stringify(m)}`);
for(const part of ['form','send']) assert.ok(m[part].top>=-0.5 && m[part].bottom<=m.footer.top+0.5,`${part} outside the view: ${JSON.stringify(m)}`);
assert.ok(m.membersLines && m.members.bottom<=m.log.top+0.5,`members line overlaps the transcript: ${JSON.stringify(m)}`);
assert.ok(m.picker.bottom<=m.form.bottom+0.5 && m.log.bottom<=m.form.top+0.5,`composer overlaps: ${JSON.stringify(m)}`);
assert.ok(m.field.width>=160 && m.field.left>=m.picker.right-0.5,`message field is squeezed: ${JSON.stringify({field:m.field,picker:m.picker})}`);
assert.ok(m.details.width>=44,`Room details is under 44px: ${JSON.stringify(m)}`);
assert.ok(m.details.right>=m.form.right-1 && m.members.right<=m.details.left+0.5,`Room details is not at the right edge after the members line: ${JSON.stringify({details:m.details,members:m.members,form:m.form})}`);
if(state!=='long members') assert.ok(m.chipsWhole,`a short member name is truncated: ${JSON.stringify(m)}`);
if(state==='closed room') assert.ok(m.warning.top>=m.members.bottom-0.5 && m.warning.bottom<=m.log.top+0.5 && m.open.bottom<=m.footer.top+0.5,`recovery notice is not above the messages: ${JSON.stringify(m)}`);
});
}