Adds the Console chat view: project and thread lists, named create forms,
per-thread drafts, explicit Resume, paging and signed-in sends. The one
HTTP-to-bus seam is packages/webui/src/chat.mjs, with fixed routes in
chat-http.mjs: exact HTTPS Origin, same-origin fetch site, JSON content
type, and 403 for each. Every label and message body renders inert,
including bidi controls. CSP unchanged, no inline script.
Owner Rocko. Reviewer Filbert: r1 changes (comment 28933), r2 approve
(comment 28950) on candidate manifest 9a665434...4034, with the four r1
survivors killed by new assertions. Mos decision 123 go on the public/
diff, sha 78574095 (mos-0780). Sage gate on 19fe85c3e: 25 steps, node
2216/0, scripts 422/0, webui 179/179.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
267 lines
20 KiB
JavaScript
267 lines
20 KiB
JavaScript
// Fixture-only browser coverage uses the repository's existing CDP test helper.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { startServer } from '../src/serve.mjs';
|
|
import { fixture, close } from './fixture.mjs';
|
|
import { browser } from './browser.mjs';
|
|
const P = '10000000-0000-4000-8000-000000000001';
|
|
const A = '20000000-0000-4000-8000-000000000002';
|
|
const B = '30000000-0000-4000-8000-000000000003';
|
|
const M = '40000000-0000-4000-8000-000000000004';
|
|
const HOSTILE = '<img src=x onerror="window.injected=1">\u001b[31m\u202eevil';
|
|
const stub = `(() => {
|
|
const real = fetch.bind(window);
|
|
window.__writes=[];window.__reads=[];window.__answers=[];window.__pages=[];
|
|
window.__projects=[{id:'${P}', name:${JSON.stringify(HOSTILE)}, repo:null},{id:'\"><img src=x>',name:'Invalid record'}];
|
|
window.__threads=[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}, {id:'${B}',project:'${P}',name:'beta',instance:'assistant'}];
|
|
window.fetch=async (url,init={})=>{
|
|
const path=String(url), method=init.method||'GET';
|
|
if(path==='/api/config')return new Response(JSON.stringify({send:!location.search.includes('readonly')}));
|
|
if(window.__busRefuse&&path.startsWith('/api/bus/'))return new Response(JSON.stringify({error:'human-required'}),{status:403});
|
|
if(path.startsWith('/api/bus/'))return new Response(JSON.stringify({rows:[],unread:0,at:new Date().toISOString()}));
|
|
if(path==='/api/business')return new Response(JSON.stringify({projects:['stack']}));
|
|
if(!path.startsWith('/api/chat'))return real(url,init);
|
|
const data=value=>new Response(JSON.stringify({...value,at:new Date().toISOString()}),{headers:{'content-type':'application/json'}});
|
|
if(method==='POST'){
|
|
window.__writes.push({path,body:JSON.parse(init.body),redirect:init.redirect});
|
|
const a=window.__answers.shift();if(!a)throw new Error('No write answer');
|
|
if(a.hold)await new Promise(r=>window.__releaseWrite=r);
|
|
if(a.reject)throw new TypeError('network');
|
|
if(a.redirect)return {type:'opaqueredirect',status:0,ok:false};
|
|
return new Response(JSON.stringify(a.body),{status:a.status||200});
|
|
}
|
|
window.__reads.push(path);
|
|
if(window.__holdRead && path.includes(window.__holdRead)){window.__heldSignal=init.signal;await new Promise(r=>window.__releaseRead=r);}
|
|
if(window.__readFailure)return new Response(JSON.stringify({error:window.__readFailure}),{status:window.__readFailure==='thread-not-owned'?403:503});
|
|
if(path==='/api/chat/projects')return data({rows:window.__projects});
|
|
if(path.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:window.__threads});
|
|
if(window.__pages.length)return data(window.__pages.shift());
|
|
return data({rows:[],after:null,hasMore:false});
|
|
};
|
|
})();`;
|
|
async function setup(t, suffix = '') {
|
|
const f = await fixture(); t.after(() => f.close());
|
|
const server = await startServer({ port: 0, board: f.boardURL }); t.after(() => close(server));
|
|
const b = await browser(); t.after(() => b.close());
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: stub });
|
|
await b.navigate(`http://127.0.0.1:${server.address().port}/${suffix}#/chat`);
|
|
const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return;await new Promise(r=>setTimeout(r,30));}throw new Error(${JSON.stringify(expression)});})()`);
|
|
const set = (selector, text) => b.evaluate(`(()=>{const el=document.querySelector(${JSON.stringify(selector)});el.value=${JSON.stringify(text)};el.dispatchEvent(new Event('input',{bubbles:true}));return true;})()`);
|
|
const click = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)}).click(),true`);
|
|
const route = async id => { await b.evaluate(`location.hash='#/chat/threads/${id}',true`); await wait(`document.querySelector('.chat-conversation')?.dataset.thread==='${id}'`); };
|
|
await wait('document.querySelector(".chat-projects")');
|
|
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');
|
|
};
|
|
return { b, wait, set, click, route, refresh };
|
|
}
|
|
test('chat browser: list/create named projects and threads, hostile labels inert and no implicit writes', async t => {
|
|
const { b, wait, set, click } = await setup(t);
|
|
assert.equal(await b.evaluate('window.__writes.length'), 0);
|
|
assert.equal(await b.evaluate('window.injected'), undefined);
|
|
assert.equal(await b.evaluate('!!document.querySelector(".chat-projects img")'), false);
|
|
assert.match(await b.evaluate('document.querySelector(".chat-projects").textContent'), /\\u001b|␛/);
|
|
const projectText = await b.evaluate('document.querySelector(".chat-projects").textContent');
|
|
assert.doesNotMatch(projectText, /[\u202a-\u202e\u2066-\u2069]/u);
|
|
assert.match(projectText, /\\u202e/);
|
|
assert.equal(await b.evaluate(`!!document.querySelector('#sections a[href="#/chat"]')`), true);
|
|
await b.key('k', 'KeyK', 2);
|
|
await wait('document.querySelector("#cmdk-dialog").open');
|
|
assert.equal(await b.evaluate(`!!document.querySelector('#cmdk-list [data-href="#/chat"]')`), true);
|
|
await b.key('Escape');
|
|
await set('#chat-project-name', 'new project');
|
|
await set('#chat-repo', 'stack');
|
|
await b.evaluate(`window.__answers.push({body:{id:'${P}'}}),true`);
|
|
await click('#chat-project-form button[type=submit]');
|
|
await wait('document.querySelector("#chat-thread-form")');
|
|
assert.deepEqual(await b.evaluate('window.__writes[0].body'), { name: 'new project', repo: 'stack' });
|
|
assert.equal(await b.evaluate('window.__writes[0].path'), '/api/chat/projects');
|
|
await set('#chat-thread-name', 'named thread');
|
|
await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'assistant'}}),true`);
|
|
await click('#chat-thread-form button[type=submit]');
|
|
await wait('document.querySelector("#chat-send-form")');
|
|
assert.deepEqual(await b.evaluate('window.__writes[1].body'), { project: P, name: 'named thread' });
|
|
assert.equal(await b.evaluate('window.__writes[1].path'), '/api/chat/threads');
|
|
assert.equal(await b.evaluate('window.__writes.length'), 2);
|
|
});
|
|
test('chat browser: thread drafts, receipts and cursor history stay isolated; refresh never writes', async t => {
|
|
const { b, wait, set, click, route, refresh } = await setup(t);
|
|
await b.evaluate(`window.__pages.push({rows:[{id:'${M}',body:${JSON.stringify(HOSTILE)},from_role:'human',at:'2026-10-18T00:00:00Z'}],after:'${M}',hasMore:true}),true`);
|
|
await route(A);
|
|
assert.equal(await b.evaluate('!!document.querySelector(".chat-log img")'), false);
|
|
const messageText = await b.evaluate('document.querySelector(".chat-log").textContent');
|
|
assert.doesNotMatch(messageText, /[\u202a-\u202e\u2066-\u2069]/u);
|
|
assert.match(messageText, /\\u202e/);
|
|
await set('#chat-body', 'alpha draft');
|
|
await b.evaluate('(()=>{const e=document.querySelector("#chat-body");e.focus();e.setSelectionRange(2,5);return true})()');
|
|
await refresh();
|
|
await wait('document.querySelector("#chat-body")?.value === "alpha draft" && !document.querySelector(".chat-loading")');
|
|
assert.deepEqual(await b.evaluate('(()=>{const e=document.querySelector("#chat-body");return [document.activeElement===e,e.selectionStart,e.selectionEnd]})()'), [true,2,5]);
|
|
await route(B); await set('#chat-body','beta draft'); await route(A);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'alpha draft');
|
|
await b.evaluate(`window.__answers.push({body:{id:'${M}'}}),true`);
|
|
await click('#chat-send-form button[type=submit]'); await wait('document.querySelector("#chat-receipt")?.textContent.includes("Accepted")');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), '');
|
|
await route(B); assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'beta draft');
|
|
assert.doesNotMatch(await b.evaluate('document.querySelector("#chat-receipt").textContent'), /Accepted/);
|
|
assert.deepEqual(await b.evaluate('window.__writes.map(x=>x.body)'), [{body:'alpha draft'}]);
|
|
assert.equal(await b.evaluate('window.__writes[0].redirect'), 'manual');
|
|
assert.equal(await b.evaluate('window.__writes[0].path'), `/api/chat/threads/${A}/messages`);
|
|
await b.evaluate('window.__answers.push({body:{opened:location.hash.split("/").at(-1)}}),true');
|
|
await click('#chat-resume'); await wait('document.querySelector("#chat-receipt")?.textContent.includes("requested")');
|
|
assert.equal(await b.evaluate('window.__writes.length'), 2);
|
|
assert.equal(await b.evaluate('window.__writes[1].path'), `/api/chat/threads/${B}/open`);
|
|
});
|
|
test('chat browser: uncertain writes retain draft, ownership refusal clears it, read-only cannot send', async t => {
|
|
const { b, wait, set, click, route, refresh } = await setup(t);
|
|
await route(A); await set('#chat-body', 'keep this');
|
|
await b.evaluate('window.__answers.push({reject:true}),true'); await click('#chat-send-form button[type=submit]');
|
|
await wait('document.querySelector("#chat-receipt")?.textContent.includes("unknown")');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'keep this');
|
|
assert.equal(await b.evaluate('window.__writes.length'), 1);
|
|
await b.evaluate('window.__readFailure="no-bus-host";true');
|
|
await refresh();
|
|
await wait('document.querySelector(".chat-error")');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'keep this');
|
|
await wait('!document.querySelector("#refresh").disabled');
|
|
await b.evaluate('window.__readFailure="thread-not-owned";true');
|
|
await refresh();
|
|
await wait('document.querySelector(".chat-error")?.textContent.includes("not available")');
|
|
assert.equal(await b.evaluate('!!document.querySelector("#chat-body")'), false);
|
|
await wait('!document.querySelector("#refresh").disabled');
|
|
await b.evaluate('window.__readFailure=null;true');
|
|
await refresh();
|
|
await wait('document.querySelector("#chat-body")'); assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), '');
|
|
await b.navigate((await b.evaluate('location.href')).replace('/#/','/?readonly#/'));
|
|
await wait('document.querySelector("#chat-body")');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").disabled'), true);
|
|
await set('#chat-body','read-only must not submit');
|
|
await b.evaluate('document.querySelector("#chat-send-form").dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}));true');
|
|
assert.equal(await b.evaluate('window.__writes.length'), 0);
|
|
});
|
|
|
|
test('chat browser: a late read from A cannot paint B; sign-out clears private state and stops reads', async t => {
|
|
const { b, wait, set, click, route, refresh } = await setup(t);
|
|
await route(A); await set('#chat-body', 'private alpha');
|
|
await b.evaluate(`window.__holdRead='${A}/messages';document.querySelector('#refresh').click();true`);
|
|
await wait('typeof window.__releaseRead === "function"');
|
|
await route(B); await set('#chat-body', 'private beta');
|
|
assert.equal(await b.evaluate('window.__heldSignal.aborted'), true);
|
|
await b.evaluate('(async()=>{window.__holdRead=null;window.__readFailure="thread-not-owned";window.__releaseRead();await new Promise(r=>setTimeout(r,100));window.__readFailure=null;return true})()');
|
|
assert.equal(await b.evaluate('!!document.querySelector(".chat-error")'), false);
|
|
assert.equal(await b.evaluate('document.querySelector(".chat-conversation").dataset.thread'), B);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'private beta');
|
|
await b.evaluate('window.__answers.push({hold:true,redirect:true}),true');
|
|
await click('#chat-send-form button[type=submit]');
|
|
await wait('typeof window.__releaseWrite === "function"');
|
|
await b.evaluate('document.dispatchEvent(new Event("mosaic-signed-out"));window.__releaseWrite();true');
|
|
await wait('document.body.classList.contains("signed-out")');
|
|
const count = await b.evaluate('window.__reads.length');
|
|
await b.evaluate('document.querySelector("#refresh").click();location.hash="#/chat";true');
|
|
await new Promise(r => setTimeout(r, 150));
|
|
assert.equal(await b.evaluate('window.__reads.length'), count);
|
|
assert.doesNotMatch(await b.evaluate('document.body.innerText'), /private alpha|private beta/);
|
|
assert.equal(await b.evaluate('window.__writes.length'), 1);
|
|
});
|
|
|
|
test('chat browser: page forward and deduplicate replies; pending and byte-limit submissions do not duplicate', async t => {
|
|
const { b, wait, set, click, route, refresh } = await setup(t);
|
|
const row = { id: M, body: 'first', from_role: 'human', at: '2026-10-18T00:00:00Z' };
|
|
await b.evaluate(`window.__pages.push(${JSON.stringify({rows:[row],after:M,hasMore:true})}),true`);
|
|
await route(A);
|
|
const reply = { ...row, id: P, body: 'assistant reply', from_role: 'assistant' };
|
|
await b.evaluate(`window.__pages.push(${JSON.stringify({rows:[row,reply],after:P,hasMore:false})}),true`);
|
|
await click('#chat-next'); await wait('document.querySelector(".chat-log")?.textContent.includes("assistant reply")');
|
|
assert.equal(await b.evaluate('document.querySelectorAll(".chat-log li").length'), 2);
|
|
assert.ok((await b.evaluate('window.__reads')).some(x => x.includes('after=' + M)));
|
|
await set('#chat-body', '界'.repeat(6000)); await click('#chat-send-form button[type=submit]');
|
|
await wait('document.querySelector("#chat-receipt")?.textContent.includes("too long")');
|
|
assert.equal(await b.evaluate('window.__writes.length'), 0);
|
|
await set('#chat-body', 'one send');
|
|
await b.evaluate(`window.__answers.push({hold:true,body:{id:'${P}'}}),true`);
|
|
await click('#chat-send-form button[type=submit]'); await wait('typeof window.__releaseWrite === "function"');
|
|
await b.evaluate('document.querySelector("#chat-send-form").dispatchEvent(new Event("submit",{bubbles:true,cancelable:true}));true');
|
|
assert.equal(await b.evaluate('window.__writes.length'), 1);
|
|
await b.evaluate('window.__releaseWrite();true');
|
|
await wait('!document.querySelector("#chat-send-form button[type=submit]").disabled');
|
|
});
|
|
|
|
test('chat browser: scrolled history stays put, New messages and an own send return to the tail', async t => {
|
|
const { b, wait, set, click, route, refresh } = await setup(t);
|
|
const rows = Array.from({length:40}, (_,i)=>({id:`90000000-0000-4000-8000-${String(i).padStart(12,'0')}`,body:'history '+i+'\nsecond line',from_role:'assistant',at:'2026-10-18T00:00:00Z'}));
|
|
await b.evaluate(`window.__pages.push(${JSON.stringify({rows,after:rows.at(-1).id,hasMore:false})}),true`);
|
|
await route(A);
|
|
await set('#chat-body','scrolled draft');
|
|
await b.evaluate('(()=>{const l=document.querySelector(".chat-log");l.scrollTop=100;l.dispatchEvent(new Event("scroll"));return true})()');
|
|
await refresh();
|
|
await wait('!document.querySelector("#chat-new").hidden');
|
|
assert.equal(await b.evaluate('document.querySelector(".chat-log").scrollTop'), 100);
|
|
await click('#chat-new');
|
|
assert.equal(await b.evaluate('(()=>{const l=document.querySelector(".chat-log");return l.scrollTop+l.clientHeight>=l.scrollHeight-2})()'), true);
|
|
await b.evaluate('(()=>{const l=document.querySelector(".chat-log");l.scrollTop=100;l.dispatchEvent(new Event("scroll"));return true})()');
|
|
await b.evaluate(`window.__answers.push({body:{id:'${M}'}}),true`);
|
|
await click('#chat-send-form button[type=submit]');
|
|
await wait('document.querySelector("#chat-receipt").textContent.includes("Accepted")');
|
|
assert.equal(await b.evaluate('(()=>{const l=document.querySelector(".chat-log");return l.scrollTop+l.clientHeight>=l.scrollHeight-2})()'), true);
|
|
});
|
|
|
|
test('chat browser: automatic polling follows a reply without a POST or a lost draft', {timeout:20000}, async t => {
|
|
const { b, wait, set, route } = await setup(t);
|
|
await route(A); await set('#chat-body','draft during reply');
|
|
await b.evaluate(`window.__pages.push(${JSON.stringify({rows:[{id:M,body:'automatic assistant reply',from_role:'assistant',at:'2026-10-18T00:00:00Z'}],after:M,hasMore:false})}),true`);
|
|
await b.evaluate('(async()=>{for(let n=0;n<300;n++){if(document.querySelector(".chat-log").textContent.includes("automatic assistant reply"))return;await new Promise(r=>setTimeout(r,50));}throw new Error("poll did not follow reply")})()');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'draft during reply');
|
|
assert.equal(await b.evaluate('window.__writes.length'), 0);
|
|
});
|
|
|
|
test('chat browser: display stays bounded while every forward page remains reachable', async t => {
|
|
const { b, wait, click, route } = await setup(t);
|
|
const rows = Array.from({length:300}, (_,i)=>({id:`91000000-0000-4000-8000-${String(i).padStart(12,'0')}`,body:'bounded message '+i,from_role:'assistant',at:'2026-10-18T00:00:00Z'}));
|
|
const pages = [0,100,200].map(start=>({rows:rows.slice(start,start+100),after:rows[start+99].id,hasMore:start<200}));
|
|
await b.evaluate(`window.__pages.push(...${JSON.stringify(pages)}),true`);
|
|
await route(A);
|
|
await click('#chat-next'); await wait('document.querySelector(".chat-log").textContent.includes("bounded message 199")');
|
|
await click('#chat-next'); await wait('document.querySelector(".chat-log").textContent.includes("bounded message 299")');
|
|
assert.equal(await b.evaluate('document.querySelectorAll(".chat-log li").length'), 200);
|
|
assert.doesNotMatch(await b.evaluate('document.querySelector(".chat-log").textContent'), /bounded message 0\n/);
|
|
});
|
|
|
|
test('chat browser: a confirmed visible message replaces its pending receipt; malformed receipts stay unknown', async t => {
|
|
const { b, wait, set, click, route, refresh } = await setup(t);
|
|
await route(A); await set('#chat-body','sent then visible');
|
|
await b.evaluate(`window.__answers.push({body:{id:'${M}'}}),true`);
|
|
await click('#chat-send-form button[type=submit]'); await wait('document.querySelector("#chat-receipt").textContent.includes("Accepted")');
|
|
await b.evaluate(`window.__pages.push(${JSON.stringify({rows:[{id:M,body:'sent then visible',from_role:'human'}],after:M,hasMore:false})});true`);
|
|
await refresh();
|
|
await wait('document.querySelector("#chat-receipt").textContent.includes("shown")');
|
|
await set('#chat-body','keep malformed');
|
|
await b.evaluate('window.__answers.push({body:{}}),true');
|
|
await click('#chat-send-form button[type=submit]'); await wait('document.querySelector("#chat-receipt").textContent.includes("unknown")');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'keep malformed');
|
|
assert.equal(await b.evaluate('window.__writes.length'), 2);
|
|
});
|
|
|
|
test('chat browser: a refusal elsewhere forgets private chat drafts', async t => {
|
|
const { b, wait, set, route } = await setup(t);
|
|
await route(A); await set('#chat-body','private draft before refusal');
|
|
await b.evaluate('window.__busRefuse=true;location.hash="#/tasks";true');
|
|
await wait('document.querySelector("#bus-view")?.textContent.includes("refused")');
|
|
await b.evaluate('window.__busRefuse=false;true');
|
|
await route(A);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), '');
|
|
});
|
|
|
|
test('chat browser: a late project creation receipt cannot navigate away from another thread', async t => {
|
|
const { b, wait, set, click, route, refresh } = await setup(t);
|
|
await set('#chat-project-name','late project');
|
|
await b.evaluate(`window.__answers.push({hold:true,body:{id:'${P}'}}),true`);
|
|
await click('#chat-project-form button[type=submit]');
|
|
await wait('typeof window.__releaseWrite === "function"');
|
|
await route(A); await set('#chat-body','stay in alpha');
|
|
await b.evaluate('(async()=>{window.__releaseWrite();await new Promise(r=>setTimeout(r,100));return true})()');
|
|
assert.equal(await b.evaluate('location.hash'), '#/chat/threads/' + A);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'stay in alpha');
|
|
});
|