447 lines
33 KiB
JavaScript
447 lines
33 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.__instances=[{instance:'assistant',name:'assistant'}];
|
|
window.__projects=[{id:'${P}', name:${JSON.stringify(HOSTILE)}, repo:null},{id:'\"><img src=x>',name:'Invalid record'}];
|
|
window.__sessions={};
|
|
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};
|
|
if(a.body?.state==='cancelled')window.__operation=a.body;
|
|
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/instances')return window.__instanceFailure ? new Response(JSON.stringify({error:'not-configured'}),{status:503}) : data({rows:window.__instances});
|
|
if(path==='/api/chat/projects')return data({rows:window.__projects});
|
|
if(path.endsWith('/sessions')){const t=path.split('/')[4];return data({rows:window.__sessions[t]||[{id:t,name:'default',default:true}]});}
|
|
if(path.includes('/operation?'))return data(window.__operation);
|
|
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', instance: 'assistant' });
|
|
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',session:A}]);
|
|
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');
|
|
});
|
|
|
|
test('C2-09a picker: no silent choice, duplicate hostile labels stay inert, selection survives refresh and reaches POST', async t => {
|
|
const { b, wait, set, click, refresh } = await setup(t);
|
|
await b.evaluate(`window.__instances=[{instance:'bean',name:${JSON.stringify(HOSTILE)}},{instance:'chet',name:${JSON.stringify(HOSTILE)}}];location.hash='#/chat/projects/${P}';true`);
|
|
await wait('document.querySelectorAll("#chat-instance option").length===3');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-instance").required'), true);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
|
const labels = await b.evaluate('[...document.querySelectorAll("#chat-instance option")].map(o=>o.textContent)');
|
|
assert.match(labels[1], /\(bean\)/);
|
|
assert.match(labels[2], /\(chet\)/);
|
|
assert.doesNotMatch(labels.join(''), /[\u202a-\u202e\u2066-\u2069]/u);
|
|
assert.equal(await b.evaluate('!!document.querySelector("#chat-thread-form img")'), false);
|
|
assert.equal(await b.evaluate('window.injected'), undefined);
|
|
await set('#chat-thread-name', 'Bean discussion');
|
|
await click('#chat-thread-form button');
|
|
assert.equal(await b.evaluate('window.__writes.length'), 0);
|
|
await set('#chat-instance', 'bean');
|
|
await refresh();
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'bean');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'Bean discussion');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
|
|
await b.evaluate(`window.__answers.push({body:{id:'${A}',instance:'bean'}});true`);
|
|
await click('#chat-thread-form button');
|
|
await wait('window.__writes.length===1');
|
|
assert.deepEqual(await b.evaluate('window.__writes[0]'), { path: '/api/chat/threads', body: { project: P, name: 'Bean discussion', instance: 'bean' }, redirect: 'manual' });
|
|
});
|
|
|
|
test('C2-09a picker: metadata failure, empty set and removed pick explain disabled state and recover without switching', async t => {
|
|
const { b, wait, set, refresh } = await setup(t);
|
|
await b.evaluate(`window.__instanceFailure=true;location.hash='#/chat/projects/${P}';true`);
|
|
await wait('document.querySelector("#chat-thread-form") && !document.querySelector(".chat-loading")');
|
|
assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /read the thread instances/);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
|
await b.evaluate('window.__instanceFailure=false;window.__instances=[];true');
|
|
await refresh();
|
|
assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /No thread instances/);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
|
await b.evaluate("window.__instances=[{instance:'bean',name:'Bean'},{instance:'chet',name:'Chet'}];true");
|
|
await refresh();
|
|
await set('#chat-instance', 'bean');
|
|
await b.evaluate("window.__instances=[{instance:'chet',name:'Chet'}];true");
|
|
await refresh();
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '');
|
|
assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /no longer available/);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
|
await refresh();
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), '', 'another refresh must not silently select the one remaining instance');
|
|
await set('#chat-instance', 'chet');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), false);
|
|
});
|
|
|
|
test('C2-09a picker: a single instance is chosen explicitly but read-only mode cannot create a thread', async t => {
|
|
const { b, wait } = await setup(t, '?readonly');
|
|
await b.evaluate(`location.hash='#/chat/projects/${P}';true`);
|
|
await wait('document.querySelectorAll("#chat-instance option").length===2');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-instance").value'), 'assistant');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-instance").disabled'), true);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
|
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /only reads/);
|
|
assert.equal(await b.evaluate('window.__writes.length'), 0);
|
|
});
|
|
|
|
|
|
test('264e chooser: two sessions keep drafts, cursors, deep links and counts independent', async t => {
|
|
const B = '30000000-0000-8000-8000-000000000003'; // mixed v4/v8 logical sessions
|
|
const { b, wait, set, click, route, refresh } = await setup(t);
|
|
await b.evaluate(`window.__sessions['${A}']=[{id:'${A}',name:'default',default:true,compactions:{known:true,count:3,reason:null}}, {id:'${B}',name:'second',default:false,compactions:{known:false,count:0,reason:'harness-unsupported'}}],true`);
|
|
await route(A);
|
|
assert.match(await b.evaluate('document.querySelector("#chat-compactions").textContent'), /Compactions: 3/);
|
|
await set('#chat-body', 'first session draft');
|
|
await b.evaluate(`document.querySelector('#chat-session').value='${B}';document.querySelector('#chat-session').dispatchEvent(new Event('change',{bubbles:true}));true`);
|
|
await wait(`document.querySelector('.chat-conversation')?.dataset.session==='${B}'`);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), '');
|
|
assert.match(await b.evaluate('document.querySelector("#chat-compactions").textContent'), /unknown.*harness-unsupported/);
|
|
await set('#chat-body', 'second session draft');
|
|
await b.evaluate(`location.hash='#/chat/threads/${A}/sessions/${A}',true`);
|
|
await wait(`document.querySelector('.chat-conversation')?.dataset.session==='${A}'`);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'first session draft');
|
|
await refresh();
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'first session draft');
|
|
assert.ok((await b.evaluate('window.__reads')).some(p => p.includes(`session=${B}`)));
|
|
await b.evaluate(`window.__answers.push({body:{id:'${M}'}}),true`);
|
|
await click('#chat-send-form button[type=submit]');
|
|
await wait('window.__writes.length===1');
|
|
assert.deepEqual(await b.evaluate('window.__writes[0].body'), {body:'first session draft',session:A});
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-clear").disabled'), true);
|
|
await b.evaluate(`location.hash='#/chat/threads/${A}/sessions/${M}',true`);
|
|
await wait('document.querySelector(".chat-area h1")?.textContent === "Thread unavailable"');
|
|
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'), false);
|
|
assert.equal(await b.evaluate('window.__writes.length'), 1);
|
|
});
|
|
|
|
test('264e clear confirmation and status never resend, force-stop or change selection', async t => {
|
|
const { b, wait, click, route, refresh } = await setup(t);
|
|
await b.evaluate(`window.__sessions['${A}']=[{id:'${A}',name:'default',default:true,state:'active',revision:2},{id:'${B}',name:'second',state:'active',revision:0}];window.confirm=()=>false;true`);
|
|
await route(A); await click('#chat-clear');
|
|
assert.equal(await b.evaluate('window.__writes.length'), 0);
|
|
await b.evaluate(`window.confirm=()=>true;window.__answers.push({body:{op:'placeholder',thread:'${A}',session:'${A}',successor:'${M}',state:'uncertain',start:null,refusal:null,at:null}});true`);
|
|
// The fixture echoes the actual idempotency key, as the broker contract does.
|
|
await b.evaluate(`(()=>{const real=window.fetch;window.fetch=async(u,i)=>{const r=await real(u,i);if(String(u).endsWith('/clear')){const v=await r.json();v.op=JSON.parse(i.body).op;window.__operation=v;return new Response(JSON.stringify(v));}return r;};return true;})()`);
|
|
await click('#chat-clear');
|
|
await wait('document.querySelector("#chat-clear-receipt")?.textContent.includes("uncertain")');
|
|
const posted = await b.evaluate('window.__writes[0]');
|
|
assert.equal(posted.path, `/api/chat/threads/${A}/clear`);
|
|
assert.deepEqual(Object.keys(posted.body).sort(), ['confirm','op','revision','session']);
|
|
assert.equal(posted.body.session, A); assert.equal(posted.body.revision, 2); assert.equal(posted.body.confirm, true);
|
|
await click('#chat-operation-form button');
|
|
await wait('window.__reads.some(p=>p.includes("/operation?"))');
|
|
await refresh();
|
|
assert.equal(await b.evaluate('window.__writes.length'), 1);
|
|
assert.equal(await b.evaluate('document.querySelector(".chat-conversation").dataset.session'), A);
|
|
});
|
|
|
|
test('264e new session keeps its operation key after an unknown response and uses no implicit open', async t => {
|
|
const {b,wait,set,click,route}=await setup(t);
|
|
await route(A); await set('#chat-session-name','new context');
|
|
await b.evaluate('window.__answers.push({reject:true}),true');
|
|
await click('#chat-session-form button');
|
|
await wait('document.querySelector("#chat-session-form [role=status]")?.textContent.includes("Outcome unknown")');
|
|
const first=await b.evaluate('window.__writes[0]');
|
|
assert.equal(first.path, `/api/chat/threads/${A}/sessions`);
|
|
assert.equal(first.body.name,'new context');
|
|
assert.match(first.body.op,/^[a-f0-9-]{36}$/);
|
|
// A deliberate retry uses the pinned payload; selecting a session never replays this write.
|
|
await b.evaluate(`window.__answers.push({body:{id:'${B}'}});window.__sessions['${A}']=[{id:'${A}',name:'default',default:true},{id:'${B}',name:'new context'}];true`);
|
|
await click('#chat-session-form button');
|
|
await wait(`document.querySelector('.chat-conversation')?.dataset.session==='${B}'`);
|
|
assert.deepEqual(await b.evaluate('window.__writes[1].body'), first.body);
|
|
assert.equal(await b.evaluate('window.__writes.length'),2);
|
|
assert.equal(await b.evaluate('location.hash'),`#/chat/threads/${A}/sessions/${B}`);
|
|
});
|
|
|
|
test('264e cancel shows before/after retirement and never starts or replays work', async t => {
|
|
const {b,wait,set,click,route}=await setup(t);
|
|
await b.evaluate(`window.__sessions['${A}']=[{id:'${A}',name:'default',default:true,state:'active',revision:2}];window.confirm=()=>true;true`);
|
|
await route(A);
|
|
for (const retired of [null,true]) {
|
|
const envelope={op:retired?'after-retirement':'before-retirement',thread:A,session:A,successor:M,state:'cancelled',start:retired?'held':null,retired,refusal:null,at:null};
|
|
await set('#chat-operation',envelope.op);
|
|
await b.evaluate(`window.__operation=${JSON.stringify({...envelope,state:'uncertain'})};window.__answers.push({body:${JSON.stringify(envelope)}});true`);
|
|
await click('#chat-cancel');
|
|
await b.evaluate(`window.__operation=${JSON.stringify(envelope)};true`);
|
|
await wait(`document.querySelector('#chat-clear-receipt')?.textContent.includes('${retired?'startup cancelled':'old session active'}')`);
|
|
const last=await b.evaluate('window.__writes.at(-1)');
|
|
assert.equal(last.path,`/api/chat/threads/${A}/cancel`);
|
|
assert.deepEqual(last.body,{op:envelope.op,confirm:true});
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-cancel").disabled'),true);
|
|
}
|
|
assert.equal(await b.evaluate('window.__writes.length'),2);
|
|
assert.equal(await b.evaluate('window.__writes.some(w=>/\\/(open|messages|sessions|clear)$/.test(w.path))'),false);
|
|
});
|
|
|
|
test('272 B1: a bad-session deep link preserves the draft and lost clear operation on A', async t => {
|
|
const {b,wait,set,click,route}=await setup(t);
|
|
await b.evaluate(`window.__sessions['${A}']=[{id:'${A}',name:'default',default:true,state:'active',revision:2},{id:'${B}',name:'sibling',state:'active',revision:0}];window.confirm=()=>true;true`);
|
|
await route(A);
|
|
await set('#chat-body','keep A draft through invalid session');
|
|
await b.evaluate('window.__answers.push({reject:true}),true');
|
|
await click('#chat-clear');
|
|
await wait('window.__writes.length===1 && !document.querySelector("#chat-clear").disabled');
|
|
const op=await b.evaluate('window.__writes[0].body.op');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-operation").value'),op);
|
|
await b.evaluate(`location.hash='#/chat/threads/${A}/sessions/${M}',true`);
|
|
await wait('document.querySelector(".chat-area h1")?.textContent === "Thread unavailable"');
|
|
assert.equal(await b.evaluate('!!document.querySelector("#chat-send-form")'),false);
|
|
await b.evaluate(`location.hash='#/chat/threads/${A}/sessions/${A}',true`);
|
|
await wait(`document.querySelector('.chat-conversation')?.dataset.session==='${A}'`);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'),'keep A draft through invalid session');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-operation").value'),op);
|
|
await click('#chat-clear');
|
|
await wait('window.__reads.some(p=>p.includes("/operation?"))');
|
|
assert.equal(await b.evaluate('window.__writes.length'),1,'a retained operation is queried, never replaced with a new clear');
|
|
});
|