A same-thread refresh kept the last resolved live controls until the session and history reads finished. When the fresh project lookup finds the project archived or the archive state unknown, withdraw the unguarded thread controls and remount the request panel fenced before those reads.
211 lines
21 KiB
JavaScript
211 lines
21 KiB
JavaScript
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, `<button data-request-${attr}="${value}">Go</button>`, `[data-request-${attr}]`);
|
|
await force(b, '<form class="request-answer"><textarea>answer</textarea><button>Send</button></form>', '.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, `<button data-revert="${'a'.repeat(40)}">Revert</button>`, '[data-revert]');
|
|
await force(b, '<form id="chat-share-form"><input id="chat-share-target" value="user:reader"><button>Share</button></form>', '#chat-share-form', true);
|
|
await force(b, '<button data-unshare="user:reader">Unshare</button>', '[data-unshare]');
|
|
await force(b, '<form class="request-answer"><textarea>answer</textarea><button>Send</button></form>', '.request-answer', true);
|
|
for (const [attr, value] of [['resend',''],['mode','autonomous'],['cancel',''],['cancel-confirm',''],['revoke','']]) await force(b, `<button data-request-${attr}="${value}">Go</button>`, `[data-request-${attr}]`);
|
|
await force(b, '<form class="request-revoke-form"><textarea>reason</textarea><button>Revoke</button></form>', '.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, '<button id="chat-room-open">Open</button>', '#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, '<button id="chat-room-open">Open</button>', '#chat-room-open');
|
|
await force(b, '<form id="chat-room-send-form"><textarea id="chat-room-body">hello</textarea><input value="hello"><button>Send</button></form>', '#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/);
|
|
});
|