Files
stack/packages/webui/tests/projects-slice3-browser.test.mjs

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')`);
}
});