665 lines
49 KiB
JavaScript
665 lines
49 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 && (!window.__failurePath || path.includes(window.__failurePath)))return new Response(JSON.stringify({error:window.__readFailure}),{status:['thread-not-owned','access-refused'].includes(window.__readFailure)?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 = '', initial = '', startRoute = '#/chat') {
|
|
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 });
|
|
if (initial) await b.call('Page.addScriptToEvaluateOnNewDocument', { source: initial });
|
|
await b.navigate(`http://127.0.0.1:${server.address().port}/${suffix}${startRoute}`);
|
|
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 () => {
|
|
// A successful read can leave the existing DOM in place. Await the real
|
|
// renderer instead of using replacement of the old element as its signal.
|
|
await b.evaluate(`MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`);
|
|
};
|
|
return { b, wait, set, click, route, refresh };
|
|
}
|
|
|
|
test('chat browser: initial disabled channel reports unavailable lists and does not offer creation', async t => {
|
|
const { b } = await setup(t, '', 'window.__readFailure="channel-disabled"');
|
|
const text = await b.evaluate('document.querySelector(".chat-area").textContent');
|
|
assert.match(text, /chat.*channel.*disabled/i);
|
|
assert.doesNotMatch(text, /No projects yet|No threads yet|mosaic plugins|SENTINEL/i);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-project-form button")?.disabled ?? true'), true);
|
|
assert.equal(await b.evaluate('window.__writes.length'), 0);
|
|
});
|
|
|
|
test('chat browser: refused creation keeps the draft, disables creation and recovers after refresh', async t => {
|
|
const { b, wait, set, click, refresh } = await setup(t);
|
|
await set('#chat-project-name', 'Pomodoro Timer');
|
|
await b.evaluate('window.__answers.push({status:503,body:{error:"channel-disabled",sent:false,message:"SENTINEL private transport"}}),true');
|
|
await click('#chat-project-form button[type=submit]');
|
|
await wait('document.querySelector("#chat-project-form [role=status]")?.textContent.includes("Not sent")');
|
|
assert.match(await b.evaluate('document.querySelector("#chat-project-form [role=status]").textContent'), /channel.*disabled/i);
|
|
assert.doesNotMatch(await b.evaluate('document.querySelector(".chat-area").textContent'), /SENTINEL/);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-project-name").value'), 'Pomodoro Timer');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-project-form button").disabled'), true);
|
|
await refresh();
|
|
await wait('!document.querySelector("#chat-project-form button").disabled');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-project-name").value'), 'Pomodoro Timer');
|
|
assert.equal(await b.evaluate('window.__writes.length'), 1);
|
|
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.equal(await b.evaluate('window.__writes[1].body.name'), 'Pomodoro Timer');
|
|
});
|
|
|
|
test('chat browser: failed refresh marks cached lists stale; successful empty reads retain creation', async t => {
|
|
const { b, wait, refresh } = await setup(t);
|
|
await b.evaluate('window.__readFailure="channel-disabled";true');
|
|
await refresh();
|
|
await wait('document.querySelector(".chat-error")');
|
|
const failed = await b.evaluate('document.querySelector(".chat-area").textContent');
|
|
assert.match(failed, /channel.*disabled/i);
|
|
assert.match(failed, /previous|cached|stale/i);
|
|
assert.doesNotMatch(failed, /No projects yet|No threads yet/);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-project-form button").disabled'), true);
|
|
await b.evaluate('window.__readFailure=null;window.__projects=[];window.__threads=[];true');
|
|
await refresh();
|
|
await wait('document.querySelector(".chat-area").textContent.includes("No projects yet")');
|
|
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /No threads yet/);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-project-form button").disabled'), false);
|
|
assert.equal(await b.evaluate('window.__writes.length'), 0);
|
|
});
|
|
|
|
test('chat browser: disabled thread creation retains its draft and refresh restores the action', async t => {
|
|
const { b, wait, set, click, refresh } = await setup(t);
|
|
await b.evaluate(`location.hash='#/chat/projects/${P}';true`);
|
|
await wait('document.querySelector("#chat-thread-form button") && !document.querySelector("#chat-thread-form button").disabled');
|
|
await set('#chat-thread-name', 'planning');
|
|
await b.evaluate('window.__answers.push({status:503,body:{error:"channel-disabled",sent:false}}),true');
|
|
await click('#chat-thread-form button');
|
|
await wait('document.querySelector("#chat-thread-form [role=status]").textContent.includes("Not sent")');
|
|
assert.match(await b.evaluate('document.querySelector("#chat-thread-form [role=status]").textContent'), /channel.*disabled/i);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'planning');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form button").disabled'), true);
|
|
await refresh();
|
|
await wait('!document.querySelector("#chat-thread-form button").disabled');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-thread-name").value'), 'planning');
|
|
assert.equal(await b.evaluate('window.__writes.length'), 1);
|
|
});
|
|
|
|
test('chat browser: uncertain disabled send remains unknown, preserves draft and does not retry', async t => {
|
|
const { b, wait, set, click, route, refresh } = await setup(t);
|
|
await route(A); await set('#chat-body', 'unsure draft');
|
|
await b.evaluate('window.__answers.push({status:503,body:{error:"channel-disabled",message:"SENTINEL"}}),true');
|
|
await click('#chat-send-form button');
|
|
await wait('document.querySelector("#chat-receipt").textContent.includes("Outcome unknown")');
|
|
assert.doesNotMatch(await b.evaluate('document.querySelector("#chat-receipt").textContent'), /Not sent|SENTINEL/);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'unsure draft');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-send-form button").disabled'), true);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-session-form button").disabled'), true);
|
|
await refresh();
|
|
await wait('!document.querySelector("#chat-send-form button").disabled');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'unsure draft');
|
|
assert.equal(await b.evaluate('window.__writes.length'), 1);
|
|
});
|
|
|
|
test('chat browser: a refused send explains the disabled channel and retains the message', async t => {
|
|
const { b, wait, set, click, route } = await setup(t);
|
|
await route(A); await set('#chat-body', 'keep my message');
|
|
await b.evaluate('window.__answers.push({status:503,body:{error:"channel-disabled",sent:false,message:"SENTINEL"}}),true');
|
|
await click('#chat-send-form button');
|
|
await wait('document.querySelector("#chat-receipt").textContent.includes("Not sent")');
|
|
const receipt = await b.evaluate('document.querySelector("#chat-receipt").textContent');
|
|
assert.match(receipt, /channel.*disabled/i);
|
|
assert.doesNotMatch(receipt, /Outcome unknown|SENTINEL/);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'keep my message');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-send-form button").disabled'), true);
|
|
assert.equal(await b.evaluate('window.__writes.length'), 1);
|
|
});
|
|
|
|
test('chat browser: failed shared-thread read does not claim an empty shared list', async t => {
|
|
const { b, wait, refresh } = await setup(t);
|
|
await b.evaluate('window.__failurePath="shared=true";window.__readFailure="channel-disabled";true');
|
|
await refresh();
|
|
await wait('document.querySelector(".chat-error")');
|
|
const text = await b.evaluate('document.querySelector(".chat-area").textContent');
|
|
assert.match(text, /channel.*disabled/i);
|
|
assert.doesNotMatch(text, /No threads yet/);
|
|
});
|
|
|
|
test('chat browser: a shared-list refusal does not discard an owned thread draft', async t => {
|
|
const { b, wait, set, route, refresh } = await setup(t);
|
|
await route(A); await set('#chat-body', 'owned thread draft');
|
|
await b.evaluate('window.__failurePath="shared=true";window.__readFailure="access-refused";true');
|
|
await refresh(); await wait('document.querySelector(".chat-error")');
|
|
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'), 'owned thread draft');
|
|
});
|
|
|
|
test('chat browser: a refused share explains the disabled channel without exposing transport text', async t => {
|
|
const { b, wait, set, click, route } = await setup(t);
|
|
await route(A); await set('#chat-share-target', 'user:reader');
|
|
await b.evaluate('window.__answers.push({status:503,body:{error:"channel-disabled",sent:false,message:"SENTINEL"}}),true');
|
|
await click('#chat-share-form button');
|
|
await wait('document.querySelector("#chat-share-form + [role=status]")?.textContent.includes("refused")');
|
|
const receipt = await b.evaluate('document.querySelector("#chat-share-form + [role=status]").textContent');
|
|
assert.match(receipt, /chat channel.*disabled/i);
|
|
assert.doesNotMatch(receipt, /SENTINEL/);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-share-target").value'), 'user:reader');
|
|
assert.equal(await b.evaluate('window.__writes.length'), 1);
|
|
});
|
|
test('chat browser: session catalog recovery restores controls and scopes drafts and messages', async t => {
|
|
const { b, wait, set, click, route, refresh } = await setup(t);
|
|
await b.evaluate('window.__failurePath="/sessions";window.__readFailure="access-refused";true');
|
|
await route(A);
|
|
assert.match(await b.evaluate('document.querySelector(".chat-area").textContent'), /sessions.*not.*available/i);
|
|
await set('#chat-body', 'legacy draft');
|
|
await b.evaluate(`window.__readFailure=null;window.__sessions['${A}']=[{id:'${B}',name:'default',default:true,state:'active',revision:2}];window.__pages.push({rows:[{id:'${M}',body:'session history',from_role:'assistant',at:'2026-10-18T00:00:00Z'}],after:'${M}',hasMore:false});true`);
|
|
await refresh();
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-session")?.value'), B);
|
|
assert.equal(await b.evaluate('!!document.querySelector("#chat-session-form") && !!document.querySelector("#chat-clear")'), true);
|
|
assert.match(await b.evaluate('document.querySelector(".chat-log").textContent'), /session history/);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), '', 'legacy draft is not copied into another session');
|
|
await set('#chat-body', 'session draft');
|
|
await b.evaluate(`window.__answers.push({body:{id:'${P}'}}),true`);
|
|
await click('#chat-send-form button');
|
|
await wait('document.querySelector("#chat-receipt").textContent.includes("Accepted")');
|
|
assert.deepEqual(await b.evaluate('window.__writes[0].body'), { body: 'session draft', session: B });
|
|
await b.evaluate('window.__readFailure="access-refused";true');
|
|
await refresh();
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-body").value'), 'legacy draft');
|
|
});
|
|
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="#/projects"]')`), 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="#/projects"]')`), 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 === "Conversation 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}`);
|
|
});
|
|
|
|
// org-lan-bind: a Console opened by LAN name is not a secure context, so crypto.randomUUID is
|
|
// absent there. A new session still gets a random v4 operation key.
|
|
test('a new session gets a v4 operation key without crypto.randomUUID', async t => {
|
|
const {b,wait,set,click,route}=await setup(t,'','delete Crypto.prototype.randomUUID');
|
|
assert.equal(await b.evaluate('typeof crypto.randomUUID'),'undefined');
|
|
await route(A); await set('#chat-session-name','new context');
|
|
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}'`);
|
|
const write=await b.evaluate('window.__writes[0]');
|
|
assert.equal(write.path, `/api/chat/threads/${A}/sessions`);
|
|
assert.match(write.body.op,/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
|
|
});
|
|
|
|
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 === "Conversation 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');
|
|
});
|
|
|
|
test('archive navigation exposes saved threads and unarchive with shared button styling', async t => {
|
|
const initial = `window.MutationObserver=class { observe(){} disconnect(){} };window.__projects=[];window.__archiveRows=[{id:'${P}',name:${JSON.stringify(HOSTILE)},repo:null,archived:true}];
|
|
window.confirm=()=>true;
|
|
window.__sessions['${A}']=[{id:'${A}',name:'default',default:true,state:'active',revision:2}];
|
|
window.__operation={op:'pre-archive',thread:'${A}',session:'${A}',successor:'${M}',state:'retired',start:null,refusal:null,at:null};
|
|
const base=window.fetch;window.fetch=async(url,opts)=>String(url)==='/api/chat/projects/archived'?new Response(JSON.stringify({rows:window.__archiveRows})):base(url,opts);`;
|
|
const { b, wait, click, set } = await setup(t, '', initial, '#/projects');
|
|
await wait('document.querySelector("#project-archive-toggle")');
|
|
assert.equal(await b.evaluate('document.querySelector("#project-archive-list").children.length'), 0);
|
|
await click('#project-archive-toggle');
|
|
await wait('document.querySelector("#project-archive-list a")');
|
|
assert.equal(await b.evaluate('document.querySelector("#project-archive-list a strong").textContent'), HOSTILE);
|
|
assert.equal(await b.evaluate('window.injected??null'), null);
|
|
const hooks = await b.evaluate('Array.from(document.querySelectorAll(".project-archived-filter, .project-archived-filter [class]")).flatMap(el=>Array.from(el.classList))');
|
|
assert.ok(hooks.includes('project-card') && hooks.includes('btn'), JSON.stringify(hooks));
|
|
assert.equal(await b.evaluate('!!document.querySelector("#project-archive-list a button")'), false);
|
|
assert.equal(await b.evaluate('document.querySelector("#project-archive-list a").getAttribute("href")'), `#/projects/${P}`);
|
|
await click('#project-archive-list a');
|
|
await wait('document.querySelector(".project-archive-control")');
|
|
assert.equal(await b.evaluate('document.querySelector(".chat-area h1").textContent'), '<img src=x onerror="window.injected=1">\\u001b[31m\\u202eevil');
|
|
assert.equal(await b.evaluate(`new Set([...document.querySelectorAll('a[href*="${A}"],a[href*="${B}"]')].map(a=>a.getAttribute("href"))).size`), 2);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-thread-form")'), null);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-room-open")'), null);
|
|
assert.equal(await b.evaluate('document.querySelector("#project-archive-button").textContent'), 'Unarchive');
|
|
await click(`a[href="#/projects/rooms/${P}"]`);
|
|
await wait('document.querySelector(".chat-room")');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-room-send-form")'), null);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-room-open")'), null);
|
|
await click(`.chat-room a[href="#/projects/${P}"]`);
|
|
await wait('document.querySelector("#project-archive-button")');
|
|
await click(`a[href="#/projects/threads/${A}"]`);
|
|
await wait('document.querySelector(".chat-conversation")');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-resume")'), null);
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-send-form")'), null);
|
|
assert.ok(await b.evaluate('!!document.querySelector("#chat-session")'));
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-clear").disabled'), true);
|
|
await set('#chat-operation', 'pre-archive');
|
|
await click('#chat-operation-form button');
|
|
await wait('document.querySelector("#chat-clear-receipt")?.textContent.includes("retired")');
|
|
assert.equal(await b.evaluate('document.querySelector("#chat-cancel").disabled'), true);
|
|
await b.evaluate(`location.hash='#/chat/projects/${P}';true`);
|
|
await wait(`document.querySelector('a[href="#/chat/rooms/${P}"]')`);
|
|
assert.ok(await b.evaluate(`!!document.querySelector('a[href="#/chat/rooms/${P}"]')`));
|
|
await b.evaluate(`location.hash='#/projects/${P}';true`);
|
|
await wait('document.querySelector("#project-archive-button")');
|
|
await b.evaluate(`window.__projects=[{id:'${P}',name:${JSON.stringify(HOSTILE)},repo:null}];window.__archiveRows=[];window.__answers.push({body:{project:'${P}',archived:false}});true`);
|
|
await click('#project-archive-button');
|
|
await wait('window.__writes.at(-1)?.path.endsWith("/unarchive")');
|
|
await b.evaluate(`location.hash='#/projects/${P}/settings'`);
|
|
await wait('document.querySelector("#project-archive-button")?.textContent==="Archive project"');
|
|
assert.equal(await b.evaluate('window.__writes.at(-1).path'), `/api/chat/projects/${P}/unarchive`);
|
|
await b.evaluate(`location.hash='#/projects/${P}/conversations'`);
|
|
await wait('!!document.querySelector("#chat-thread-form")');
|
|
});
|