import test from 'node:test'; import assert from 'node:assert/strict'; import { setup } from './helpers/w15-conversations.mjs'; test('projects list presents full-width linked rows at desktop and mobile sizes', async t => { const { b, render } = await setup(t); await b.evaluate(`(() => { const old=window.fetch; window.fetch=(url,opts)=>String(url)==='/api/chat/projects' ? Promise.resolve(new Response(JSON.stringify({rows:[1,2,3].map((n)=>({id:'10000000-0000-4000-8000-00000000000'+n,name:n===2?'A much longer project name for testing':'Project '+n,repo:null}))}),{headers:{'content-type':'application/json'}})) : old(url,opts); })()`); await render('/projects'); for (const [width, columns] of [[1440,1],[390,1]]) { await b.viewport(width,1000); const result=await b.evaluate(`(() => {const grid=document.querySelector('.chat-projects');const cards=[...grid.querySelectorAll('.project-card')];return {columns:getComputedStyle(grid).gridTemplateColumns.split(' ').length,heights:cards.map(c=>c.getBoundingClientRect().height),links:cards.map(c=>c.matches('a[href]')),overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth};})()`); assert.equal(result.columns,columns); assert.equal(result.links.length,3); assert.ok(result.links.every(Boolean)); if(width===1440) assert.equal(new Set(result.heights).size,1); assert.equal(result.overflow,false); } }); test('project section routes expose the shared navigation and current section', async t => { const { b, render } = await setup(t); const id='10000000-0000-4000-8000-000000000001'; for (const section of ['conversations','tasks','requirements','files']) { await render(`/projects/${id}/${section}`); assert.ok((await b.evaluate(`document.querySelector('nav[aria-label="Project sections"] [aria-current="page"]')?.textContent.trim().toLowerCase()`)).startsWith(section)); assert.equal(await b.evaluate(`document.querySelector('.chat-area h1')?.textContent`),'Active project'); } }); test('project tree remembers disclosure state and links to project sections', async t => { const { b, wait }=await setup(t); await b.evaluate(`window.MosaicProjectTree.mount({container:document.querySelector('#project-tree'),path:'/projects'})`); await wait(`document.querySelector('[data-project-disclosure]')`); assert.equal(await b.evaluate(`getComputedStyle(document.querySelector('#project-tree')).display`), 'block'); await b.evaluate(`document.querySelector('[data-project-disclosure]').click()`); assert.equal(await b.evaluate(`document.querySelector('[data-project-disclosure]').getAttribute('aria-expanded')`),'true'); assert.ok(await b.evaluate(`!!document.querySelector('#project-tree a[href$="/conversations"]')`)); await b.evaluate(`window.MosaicProjectTree.mount({container:document.querySelector('#project-tree'),path:'/projects'})`); await wait(`document.querySelector('[data-project-disclosure]')?.getAttribute('aria-expanded')==='true'`); }); test('request gathering exposes selectable PRDs and groups files by kind', async t => { const { b, render, wait }=await setup(t); await b.evaluate(`(() => { const old=window.fetch; const json=v=>new Response(JSON.stringify(v),{headers:{'content-type':'application/json'}}); window.fetch=(url,opts)=>{const p=String(url); if(p==='/api/config') return Promise.resolve(json({send:true,requests:true})); if(p.includes('/messages')) return Promise.resolve(json({rows:[{id:'40000000-0000-4000-8000-000000000001',from_role:'human',request:'request-1'},{id:'40000000-0000-4000-8000-000000000002',from_role:'human',request:'request-2'}],hasMore:false,after:null})); const m=p.match(/^\\/api\\/requests\\/(request-[12])(\\/draft|\\/workspace)?$/); if(m){ const id=m[1],candidate={request:id,project:'repo-key',mode_revision:1,prd_version:id,records_commit:'a',prd_path:'docs/prd.md',prd_blob:'b',spec_commit:'c',requirements:[{id:'R1',title:'Feature',path:'req',blob:'d'}]}; if(m[2]==='/draft') return Promise.resolve(json({candidate,prd:'A real PRD for '+id,requirements:[{id:'R1',title:'Feature',text:'Details'}],snapshot:{id:null,revision:0}})); if(m[2]==='/workspace') return Promise.resolve(json({available:true,entries:[{name:id+'.js'}]})); return Promise.resolve(json({version:1,request:id,revision:1,phase:'implementation',project:'repo-key',mode:'guided',mode_revision:1,candidate,snapshot:null,tasks:[]})); } return old(url,opts); }; })()`); const id='10000000-0000-4000-8000-000000000001'; await b.evaluate(`document.querySelector('#refresh').click()`); await render(`/projects/${id}/requirements`); await wait(`document.querySelector('#project-request-select')?.options.length===2`); assert.match(await b.evaluate(`document.querySelector('[data-project-content]').textContent`),/A real PRD for request-1/); await b.evaluate(`const s=document.querySelector('#project-request-select');s.value='request-2';s.dispatchEvent(new Event('change',{bubbles:true}))`); assert.match(await b.evaluate(`document.querySelector('[data-project-content]').textContent`),/A real PRD for request-2/); await render(`/projects/${id}/files`); await wait(`document.querySelector('[data-project-content]')?.textContent.includes('request-1.js')`); const text=await b.evaluate(`document.querySelector('[data-project-content]').textContent`); assert.match(text,/Reviewed deliveries/); assert.match(text,/Draft workspace/); assert.match(text,/request-2.js/); assert.match(text,/Project documents/); }); test('unavailable request reads do not become empty request claims', async t => { const {b,render,wait}=await setup(t); await b.evaluate(`(() => { const old=window.fetch; window.fetch=(url,opts)=>String(url)==='/api/config' ? Promise.resolve(new Response(JSON.stringify({send:true,requests:false}),{headers:{'content-type':'application/json'}})) : old(url,opts); })()`); await b.evaluate(`document.querySelector('#refresh').click()`); await render('/projects/10000000-0000-4000-8000-000000000001'); await wait(`document.querySelector('[data-project-content]')?.textContent.includes('Not available yet')`); const text=await b.evaluate(`document.querySelector('[data-project-content]').textContent`); assert.doesNotMatch(text,/No active requests|No active request|No request tasks available/); assert.match(text,/Request status unavailable/); }); test('overview prioritizes an active decision and Files rejects foreign delivery evidence', async t => { const {b,render,wait}=await setup(t); await b.evaluate(`(() => { const old=window.fetch; const json=v=>new Response(JSON.stringify(v),{headers:{'content-type':'application/json'}}); window.fetch=(url,opts)=>{const p=String(url); if(p==='/api/config')return Promise.resolve(json({send:true,requests:true})); if(p.includes('/messages'))return Promise.resolve(json({rows:[{id:'40000000-0000-4000-8000-000000000001',from_role:'human',request:'decision-1',at:'2026-11-06T10:00:00Z'},{id:'40000000-0000-4000-8000-000000000002',from_role:'human',request:'done-1',at:'2026-11-06T11:00:00Z'}],hasMore:false,after:null})); if(p==='/api/requests/decision-1')return Promise.resolve(json({version:1,request:'decision-1',revision:1,phase:'review',project:'repo-key',mode:'guided',wait:{kind:'decision'},tasks:['vikunja:2/12']})); if(p==='/api/requests/done-1')return Promise.resolve(json({version:1,request:'done-1',revision:1,phase:'complete',project:'repo-key',snapshot:'snap',delivery:'delivery-1',delivery_evidence:{id:'delivery-1',request:'done-1',project:'another-project',snapshot:'snap',artifact:{path:'foreign.html'},verification:{}},tasks:[]})); if(p==='/api/bus/tasks')return Promise.resolve(json({rows:[{task_ref:'vikunja:2/12',fields:{title:'A task',bucket:3,done:false,assignees:['coder']}}]})); if(p.startsWith('/api/requests/'))return Promise.resolve(json({available:true,entries:[]})); return old(url,opts); }; })()`); await b.evaluate(`document.querySelector('#refresh').click()`); const id='10000000-0000-4000-8000-000000000001'; await render(`/projects/${id}`); await wait(`document.querySelector('[data-project-content]')?.textContent.includes('decision needs your input')`); assert.match(await b.evaluate(`document.querySelector('.project-work').textContent`),/decision-1/); await render(`/projects/${id}/tasks`); await wait(`document.querySelector('[data-project-content]')?.textContent.includes('A task')`); assert.match(await b.evaluate(`document.querySelector('[data-project-content]').textContent`),/decision-1.*bucket 3.*coder/s); await render(`/projects/${id}/files`); await wait(`document.querySelector('[data-project-content]')?.textContent.includes('Reviewed deliveries')`); assert.doesNotMatch(await b.evaluate(`document.querySelector('[data-project-content]').textContent`),/foreign\.html/); }); test('conversation and room details open as keyboard accessible dialogs', async t => { const {b,render}=await setup(t); const p='10000000-0000-4000-8000-000000000001', thread='20000000-0000-4000-8000-000000000001'; for(const route of [`/projects/threads/${thread}`,`/projects/rooms/${p}`]) { await render(route); await b.evaluate(`document.querySelector('[data-project-dialog]').focus()`); await b.key('Enter'); assert.equal(await b.evaluate(`document.querySelector('dialog[open]')!==null`),true); await b.key('Escape'); assert.equal(await b.evaluate(`document.querySelector('dialog[open]')===null`),true); assert.equal(await b.evaluate(`document.activeElement.hasAttribute('data-project-dialog')`),true); } }); test('canonical project breadcrumb and tabs navigate at desktop and mobile widths', async t => { const {b,wait}=await setup(t), id='10000000-0000-4000-8000-000000000001'; for(const width of [1440,390]) { await b.viewport(width,900); await b.evaluate(`location.hash='#/projects/${id}'`); await wait(`document.querySelector('.project-tabs [aria-current]')?.textContent.includes('Overview')`); assert.ok(await b.evaluate(`!!document.querySelector('.project-actions a[href*="/requirements"]')`)); assert.ok(await b.evaluate(`!!document.querySelector('.project-tabs a[href$="/files"]')`)); const info=await b.evaluate(`(()=>{const c=document.querySelector('.project-crumb-current'), links=[...document.querySelectorAll('.project-actions a,.project-tabs a')].filter(a=>a.getClientRects().length>0);return {visible:getComputedStyle(c).display!=='none',targets:links.length>0&&links.every(a=>a.getBoundingClientRect().height>=44),overflow:document.documentElement.scrollWidth>document.documentElement.clientWidth}})()`); assert.equal(info.visible,width>699); assert.ok(info.targets); assert.equal(info.overflow,false); await b.evaluate(`location.hash='#/projects/${id}/files'`); await wait(`document.querySelector('.project-tabs [aria-current]')?.textContent.includes('Files')`); } });