import test from 'node:test'; import assert from 'node:assert/strict'; import {setup,P,T} from './helpers/w15-conversations.mjs'; const faults={network:{network:true},server:{status:503,body:{error:'storage-refused'}},refusal404:{status:404,body:{error:'other-refusal'}},refusal403:{status:403,body:{error:'forbidden'}},malformed:{raw:'{not-json',status:200},malformed500:{raw:'not-json',status:500}}; const missingRequest={status:404,body:{error:'request-not-found'}}; const missingRecords={status:502,body:{error:'project-records-missing'}}; const missingNorth={status:404,body:{error:'project-read-missing'}}; const candidate={request:'request-1',project:'repo-key',mode_revision:1,prd_version:'v1',records_commit:'a',prd_path:'docs/prd.md',prd_blob:'b',spec_commit:'c',requirements:[{id:'R1',title:'Feature',path:'requirements/R1.json',blob:'d'}]}; const state={version:1,request:'request-1',revision:1,phase:'implementation',project:'repo-key',mode:'guided',mode_revision:1,candidate,snapshot:null,tasks:[]}; test('independent overview read error matrix',async t=>{ const {b,render,wait}=await setup(t); await b.evaluate(`(()=>{const old=fetch;const reply=async s=>{if(s.network)throw new TypeError('fixture network failure');return new Response(s.raw??JSON.stringify(s.body),{status:s.status??200,headers:{'content-type':'application/json'}})};window.fetch=(url,opts)=>{const p=String(url),s=window.__reviewScenario||{};if(p==='/api/config')return reply({body:{send:true,requests:true}});if(p.startsWith('/api/project-files?'))return reply(s.docs||{body:{files:[]}});if(p.startsWith('/api/project-file?'))return reply(s.north||${JSON.stringify(missingNorth)});if(p.startsWith('/api/requests/')&&!p.endsWith('/draft')&&!p.endsWith('/workspace'))return reply(s.request||${JSON.stringify(missingRequest)});if(p.endsWith('/draft'))return reply(s.draft||{body:{candidate:null,prd:null,requirements:[],snapshot:{id:null,revision:0}}});if(p.endsWith('/workspace'))return reply(s.workspace||{body:{available:true,entries:[]}});return old(url,opts);};})()`); async function check(name,s,expected,route=`/projects/${P}`){await t.test(name,async()=>{ await b.evaluate(`window.__reviewScenario=${JSON.stringify(s)};document.dispatchEvent(new CustomEvent('mosaic-project-room-changed'));`); await render(route); // A reread keeps the earlier paint until its result arrives (live updates), so wait for the expected text, not for 'Reading' to go. await wait(`document.querySelector('[data-project-content]') && ${expected}.test(document.querySelector('[data-project-content]').textContent)`).catch(()=>{}); const text=await b.evaluate(`document.querySelector('[data-project-content]').textContent`); assert.match(text,expected); });} await check('named record absence',{docs:missingRecords,north:missingRecords},/This project has no records yet/); await check('named north-star absence',{},/No north star yet/); await check('named lifecycle absence',{},/No active requests/); for(const [name,fault]of Object.entries(faults)){ await check('request '+name,{request:fault},/Request status unavailable|Status unavailable/); await check('documents '+name,{docs:fault},/Project documents unavailable/,`/projects/${P}/files`); await check('north star '+name,{north:fault},/North star unavailable/); } for(const [name,fault]of Object.entries(faults)){ await check('record absence plus north-star '+name,{docs:missingRecords,north:fault},/North star unavailable/); await check('requirements: record absence plus north-star '+name,{docs:missingRecords,north:fault},/North star unavailable/,`/projects/${P}/requirements`); } for(const route of [`/projects/${P}`,`/projects/${P}/requirements`]){ await check('file list unavailable but north-star records absent '+route,{docs:faults.server,north:missingRecords},/This project has no records yet/,route); await check('file list absent but north-star readable '+route,{docs:missingRecords,north:{body:{content:'North star exists'}}},/North star exists/,route); } await check('candidate draft server failure on overview',{request:{body:state},draft:faults.server},/PRD unavailable|PRD could not be read/); await check('candidate draft network failure on overview',{request:{body:state},draft:faults.network},/PRD unavailable|PRD could not be read/); await check('candidate draft malformed JSON on overview',{request:{body:state},draft:faults.malformed},/PRD unavailable|PRD could not be read/); await check('candidate draft failure on requirements',{request:{body:state},draft:faults.server},/Request or PRD unavailable/,`/projects/${P}/requirements`); }); for(const[name,fault]of Object.entries(faults))test('conversation request '+name,async t=>{ const {b,render,wait}=await setup(t); await b.evaluate(`(()=>{const old=fetch;window.fetch=async(url,opts)=>{const p=String(url);if(p==='/api/config')return new Response(JSON.stringify({send:true,requests:true}),{headers:{'content-type':'application/json'}});if(p.startsWith('/api/requests/')){const s=${JSON.stringify(fault)};if(s.network)throw new TypeError('fixture network failure');return new Response(s.raw??JSON.stringify(s.body),{status:s.status??200,headers:{'content-type':'application/json'}});}return old(url,opts);};})()`); await render(`/projects/threads/${T}`); await wait(`document.querySelector('.request-mount')?.textContent.includes('request could not be read')`); assert.match(await b.evaluate(`document.querySelector('.request-mount').textContent`),/request could not be read/i); }); test('overview and requirements show a partial-read note alongside a valid PRD', async t => { const {b,render,wait}=await setup(t); const otherCandidate={...candidate,request:'request-2'}; const goodDraft={candidate,prd:'Readable PRD text',requirements:[{id:'R1',title:'Feature',text:'Requirement text'}],snapshot:{id:null,revision:0}}; await b.evaluate(`(()=>{const old=fetch;const json=(v,status=200)=>new Response(JSON.stringify(v),{status,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('/threads/')&&p.includes('/messages?'))return Promise.resolve(json({rows:[{request:'request-1',at:'2026-11-05T12:00:00Z'},{request:'request-2',at:'2026-11-05T12:01:00Z'}]}));if(p==='/api/requests/request-1')return Promise.resolve(json(${JSON.stringify(state)}));if(p==='/api/requests/request-2')return Promise.resolve(json(${JSON.stringify({...state,request:'request-2',candidate:otherCandidate})}));if(p==='/api/requests/request-1/draft')return Promise.resolve(json(${JSON.stringify(goodDraft)}));if(p==='/api/requests/request-2/draft')return Promise.resolve(json({error:'storage-refused'},503));if(p.endsWith('/workspace'))return Promise.resolve(json({available:true,entries:[]}));return old(url,opts);};})()`); for(const route of [`/projects/${P}`,`/projects/${P}/requirements`]) { await render(route); await wait(`document.querySelector('[data-project-content]') && !document.querySelector('[data-project-content]').textContent.startsWith('Reading')`); const text=await b.evaluate(`document.querySelector('[data-project-content]').textContent`); assert.match(text,/PRD v1/); assert.match(text,/Some .*PRD.*could not be read/i); } });