// Row 278: real Console rendering and HTTP with mutable bus-answer stand-ins. // Broker claim/release semantics are covered by row 277; this tests the reader. import test from 'node:test'; import assert from 'node:assert/strict'; import { startServer } from '../src/serve.mjs'; import { BusReadError } from '../src/bus.mjs'; import { fixture, close } from './fixture.mjs'; import { browser } from './browser.mjs'; import { startTesterSocket, TESTER_ORIGIN } from '../src/tester-socket.mjs'; import { testerBrowser } from './tester-browser-fixture.mjs'; import { tmp } from '../../cli/tests/helpers.mjs'; const AT = '2026-01-01T00:00:00Z'; const THREAD = '11111111-1111-4111-8111-111111111111'; const claim = { thread: THREAD, holder_run: 'thread-run', harness: 'pi', at: AT }; async function setup(t) { const f = await fixture(); t.after(() => f.close()); const state = { rows: [], failure: null, offline: false, calls: [], hold: false, release: null }; t.after(() => { state.hold = false; state.release?.(); }); const role = { role: 'coder', holder_run: 'role-run', harness: 'pi', at: AT, state: 'idle', inputNeeded: [] }; const read = (verb, answer) => async () => { state.calls.push(verb); if (state.offline) throw new BusReadError('outcome-unknown'); if (verb === 'thread.claims' && state.failure) throw new BusReadError(state.failure); if (verb === 'thread.claims' && state.hold) await new Promise(resolve => { state.release = resolve; }); return typeof answer === 'function' ? answer() : answer; }; const bus = { agents: read('agents', [role]), inbox: read('inbox', []), messages: read('messages', { messages: [], unread: 0 }), 'thread.claims': read('thread.claims', () => state.rows) }; const web = await startServer({ port: 0, board: f.boardURL, bus }); t.after(() => close(web)); const b = await browser(); t.after(() => b.close()); const wait = expression => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expression})return true;await new Promise(r=>setTimeout(r,30));}throw Error(${JSON.stringify(expression)})})()`); await b.navigate(`http://127.0.0.1:${web.address().port}/#/agents`); await wait(`!!document.querySelector('#b-thread-claims')`); const roleHtml = () => b.evaluate(`document.querySelector('[aria-labelledby="b-claims"]').outerHTML`); const refresh = () => b.evaluate(`document.querySelector('#refresh').click(),true`); return { b, state, wait, roleHtml, refresh }; } test('row278 browser lists held claims and release preserves role rows and totals', async t => { const { b, state, wait, roleHtml, refresh } = await setup(t); const before = await roleHtml(); assert.match(await b.evaluate(`document.querySelector('[aria-labelledby="b-thread-claims"]').textContent`), /No thread claim is held/); state.rows = [claim]; await refresh(); await wait(`document.querySelector('.s1-thread-claims tbody')?.textContent.includes('${THREAD}')`); assert.deepEqual(await b.evaluate(`Array.from(document.querySelectorAll('.s1-thread-claims thead th'),e=>e.textContent)`), ['Thread', 'Holder run', 'Harness', 'Claimed at']); assert.equal(await b.evaluate(`document.querySelector('#b-claims .count').textContent`), '1'); assert.equal(await roleHtml(), before); assert.equal(await b.evaluate(`document.querySelector('.s1-thread-claims time').dateTime`), AT); state.rows = []; await refresh(); await wait(`document.querySelector('[aria-labelledby="b-thread-claims"]').textContent.includes('No thread claim is held')`); assert.equal(await b.evaluate(`document.querySelectorAll('.s1-thread-claims tbody tr').length`), 0); assert.equal(await roleHtml(), before); }); test('row278 browser claims 403 stays local and role section remains byte-equal', async t => { const { b, state, wait, roleHtml, refresh } = await setup(t); const before = await roleHtml(); state.rows = [claim]; await refresh(); await wait(`!!document.querySelector('.s1-thread-claims tbody tr')`); for (const code of ['human-required', 'not-configured', 'outcome-unknown']) { state.failure = code; await refresh(); await wait(`document.querySelector('[aria-labelledby="b-thread-claims"] code')?.textContent===${JSON.stringify(code)}`); assert.equal(await roleHtml(), before, code); assert.equal(await b.evaluate(`document.querySelector('#b-claims .count').textContent`), '1'); assert.equal(await b.evaluate(`document.querySelectorAll('.s1-thread-claims tbody tr').length`), 0, 'failed read drops claims only'); } state.offline = true; await refresh(); await wait(`!!document.querySelector('.s1-stale') && document.querySelector('[aria-labelledby="b-thread-claims"] code')?.textContent==='not-read'`); assert.equal(await roleHtml(), before, 'other reads may use the kept role section'); assert.equal(await b.evaluate(`document.querySelectorAll('.s1-thread-claims tbody tr').length`), 0, 'a later stale repaint must not recover refused claims'); }); test('row278 browser renders only four escaped claim fields and never instance or other extras', async t => { const { b, state, wait, refresh } = await setup(t); const hostile = ''; state.rows = [{ ...claim, thread: hostile, holder_run: '', harness: 'pi', instance: 'NEVER_INSTANCE', name: 'NEVER_THREAD_NAME', body: 'NEVER_MESSAGE_BODY', address: 'NEVER_ADDRESS' }]; await refresh(); await wait(`document.querySelector('.s1-thread-claims tbody')?.textContent.includes('onerror')`); const text = await b.evaluate(`document.querySelector('[aria-labelledby="b-thread-claims"]').textContent`); assert.ok(text.includes(hostile)); assert.ok(text.includes('')); assert.ok(text.includes('pi')); assert.doesNotMatch(text, /NEVER_INSTANCE|NEVER_THREAD_NAME|NEVER_MESSAGE_BODY|NEVER_ADDRESS/); assert.equal(await b.evaluate(`document.querySelectorAll('.s1-thread-claims tbody tr > *').length`), 4); assert.equal(await b.evaluate(`document.querySelectorAll('.s1-thread-claims script,.s1-thread-claims img,.s1-thread-claims b').length`), 0); assert.equal(await b.evaluate(`!!window.claimInjected`), false); }); test('row278 browser sign-out removes claims and prevents reuse of cached answers', async t => { const { b, state, wait, refresh } = await setup(t); state.rows = [claim]; await refresh(); await wait(`!!document.querySelector('.s1-thread-claims tbody tr')`); await b.evaluate(`window.claimFetch=fetch;window.fetch=(url,options)=>url==='/api/bus/thread.claims'?Promise.resolve(new Response(JSON.stringify({error:'sign-in required'}),{status:401})):window.claimFetch(url,options);true`); await refresh(); await wait(`document.body.classList.contains('signed-out') && !document.querySelector('#b-thread-claims')`); const calls = state.calls.length; await refresh(); assert.equal(state.calls.length, calls); // Simulate returning to the route while the bus is unavailable. A cache that // survived sign-out would repopulate the held thread from reader(false). state.offline = true; await b.evaluate(`window.fetch=window.claimFetch;document.body.classList.remove('signed-out');location.hash='#/agents?after-signout=1';true`); await wait(`document.querySelector('#bus-view h1')?.textContent==='The read failed'`); assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view').textContent`), new RegExp(THREAD)); }); test('row278 tester browser keeps role rows without requesting the owner claims section', async t => { const dataRoot = tmp(t, 'claims-view-'), calls = [], reads = []; const web = await startTesterSocket({ dataRoot, board: 'http://127.0.0.1:9', bus: { agents: async () => [{ role: 'coder', holder_run: 'role-run', harness: 'pi', at: AT }], messages: async () => ({ messages: [], unread: 0 }), 'thread.claims': async () => { calls.push('thread.claims'); return [claim]; }, } }); t.after(() => close(web)); const b = await testerBrowser(web); t.after(() => b.close()); b.on('Fetch.requestPaused', ({ request }) => { if (new URL(request.url).pathname === '/api/bus/thread.claims') reads.push(request.url); }); await b.navigate(TESTER_ORIGIN + '/#/agents'); await b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(document.querySelector('#tester-banner')&&document.querySelector('.s1-agents tbody tr'))return true;await new Promise(r=>setTimeout(r,30));}throw Error('tester Agents view did not render')})()`); assert.equal(await b.evaluate(`document.querySelector('#b-claims .count').textContent`), '1'); assert.equal(await b.evaluate(`document.querySelector('#b-thread-claims')===null`), true); assert.deepEqual(calls, []); assert.deepEqual(reads, []); }); test('row278 browser discards a late claims answer after sign-out', async t => { const { b, state, wait, refresh } = await setup(t); await b.evaluate(`window.claimFetch=fetch;window.fetch=async(url,options)=>{const r=await window.claimFetch(url,options);if(url==='/api/bus/thread.claims'){const json=r.json.bind(r);r.json=async()=>{const body=await json();window.lateClaimsFinished=true;return body;};}return r;};true`); state.rows = [claim]; state.hold = true; await refresh(); for (let n = 0; !state.release && n < 100; n++) await new Promise(resolve => setTimeout(resolve, 10)); assert.equal(typeof state.release, 'function'); await b.evaluate(`document.dispatchEvent(new Event('mosaic-signed-out')),true`); state.release(); state.hold = false; await wait(`window.lateClaimsFinished && document.body.classList.contains('signed-out') && !document.querySelector('#b-thread-claims')`); state.offline = true; await b.evaluate(`window.fetch=window.claimFetch;document.body.classList.remove('signed-out');location.hash='#/agents?late-answer=1';true`); await wait(`document.querySelector('#bus-view h1')?.textContent==='The read failed'`); assert.doesNotMatch(await b.evaluate(`document.querySelector('#bus-view').textContent`), new RegExp(THREAD)); });