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