114 lines
11 KiB
JavaScript
114 lines
11 KiB
JavaScript
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')`);
|
|
}
|
|
});
|