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

168 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { P, T, S, stub } from './helpers/projects-review-fixture.mjs';
async function setup(t, route = '/projects/' + P, width = 1440) {
const f=await fixture(), b=await browser({machine:false});
t.after(async()=>{await b.close();await f.close();});
await b.call('Fetch.enable',{patterns:[{urlPattern:'*'}]});
b.on('Fetch.requestPaused',p=>void b.call(p.request.url.startsWith(f.base+'/')?'Fetch.continueRequest':'Fetch.failRequest',p.request.url.startsWith(f.base+'/')?{requestId:p.requestId}:{requestId:p.requestId,errorReason:'BlockedByClient'}).catch(()=>{}));
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:stub});
await b.viewport(width,width===390?900:1000); await b.navigate(f.base+'/#'+route);
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<180;i++){if(${expr})return;await new Promise(r=>setTimeout(r,30))}throw Error('Timed out: '+${JSON.stringify(expr)})})()`);
await wait(`document.querySelector('.chat-area h1') && !document.querySelector('[data-project-content]')?.textContent.includes('Reading…')`);
return {b,wait};
}
const pulse=async b=>{await b.evaluate(`new Promise(r=>setTimeout(r,11500))`);};
test('R1 pulse preserves open Details, typing, selection and focus', {timeout:90000}, async t=>{
const {b,wait}=await setup(t,'/projects/threads/'+T);
await wait(`document.querySelector('[data-project-dialog="conversation"]')`);
await b.evaluate(`document.querySelector('[data-project-dialog="conversation"]').click(); const e=document.querySelector('#chat-session-name');e.value='typing draft';e.dispatchEvent(new Event('input',{bubbles:true}));e.focus();e.setSelectionRange(2,5)`);
await pulse(b);
const state=await b.evaluate(`(()=>{const e=document.querySelector('#chat-session-name');return {open:!!document.querySelector('#project-conversation-dialog[open]'),focus:document.activeElement.id,value:e?.value,start:e?.selectionStart,end:e?.selectionEnd}})()`);
assert.deepEqual(state,{open:true,focus:'chat-session-name',value:'typing draft',start:2,end:5});
await b.evaluate(`{const e=document.querySelector('#chat-share-target');e.value='user:someone';e.dispatchEvent(new Event('input',{bubbles:true}));e.focus();e.setSelectionRange(2,6)}`);
await pulse(b);
assert.deepEqual(await b.evaluate(`({open:!!document.querySelector('#project-conversation-dialog[open]'),focus:document.activeElement.id,value:document.querySelector('#chat-share-target').value,start:document.activeElement.selectionStart,end:document.activeElement.selectionEnd})`),{open:true,focus:'chat-share-target',value:'user:someone',start:2,end:6});
await b.key('Escape'); assert.equal(await b.evaluate(`document.activeElement.dataset.projectDialog`),'conversation');
await b.evaluate(`location.hash='#/projects/rooms/${P}'`);await wait(`document.querySelector('[data-project-dialog="room"]')`);await b.evaluate(`document.querySelector('[data-project-dialog="room"]').click()`);await pulse(b);assert.equal(await b.evaluate(`!!document.querySelector('#project-room-dialog[open]') && document.activeElement.id==='project-room-title'`),true);await b.key('Escape');assert.equal(await b.evaluate(`document.activeElement.dataset.projectDialog`),'room');
});
test('R2 incomplete request discovery cannot claim a complete Files count',async t=>{
const {b,wait}=await setup(t,'/projects');
await b.evaluate(`reviewFail['/api/chat/threads/${T}/messages?limit=100&session=${S}']=true;document.querySelector('#refresh').click();location.hash='#/projects/${P}'`);
await wait(`document.querySelector('.project-metrics')`);
const info=await b.evaluate(`({metric:document.querySelector('.project-metrics a:last-of-type').textContent,tab:document.querySelector('.project-tabs a[href$="/files"]').textContent})`);
assert.match(info.metric,/–.*Not available yet/s); assert.doesNotMatch(info.tab,/\d/);
});
test('R3 request selector survives the real refresh pulse and retains focus', {timeout:90000},async t=>{
const {b,wait}=await setup(t,'/projects/'+P+'/requirements');
await wait(`document.querySelector('#project-request-select')?.options.length===2`);
await b.evaluate(`const e=document.querySelector('#project-request-select');e.value='request-2';e.dispatchEvent(new Event('change',{bubbles:true}));e.focus()`);
await pulse(b);
assert.deepEqual(await b.evaluate(`({value:document.querySelector('#project-request-select')?.value,focus:document.activeElement.id})`),{value:'request-2',focus:'project-request-select'});
});
test('R4 long PRD has a keyboard operable six-line preview', {timeout:90000},async t=>{const {b,wait}=await setup(t,'/projects/'+P+'/requirements');await wait(`document.querySelector('[data-prd]')`);const x=await b.evaluate(`(()=>{const p=document.querySelector('[data-prd]'),button=[...document.querySelectorAll('button')].find(e=>e.textContent.includes('Show full PRD'));return {button:!!button,clipped:p.scrollHeight>p.clientHeight}})()`);assert.deepEqual(x,{button:true,clipped:true});await b.evaluate(`[...document.querySelectorAll('button')].find(e=>e.textContent.includes('Show full PRD')).focus()`);await b.key('Enter');assert.equal(await b.evaluate(`document.querySelector('[data-prd]').scrollHeight<=document.querySelector('[data-prd]').clientHeight`),true);await pulse(b);assert.equal(await b.evaluate(`document.querySelector('[data-prd]').classList.contains('expanded') && document.activeElement.matches('.project-prd-toggle')`),true);});
test('R5 mobile provenance disclosure and focus survive refresh', {timeout:90000},async t=>{const {b,wait}=await setup(t,'/projects/'+P+'/requirements',390);await wait(`document.querySelector('[data-prd]')`);assert.equal(await b.evaluate(`!!document.querySelector('aside details:not([open]) a[href^="#/docs/"]')`),true);await b.evaluate(`document.querySelector('aside summary').click();document.querySelector('aside summary').focus()`);await pulse(b);assert.equal(await b.evaluate(`document.querySelector('aside details').open && document.activeElement.matches('aside summary')`),true);});
test('R6 conversations use rows and a toggled creation form',async t=>{const {b,wait}=await setup(t,'/projects/'+P+'/conversations');await wait(`document.querySelector('.project-conversation-row')`);assert.deepEqual(await b.evaluate(`({closed:!!document.querySelector('.project-new-conversation:not([open])'),rects:document.querySelector('#chat-thread-form')?.getClientRects().length,hash:location.hash})`),{closed:true,rects:0,hash:'#/projects/'+P+'/conversations'});await b.evaluate(`document.querySelector('.project-new-conversation summary').click()`);assert.equal(await b.evaluate(`document.querySelector('#chat-thread-form').getClientRects().length>0`),true);});
test('R7 shared headers show context and one set of project counts',async t=>{const {b,wait}=await setup(t,'/projects/threads/'+T);await wait(`document.querySelector('.project-tabs a[href$="/files"] .project-count')`);assert.match(await b.evaluate(`document.querySelector('.project-heading').textContent`),/With assistant/);assert.match(await b.evaluate(`document.querySelector('.project-tabs a[href$="/files"]').textContent`),/\d/);await b.evaluate(`location.hash='#/projects/${P}'`);await wait(`document.querySelector('[data-project-content] .project-metrics')`);assert.deepEqual(await b.evaluate(`['tasks','files'].map(k=>document.querySelectorAll('.project-tabs a[href$="/'+k+'"] .project-count').length)`),[1,1]);});
test('R8 mobile conversation and room composers are visible within viewport',async t=>{const {b,wait}=await setup(t,'/projects/threads/'+T,390);await wait(`document.querySelector('#chat-send-form')`);for(const [route,selector] of [[null,'#chat-send-form'],['/projects/rooms/'+P,'#chat-room-send-form']]) {if(route){await b.evaluate(`location.hash='#${route}'`);await wait(`document.querySelector('#chat-room-send-form')`);}const x=await b.evaluate(`(()=>{const r=document.querySelector('${selector}').getBoundingClientRect();return {top:r.top,bottom:r.bottom,height:innerHeight}})()`);assert.ok(x.top>=0 && x.bottom<=x.height,JSON.stringify(x));}});
test('R9 conversation work card exposes PRD and truthful file summary',async t=>{const {b,wait}=await setup(t,'/projects/threads/'+T);await wait(`document.querySelector('.project-request-intro')?.textContent.includes('Approved')`);assert.match(await b.evaluate(`document.querySelector('.project-request-intro').textContent`),/PRD.*Approved.*(reviewed|draft)/is);assert.ok(await b.evaluate(`Number.isFinite(Date.parse(document.querySelector('.project-request-intro time')?.dateTime))`));await b.evaluate(`reviewFail['/api/requests/request-1/workspace']=true;document.querySelector('#refresh').click()`);await wait(`document.querySelector('.project-request-intro')?.textContent.includes('Draft files unavailable')`);});
test('R10 mobile stepper has readable stacked labels and icons',async t=>{const {b,wait}=await setup(t,'/projects/'+P,390);await wait(`document.querySelector('.project-stepper')`);const r=await b.evaluate(`(()=>{const e=document.querySelector('.project-stepper span');return {font:parseFloat(getComputedStyle(e).fontSize),children:e.children.length}})()`);assert.ok(r.font>=12 && r.children>=2,JSON.stringify(r));});
test('R11 overview work and direction expose backed details',async t=>{const {b,wait}=await setup(t);await wait(`document.querySelector('.project-work')`);const r=await b.evaluate(`({size:parseFloat(getComputedStyle(document.querySelector('.project-work strong')).fontSize),text:document.querySelector('.project-work').textContent,chip:!!document.querySelector('aside .project-chip')})`);assert.ok(r.size>=18 && /Observed/.test(r.text) && r.chip,JSON.stringify(r));});
test('R12 task row is one whole-row link with status',async t=>{const {b,wait}=await setup(t,'/projects/'+P+'/tasks');await wait(`document.querySelector('.project-file-row')`);assert.equal(await b.evaluate(`document.querySelector('.project-file-row').matches('a[href]') && !!document.querySelector('.project-file-row .project-chip')`),true);const rows=await b.evaluate(`[...document.querySelectorAll('.project-file-row')].map(e=>({top:e.getBoundingClientRect().top,bottom:e.getBoundingClientRect().bottom}))`);assert.ok(rows.length>=2 && rows[1].top>=rows[0].bottom,JSON.stringify(rows));});
test('R13 controls meet 44px targets in open Details and request disclosures',async t=>{
const {b,wait}=await setup(t,'/projects/threads/'+T,390);await wait(`document.querySelector('[data-project-dialog]')`);
await b.evaluate(`document.querySelector('[data-project-dialog]').click()`);
const audit=`[...document.querySelectorAll('dialog[open] input,dialog[open] select,dialog[open] button,dialog[open] a')].filter(e=>{const r=e.getBoundingClientRect();return r.width<44||r.height<44}).map(e=>e.id||e.textContent.trim())`;
assert.deepEqual(await b.evaluate(audit),[]);await b.key('Escape');
await wait(`document.querySelector('.request-details')`);await b.evaluate(`document.querySelectorAll('.request-details').forEach(e=>e.open=true)`);
const requestBad=await b.evaluate(`[...document.querySelectorAll('.request-details a,.request-details button,.request-details input,.request-details select,.request-details summary')].filter(e=>{const r=e.getBoundingClientRect();return r.width && (r.width<44||r.height<44)}).map(e=>e.textContent.trim()||e.id)`);assert.deepEqual(requestBad,[]);
await b.evaluate(`location.hash='#/projects/${P}/requirements'`);await wait(`document.querySelector('[data-prd]')`);
await b.evaluate(`document.querySelectorAll('[data-project-content] details').forEach(e=>e.open=true)`);
const bad=await b.evaluate(`[...document.querySelectorAll('[data-project-content] a,[data-project-content] button,[data-project-content] select,[data-project-content] summary')].filter(e=>{const r=e.getBoundingClientRect();return r.width && (r.width<44||r.height<44)}).map(e=>e.textContent.trim())`);assert.deepEqual(bad,[]);
});
test('R14 columns stack at 1200 and headings use project type scale',async t=>{const {b,wait}=await setup(t,'/projects/'+P,1200);await wait(`document.querySelector('.project-columns')`);const r=await b.evaluate(`({columns:getComputedStyle(document.querySelector('.project-columns')).gridTemplateColumns.split(' ').length,size:parseFloat(getComputedStyle(document.querySelector('[data-project-content] h2')).fontSize)})`);assert.equal(r.columns,1);assert.ok(r.size>=23);});
test('R15 reviewed delivery uses a file tile and mobile action layout',async t=>{const {b,wait}=await setup(t,'/projects/'+P+'/files',390);await wait(`document.querySelector('[data-project-content]')?.textContent.includes('timer.html')`);assert.equal(await b.evaluate(`!!document.querySelector('.project-delivery-icon') && !!document.querySelector('.project-delivery-actions')`),true);});
test('R16 list includes delivered status and inline project creation',async t=>{const {b,wait}=await setup(t,'/projects');await b.evaluate(`reviewStates['request-1'].phase='complete';document.querySelector('#refresh').click();location.hash='#/projects/${P}';location.hash='#/projects'`);await wait(`document.querySelector('[data-project-status]')?.textContent.includes('Updated')`);await b.evaluate(`document.querySelector('.project-create summary').click()`);const r=await b.evaluate(`({status:document.querySelector('[data-project-status]').textContent,inline:document.querySelector('.project-create').getBoundingClientRect().top>=document.querySelector('.project-list-head').getBoundingClientRect().bottom})`);assert.match(r.status,/Delivered/);assert.equal(r.inline,true);});
test('R17 closed room exposes an inline recovery action',async t=>{const {b,wait}=await setup(t,'/projects');await b.evaluate(`reviewClosed=true;location.hash='#/projects/rooms/${P}'`);await wait(`document.querySelector('.chat-room')`);assert.equal(await b.evaluate(`!!document.querySelector('.chat-room [data-room-open-inline]') && !document.querySelector('.chat-error.banner.err')`),true);});
test('R18 modal backdrop uses a themed semantic color',async t=>{const {b,wait}=await setup(t,'/projects/threads/'+T);await wait(`document.querySelector('[data-project-dialog]')`);await b.evaluate(`document.querySelector('[data-project-dialog]').click()`);assert.notEqual(await b.evaluate(`getComputedStyle(document.documentElement).getPropertyValue('--project-backdrop').trim()`),'');});
test('R19 tree includes child icons and 52px conversation rows',async t=>{const {b,wait}=await setup(t);await wait(`document.querySelector('.project-tree-children a[href*="threads"]')`);const r=await b.evaluate(`(()=>{const e=document.querySelector('.project-tree-children a[href*="threads"]');return {icon:!!e.querySelector('svg'),height:e.getBoundingClientRect().height}})()`);assert.ok(r.icon&&r.height>=52,JSON.stringify(r));});
test('R20 automatic refresh retains every project route through slow and failed reads', {timeout:240000},async t=>{
const {b,wait}=await setup(t);const routes=['/projects/'+P,'/projects/'+P+'/conversations','/projects/'+P+'/tasks','/projects/'+P+'/requirements','/projects/'+P+'/files','/projects/'+P+'/settings','/projects/threads/'+T,'/projects/rooms/'+P];
for(const [i,route] of routes.entries()) {await b.evaluate(`location.hash=${JSON.stringify('#'+route)}`);await wait(`document.querySelector('.project-header')`);await b.evaluate(`reviewReads.length=0;reviewDelay['/api/chat/projects']=${i===1?1500:0};reviewFail['/api/chat/projects']=${i===2}`);await pulse(b);assert.equal(await b.evaluate(`location.hash`),'#'+route);assert.ok(await b.evaluate(`reviewReads.includes('/api/chat/projects')`),'real pulse should read projects on '+route);await b.evaluate(`reviewFail['/api/chat/projects']=false;reviewDelay['/api/chat/projects']=0`);}
});
test('R21 room status and actions are visible on project surfaces and use real refusals',async t=>{
const {b,wait}=await setup(t);await wait(`document.querySelector('[data-project-room-status]')?.textContent.includes('Open')`);
assert.match(await b.evaluate(`document.querySelector('[data-project-content] [data-project-room-status]').textContent`),/Room: Open/);
assert.equal(await b.evaluate(`!!document.querySelector('[data-project-content] [data-project-room-status] a[href$="/rooms/${P}"]') && !document.querySelector('.project-header [data-project-room-status]')`),true);
await b.evaluate(`reviewClosed=true;document.querySelector('#refresh').click()`);await wait(`document.querySelector('[data-project-content] [data-project-room-open]')`);await b.evaluate(`document.querySelector('[data-project-content] [data-project-room-open]').click()`);await wait(`document.querySelector('[data-project-content] [data-project-room-receipt]')?.textContent.includes('refused')`);assert.ok(await b.evaluate(`reviewWrites.some(x=>x.path==='/api/chat/rooms/${P}/open')`));
await b.evaluate(`reviewFail['/api/chat/rooms/${P}/messages?limit=100']=true;document.querySelector('#refresh').click()`);await wait(`document.querySelector('[data-project-content] [data-project-room-status]')?.textContent.includes('Unavailable')`);
});
test('R22 unlinked projects give truthful repository copy without a Settings link',async t=>{
const {b,wait}=await setup(t,'/projects');await b.evaluate(`location.hash='#/projects/10000000-0000-4000-8000-000000000002/requirements'`);await wait(`document.querySelector('[data-project-content] h2')`);const text=await b.evaluate(`document.querySelector('[data-project-content]').textContent`);assert.match(text,/This project has no linked repository.*Linking one to an existing project isn't available yet/s);assert.equal(await b.evaluate(`!!document.querySelector('[data-project-content] a[href$="/settings"]')`),false);
});
test('S4 composer and Send remain in view without scrolling at desktop and mobile sizes',async t=>{
for(const [width,height,route,selector] of [[1440,1000,'/projects/threads/'+T,'#chat-send-form'],[1440,1000,'/projects/rooms/'+P,'#chat-room-send-form'],[390,900,'/projects/threads/'+T,'#chat-send-form']]) {
const {b,wait}=await setup(t,route,width); await b.viewport(width,height); await wait(`document.querySelector('${selector} button[type=submit]')`);
const rect=await b.evaluate(`(()=>{const f=document.querySelector('${selector}').getBoundingClientRect(),b=document.querySelector('${selector} button[type=submit]').getBoundingClientRect();const log=document.querySelector('.chat-log'),lr=log.getBoundingClientRect();return {top:f.top,bottom:f.bottom,buttonBottom:b.bottom,height:innerHeight,scroll:scrollY,logBottom:lr.bottom,logOverflow:getComputedStyle(log).overflowY}})()`);
assert.ok(rect.top>=0&&rect.bottom<=height&&rect.buttonBottom<=height&&rect.scroll===0&&rect.logBottom<=rect.top&&['auto','scroll'].includes(rect.logOverflow),JSON.stringify(rect));
}
});
test('S4 shared headers align across project routes and mobile details stay on the title row',async t=>{
const {b,wait}=await setup(t);
for(const route of ['/projects/'+P,'/projects/'+P+'/conversations','/projects/'+P+'/tasks','/projects/'+P+'/requirements','/projects/'+P+'/files','/projects/'+P+'/settings','/projects/threads/'+T,'/projects/rooms/'+P]) {
await b.evaluate(`location.hash=${JSON.stringify('#'+route)}`);await wait(`document.querySelector('.project-header')`);
const top=await b.evaluate(`document.querySelector('.project-header').getBoundingClientRect().top`);
assert.ok(top>=115&&top<=145,`${route}: ${top}`);
}
await b.viewport(390,900);
for(const [route,kind] of [['/projects/threads/'+T,'conversation'],['/projects/rooms/'+P,'room']]) {
await b.evaluate(`location.hash=${JSON.stringify('#'+route)}`);await wait(`!!document.querySelector('.project-chat [data-project-dialog="${kind}"]')`);
const rect=await b.evaluate(`(()=>{const h=document.querySelector('.project-header').getBoundingClientRect(),a=document.querySelector('.project-chat [data-project-dialog="${kind}"]').getBoundingClientRect();return {left:a.left,right:a.right,top:a.top,headTop:h.top,headBottom:h.bottom,width:a.width,height:a.height,subtitleRight:document.querySelector('.project-heading p').getBoundingClientRect().right}})()`);
assert.ok(rect.width>=44&&rect.height>=44&&rect.left>=0&&rect.right<=390&&rect.top<=rect.headTop+4&&rect.subtitleRight<=rect.left,JSON.stringify(rect));
}
});
test('S4 request repaint retains the gathered PRD and file counts',async t=>{
const {b,wait}=await setup(t,'/projects/threads/'+T);
await wait(`document.querySelector('.project-request-intro')?.textContent.includes('Approved')`);
await b.evaluate(`MosaicRequests.mount(document.querySelector('.chat-conversation'))`);
const info=await b.evaluate(`document.querySelector('.project-request-intro').textContent`);
assert.match(info,/PRD v3.*Approved/s); assert.match(info,/1 reviewed, 4 draft/);
});
test('S4 fix conversation composition fits the viewport and follows the reply', async t=>{
for(const width of [1440,390]) {
const {b,wait}=await setup(t,'/projects/threads/'+T,width);
await wait(`document.querySelector('.project-request-intro')?.textContent.includes('Approved') && document.querySelector('.request-details') && document.querySelector('#chat-send-form button[type=submit]')`);
const x=await b.evaluate(`(()=>{const log=document.querySelector('.chat-log'),card=document.querySelector('.project-request-intro'),details=document.querySelector('.request-details'),reply=log.querySelector('.chat-message-agent'),form=document.querySelector('#chat-send-form'),button=form.querySelector('button[type=submit]'),footer=document.querySelector('footer.foot');const lr=log.getBoundingClientRect(),dr=details.getBoundingClientRect(),br=button.getBoundingClientRect();return {overflow:document.documentElement.scrollHeight-innerHeight,after:!!(reply.compareDocumentPosition(card)&Node.DOCUMENT_POSITION_FOLLOWING),detailsBottom:dr.bottom,logBottom:lr.bottom,sendBottom:br.bottom,footerTop:footer?.getBoundingClientRect().top,summary:!!document.querySelector('.project-chat [data-chat-summary]'),hit:document.elementFromPoint(br.left+br.width/2,br.top+br.height/2)===button}})()`);
assert.ok(x.overflow<=1 && x.after && (width===390 || x.detailsBottom<=x.logBottom+1) && x.sendBottom<=x.footerTop && x.hit && !x.summary,JSON.stringify(x));
}
});
test('S4 fix mobile rows, speaker labels and independently scrolling long transcript', async t=>{
const {b,wait}=await setup(t,'/projects/threads/'+T,390);
await wait(`document.querySelector('[data-work-files]')?.textContent.includes('4 draft')`);
const initial=await b.evaluate(`(()=>{const log=document.querySelector('.chat-log').getBoundingClientRect(),rows=[...document.querySelectorAll('.project-work-row')].map(e=>e.getBoundingClientRect()),metas=[...document.querySelectorAll('.chat-message-meta')];return {rows:rows.map(r=>({top:r.top,bottom:r.bottom})),logTop:log.top,logBottom:log.bottom,first:metas[0].getBoundingClientRect().top,gaps:metas.map(e=>e.querySelector('strong').getBoundingClientRect().left-e.querySelector('.chat-avatar').getBoundingClientRect().right)};})()`);
assert.ok(initial.rows.length===2 && initial.rows[0].bottom<=initial.rows[1].top+1 && initial.gaps.every(g=>g>=4),JSON.stringify(initial));
await b.evaluate(`fixtureMessages.push(...Array.from({length:30},(_,i)=>({id:'60000000-0000-4000-8000-'+String(i).padStart(12,'0'),from_role:'assistant',body:'Long transcript entry '+i,at:'2026-11-06T10:25:00Z'})));document.querySelector('#refresh').click()`);
await wait(`document.querySelectorAll('.chat-log > li[data-message]').length>=32`);
const scroll=await b.evaluate(`(()=>{const log=document.querySelector('.chat-log');log.scrollTop=0;const before=log.scrollTop;log.scrollTop=log.scrollHeight;return {before,after:log.scrollTop,range:log.scrollHeight-log.clientHeight,page:document.documentElement.scrollHeight-innerHeight,window:scrollY}})()`);
assert.ok(scroll.range>100&&scroll.after>0&&scroll.before===0&&scroll.page<=1&&scroll.window===0,JSON.stringify(scroll));
});
test('S4 fix room keeps recipients, paging and refusal receipt above the footer', async t=>{
const {b,wait}=await setup(t,'/projects/rooms/'+P);
await wait(`document.querySelectorAll('[data-room-chip]').length>=2 && document.querySelector('#chat-room-send-form')`);
await b.evaluate(`(()=>{const old=window.fetch;window.fetch=async(url,opts)=>{const r=await old(url,opts);if(String(url).startsWith('/api/chat/rooms/${P}/messages?limit=100')){const data=await r.json();return new Response(JSON.stringify({...data,hasMore:true,after:'50000000-0000-4000-8000-000000000003'}),{headers:{'content-type':'application/json'}})}return r};document.querySelector('#refresh').click()})()`);
await wait(`document.querySelector('#chat-next')`);
await b.evaluate(`document.querySelector('[data-room-chip]').click(); const e=document.querySelector('#chat-room-body');e.value='A room message';e.dispatchEvent(new Event('input',{bubbles:true}));document.querySelector('#chat-room-send-form').requestSubmit()`);
await wait(`document.querySelector('#chat-room-receipt')?.textContent`);
const x=await b.evaluate(`(()=>{const f=document.querySelector('#chat-room-send-form'),b=f.querySelector('[type=submit]').getBoundingClientRect(),footer=document.querySelector('footer.foot').getBoundingClientRect();return {recipients:f.querySelectorAll('[data-room-chip]').length,paging:!!document.querySelector('#chat-next'),receipt:document.querySelector('#chat-room-receipt').textContent,buttonBottom:b.bottom,footerTop:footer.top,overflow:document.documentElement.scrollHeight-innerHeight}})()`);
assert.ok(x.recipients>=2&&x.paging&&/refus|unknown/i.test(x.receipt)&&x.buttonBottom<=x.footerTop&&x.overflow<=1,JSON.stringify(x));
});
test('S4 fix stale project gather cannot enrich a newer request revision', async t=>{
const {b,wait}=await setup(t,'/projects/threads/'+T);
await wait(`document.querySelector('.project-request-intro')?.textContent.includes('Approved')`);
await b.evaluate(`(()=>{document.dispatchEvent(new CustomEvent('mosaic-project-room-changed'));const old=window.fetch;window.s4Release=null;window.s4Held=false;window.fetch=async(url,opts)=>{const response=await old(url,opts);if(String(url)==='/api/requests/request-1/workspace'&&!window.s4Held){window.s4Held=true;await new Promise(r=>window.s4Release=r)}return response}; const c=document.querySelector('.chat-area');window.s4Gather=MosaicProjects.enhanceSurface(c,fixtureProjects[0],fixtureThreads,fixtureThreads[0],true,'');})()`);
await wait(`window.s4Release`);
await b.evaluate(`reviewStates['request-1']={...reviewStates['request-1'],revision:2};document.querySelector('.request-details').open=true;document.querySelector('[data-request-reload]').click()`);
await wait(`document.querySelector('.project-request-intro')?.dataset.requestRevision==='2'`);
await b.evaluate(`window.s4Release();window.s4Gather`);
const x=await b.evaluate(`({revision:document.querySelector('.project-request-intro').dataset.requestRevision,prd:document.querySelector('[data-work-prd]').textContent,files:document.querySelector('[data-work-files]').textContent})`);
assert.equal(x.revision,'2');assert.doesNotMatch(x.prd,/v3|Approved/);assert.doesNotMatch(x.files,/4 draft/);
});