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