P1-1 an open workflow editor, a dirty autonomy form and a pack's picked project keep their node, draft and focus across the 10-second poll and an org-changed repaint. P1-2 decision-http names the refused field and keeps the bus code and detail. P1-3 long editors span the full width below the org page split. P2-1 workflow roles resolve as definitions, templates and human; P2-2 a pack apply re-reads workflows, autonomy and board and keeps its receipt; P2-3 pack preview fits its column; P2-4 unknown project, agent or harness say so instead of outcome-unknown; P2-6 a stopped bus reads "bus stopped"; P2-8 Queue says why it is unavailable; P2-9 pane settings values are not clipped; P2-10 spend wording; P2-11 project header stays in its column; P2-12 project tabs fade at the edge until scrolled to the end; P2-13 Restart bus and Stop bus line up in panes; P2-15 backticks in chat text render as code. Minor: Same-as commands wrap between words; harness checkboxes share a row. Tests: org-live-edit-browser, e2e6-layout-browser, chat-replies and decision-http additions.
240 lines
19 KiB
JavaScript
240 lines
19 KiB
JavaScript
// Row 225 (C2-10b, design 7.4): replies, cites, write notices and the library status line in the
|
|
// Console chat. Fixture only: the chat and library answers are stubbed; /render.mjs is the real one.
|
|
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 C1 = 'a'.repeat(40), C2 = 'b'.repeat(40), C3 = 'c'.repeat(40), C4 = 'd'.repeat(40);
|
|
const HOSTILE = '<img src=x onerror="window.injected=1">';
|
|
const cite = (path, lines, quote, commit = C1, extra = {}) => ({ path, commit, lines, quote, ...extra });
|
|
const reply = (text, citations) => JSON.stringify({ text, citations });
|
|
const row = (id, body, check, from = 'assistant') => ({ id: `5000000${id}-0000-4000-8000-000000000000`, from_role: from, at: '2026-10-23T18:00:00.000Z', body, ...(check === undefined ? {} : { check }) });
|
|
const ROWS = [
|
|
row(1, 'Plain text answer.', undefined, 'jason'),
|
|
row(2, reply("Mara's eyes are grey.", [
|
|
cite('Book One/Chapters/Chapter 03.md', [41, 41], 'her grey eyes', C1, { as: 'canon' }),
|
|
cite('Book One/Playground/What If.md', [5, 6], 'a storm'),
|
|
cite('Notes/Eyes.md', [9, 10], 'green eyes'),
|
|
{ url: 'https://example.invalid/tides', quote: 'spring tides' },
|
|
{ url: 'http://example.invalid/plain', quote: 'plain http' },
|
|
{ url: 'javascript:window.injected=2', quote: 'script' },
|
|
]), { citations: [
|
|
{ scope: 'canon', agent: null, file: C1, last: C1 },
|
|
{ scope: 'playground', agent: null, file: C2, last: C3 },
|
|
{ scope: 'supporting', agent: 'acme/scribe', file: C2 },
|
|
{ scope: 'web', agent: null }, { scope: 'web', agent: null }, { scope: 'web', agent: null },
|
|
] }),
|
|
row(3, reply('Unchecked.', [cite('Notes/Eyes.md', [1, 1], HOSTILE)])),
|
|
row(4, JSON.stringify({ notice: 'library-write', op: 'write', path: 'Notes/Eyes.md', commit: C4 }), undefined, 'scribe'),
|
|
row(5, JSON.stringify({ notice: 'library-write', op: 'delete', path: 'Fake/Claim.md', commit: C4 }), { citations: [] }, 'forger'),
|
|
// E2E6 P2-15: a human message with inline code, a hostile span and an unpaired backtick.
|
|
row(6, `Approve with \`mosaic project north-star work\`. Not \`${HOSTILE}\`, a lone \` stays.`, undefined, 'human'),
|
|
];
|
|
const STATUS = { project: 'books', version: 1, branch: 'main', served: C3, lastFetch: 'FIVE-MINUTES-AGO',
|
|
lastError: { code: 'library-fetch-failed', at: '2026-10-23T18:00:00.000Z' }, diverged: { commit: C2, at: '2026-10-23T18:00:00.000Z' },
|
|
problems: [{ code: 'R1', path: 'Notes/Eyes.md', message: 'no heading', line: 3 }, { code: 'R2', path: HOSTILE, message: 'bad name' }] };
|
|
const stub = `(() => {
|
|
const real = fetch.bind(window);
|
|
window.__reverts=[];window.__revertAnswers=[];window.__statusReads=[];window.__confirms=[];
|
|
window.__status={status:200,body:${JSON.stringify(STATUS)}};
|
|
window.confirm=text=>{window.__confirms.push(text);return window.__confirm!==false;};
|
|
window.fetch=async (url,init={})=>{
|
|
const path=String(url), method=init.method||'GET';
|
|
const answer=(value,status=200)=>new Response(JSON.stringify(value),{status,headers:{'content-type':'application/json'}});
|
|
if(path==='/api/config')return answer({send:!location.search.includes('readonly')});
|
|
if(path.startsWith('/api/bus/'))return answer({rows:[],unread:0,at:new Date().toISOString()});
|
|
if(path==='/api/business')return answer({projects:['books']});
|
|
if(path.startsWith('/api/library/status')){
|
|
window.__statusReads.push(path);
|
|
const body={...window.__status.body};
|
|
if(body.lastFetch==='FIVE-MINUTES-AGO')body.lastFetch=new Date(Date.now()-330000).toISOString();
|
|
return answer(body,window.__status.status);
|
|
}
|
|
if(path==='/api/library/revert'){
|
|
window.__reverts.push({method,body:JSON.parse(init.body),headers:init.headers,redirect:init.redirect});
|
|
const a=window.__revertAnswers.shift();if(!a)throw new Error('No revert answer');
|
|
if(a.reject)throw new TypeError('network');
|
|
return answer(a.body,a.status||200);
|
|
}
|
|
if(!path.startsWith('/api/chat'))return real(url,init);
|
|
const data=value=>answer({...value,at:new Date().toISOString()});
|
|
if(path==='/api/chat/instances')return data({rows:[{instance:'assistant',name:'assistant'}]});
|
|
if(path==='/api/chat/projects')return data({rows:[{id:'${P}',name:'Books',repo:window.__repo===undefined?'books':window.__repo}]});
|
|
if(path.endsWith('/sessions'))return data({rows:[{id:'${A}',name:'default',default:true}]});
|
|
if(path.startsWith('/api/chat/threads')&&!path.includes('/messages'))return data({rows:[{id:'${A}',project:'${P}',name:'alpha',instance:'assistant'}]});
|
|
return data({rows:${JSON.stringify(ROWS)},after:null,hasMore:false});
|
|
};
|
|
})();`;
|
|
async function setup(t, { suffix = '', before = '' } = {}) {
|
|
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 + before });
|
|
await b.navigate(`http://127.0.0.1:${server.address().port}/${suffix}#/chat/threads/${A}`);
|
|
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 text = selector => b.evaluate(`document.querySelector(${JSON.stringify(selector)})?.textContent ?? null`);
|
|
const message = n => `.chat-log > li:nth-child(${n})`;
|
|
await wait('document.querySelector(".chat-cites")');
|
|
return { b, wait, text, message };
|
|
}
|
|
|
|
test('chat replies: free text, cites with badges, the agent label, changed since and safe links', async t => {
|
|
const { b, text, message } = await setup(t);
|
|
assert.equal(await text(`${message(1)} .chat-body`), 'Plain text answer.');
|
|
assert.equal(await text(`${message(2)} .chat-body`), "Mara's eyes are grey.");
|
|
// E2E6 P2-15: backtick pairs render as code and their contents stay text.
|
|
assert.deepEqual(await b.evaluate(`[...document.querySelectorAll(${JSON.stringify(message(6) + ' .chat-body code')})].map(c => c.textContent)`), ['mosaic project north-star work', HOSTILE]);
|
|
assert.equal(await text(`${message(6)} .chat-body`), `Approve with mosaic project north-star work. Not ${HOSTILE}, a lone \` stays.`);
|
|
assert.equal(await b.evaluate(`document.querySelectorAll('.chat-log img').length`), 0);
|
|
const cites = await b.evaluate(`[...document.querySelectorAll(${JSON.stringify(message(2) + ' .chat-cites > li')})].map(li => ({
|
|
badge: li.querySelector('.chat-scope')?.textContent ?? null, ref: (li.querySelector('a') ?? li.querySelector('code'))?.textContent,
|
|
href: li.querySelector('a')?.getAttribute('href') ?? null, rel: li.querySelector('a')?.getAttribute('rel') ?? null,
|
|
quote: li.querySelector('q').textContent, agent: li.querySelector('.chat-agent-written')?.textContent ?? null,
|
|
changed: !!li.querySelector('.chat-changed') }))`);
|
|
assert.deepEqual(cites, [
|
|
{ badge: 'canon', ref: 'Book One/Chapters/Chapter 03.md:41 @aaaaaaa', href: null, rel: null, quote: 'her grey eyes', agent: null, changed: false },
|
|
{ badge: 'playground', ref: 'Book One/Playground/What If.md:5-6 @aaaaaaa', href: null, rel: null, quote: 'a storm', agent: null, changed: true },
|
|
// `last` absent: nothing is marked changed (sage-3596).
|
|
{ badge: 'supporting', ref: 'Notes/Eyes.md:9-10 @aaaaaaa', href: null, rel: null, quote: 'green eyes', agent: 'agent-written: acme/scribe', changed: false },
|
|
{ badge: 'web', ref: 'https://example.invalid/tides', href: 'https://example.invalid/tides', rel: 'noopener noreferrer', quote: 'spring tides', agent: null, changed: false },
|
|
{ badge: 'web', ref: 'http://example.invalid/plain', href: null, rel: null, quote: 'plain http', agent: null, changed: false },
|
|
{ badge: 'web', ref: 'javascript:window.injected=2', href: null, rel: null, quote: 'script', agent: null, changed: false },
|
|
]);
|
|
// A reply with no check result: no badge, no label, never changed; its quote is inert text.
|
|
assert.equal(await b.evaluate(`document.querySelectorAll(${JSON.stringify(message(3) + ' .chat-scope, ' + message(3) + ' .chat-label')}).length`), 0);
|
|
assert.equal(await text(`${message(3)} q`), HOSTILE);
|
|
assert.equal(await b.evaluate('!!(document.querySelector(".chat-log img") || window.injected)'), false);
|
|
});
|
|
|
|
test('chat replies: a write notice reads as its line with a Revert; a refused revert shows its code', async t => {
|
|
const { b, wait, text, message } = await setup(t);
|
|
assert.equal(await text(`${message(4)} .chat-notice > span`), 'scribe wrote Notes/Eyes.md @ddddddd');
|
|
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(message(4) + ' [data-revert]')}).dataset.revert`), C4);
|
|
assert.equal(await b.evaluate('document.querySelectorAll("[data-revert]").length'), 1, 'only the notice has a Revert');
|
|
// Cancelled at the confirm: nothing is sent.
|
|
await b.evaluate('window.__confirm=false;document.querySelector("[data-revert]").click();true');
|
|
assert.equal(await b.evaluate('window.__reverts.length'), 0);
|
|
assert.match(await b.evaluate('window.__confirms[0]'), /^Revert ddddddd\?/);
|
|
// Before C2-02 the bus refuses the verb; the page shows that code and doesn't retry.
|
|
await b.evaluate(`window.__confirm=true;window.__revertAnswers.push({status:502,body:{error:'unknown-verb',message:'not reverted: the bus refused it (unknown-verb).',sent:false}});document.querySelector("[data-revert]").click();true`);
|
|
await wait('document.querySelector(".chat-revert-receipt").textContent.startsWith("Not")');
|
|
assert.equal(await text('.chat-revert-receipt'), 'Not reverted (unknown-verb).');
|
|
assert.deepEqual(await b.evaluate('window.__reverts'), [{ method: 'POST', body: { project: 'books', commit: C4 }, headers: { 'content-type': 'application/json' }, redirect: 'manual' }]);
|
|
for (const [answer, receipt] of [
|
|
[{ status: 409, body: { error: 'library-revert-not-agent', sent: false } }, 'Not reverted (library-revert-not-agent).'],
|
|
[{ status: 409, body: { error: 'stale-base', sent: false } }, 'Not reverted (stale-base).'],
|
|
[{ status: 503, body: { error: 'outcome-unknown', sent: 'unknown' } }, 'Outcome unknown. Read the library status before trying again; nothing was retried.'],
|
|
[{ reject: true }, 'Outcome unknown. Read the library status before trying again; nothing was retried.'],
|
|
[{ body: { project: 'books', path: 'Notes/Eyes.md', reverted: C4, commit: C1 } }, 'Reverted in aaaaaaa.'],
|
|
]) {
|
|
const n = await b.evaluate('window.__reverts.length');
|
|
await b.evaluate(`window.__revertAnswers.push(${JSON.stringify(answer)});document.querySelector("[data-revert]").click();true`);
|
|
await wait(`window.__reverts.length===${n + 1} && !document.querySelector("[data-revert]").disabled`);
|
|
assert.equal(await text('.chat-revert-receipt'), receipt);
|
|
}
|
|
assert.ok(await b.evaluate('window.__statusReads.length') >= 2, 'the status line is read again after a revert');
|
|
});
|
|
|
|
test('chat replies: a read-only Console shows the notice with Revert disabled', async t => {
|
|
const { b, message } = await setup(t, { suffix: '?readonly' });
|
|
assert.equal(await b.evaluate(`document.querySelector(${JSON.stringify(message(4) + ' [data-revert]')}).disabled`), true);
|
|
await b.evaluate('document.querySelector("[data-revert]").click();true');
|
|
assert.equal(await b.evaluate('window.__reverts.length + window.__confirms.length'), 0);
|
|
});
|
|
|
|
test('chat status line: served commit and age, divergence, fetch error and the problems one click away', async t => {
|
|
const { b, wait, text } = await setup(t);
|
|
await wait('document.querySelector(".chat-library")');
|
|
assert.equal(await b.evaluate('window.__statusReads[0]'), '/api/library/status?project=books');
|
|
assert.equal(await text('.chat-library [role=status]'), 'Library at ccccccc · fetched 5 min ago · library-diverged at bbbbbbb · fetch error library-fetch-failed');
|
|
assert.equal(await text('.chat-library-problems summary'), '2 problems');
|
|
assert.deepEqual(await b.evaluate('[...document.querySelectorAll(".chat-library-problems li")].map(li => li.textContent)'),
|
|
['R1 Notes/Eyes.md:3 no heading', `R2 ${HOSTILE} bad name`]);
|
|
assert.equal(await b.evaluate('!!document.querySelector(".chat-library img")'), false);
|
|
});
|
|
|
|
test('chat status line: limits when the status carries them, no problems, and refusals', async t => {
|
|
const limits = { ...STATUS, lastError: null, diverged: null, problems: [], replyLimits: { requireCitation: true, citationsPerReply: 4, fallback: ['citationsPerReply'], problem: null } };
|
|
const { b, wait, text } = await setup(t, { before: `window.__status={status:200,body:${JSON.stringify(limits)}};` });
|
|
await wait('document.querySelector(".chat-library")');
|
|
assert.equal(await text('.chat-library [role=status]'), 'Library at ccccccc · fetched 5 min ago · citations required, up to 4 per reply · limits fell back: citationsPerReply · no problems');
|
|
assert.equal(await b.evaluate('!!document.querySelector(".chat-library-problems")'), false);
|
|
for (const [status, error, shown] of [[502, 'library-state-invalid', 'Library status unavailable (library-state-invalid).'], [502, 'Not A Code', 'Library status unavailable (invalid-response).'],
|
|
[403, 'owner-only', null], [502, 'project-not-declared', null], [502, 'library-mapping-invalid', 'Library status unavailable (library-mapping-invalid).'], [503, 'project-not-configured', null], [503, 'not-configured', null]]) {
|
|
await wait('!document.querySelector("#refresh").disabled');
|
|
await b.evaluate(`window.__status={status:${status},body:{error:${JSON.stringify(error)}}};document.querySelector("#refresh").click();true`);
|
|
await wait(`window.__statusReads.length && document.querySelector(".chat-conversation") && ${shown === null ? '!document.querySelector(".chat-library")' : `document.querySelector(".chat-library")?.textContent===${JSON.stringify(shown)}`}`);
|
|
}
|
|
});
|
|
|
|
test('chat status line: a project with no repository link reads no library status', async t => {
|
|
const { b } = await setup(t, { before: 'window.__repo=null;' });
|
|
assert.equal(await b.evaluate('window.__statusReads.length'), 0);
|
|
assert.equal(await b.evaluate('!!document.querySelector(".chat-library")'), false);
|
|
});
|
|
|
|
// Removing the nested problem display would hide the reason default limits are in force.
|
|
test('chat status line: unreadable reply limits show their problem and both fallback keys', async t => {
|
|
const limits = { ...STATUS, lastError: null, diverged: null, problems: [],
|
|
replyLimits: { requireCitation: false, citationsPerReply: 8,
|
|
fallback: ['requireCitation', 'citationsPerReply'], problem: 'reply-limits-unreadable' } };
|
|
const { wait, text } = await setup(t, { before: `window.__status={status:200,body:${JSON.stringify(limits)}};` });
|
|
await wait('document.querySelector(".chat-library")');
|
|
assert.equal(await text('.chat-library [role=status]'), 'Library at ccccccc · fetched 5 min ago · citations optional, up to 8 per reply · limits fell back: requireCitation, citationsPerReply · reply-limits-unreadable · no problems');
|
|
});
|
|
|
|
// Comparing commit to last would incorrectly mark the first cite; trusting missing file would
|
|
// incorrectly mark the second. The third ensures malformed last never becomes a changed marker.
|
|
test('chat replies: unchanged file at a different library commit and incomplete checks stay unmarked', async t => {
|
|
const { b, wait } = await setup(t);
|
|
await b.evaluate(`window.__checkOverride={citations:[{scope:'canon',agent:null,file:'${C2}',last:'${C2}'},
|
|
{scope:'playground',agent:null,last:'${C3}'}, {scope:'supporting',agent:null,file:'${C2}',last:'bad'}]};true`);
|
|
await b.evaluate(`const old=window.fetch;window.fetch=async(url,init)=>{const res=await old(url,init);
|
|
if(!String(url).includes('/messages'))return res;
|
|
const data=await res.json();data.rows[1].check=window.__checkOverride;
|
|
return new Response(JSON.stringify(data),{headers:{'content-type':'application/json'}});
|
|
};true`);
|
|
await b.evaluate(`MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`);
|
|
assert.equal(await b.evaluate('document.querySelectorAll(".chat-changed").length'), 0);
|
|
});
|
|
|
|
test('library age changes without replacing the conversation', async t => {
|
|
const { b, wait } = await setup(t);
|
|
await b.evaluate(`window.__status.body.lastFetch=new Date(Date.now()-330000).toISOString();MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`);
|
|
await wait(`window.__statusReads.length>=2`);
|
|
await b.evaluate(`(()=>{const field=document.querySelector('#chat-body');field.focus();field.value='draft';field.dispatchEvent(new Event('input',{bubbles:true}));field.setSelectionRange(2,2);window.savedReplyField=field;window.savedReplyMessage=document.querySelector('[data-message]');const now=Date.now();Date.now=()=>now+120000;})()`);
|
|
await b.evaluate(`MosaicChat.render({path:location.hash.slice(1),container:document.querySelector('#bus-view'),canSend:true,navigated:false})`);
|
|
await wait(`document.querySelector('.chat-library')?.textContent.includes('7 min ago')`);
|
|
assert.equal(await b.evaluate(`savedReplyMessage.isConnected && savedReplyField.isConnected && document.activeElement===savedReplyField && savedReplyField.selectionStart===2`), true);
|
|
});
|
|
|
|
// Invalid configuration is a diagnostic, unlike an ordinary project with no library mapping.
|
|
test('chat status line: an invalid library mapping remains visible for diagnosis', async t => {
|
|
const { b, wait, text } = await setup(t, { before: "window.__status={status:502,body:{error:'library-mapping-invalid'}};" });
|
|
await wait('window.__statusReads.length===1');
|
|
assert.equal(await text('.chat-library [role=status]'), 'Library status unavailable (library-mapping-invalid).');
|
|
assert.equal(await b.evaluate('window.__reverts.length'), 0);
|
|
});
|
|
|
|
// A guard-checked agent send cannot impersonate a broker-inserted write notice, even if it
|
|
// names another agent's reachable commit and invents a different path (dewey-0937 B1).
|
|
test('chat replies: a checked notice-shaped agent message is plain text with no Revert', async t => {
|
|
const { b, text, message } = await setup(t);
|
|
assert.equal(await text(`${message(5)} .chat-body`), JSON.stringify({ notice: 'library-write', op: 'delete', path: 'Fake/Claim.md', commit: C4 }));
|
|
assert.equal(await b.evaluate(`document.querySelectorAll(${JSON.stringify(message(5) + ' [data-revert], ' + message(5) + ' .chat-notice')}).length`), 0);
|
|
assert.equal(await b.evaluate('window.__reverts.length'), 0);
|
|
assert.equal(await text(`${message(4)} .chat-notice > span`), 'scribe wrote Notes/Eyes.md @ddddddd', 'the unchecked broker notice still renders');
|
|
});
|
|
|
|
// The granted-user fence decides owner-only before calling any library transport (N1).
|
|
test('chat replies: owner-only without a sent field is a definite Revert refusal', async t => {
|
|
const { b, wait, text } = await setup(t);
|
|
await b.evaluate(`window.__revertAnswers.push({status:403,body:{error:'owner-only'}});document.querySelector('[data-revert]').click();true`);
|
|
await wait('!document.querySelector("[data-revert]").disabled');
|
|
assert.equal(await text('.chat-revert-receipt'), 'Not reverted (owner-only).');
|
|
assert.equal(await b.evaluate('window.__reverts.length'), 1);
|
|
});
|