Files
stack/packages/webui/tests/s7-fidelity-browser.test.mjs

179 lines
19 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';
async function setup(t, route, extra = '') {
const f = await fixture(), b = await browser({machine:false});
t.after(async () => { await b.close(); await f.close(); });
await b.call('Page.addScriptToEvaluateOnNewDocument', {source: stub + extra});
await b.viewport(1440, 1000); await b.navigate(f.base + '/#' + route);
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('Timed out: '+${JSON.stringify(expr)})})()`);
return {b,wait};
}
test('conversation follows content that expands after initial paint and respects a reader scrolled up', async t => {
const extra = `;for(let i=0;i<18;i++) fixtureMessages.push({id:'long-'+i,from_role:'human',body:'Message '+i+' '.repeat(2)+'long text '.repeat(12),at:'2026-11-06T10:26:00Z'});`;
const {b,wait} = await setup(t,'/projects/threads/'+T,extra);
await wait(`document.querySelector('.project-request-intro') && document.querySelector('[data-message="long-17"]')`);
await b.evaluate(`new Promise(r=>setTimeout(r,500))`);
const bottom = `(()=>{const l=document.querySelector('.chat-log');return l.scrollHeight-l.clientHeight-l.scrollTop})()`;
assert.ok(await b.evaluate(bottom) <= 2, 'initial expanded transcript should end at newest message');
await b.evaluate(`(()=>{const l=document.querySelector('.chat-log');l.scrollTop=0;l.dispatchEvent(new Event('scroll'));fixtureMessages.push({id:'new',from_role:'human',body:'Newest',at:'2026-11-06T10:27:00Z'});document.querySelector('#refresh').click()})()`);
await wait(`document.querySelector('[data-message="new"]')`);
assert.equal(await b.evaluate(`document.querySelector('.chat-log').scrollTop`),0);
await b.evaluate(`document.querySelector('#chat-new').click()`);
assert.ok(await b.evaluate(bottom) <= 2);
await b.evaluate(`fixtureMessages.push({id:'newer',from_role:'human',body:'Newest again',at:'2026-11-06T10:28:00Z'});document.querySelector('#refresh').click()`);
await wait(`document.querySelector('[data-message="newer"]')`);
assert.ok(await b.evaluate(bottom) <= 2, 'a reader at the end follows new messages');
});
test('conversation stays at the newest message when the log shrinks after landing at the end', async t => {
const extra = `;for(let i=0;i<18;i++) fixtureMessages.push({id:'long-'+i,from_role:'human',body:'Message '+i+' long text '.repeat(12),at:'2026-11-06T10:26:00Z'});`;
const {b,wait} = await setup(t,'/projects/threads/'+T,extra);
await wait(`document.querySelector('[data-message="long-17"]')`);
await b.evaluate(`new Promise(r=>setTimeout(r,300))`);
const bottom = `(()=>{const l=document.querySelector('.chat-log');return l.scrollHeight-l.clientHeight-l.scrollTop})()`;
assert.ok(await b.evaluate(bottom) <= 2);
const before = await b.evaluate(`document.querySelector('.chat-log').clientHeight`);
await b.evaluate(`(()=>{const l=document.querySelector('.chat-log'),g=document.createElement('div');g.style.cssText='height:23px;flex:none';l.after(g)})()`);
await b.evaluate(`new Promise(r=>requestAnimationFrame(()=>requestAnimationFrame(()=>setTimeout(r,50))))`);
assert.ok(await b.evaluate(`document.querySelector('.chat-log').clientHeight`) < before, 'the fixture must shrink the log');
assert.ok(await b.evaluate(bottom) <= 2, 'a following reader stays at the newest message after the log shrinks');
});
test('Agents resolves known claim holders and conversation names while retaining unknown runs', async t => {
const extra = `;{const old=window.fetch;window.fetch=(url,init)=>{const p=String(url);const json=v=>Promise.resolve(new Response(JSON.stringify(v),{headers:{'content-type':'application/json'}}));if(p==='/api/bus/agents')return json({rows:[{role:'chief',holder_run:'r123456789012',harness:'pi',at:'2026-01-01T00:00:00Z',state:'idle'}]});if(p==='/api/bus/thread.claims')return json({rows:[{thread:'${T}',holder_run:'r123456789012',harness:'pi',at:'2026-01-01T00:00:00Z'},{thread:'unknown-thread',holder_run:'r82ad456d5d28',harness:'pi',at:'2026-01-01T00:00:00Z'}]});if(p==='/api/bus/instances')return json({rows:[{instances:{chief:{running:['r123456789012']}}}]});return old(url,init)}}`;
const {b,wait} = await setup(t,'/agents',extra);
await wait(`document.querySelector('.s1-thread-claims tbody tr')`);
const text=await b.evaluate(`document.querySelector('#bus-view').textContent`);
assert.match(text,/Build offline timer/); assert.match(text,/chief/); assert.match(text,/r82ad456.*not running/s);
const holder=await b.evaluate(`document.querySelector('[aria-labelledby="b-claims"] tbody tr td:nth-child(4)').textContent`);
assert.match(holder,/chief/); assert.match(holder,/r123456/);
});
test('empty request status stays absent and read failures remain visible', async t => {
const {b,wait}=await setup(t,'/projects/threads/'+T,`;fixtureMessages.forEach(m=>delete m.request);`);
await wait(`document.querySelector('.chat-log [data-message]')`);
await b.evaluate(`new Promise(r=>setTimeout(r,300))`);
assert.equal(await b.evaluate(`document.querySelector('[data-conversation-request-status]').textContent`),'');
await b.evaluate(`reviewFail['/api/chat/threads/${T}/messages?limit=100&session=30000000-0000-4000-8000-000000000001']=true;document.querySelector('#refresh').click()`);
await wait(`document.querySelector('[data-conversation-request-status]')?.textContent.includes('unavailable')`);
});
test('sidebar child labels truncate and mobile business role names remain whole', async t => {
const {b,wait}=await setup(t,'/projects/threads/'+T);
await wait(`document.querySelector('.project-tree-children a[href$="conversations"] span')`);
const tree=await b.evaluate(`(()=>{const e=document.querySelector('.project-tree-children a[href$="conversations"] span'),s=getComputedStyle(e),r=e.getBoundingClientRect();return {whiteSpace:s.whiteSpace,overflow:s.overflow,ellipsis:s.textOverflow,right:r.right}})()`);
assert.deepEqual([tree.whiteSpace,tree.overflow,tree.ellipsis],['nowrap','hidden','ellipsis']); assert.ok(tree.right<=206,JSON.stringify(tree));
await b.viewport(390,900); await b.evaluate(`window.fetch=((old)=>(url,init)=>String(url)==='/api/business'?Promise.resolve(new Response(JSON.stringify({business:{id:'demo',instances:[{instance:'assistant',definition:'assistant'}]}}),{headers:{'content-type':'application/json'}})):old(url,init))(window.fetch);location.hash='#/settings/business'`);
await wait(`document.querySelector('.s1-roles tbody th')`);
const role=await b.evaluate(`(()=>{const e=document.querySelector('.s1-roles tbody th'),s=getComputedStyle(e),r=e.getBoundingClientRect();return {wrap:s.overflowWrap,word:s.wordBreak,width:r.width,scroll:e.scrollWidth}})()`);
assert.equal(role.wrap,'anywhere'); assert.equal(role.word,'normal'); assert.ok(role.width>=role.scroll,JSON.stringify(role));
});
const staleFixture = `;{const old=window.fetch;window.instancesFail=false;window.fetch=(url,init)=>{const p=String(url),json=(v,status=200)=>Promise.resolve(new Response(JSON.stringify(v),{status,headers:{'content-type':'application/json'}}));if(p==='/api/bus/agents')return json({rows:[{role:'chief',holder_run:'r-old-chief-123456',harness:'pi',at:'2026-01-01T00:00:00Z',state:'idle'}]});if(p==='/api/bus/thread.claims')return json({rows:[{thread:'${T}',holder_run:'r-old-chief-123456',harness:'pi',at:'2026-01-01T00:00:00Z'}]});if(p==='/api/bus/instances')return window.instancesFail?json({error:'unavailable'},503):json({rows:[{instances:{chief:{running:['r-new-chief-654321']}}}]});return old(url,init)}}`;
for (const failed of [false,true]) test(`stale role and thread holders disclose ${failed?'unavailable':'stopped'} running status`, async t => {
const {b,wait}=await setup(t,'/agents',staleFixture + (failed?';window.instancesFail=true;':''));
await wait(`document.querySelector('.s1-thread-claims tbody tr')`);
const cells=await b.evaluate(`['[aria-labelledby="b-claims"] tbody td:nth-child(4)','.s1-thread-claims tbody td:nth-child(2)'].map(s=>document.querySelector(s).textContent)`);
for(const cell of cells) { assert.match(cell,/chief/); assert.match(cell,/r-old-ch/); assert.match(cell,failed?/running status unavailable/:/not running/); }
});
test('empty composer line fits at desktop and mobile widths',async t=>{
const {b,wait}=await setup(t,'/projects/threads/'+T); await wait(`document.querySelector('#chat-body')`);
for(const width of [1440,390]) { await b.viewport(width,width===390?900:1000); const x=await b.evaluate(`(()=>{const e=document.querySelector('#chat-body'),s=getComputedStyle(e),r=e.getBoundingClientRect();return {client:e.clientHeight,scroll:e.scrollHeight,line:parseFloat(s.lineHeight),top:parseFloat(s.paddingTop),bottom:parseFloat(s.paddingBottom),height:r.height,borderTop:parseFloat(s.borderTopWidth),borderBottom:parseFloat(s.borderBottomWidth),placeholder:e.placeholder}})()`); assert.ok(x.placeholder,JSON.stringify(x)); assert.ok(x.scroll<=x.client,JSON.stringify(x)); assert.ok(x.top+x.line+x.bottom<=x.height-x.borderTop-x.borderBottom+0.1,JSON.stringify(x)); }
});
test('stepper uses inline desktop labels and mobile columns on conversation and overview',async t=>{
const {b,wait}=await setup(t,'/projects/threads/'+T);
for(const route of ['/projects/threads/'+T,'/projects/'+P]) { await b.evaluate(`location.hash='#${route}'`); await wait(route.includes('/threads/') ? `document.querySelector('.project-chat .project-stepper .current')` : `document.querySelector('.project-work .project-stepper .current')`);
for(const width of [1440,390]) {await b.viewport(width,width===390?900:1000); const x=await b.evaluate(`(()=>{const root=document.querySelector('.project-stepper'),steps=[...root.querySelectorAll(':scope > span')],s=steps[0],i=s.querySelector('i').getBoundingClientRect(),b=s.querySelector('b'),r=b.getBoundingClientRect(),cs=getComputedStyle(b),cur=getComputedStyle(root.querySelector('.current'));return {font:cs.fontSize,weight:cs.fontWeight,dir:getComputedStyle(s).flexDirection,delta:Math.abs(i.top+i.height/2-r.top-r.height/2),separators:root.querySelectorAll('.project-step-separator').length,radius:cur.borderRadius,columns:steps.map(e=>e.getBoundingClientRect().width)}})()`); if(width===1440){assert.equal(x.font,'14px');assert.equal(x.weight,'600');assert.equal(x.dir,'row');assert.ok(x.delta<1,JSON.stringify(x));assert.equal(x.separators,3);assert.ok(parseFloat(x.radius)>=16,JSON.stringify(x));}else{assert.equal(x.dir,'column');assert.ok(Math.max(...x.columns)-Math.min(...x.columns)<1,JSON.stringify(x));} }
}
});
test('expanded tree stays within sidebar on conversation and room',async t=>{
const {b,wait}=await setup(t,'/projects/threads/'+T);
for(const route of ['/projects/threads/'+T,'/projects/rooms/'+P]) {await b.evaluate(`location.hash='#${route}'`); await wait(route.includes('/rooms/') ? `document.querySelector('#chat-room-send-form')` : `document.querySelector('#chat-send-form')`);await wait(`document.querySelector('.project-tree-children:not([hidden]) a[href$="conversations"] span')`);const x=await b.evaluate(`(()=>{const tree=document.querySelector('nav.tree'),e=document.querySelector('.project-tree-children a[href$="conversations"] span');return {client:tree.clientWidth,scroll:tree.scrollWidth,right:e.getBoundingClientRect().right,ellipsis:getComputedStyle(e).textOverflow}})()`);assert.ok(x.scroll<=x.client,JSON.stringify(x));assert.ok(x.right<=206,JSON.stringify(x));assert.equal(x.ellipsis,'ellipsis');}
});
test('desktop agent message matches avatar and card geometry',async t=>{
const {b,wait}=await setup(t,'/projects/threads/'+T);await wait(`document.querySelector('.chat-message-agent .chat-body') && document.querySelector('.project-request-item .request-panel')`);
const x=await b.evaluate(`(()=>{const m=document.querySelector('.chat-message-agent'),a=m.querySelector('.chat-avatar'),name=m.querySelector('strong'),bubble=m.querySelector('.chat-body').getBoundingClientRect(),card=document.querySelector('.project-request-item .request-mount').getBoundingClientRect(),s=getComputedStyle(a);return {font:s.fontSize,weight:s.fontWeight,name:name.getBoundingClientRect().left,left:bubble.left,right:bubble.right,width:bubble.width,cardRight:card.right}})()`);
assert.equal(x.font,'14px');assert.equal(x.weight,'700');assert.ok(Math.abs(x.name-296)<1,JSON.stringify(x));assert.ok(Math.abs(x.left-296)<1&&Math.abs(x.width-744)<1&&Math.abs(x.right-x.cardRight)<1,JSON.stringify(x));
});
test('role instances reflow within the mobile panel', async t => {
const {b,wait}=await setup(t,'/settings/business');
await b.viewport(390,844);
await b.evaluate(`window.fetch=((old)=>(url,init)=>String(url)==='/api/business'?Promise.resolve(new Response(JSON.stringify({business:{id:'demo',instances:[{instance:'assistant',definition:'assistant'}]}}),{headers:{'content-type':'application/json'}})):old(url,init))(window.fetch);document.querySelector('#refresh').click()`);
await wait(`document.querySelector('.s1-roles tbody th')`);
const x=await b.evaluate(`(()=>{const t=document.querySelector('.s1-roles'),w=t.closest('.table-wrap'),r=w.getBoundingClientRect();return {right:r.right,tableRight:t.getBoundingClientRect().right,viewport:innerWidth,overflow:getComputedStyle(w).overflowX,scroll:w.scrollWidth,client:w.clientWidth,document:document.documentElement.scrollWidth}})()`);
assert.ok(x.right<=x.viewport,JSON.stringify(x));
assert.match(x.overflow,/auto|scroll/); assert.ok(x.tableRight<=x.viewport && x.scroll<=x.client,JSON.stringify(x)); assert.ok(x.document<=x.viewport,JSON.stringify(x));
});
test('an archived organization read disables a retained conversation draft', async t => {
const {b,wait}=await setup(t,'/projects/threads/'+T);
await wait(`document.querySelector('#chat-body') && !document.querySelector('#chat-body').disabled`);
await b.evaluate(`document.querySelector('#chat-body').value='Keep this draft';document.querySelector('#chat-body').dispatchEvent(new Event('input',{bubbles:true}));window.fetch=((old)=>(url,init)=>String(url)==='/api/chat/projects'?Promise.resolve(new Response(JSON.stringify({error:'org-archived'}),{status:409,headers:{'content-type':'application/json'}})):old(url,init))(window.fetch);document.querySelector('#refresh').click()`);
await wait(`document.querySelector('.chat-error')`);
assert.equal(await b.evaluate(`document.querySelector('#chat-body').disabled`),true);
assert.equal(await b.evaluate(`document.querySelector('#chat-body').value`),'Keep this draft');
assert.match(await b.evaluate(`document.querySelector('.chat-area').innerText`),/organization.*archived/i);
});
test('a room-not-open read disables a retained draft with the room reason', async t => {
const {b,wait}=await setup(t,'/projects/threads/'+T);
await wait(`document.querySelector('#chat-body') && !document.querySelector('#chat-body').disabled`);
await b.evaluate(`document.querySelector('#chat-body').value='Keep this draft';document.querySelector('#chat-body').dispatchEvent(new Event('input',{bubbles:true}));window.fetch=((old)=>(url,init)=>String(url)==='/api/chat/projects'?Promise.resolve(new Response(JSON.stringify({error:'room-not-open'}),{status:409,headers:{'content-type':'application/json'}})):old(url,init))(window.fetch);document.querySelector('#refresh').click()`);
await wait(`document.querySelector('.chat-error')`);
assert.equal(await b.evaluate(`document.querySelector('#chat-body').disabled`),true);
assert.equal(await b.evaluate(`document.querySelector('#chat-body').value`),'Keep this draft');
const text=await b.evaluate(`document.querySelector('.chat-area').innerText`);
assert.match(text,/room.*not open/i); assert.doesNotMatch(text,/organization is archived/i);
});
test('project read error banner does not overlap New project at desktop width', async t => {
const {b,wait}=await setup(t,'/projects',`;reviewFail['/api/chat/projects']=true;`);
await wait(`document.querySelector('.chat-error') && document.querySelector('.project-create summary')`);
const x=await b.evaluate(`(()=>{const a=document.querySelector('.chat-error').getBoundingClientRect(),b=document.querySelector('.project-create summary').getBoundingClientRect();return {overlap:a.left<b.right&&a.right>b.left&&a.top<b.bottom&&a.bottom>b.top}})()`);
assert.equal(x.overlap,false);
});
test('a refused queue read keeps Queue as the page heading', async t => {
const extra=`;{const old=window.fetch;window.fetch=(url,init)=>String(url)==='/api/queue'?Promise.resolve(new Response(JSON.stringify({error:'queue-refused',message:'fixture refusal'}),{status:503,headers:{'content-type':'application/json'}})):old(url,init)}`;
const {b,wait}=await setup(t,'/queue',extra);
await wait(`document.querySelector('#bus-view [role=alert]')`);
assert.equal(await b.evaluate(`document.querySelector('#bus-view h1').textContent`),'Queue');
assert.match(await b.evaluate(`document.querySelector('#bus-view [role=alert]').innerText`),/queue refused the read/i);
});
test('sidebar sprite is requested again after a failed read and recovery', async t => {
const {b,wait}=await setup(t,'/projects');
await wait(`document.querySelector('#sections use[href*="icons.svg"]')`);
const before = await b.evaluate(`document.querySelector('#sections use[href*="icons.svg"]').getAttribute('href')`);
await b.evaluate(`reviewFail['/api/chat/projects']=true;document.querySelector('#refresh').click()`);
await wait(`document.querySelector('.chat-error')`);
await b.evaluate(`reviewFail['/api/chat/projects']=false;document.querySelector('#refresh').click()`);
await wait(`!document.querySelector('.chat-error') && document.querySelector('#sections use[href*="?retry="]')`);
const after = await b.evaluate(`document.querySelector('#sections use[href*="icons.svg"]').getAttribute('href')`);
assert.notEqual(after,before);
});
test('sidebar sprite is retried after an outage redraw before reads recover', async t => {
const {b,wait}=await setup(t,'/projects');
await wait(`document.querySelector('#sections use[href*="icons.svg"]')`);
const before=await b.evaluate(`document.querySelector('#sections use').getAttribute('href')`);
await b.evaluate(`{const old=window.fetch;window.netDown=true;window.fetch=(url,init)=>window.netDown && String(url).startsWith('/api/')?Promise.reject(new TypeError('offline')):old(url,init);document.querySelector('#refresh').click()}`);
await wait(`document.querySelector('.chat-error')`);
await b.evaluate(`document.querySelector('#refresh').click()`);
await b.evaluate(`new Promise(r=>setTimeout(r,100))`);
await b.evaluate(`window.netDown=false;document.querySelector('#refresh').click()`);
await wait(`!document.querySelector('.chat-error') && document.querySelector('#sections use[href*="?retry="]')`);
assert.notEqual(await b.evaluate(`document.querySelector('#sections use').getAttribute('href')`),before);
});