Files
stack/packages/webui/tests/s6b-error-matrix-browser.test.mjs
jason.woltje c27ff53649 webui: reread request panels, files and summary on every refresh
The refresh shortcuts (unchanged view, transcript-only change) skipped the request
summary and file rereads, so a request's phase change showed only after a full
repaint. One refreshRequestData helper now runs on every paint path; a quiet reread
keeps the present content until a changed result arrives.

s6b's error matrix waited for the 'Reading' placeholder to go, which a quiet
reread no longer shows, so it could read the previous scenario. It now waits for
the expected text.
2026-11-08 23:22:43 -06:00

63 lines
7.1 KiB
JavaScript

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