import test from 'node:test'; import assert from 'node:assert/strict'; import { w15Fixture } from './w15-fixture.mjs'; test('Dashboard routes, metrics and breadcrumbs reflect real reads and unavailable sources', {timeout:90000}, async t => { const scenario = { '/api/bus/agents': {rows:[{role:'coder',state:'idle'}]}, '/api/bus/inbox': {rows:[{id:'decision-1',question:'Choose a direction?'}]}, '/api/bus/messages': {rows:[],unread:2}, '/api/bus/tasks': {rows:[{task_ref:'vikunja:1/2',fields:{done:false}},{task_ref:'vikunja:1/3',fields:{done:true}}]}, '/api/chat/projects': {rows:[{id:'active',name:'Active'},{id:'archived',name:'Archived',archived:true}]}, '/api/business': {business:{id:'test-business'}}, }; const {f,b,wait}=await w15Fixture(t,{scenario}); await b.navigate(f.base+'/#/'); await wait('document.querySelector("#dashboard-kpis").textContent.includes("test") || document.querySelector("#dashboard-business").textContent.includes("test-business")'); const cells=await b.evaluate('[...document.querySelectorAll(".kpi")].map(x=>[...x.children].map(y=>y.textContent))'); assert.deepEqual(cells.map(x=>x[1]),['1','1','2','1','1','–','–']); assert.equal(cells[5][2],'Not available yet'); assert.equal(cells[6][2],'Not available yet'); assert.match(await b.evaluate('document.querySelector("#dashboard-updated").textContent'), /^Updated \d+ s ago$/); assert.match(await b.evaluate('document.querySelector("#dashboard-inbox-items").textContent'), /Choose a direction/); assert.equal(await b.evaluate('document.title'), 'Dashboard · Mosaic Console'); await b.evaluate('location.hash="#/board"'); await wait('document.querySelector("#bus-view").hidden'); assert.equal(await b.evaluate('document.querySelector("#sections [aria-current]").textContent.trim()'),'Dashboard'); for (const name of ['projects','inbox','messages','tasks','agents','queue','business','docs','settings']) { await b.evaluate(`location.hash=${JSON.stringify('#/'+name)}`); await wait(`document.querySelector('#breadcrumbs').textContent.includes(${JSON.stringify(name[0].toUpperCase()+name.slice(1))})`); assert.ok(await b.evaluate('!!document.querySelector("#breadcrumbs [aria-current=page]")')); } }); test('avatar menu supports keyboard navigation and local or SSO sign out', {timeout:90000}, async t => { const {f,b,wait}=await w15Fixture(t); await b.navigate(f.base+'/#/'); await wait('!!document.querySelector("#avatar-button")'); assert.equal(await b.evaluate('document.querySelector("#avatar-name").textContent'),'Local operator'); await b.evaluate('document.querySelector("#avatar-button").focus();document.querySelector("#avatar-button").click()'); await b.key('Escape'); assert.equal(await b.evaluate('document.querySelector("#avatar-menu").hidden'),true); await b.key('ArrowDown'); assert.equal(await b.evaluate('document.activeElement.textContent'),'Settings'); assert.equal(await b.evaluate('document.querySelector("#local-sign-out").textContent.includes("Not signed in (local dev)")'),true); assert.equal(await b.evaluate('document.querySelectorAll("#avatar-menu select").length'),0); await b.key('Escape'); assert.equal(await b.evaluate('document.activeElement.id'),'avatar-button'); assert.equal(await b.evaluate('document.querySelector("#avatar-menu").hidden'),true); await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`window.__scenario['/api/config']={sso:true,send:false}`}); await b.call('Page.reload'); await wait('!!document.querySelector("#sign-out") && !document.querySelector("#sign-out").hidden'); await b.evaluate('document.querySelector("#avatar-button").click()'); assert.deepEqual(await b.evaluate('(()=>{const f=document.querySelector("#sign-out");return [f.hidden,f.method,f.getAttribute("action"),document.querySelector("#local-sign-out").hidden]})()'),[false,'post','/auth/logout',true]); assert.deepEqual(await b.evaluate('(()=>{const r=document.querySelector("#sign-out button").getBoundingClientRect();return [r.width>=44,r.height>=44]})()'),[true,true]); }); test('compact header keeps search and theme switch usable, with refresh and pause in the avatar menu', {timeout:90000}, async t => { const {f,b,wait}=await w15Fixture(t); await b.call('Emulation.setDeviceMetricsOverride',{width:390,height:900,deviceScaleFactor:1,mobile:false}); await b.navigate(f.base+'/#/'); await wait('document.querySelectorAll("#dashboard-kpis .kpi").length > 0'); const state = await b.evaluate(`(()=>{const ids=['nav-open','cmdk','theme-switch','avatar-button'];const boxes=ids.map(id=>document.getElementById(id).getBoundingClientRect());return {tops:boxes.map(x=>x.top), widths:boxes.map(x=>x.width), overflow:document.documentElement.scrollWidth>innerWidth, pauseVisible:!!document.querySelector('#pause').getClientRects().length, zeroBadges:document.querySelectorAll('#sections .count').length, held:document.querySelector('#held').textContent}})()`); assert.ok(Math.max(...state.tops)-Math.min(...state.tops)<15, JSON.stringify(state)); assert.ok(state.widths.every(x=>x>=44)); assert.equal(state.overflow,false); assert.equal(state.pauseVisible,false); assert.equal(state.zeroBadges,0); assert.doesNotMatch(state.held,/behind the sign-in proxy/); await b.evaluate('document.querySelector("#cmdk").click()'); assert.equal(await b.evaluate('document.querySelector("#cmdk-dialog").open'),true); await b.key('Escape'); await b.evaluate('document.querySelector("#avatar-button").click()'); assert.equal(await b.evaluate('!!document.querySelector("#mobile-refresh").getClientRects().length && !document.querySelector("#refresh").getClientRects().length'),true); assert.equal(await b.evaluate('!!document.querySelector("#mobile-pause").getClientRects().length'),true); assert.equal(await b.evaluate('(()=>{let n=0;document.querySelector("#refresh").addEventListener("click",()=>n++,{once:true});document.querySelector("#mobile-refresh").click();return n})()'),1); await b.evaluate('document.querySelector("#mobile-pause").click()'); assert.equal(await b.evaluate('document.querySelector("#pause").getAttribute("aria-pressed")'),'true'); }); test('processing polls preserve decision focus and unchanged inbox nodes', {timeout:90000}, async t => { const {f,b,wait}=await w15Fixture(t,{scenario:{'/api/bus/inbox':{rows:[{id:'decision-1',question:"Jason's direction?"}]},'/api/processing':{halted:false}}}); await b.call('Page.addScriptToEvaluateOnNewDocument',{source:`document.addEventListener('mosaic-processing-state',()=>window.__processingReads=(window.__processingReads||0)+1)`}); await b.navigate(f.base+'/#/'); await wait('document.querySelector("#dashboard-inbox-items a") && window.__processingReads'); const reads=await b.evaluate('window.__processingReads'); await b.evaluate('window.__focusedDecision=document.querySelector("#dashboard-inbox-items a");window.__focusedDecision.focus()'); await wait(`window.__processingReads > ${reads}`); await b.evaluate(`window.MosaicDashboard.update({inbox:[{id:'decision-1',question:"Jason's direction?"}]})`); assert.equal(await b.evaluate('document.activeElement===window.__focusedDecision && window.__focusedDecision.isConnected'),true); }); test('avatar closes when Tab or Shift+Tab leaves its wrapper', {timeout:90000}, async t => { const {f,b,wait}=await w15Fixture(t); await b.navigate(f.base+'/#/'); await wait('!!document.querySelector("#avatar-button")'); await b.evaluate('document.querySelector("#avatar-button").focus()'); await b.key('ArrowDown'); await b.evaluate('document.querySelector("#mode").focus()'); await b.key('Tab'); assert.deepEqual(await b.evaluate('[document.querySelector("#avatar-menu").hidden,document.querySelector("#avatar-button").getAttribute("aria-expanded")]'),[true,'false']); await b.evaluate('document.querySelector("#avatar-button").focus()'); await b.key('ArrowDown'); await b.evaluate('document.querySelector("#avatar-button").focus()'); await b.key('Tab','Tab',8); assert.deepEqual(await b.evaluate('[document.querySelector("#avatar-menu").hidden,document.querySelector("#avatar-button").getAttribute("aria-expanded")]'),[true,'false']); }); test('dashboard primary targets have 44px width and height', {timeout:90000}, async t => { const {f,b,wait}=await w15Fixture(t); await b.viewport(1440,1000); await b.navigate(f.base+'/#/'); await wait('document.querySelectorAll("#dashboard-kpis .kpi").length'); for (const width of [1440,390]) { await b.viewport(width,900); await b.evaluate('document.querySelector("#avatar-button").click()'); const boxes=await b.evaluate(`(()=>{const selectors=['#avatar-menu a[role=menuitem]','#avatar-menu button[role=menuitem]','#dashboard-inbox > a','#show-machine'].concat(innerWidth>=760?['#processing-link']:[]);return selectors.flatMap(s=>[...document.querySelectorAll(s)].filter(e=>e.getClientRects().length).map(e=>{const r=(s==='#show-machine'?e.closest('label'):e).getBoundingClientRect();return [s,r.width,r.height]}))})()`); assert.ok(boxes.every(([,w,h])=>w>=44&&h>=44),JSON.stringify(boxes)); await b.evaluate('document.querySelector("#avatar-button").click()'); } }); test('Inbox reports the time of its first successful bus read', {timeout:90000}, async t => { const {f,b,wait}=await w15Fixture(t); await b.navigate(f.base+'/#/inbox'); await wait('document.querySelector("#bus-view h1")?.textContent === "Inbox"'); assert.doesNotMatch(await b.evaluate('document.querySelector("#bus-status").textContent'),/Read from the bus never/); });