179 lines
19 KiB
JavaScript
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);
|
|
});
|