import test from 'node:test'; import assert from 'node:assert/strict'; import {broker} from '../../cli/tests/helpers.mjs'; import {views} from '../../bus/src/views.mjs'; import {startServer} from '../src/serve.mjs'; import {close} from './fixture.mjs'; import {browser} from './browser.mjs'; import {BusReadError} from '../src/bus.mjs'; function seed(t){ const s=broker(t),call=(verb,args={})=>s.b.request(s.reader,{verb,args}); const bus=views({call:async(...args)=>structuredClone(call(...args))}); const send=body=>s.b.request(s.coder,{verb:'message.send',args:{to:'human',body,class:'INFO'}}).id; return {...s,bus,send}; } test('messages HTTP read pages and preserves metadata; bad arguments refuse before a read; other reads unchanged',async t=>{ const s=seed(t),a=s.send('first'),b=s.send('second'); const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:s.bus});t.after(()=>close(web)); const url=`http://127.0.0.1:${web.address().port}/api/bus/`; const get=async path=>{const res=await fetch(url+path);return {status:res.status,body:await res.json()}}; const page=await get('messages?limit=1');assert.equal(page.status,200); assert.deepEqual(page.body.rows.map(m=>m.id),[b]); assert.equal(page.body.total,2);assert.equal(page.body.unread,2);assert.equal(page.body.next,b); const next=await get(`messages?after=${b}&limit=1`);assert.deepEqual(next.body.rows.map(m=>m.id),[a]);assert.equal(next.body.next,null); for(const query of ['limit=0','limit=201','limit=1.5','limit=no','limit=','after=','after=%3Cb%3E','limit=2&limit=3','after=x&after=y','extra=1']){ const res=await get('messages?'+query);assert.equal(res.status,400,query);assert.equal(res.body.error,'invalid-request'); } for(const verb of ['inbox','tasks','agents','replies?id='+a,'decisions?list=recent','credentials','trail?subject='+a]){ const res=await get(verb);assert.equal(res.status,200,verb);assert.deepEqual(Object.keys(res.body).sort(),['at','rows']); const [name,query='']=verb.split('?'),params=new URLSearchParams(query); const args=Object.fromEntries(params); assert.deepEqual(res.body.rows,structuredClone(s.b.request(s.reader,{verb:name,args})),verb); assert.match(res.body.at,/^\d{4}-.*Z$/); } }); test('Console Messages lists late unsolicited messages on polling with sender class time read state and trail; nav count includes beyond the page; reads never deliver', {timeout:45000},async t=>{ const s=seed(t),early=s.send('early'); const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:s.bus}); const b=await browser({machine:false}); const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,25))}throw Error('condition timed out')})()`); try{ // Reach a real scheduled poll without making a test wait ten seconds. await b.call('Page.addScriptToEvaluateOnNewDocument',{source:'const originalTimeout=window.setTimeout; window.setTimeout=(fn,ms,...args)=>originalTimeout(fn,ms===10000?100:ms,...args);'}); await b.navigate(`http://127.0.0.1:${web.address().port}/#/messages`); await wait('document.querySelector("#message-list")'); const late=s.send(' late unsolicited'); const before=s.store.all('SELECT * FROM deliveries'); await wait('document.querySelector("#message-list")?.textContent.includes("late unsolicited")'); assert.deepEqual(s.store.all('SELECT * FROM deliveries'),before); const items=await b.evaluate('[...document.querySelectorAll("#message-list li")].map(e=>({text:e.textContent,href:e.querySelector("a").getAttribute("href")}))'); assert.match(items[0].text,/coder.*INFO.*Unread/s);assert.match(await b.evaluate('document.querySelector("#message-list time").getAttribute("datetime")'), /^\d{4}-.*Z$/); await b.viewport(400,900); assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'),true); if(process.env.ROCKO_MESSAGES_SHOT) await b.screenshot(process.env.ROCKO_MESSAGES_SHOT); assert.equal(items[0].href,`#/trail/message/${late}`);assert.equal(items[1].href,`#/trail/message/${early}`); assert.equal(await b.evaluate('!!window.injected || !!document.querySelector("#message-list img")'),false); for(let i=0;i<52;i++)s.send(`backlog ${i}`); await wait(`document.querySelector('#sections a[href="#/messages"]')?.textContent.includes("54")`); await wait('document.querySelectorAll("#message-list li").length===50'); assert.ok(await b.evaluate('!!document.querySelector("#bus-view a[data-older]")')); await b.evaluate('document.querySelector("#bus-view a[data-older]").click(),true'); await wait('document.querySelectorAll("#message-list li").length===4'); assert.match(await b.evaluate('document.querySelector("#message-list").textContent'),/late unsolicited/); const got=s.b.request(s.human,{verb:'message.receive'}); for(const m of got)s.b.request(s.human,{verb:'message.read',args:{id:m.id}}); await wait('document.querySelector("#message-list")?.textContent.includes("Read") && !document.querySelector("#message-list")?.textContent.includes("Unread")'); assert.equal(await b.evaluate(`document.querySelector('#sections a[href="#/messages"] .count').textContent`),'0'); }finally{await b.close();await close(web)} }); test('Messages nav count polls on Tasks and Board and clears on a Board refusal; messages 403 refuses local pages', {timeout:45000}, async t=>{ const s=seed(t);s.send('one');s.send('two'); let messagesFailure=null; const bus={...s.bus,messages:async args=>{if(messagesFailure)throw new BusReadError(messagesFailure);return s.bus.messages(args);}}; const at=()=>new Date().toISOString(), localReads=[]; const reads={ queue:async()=>{localReads.push('queue');return {rows:[],notes:[],at:at()};}, business:async()=>{localReads.push('business');return {business:{id:'demo',human:'jason',arbiters:{},projects:[],vars:{},instances:[],actions:[],credentials:[]},at:at()};}, settings:async()=>{localReads.push('settings');return {release:'0.0.1',business:'demo',notifier:{binding:null},at:at()};}, }; const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads}); const b=await browser({machine:false}); const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,25))}throw Error('condition timed out: '+${JSON.stringify(expr)})})()`); const badge=`document.querySelector('#sections a[href="#/messages"] .count')`; try{ await b.call('Page.addScriptToEvaluateOnNewDocument',{source:'const originalTimeout=window.setTimeout; window.setTimeout=(fn,ms,...args)=>originalTimeout(fn,ms===10000?100:ms,...args);'}); await b.navigate(`http://127.0.0.1:${web.address().port}/#/tasks`); await wait(`document.querySelector('#bus-view h1')?.textContent==='Tasks' && ${badge}?.textContent==='2'`); s.send('three');await wait(`${badge}?.textContent==='3'`); await b.evaluate('location.hash="#/",true'); await wait(`document.querySelector('#bus-view').hidden && document.querySelector('#held')?.textContent.includes('coder') && ${badge}?.textContent==='3'`); s.send('four');await wait(`${badge}?.textContent==='4'`); messagesFailure='human-required'; await wait(`!${badge} && document.querySelector('#held')?.textContent.includes('refused')`); messagesFailure=null;await wait(`${badge}?.textContent==='4'`); for(const [path,title] of [['queue','Queue'],['business','Business demo'],['settings','Settings']]){ await b.evaluate(`location.hash=${JSON.stringify('#/'+path)},true`); await wait(`document.querySelector('#bus-view h1')?.textContent===${JSON.stringify(title)} && ${badge}?.textContent==='4'`); const before=localReads.length; messagesFailure='human-required'; await wait(`!!document.querySelector('#bus-view .banner.ref') && !${badge}`); assert.match(await b.evaluate('document.querySelector("#bus-view h1").textContent'),/Bus refused the read/); assert.equal(localReads.length,before,'a refused messages pre-read prevents the local read'); messagesFailure=null; await wait(`document.querySelector('#bus-view h1')?.textContent===${JSON.stringify(title)} && ${badge}?.textContent==='4'`); } }finally{await b.close();await close(web)} });