import test from 'node:test'; import assert from 'node:assert/strict'; import { setup, state, REQUEST, THREAD, question, wait, draft, candidate } from './helpers/w15-requests.mjs'; const archived = { id: THREAD, name: 'Archived', repo: 'library', archived: true }; const live = { id: THREAD, name: 'Live', repo: 'library' }; const active = state({ question, candidate, snapshot: 'snapshot-1', wait: wait('question', 'question', 'q-1') }); const material = { ...draft, snapshot: { id: 'snapshot-1', revision: 2 } }; const url = 'https://git.example/owner/draft'; const until = (b, expression) => b.evaluate(`(async()=>{const end=Date.now()+10000;while(!(${expression})){if(Date.now()>end)throw Error('timed out: '+${JSON.stringify(expression)});await new Promise(r=>setTimeout(r,25))}return true})()`); const refresh = (b, path) => b.evaluate(`window.MosaicChat.render({path:${JSON.stringify(path)},container:document.querySelector('#bus-view'),canSend:true,navigated:false}).then(()=>true)`); async function boot(t) { const x = await setup(t, { states: { [REQUEST]: active }, draft: material, shares: [{ to: 'user:reader' }] }); await x.b.call('Page.addScriptToEvaluateOnNewDocument', { source: `(()=>{ window.__trace=[]; window.__failProjects=false; window.__pauseProjects=false; window.__pauseThreads=false; window.__liveProjects=[${JSON.stringify(live)}]; const original=window.fetch; window.fetch=async(url,init={})=>{ const path=String(url),method=(init.method||'GET').toUpperCase();window.__trace.push({path,method}); if(path==='/api/chat/projects' && window.__pauseProjects) await new Promise(resolve=>window.__releaseProjects=resolve); if(path.endsWith('/sessions') && window.__pauseSessions) await new Promise(resolve=>window.__releaseSessions=resolve); if((path==='/api/chat/threads' || path==='/api/chat/threads?shared=true') && window.__pauseThreads) await new Promise(resolve=>(window.__releaseThreads ||= []).push(resolve)); if(path==='/api/chat/projects' && window.__failProjects) return new Response(JSON.stringify({error:'unavailable'}),{status:window.__failProjects===true?503:window.__failProjects}); if(path==='/api/config'){const r=await original(url,init),v=await r.json();return new Response(JSON.stringify({...v,projectRemotes:true,projectRemoteWrites:true}));} if(path==='/api/project-remotes/library')return new Response(JSON.stringify({project:'library',remote:null,revision:'${'a'.repeat(64)}',firstSaveAllowed:true})); if(path==='/api/chat/rooms/${THREAD}/messages?limit=100')return new Response(JSON.stringify({rows:[],after:null,hasMore:false})); if(path==='/api/chat/threads?project=${THREAD}') { const r=await original(url,init),data=await r.json();data.rows=data.rows.map(row=>({...row,name:'assistant'}));return new Response(JSON.stringify(data)); } if(method==='GET' && path.includes('/api/chat/threads/') && path.includes('/messages')) { const r=await original(url,init),data=await r.json();data.rows.push({id:'70000000-0000-4000-8000-000000000007',from_role:'assistant',at:'2026-11-05T12:00:00Z',body:JSON.stringify({notice:'library-write',op:'write',path:'notes.md',commit:'${'a'.repeat(40)}'})});return new Response(JSON.stringify(data)); } if(path.startsWith('/api/library/status'))return new Response(JSON.stringify({served:'${'b'.repeat(40)}',problems:[]})); return original(url,init); };window.confirm=()=>true; })()` }); await x.b.call('Page.addScriptToEvaluateOnNewDocument', { source: `sessionStorage.setItem('mosaic-request-answer:${REQUEST}',JSON.stringify({op:'answer-retained',text:'answer',question:'q-1',revision:7}));` }); await x.navigate(1000); return x; } test('same-thread refresh keeps live controls throughout two pending reads', async t => { const { b } = await boot(t), path = `/projects/threads/${THREAD}`; await setLive(b); await refresh(b, path); const selectors = ['#chat-session-form input', '#chat-session-form button', '#chat-share-form input', '#chat-share-form button', '[data-unshare]', '#chat-cancel', '[data-revert]', '.request-answer button', '[data-request-resend]', '[data-request-mode]', '[data-request-cancel]', '[data-request-revoke]']; await until(b, `document.querySelector('[data-request-revoke]') && document.querySelector('[data-revert]')`); await b.evaluate(`(()=>{const e=document.querySelector('#chat-operation');e.value='clear-1';e.dispatchEvent(new Event('input',{bubbles:true}));window.__samples=[];window.__sample=()=>window.__samples.push({controls:${JSON.stringify(selectors)}.map(s=>{const e=document.querySelector(s);return {selector:s,present:!!e,disabled:e?.disabled}}), notices:[...document.querySelectorAll('.chat-area,.request-panel')].map(e=>e.textContent.includes('Archive status unavailable'))});window.__sample();window.__observer=new MutationObserver(window.__sample);window.__observer.observe(document.querySelector('#bus-view'),{subtree:true,childList:true,attributes:true,characterData:true});return true})()`); for (let tick = 0; tick < 2; tick++) { await b.evaluate(`window.__pauseProjects=true;window.__pauseThreads=true;window.__releaseProjects=null;window.__releaseThreads=[];window.__refresh=window.MosaicChat.render({path:${JSON.stringify(path)},container:document.querySelector('#bus-view'),canSend:true,navigated:false});true`); await until(b, `window.__releaseProjects && window.__releaseThreads.length===2`); await b.evaluate(`new Promise(resolve=>setTimeout(()=>{window.__sample();resolve(true)},150))`); await b.evaluate(`window.__pauseProjects=false;window.__pauseThreads=false;window.__releaseProjects();window.__releaseThreads.forEach(release=>release());window.__refresh.then(()=>true)`); await until(b, `document.querySelector('[data-request-revoke]') && document.querySelector('[data-revert]')`); // The operation input is preserved across repaint, so Cancel remains enabled. await b.evaluate(`window.__sample();true`); } const samples = await b.evaluate(`window.__observer.disconnect();window.__samples`); for (const [i, sample] of samples.entries()) { for (const control of sample.controls) assert.equal(control.present && !control.disabled, true, `sample ${i}: ${control.selector} missing or disabled`); assert.deepEqual(sample.notices, [false, false], `sample ${i}: archive unavailable notice`); } }); test('switching threads withdraws controls while unresolved and keeps unknown after failure', async t => { const { b } = await boot(t), path = `/projects/threads/${THREAD}`; await setLive(b); await refresh(b, path); await until(b, `document.querySelector('[data-request-cancel]') && document.querySelector('#chat-session-form button')`); const other = '30000000-0000-4000-8000-000000000003'; await b.evaluate(`window.__pauseProjects=true;window.__pauseThreads=true;window.__failProjects=true;window.__releaseThreads=[];window.__refresh=window.MosaicChat.render({path:'/projects/threads/${other}',container:document.querySelector('#bus-view'),canSend:true,navigated:false});true`); await until(b, `window.__releaseProjects && window.__releaseThreads.length===2`); assert.equal(await b.evaluate(`document.querySelector('#chat-session-form button').disabled`), true); assert.equal(await b.evaluate(`!!document.querySelector('[data-request-cancel]')`), false); assert.match(await b.evaluate(`document.querySelector('.chat-area').textContent`), /Archive status unavailable/); assert.match(await b.evaluate(`document.querySelector('.request-panel').textContent`), /Archive status unavailable/); await b.evaluate(`window.__pauseProjects=false;window.__pauseThreads=false;window.__releaseProjects();window.__releaseThreads.forEach(release=>release());window.__refresh.then(()=>true)`); assert.match(await b.evaluate(`document.querySelector('.chat-area').textContent`), /Archive status unavailable/); assert.equal(await b.evaluate(`!!document.querySelector('#chat-session-form, [data-request-cancel]')`), false); }); test('same-thread refresh withdraws writes once archived or unknown resolves, before session reads', async t => { const { b } = await boot(t), path = `/projects/threads/${THREAD}`; const variants = [ ['listed archived', `window.__liveProjects=[${JSON.stringify(archived)}];`, /This project is archived/], ['resolved archived', `window.__liveProjects=[];window.__archiveRows=[${JSON.stringify(archived)}];`, /This project is archived/], ['unknown', `window.__liveProjects=[];window.__archiveRows=[];window.__archiveFailure='network';`, /Archive status unavailable/], ]; for (const [name, change, after] of variants) { await b.evaluate(`window.__archiveFailure=false;true`); await setLive(b); await refresh(b, path); await until(b, `document.querySelector('[data-request-mode]') && document.querySelector('#chat-session-form button') && !document.querySelector('#chat-session-form button').disabled`); await b.evaluate(`${change}window.__pauseSessions=true;window.__releaseSessions=null;window.__trace=[];window.__refresh=window.MosaicChat.render({path:${JSON.stringify(path)},container:document.querySelector('#bus-view'),canSend:true,navigated:false});true`); await until(b, `window.__releaseSessions`); // The archive state is resolved; the sessions read and the repaint are still pending. for (const selector of ['[data-request-mode]', '[data-request-cancel]', '[data-request-revoke]', '[data-request-resend]', '.request-answer button']) assert.equal(await b.evaluate(`!!document.querySelector(${JSON.stringify(selector)})`), false, `${name}: ${selector} offered while the sessions read is pending`); for (const selector of ['#chat-session-form input', '#chat-session-form button', '#chat-share-form button', '[data-unshare]', '#chat-cancel', '[data-revert]']) assert.equal(await b.evaluate(`[...document.querySelectorAll(${JSON.stringify(selector)})].every(e=>e.disabled)`), true, `${name}: ${selector} enabled while the sessions read is pending`); for (const [attr, value] of [['mode', 'autonomous'], ['cancel', ''], ['cancel-confirm', ''], ['resend', ''], ['revoke', '']]) await force(b, ``, `[data-request-${attr}]`); await force(b, '
', '.request-answer', true); await noWrites(b); await b.evaluate(`window.__pauseSessions=false;window.__releaseSessions();window.__refresh.then(()=>true)`); assert.match(await b.evaluate(`document.querySelector('.chat-area').textContent`), after, name); assert.equal(await b.evaluate(`!!document.querySelector('[data-request-mode], [data-request-cancel]')`), false, `${name}: request writes after the repaint`); await noWrites(b); } }); async function setArchived(b) { await b.evaluate(`window.__liveProjects=[];window.__archiveRows=[${JSON.stringify(archived)}];true`); } async function setLive(b) { await b.evaluate(`window.__failProjects=false;window.__archiveRows=[];window.__liveProjects=[${JSON.stringify(live)}];true`); } async function noWrites(b) { assert.deepEqual(await b.evaluate(`window.__trace.filter(x=>x.method!=='GET')`), []); } async function force(b, markup, selector, submit = false) { await b.evaluate(`(()=>{const root=document.querySelector('.request-panel')||document.querySelector('.chat-area');root.insertAdjacentHTML('beforeend',${JSON.stringify(markup)});const e=root.querySelector(${JSON.stringify(selector)});${submit ? "e.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}))" : 'e.click()'};return true})()`); } test('failed settings refresh keeps archived save fenced and live recovery preserves draft', async t => { const { b } = await boot(t), path = `/projects/${THREAD}/settings`; await b.evaluate(`location.hash=${JSON.stringify('#' + path)};true`); await until(b, `document.querySelector('#project-remote-url')`); await b.evaluate(`window.__oldForm=document.querySelector('.project-remote form');const e=document.querySelector('#project-remote-url');e.value=${JSON.stringify(url)};e.dispatchEvent(new Event('input',{bubbles:true}));true`); await setArchived(b); await refresh(b, path); await until(b, `document.querySelector('#project-archive-button')?.textContent==='Unarchive'`); await b.evaluate(`window.__failProjects=true;window.__pauseProjects=true;window.__trace=[];window.__refresh=window.MosaicChat.render({path:${JSON.stringify(path)},container:document.querySelector('#bus-view'),canSend:true,navigated:false});true`); await until(b, `window.__releaseProjects`); await b.evaluate(`window.__oldForm.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));true`); await noWrites(b); await b.evaluate(`window.__pauseProjects=false;window.__releaseProjects();window.__refresh.then(()=>true)`); assert.equal(await b.evaluate(`document.querySelector('#project-archive-button')?.textContent`), 'Unarchive'); assert.match(await b.evaluate(`document.querySelector('.project-remote').textContent`), /archived/i); assert.equal(await b.evaluate(`!!document.querySelector('.project-remote form')`), false, 'archived settings must not offer Save'); await b.evaluate(`(()=>{window.__oldForm.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));document.querySelector('.project-remote').append(window.__oldForm);const e=window.__oldForm.querySelector('input');e.disabled=false;window.__oldForm.querySelector('button').disabled=false;e.focus();return true})()`); await b.key('Enter'); await b.evaluate(`window.__oldForm.requestSubmit();window.__oldForm.remove();true`); await noWrites(b); await setLive(b); await refresh(b, path); await until(b, `document.querySelector('#project-remote-url')`); assert.equal(await b.evaluate(`document.querySelector('#project-remote-url').value`), url); assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').disabled`), false); await b.evaluate(`window.__failProjects=true;true`); await refresh(b, path); assert.equal(await b.evaluate(`document.querySelector('#project-archive-button')?.textContent`), 'Archive project'); assert.equal(await b.evaluate(`document.querySelector('.project-remote button[type=submit]').disabled`), false); await b.evaluate(`window.__failProjects=403;true`); await refresh(b, path); assert.equal(await b.evaluate(`!!document.querySelector('#project-archive-button, .project-remote form')`), false); }); test('settings write guard agrees with displayed archived metadata', async t => { const { b } = await boot(t), path = `/projects/${THREAD}/settings`; await b.evaluate(`window.__liveProjects=[${JSON.stringify({ ...archived, archived: 'yes' })}];location.hash=${JSON.stringify('#' + path)};true`); await until(b, `document.querySelector('#project-archive-button')?.textContent==='Unarchive' && document.querySelector('.project-remote')`); assert.equal(await b.evaluate(`!!document.querySelector('.project-remote form')`), false); await b.evaluate(`window.__failProjects=true;true`); await refresh(b, path); assert.equal(await b.evaluate(`document.querySelector('#project-archive-button')?.textContent`), 'Unarchive'); assert.equal(await b.evaluate(`!!document.querySelector('.project-remote form')`), false); }); test('failed thread refresh keeps known archived writes fenced and live recovery restores them', async t => { const { b } = await boot(t), path = `/projects/threads/${THREAD}`; await setArchived(b); await refresh(b, path); await until(b, `document.querySelector('.chat-area')?.textContent.includes('This project is archived') && document.querySelector('.request-panel')`); await b.evaluate(`window.__failProjects=true;window.__pauseProjects=true;window.__trace=[];window.__refresh=window.MosaicChat.render({path:${JSON.stringify(path)},container:document.querySelector('#bus-view'),canSend:true,navigated:false});true`); await until(b, `window.__releaseProjects`); await b.evaluate(`(()=>{const f=document.querySelector('#chat-session-form');f.querySelector('input').disabled=false;f.querySelector('input').value='new';f.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));return true})()`); await noWrites(b); await b.evaluate(`window.__pauseProjects=false;window.__releaseProjects();window.__refresh.then(()=>true)`); assert.match(await b.evaluate(`document.querySelector('.chat-area').textContent`), /This project is archived/); for (const selector of ['#chat-send-form', '#chat-resume', '#chat-share-form', '[data-unshare]', '[data-revert]', '.request-answer', '[data-request-resend]', '[data-request-mode]', '[data-request-cancel]', '[data-request-revoke]']) assert.equal(await b.evaluate(`!!document.querySelector(${JSON.stringify(selector)})`), false, selector); for (const selector of ['#chat-session-form button', '#chat-clear', '#chat-cancel']) assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(selector)}).disabled`), true, selector); await b.evaluate(`(()=>{const e=document.querySelector('#chat-operation');e.value='clear-1';e.dispatchEvent(new Event('input',{bubbles:true}));const button=document.querySelector('#chat-cancel');button.disabled=false;button.click();const f=document.querySelector('#chat-session-form');f.querySelector('input').disabled=false;f.querySelector('input').value='new';f.querySelector('button').disabled=false;f.dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));f.querySelector('input').focus();return true})()`); await b.key('Enter'); await force(b, ``, '[data-revert]'); await force(b, '
', '#chat-share-form', true); await force(b, '', '[data-unshare]'); await force(b, '
', '.request-answer', true); for (const [attr, value] of [['resend',''],['mode','autonomous'],['cancel',''],['cancel-confirm',''],['revoke','']]) await force(b, ``, `[data-request-${attr}]`); await force(b, '
', '.request-revoke-form', true); await noWrites(b); await setLive(b); await refresh(b, path); await until(b, `document.querySelector('.request-answer') && document.querySelector('[data-revert]')`); for (const selector of ['#chat-session-form button', '#chat-clear', '#chat-resume', '#chat-send-form button', '#chat-share-form button', '[data-unshare]', '[data-revert]', '.request-answer button', '[data-request-resend]', '[data-request-mode]', '[data-request-cancel]', '[data-request-revoke]']) assert.equal(await b.evaluate(`!document.querySelector(${JSON.stringify(selector)}).disabled`), true, selector); await b.evaluate(`(()=>{const e=document.querySelector('#chat-operation');e.value='clear-1';e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`); assert.equal(await b.evaluate(`document.querySelector('#chat-cancel').disabled`), false); await b.evaluate(`window.__failProjects=true;true`); await refresh(b, path); assert.equal(await b.evaluate(`!document.querySelector('#chat-session-form button').disabled`), true); await b.evaluate(`window.__failProjects=404;true`); await refresh(b, path); assert.equal(await b.evaluate(`!!document.querySelector('#chat-session-form, #chat-send-form')`), false); }); test('failed archived room refresh never offers open or send and live recovery restores room', async t => { const { b } = await boot(t), path = `/projects/rooms/${THREAD}`; await setArchived(b); await b.evaluate(`location.hash=${JSON.stringify('#' + path)};true`); await until(b, `document.querySelector('.chat-room')?.textContent.includes('This project is archived')`); await b.evaluate(`window.__failProjects=true;window.__pauseProjects=true;window.__trace=[];window.__refresh=window.MosaicChat.render({path:${JSON.stringify(path)},container:document.querySelector('#bus-view'),canSend:true,navigated:false});true`); await until(b, `window.__releaseProjects`); await force(b, '', '#chat-room-open'); await noWrites(b); await b.evaluate(`window.__pauseProjects=false;window.__releaseProjects();window.__refresh.then(()=>true)`); assert.match(await b.evaluate(`document.querySelector('.chat-room').textContent`), /This project is archived/); assert.equal(await b.evaluate(`!!document.querySelector('#chat-room-open, #chat-room-send-form')`), false); await force(b, '', '#chat-room-open'); await force(b, '
', '#chat-room-send-form', true); await b.evaluate(`document.querySelector('#chat-room-send-form input').focus();true`); await b.key('Enter'); await noWrites(b); await setLive(b); await refresh(b, path); assert.equal(await b.evaluate(`!!document.querySelector('#chat-room-open')`), true); assert.equal(await b.evaluate(`!document.querySelector('#chat-room-send-form button[type=submit]').disabled`), true); await b.evaluate(`window.__failProjects=true;true`); await refresh(b, path); assert.equal(await b.evaluate(`!!document.querySelector('#chat-room-open')`), true); await b.evaluate(`window.__failProjects=false;true`); await setArchived(b); await refresh(b, path); await b.evaluate(`window.__failProjects=404;true`); await refresh(b, path); assert.doesNotMatch(await b.evaluate(`document.querySelector('.chat-room').textContent`), /This project is archived/); });