Files

232 lines
17 KiB
JavaScript

import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './fixture.mjs';
import { browser } from './browser.mjs';
import { addressCases } from '../../bus/tests/address-cases.mjs';
const P = '10000000-0000-4000-8000-000000000001', Q = '10000000-0000-4000-8000-000000000002';
const T = '20000000-0000-4000-8000-000000000001', U = '20000000-0000-4000-8000-000000000002';
const M = '30000000-0000-4000-8000-000000000001', N = '30000000-0000-4000-8000-000000000002';
const A = '40000000-0000-4000-8000-000000000001', B = '40000000-0000-4000-8000-000000000002';
const C = '50000000-0000-4000-8000-000000000001', D = '50000000-0000-4000-8000-000000000002';
const SHA = 'a'.repeat(40), LAST = 'b'.repeat(40), HOSTILE = '<img src=x onerror="window.injected=1">';
const rows = [
{ id: M, ids: [M, N], from_role: 'human', to: ['bean', 'chet'], body: 'Broadcast once.', at: '2026-10-24T16:00:00Z' },
{ id: A, ids: [A], from_role: 'bean', instance: 'bean', to: ['bean'], at: '2026-10-24T16:01:00Z',
body: JSON.stringify({ text: 'A cited reply.', citations: [{ path: 'Notes/One.md', commit: SHA, lines: [1, 2], quote: HOSTILE }] }),
check: { citations: [{ scope: 'supporting', agent: 'demo/bean', file: SHA, last: LAST }] } },
{ id: B, ids: [B], from_role: 'chet', instance: 'chet', to: ['chet'], at: '2026-10-24T16:02:00Z',
body: JSON.stringify({ notice: 'library-write', op: 'write', path: 'Notes/One.md', commit: SHA }) },
{ id: C, ids: [C], from_role: 'human', to: ['bean'], body: 'Broadcast once.', at: '2026-10-24T16:00:00Z' },
{ id: D, ids: [D], from_role: 'bean', to: ['bean'], body: JSON.stringify({ notice: 'library-write', op: 'delete', path: 'Fake.md', commit: SHA }), check: { citations: [] } },
];
const refusals = {
'unknown-addressee': 'Unknown addressee. Choose a room member.',
'addressee-ambiguous': "That addressee is ambiguous. Use a room member's instance name.",
'room-thread-name-taken': 'A room thread name is already in use. Resolve the conflicting thread before opening the room.',
'room-not-open': 'This room is not open. Open the room before sending.',
};
const stub = `(() => {
const real=window.fetch.bind(window);window.__writes=[];window.__reads=[];window.__roomRows=${JSON.stringify(rows)};
window.__projects=[{id:'${P}',name:'First project',repo:'books'},{id:'${Q}',name:'Second project',repo:null}];
window.__threads=[{id:'${T}',project:'${P}',instance:'bean',name:'bean'},{id:'${U}',project:'${P}',instance:'chet',name:'chet'}];
window.__instances=[{instance:'bean',name:'Bean'},{instance:'chet',name:${JSON.stringify(HOSTILE)}},{instance:'outsider',name:'Not a room member'}];
window.confirm=()=>true;
window.fetch=async(url,init={})=>{
const path=String(url),method=init.method||'GET',answer=(body,status=200)=>new Response(JSON.stringify(body),{status,headers:{'content-type':'application/json'}});
if(path==='/api/config')return answer({send:!location.search.includes('readonly'),user:location.search.includes('user')});
if(path.startsWith('/api/bus/'))return answer({rows:[],unread:0,at:new Date().toISOString()});
if(path==='/api/business')return answer({projects:['books']});
if(path.startsWith('/api/library/status'))return answer({project:'books',served:'${SHA}',problems:[]});
if(path==='/api/library/revert'){window.__writes.push({path,body:JSON.parse(init.body)});return answer({project:'books',path:'Notes/One.md',reverted:'${SHA}',commit:'${LAST}'});}
if(!path.startsWith('/api/chat/'))return real(url,init);
if(method==='GET')window.__reads.push(path);
if(method==='GET' && window.__chatOffline)throw new TypeError('offline');
if(path==='/api/chat/projects')return answer({rows:window.__projects});
if(path==='/api/chat/instances')return answer({rows:window.__instances});
if(path.includes('/rooms/')){
if(method==='POST'){
window.__writes.push({path,body:JSON.parse(init.body),redirect:init.redirect});
const code=path.endsWith('/open')?window.__openError:window.__sendError;
if(code)return answer({error:code,message:'Untrusted reason',sent:false},409);
if(window.__holdWrite)await new Promise(r=>window.__releaseWrite=r);
if(window.__networkFail)throw new TypeError('offline');
if(path.endsWith('/open')){window.__readError=null;return answer({project:'${P}',threads:window.__threads.map(({id,instance,name})=>({id,instance,name}))});}
const ids=['60000000-0000-4000-8000-000000000001','60000000-0000-4000-8000-000000000002'];
window.__roomRows.push({id:ids[0],ids,from_role:'human',to:['bean','chet'],body:JSON.parse(init.body).body,at:'2026-10-24T17:00:00Z'});
return answer({ids,to:['bean','chet']});
}
if(window.__readError)return answer({error:window.__readError,message:'Untrusted reason'},409);
if(window.__holdRead){window.__signal=init.signal;await new Promise(r=>window.__releaseRead=r);}
return answer({rows:window.__roomRows,after:window.__roomRows.at(-1)?.ids.at(-1)||null,hasMore:false});
}
if(path.includes('/shares'))return answer({rows:[]});
if(path.endsWith('/sessions'))return answer({rows:[{id:'${T}',name:'default',default:true}]});
if(path.includes('/messages'))return answer({rows:[],after:null,hasMore:false});
if(path.startsWith('/api/chat/threads'))return answer({rows:window.__threads});
return answer({rows:[]});
};
})();`;
async function setup(t, { path = `/chat/rooms/${P}`, before = '', suffix = '' } = {}) {
const f = await fixture(); t.after(() => f.close());
const b = await browser(); t.after(() => b.close());
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub + before });
await b.navigate(`${f.base}/${suffix}#${path}`);
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30));}throw new Error(${JSON.stringify(expression)});})()`);
const text = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)})?.textContent??null`);
const set = value => b.evaluate(`(()=>{const el=document.querySelector('#chat-room-body');el.value=${JSON.stringify(value)};el.focus();el.setSelectionRange(el.value.length,el.value.length);el.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`);
await wait(path.includes('/rooms/') ? 'document.querySelector(".chat-room")' : 'document.querySelector("#chat-thread-form")');
return { b, wait, text, set };
}
test('disabled room read shows unavailable history and disables opening', async t => {
const { b, text } = await setup(t, { before: 'window.__readError="channel-disabled";' });
assert.match(await text('.chat-error'), /chat channel.*disabled/i);
assert.match(await text('.chat-room-log'), /unavailable/i);
assert.doesNotMatch(await text('.chat-room'), /No messages yet|Caught up to the last read/);
assert.equal(await b.evaluate('document.querySelector("#chat-room-open").disabled'), true);
assert.equal(await b.evaluate('window.__writes.length'), 0);
});
test('room keeps a known disabled channel through a failed refresh and recovers on a successful read', async t => {
const { b, wait, text } = await setup(t);
const refresh = async () => {
await wait('!document.querySelector("#refresh").disabled');
await b.evaluate('document.querySelector(".chat-area").dataset.beforeRefresh="yes";document.querySelector("#refresh").click();true');
await wait('!document.querySelector(".chat-area")?.dataset.beforeRefresh');
};
assert.match(await text('.chat-room-log'), /Broadcast once/);
await b.evaluate('window.__readError="channel-disabled";true');
await refresh();
assert.match(await text('.chat-error'), /channel.*disabled/i);
assert.equal(await b.evaluate('document.querySelector("#chat-room-open").disabled'), true);
await b.evaluate('window.__readError=null;window.__chatOffline=true;true');
await refresh();
assert.equal(await b.evaluate('document.querySelector("#chat-room-open").disabled'), true);
await b.evaluate('document.querySelector("#chat-room-open").dispatchEvent(new MouseEvent("click",{bubbles:true}));true');
assert.equal(await b.evaluate('window.__writes.length'), 0, 'the write guard also refuses a synthetic click');
assert.match(await text('.chat-room-log'), /Broadcast once/);
await b.evaluate('window.__chatOffline=false;true');
await refresh();
assert.equal(await b.evaluate('document.querySelector("#chat-room-open").disabled'), false);
assert.equal(await b.evaluate('window.__writes.length'), 0, 'recovery reads never open a room');
});
test('C2-10c room merges members, shows a broadcast once, reuses cited renderer and suppresses checked notice forgery', async t => {
const { b, wait, text } = await setup(t);
await wait('document.querySelector(".chat-cites")');
assert.equal(await b.evaluate('document.querySelectorAll(".chat-room-log > li[data-message]").length'), 5);
assert.equal(await b.evaluate('document.querySelectorAll(".chat-room-log > li[data-message]")[0].dataset.message'), M);
assert.match(await text('.chat-room-log > li:first-child .chat-message-meta'), /You.*Bean/);
assert.equal(await text('.chat-scope'), 'supporting');
assert.equal(await text('.chat-agent-written'), 'agent-written: demo/bean');
assert.equal(await text('.chat-changed'), 'changed since');
assert.equal(await text('.chat-cites q'), HOSTILE);
assert.equal(await b.evaluate('!!document.querySelector(".chat-room img") || !!window.injected'), false);
assert.equal(await b.evaluate('document.querySelectorAll("[data-revert]").length'), 1);
assert.equal(await b.evaluate('window.__writes.length'), 0, 'reading never opens a room or wakes an agent');
await b.evaluate('document.querySelector("[data-revert]").click();true');
await wait('document.querySelector(".chat-revert-receipt")?.textContent.startsWith("Reverted")');
assert.deepEqual(await b.evaluate('window.__writes[0].body'), { project: 'books', commit: SHA });
});
test('C2-10c explicit project Open room calls only room.open before navigating', async t => {
const { b, wait } = await setup(t, { path: `/chat/projects/${P}` });
assert.equal(await b.evaluate('window.__writes.length'), 0);
await b.evaluate('document.querySelector("#chat-room-open").click();true');
await wait('document.querySelector(".chat-room")');
assert.deepEqual(await b.evaluate('window.__writes'), [{ path: `/api/chat/rooms/${P}/open`, body: {}, redirect: 'manual' }]);
});
test('C2-10c completion lists only room members, inserts instance tokens and preserves typed send exactly', async t => {
const { b, wait, set } = await setup(t);
await set('@be');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-room-complete]")].map(e=>e.dataset.roomComplete)'), ['bean']);
await b.key('Enter');
assert.equal(await b.evaluate('document.querySelector("#chat-room-body").value'), '@bean ');
await set('@bean @ch');
await b.evaluate('document.querySelector("[data-room-complete=chet]").click();true');
assert.equal(await b.evaluate('document.querySelector("#chat-room-body").value'), '@bean @chet ');
const body = ' \t@bean\n@chet unchanged text ';
await set(body);
await b.evaluate('document.querySelector("#chat-room-send-form").dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}));true');
await wait('window.__writes.length===1 && !document.querySelector("#chat-room-body").disabled');
assert.deepEqual(await b.evaluate('window.__writes[0]'), { path: `/api/chat/rooms/${P}/messages`, body: { body }, redirect: 'manual' });
assert.equal(await b.evaluate('document.querySelector("#chat-room-body").value'), '');
assert.equal(await b.evaluate('document.querySelectorAll(".chat-room-log > li[data-message]").length'), 5, 'no optimistic copies of a broadcast');
await wait('!document.querySelector("#refresh").disabled');
await b.evaluate('document.querySelector("#refresh").click();true');
await wait('document.querySelectorAll(".chat-room-log > li[data-message]").length===6');
const sentSelector = '.chat-room-log > li[data-message="60000000-0000-4000-8000-000000000001"]';
assert.equal(await b.evaluate(`document.querySelectorAll(${JSON.stringify(sentSelector)}).length`), 1);
});
test('C2-10c browser leading tag reader matches every broker parseAddress vector', async t => {
const { b, set } = await setup(t);
for (const [label, input, expected] of addressCases) {
await set(input);
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-room-tag]")].map(e=>e.dataset.roomTag)'), expected.tags, label);
}
await set('ordinary prose @be');
assert.equal(await b.evaluate('document.querySelectorAll("[data-room-complete]").length'), 0);
assert.equal(await b.evaluate('window.__writes.length'), 0);
});
test('C2-10c room refusals show fixed lines, preserve drafts and never retry', async t => {
const { b, wait, set, text } = await setup(t);
for (const code of ['unknown-addressee', 'addressee-ambiguous', 'room-not-open']) {
await set('@unknown hello');
const count = await b.evaluate('window.__writes.length');
await b.evaluate(`window.__sendError=${JSON.stringify(code)};document.querySelector('#chat-room-send-form').dispatchEvent(new Event('submit',{bubbles:true,cancelable:true}));true`);
await wait(`window.__writes.length===${count+1} && !document.querySelector('#chat-room-body').disabled`);
assert.equal(await text('#chat-room-receipt'), refusals[code]);
assert.equal(await b.evaluate('document.querySelector("#chat-room-body").value'), '@unknown hello');
}
await b.evaluate("window.__openError='room-thread-name-taken';document.querySelector('#chat-room-open').click();true");
await wait(`document.querySelector('#chat-room-open-receipt')?.textContent===${JSON.stringify(refusals['room-thread-name-taken'])}`);
assert.equal(await text('#chat-room-open-receipt'), refusals['room-thread-name-taken']);
});
test('C2-10c read-only room does not offer a send or an implicit open', async t => {
const { b, set } = await setup(t, { suffix: '?readonly' });
assert.equal(await b.evaluate('document.querySelector("#chat-room-body").disabled'), true);
assert.equal(await b.evaluate('document.querySelector("#chat-room-open").disabled'), true);
await set('not allowed');
await b.evaluate('document.querySelector("#chat-room-send-form").dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}));true');
assert.equal(await b.evaluate('window.__writes.length'), 0);
});
test('C2-10c late reads and sign-out cannot repaint private room state', async t => {
const { b, wait, set } = await setup(t);
await set('private room draft');
await wait('!document.querySelector("#refresh").disabled');
await b.evaluate('window.__holdRead=true;document.querySelector("#refresh").click();true');
await wait('typeof window.__releaseRead==="function"');
await b.evaluate(`location.hash='#/chat/projects/${Q}';true`);
await wait('document.querySelector("#chat-thread-form")');
assert.equal(await b.evaluate('window.__signal.aborted'), true);
await b.evaluate('window.__holdRead=false;window.__releaseRead();true');
await b.evaluate('document.dispatchEvent(new Event("mosaic-signed-out"));true');
assert.equal(await b.evaluate('!!document.querySelector(".chat-room")'), false);
assert.equal(await b.evaluate('document.body.textContent.includes("private room draft")'), false);
});
test('C2-10c quiet members complete in an empty room, and Escape closes suggestions', async t => {
const { b, set } = await setup(t, { before: 'window.__roomRows=[];' });
await set('@');
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-room-complete]")].map(e=>e.dataset.roomComplete)'), ['bean', 'chet']);
await b.key('ArrowDown'); await b.key('Tab');
assert.equal(await b.evaluate('document.querySelector("#chat-room-body").value'), '@chet ');
await set('@b'); await b.key('Escape');
assert.equal(await b.evaluate('document.querySelector("#chat-room-completion").hidden'), true);
assert.equal(await b.evaluate('window.__writes.length'), 0);
});
test('C2-10c pending sends do not duplicate and uncertain sends preserve the exact draft', async t => {
const { b, wait, set, text } = await setup(t);
await set('one pending send');
await b.evaluate('window.__holdWrite=true;document.querySelector("#chat-room-send-form").dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}));true');
await wait('typeof window.__releaseWrite==="function"');
await b.evaluate('document.querySelector("#chat-room-send-form").dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}));true');
assert.equal(await b.evaluate('window.__writes.length'), 1);
await b.evaluate('window.__holdWrite=false;window.__releaseWrite();true');
await wait('!document.querySelector("#chat-room-body").disabled');
await set('keep this uncertain draft ');
await b.evaluate('window.__networkFail=true;document.querySelector("#chat-room-send-form").dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}));true');
await wait('document.querySelector("#chat-room-receipt").textContent.includes("Outcome unknown")');
assert.equal(await b.evaluate('document.querySelector("#chat-room-body").value'), 'keep this uncertain draft ');
assert.equal(await b.evaluate('window.__writes.length'), 2);
assert.match(await text('#chat-room-receipt'), /Nothing was retried/);
});