Files
stack/packages/webui/tests/chat-browser.test.mjs
T
jason.woltjeandClaude Opus 5.5 850484a7d2 webui: Console chat area over the bus's project and thread verbs (row 129, #1623)
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]>
2026-10-18 12:16:42 -05:00

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');
});