232 lines
17 KiB
JavaScript
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/);
|
|
});
|