// Row 95 (#1589): the Board leads with the stack's held roles, from the bus `agents` read, and // keeps the machine scan's sessions behind "Show machine sessions", off by default and kept in // the page's settings. A redirect answer on any Console fetch is the sign-in proxy's: the page // stops and says, in one line, to reload and sign in. The roles come from an in-process broker // through its reader session. A script added before the page loads records every fetch the page // makes, answers /api/config with send on (unless the URL has ?nosend), answers POST // /api/bus/message from a queue, and answers any path in window.__redirect the way fetch does // under redirect: 'manual' (type opaqueredirect, status 0). import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Store } from '../../bus/src/store.mjs'; import { Broker } from '../../bus/src/broker.mjs'; import { views } from '../../bus/src/views.mjs'; import { startServer } from '../src/serve.mjs'; import { browser } from './browser.mjs'; import { fixture, close } from './fixture.mjs'; const businesses = { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'cto', delivery: 'pm' }, roles: { pm: { authority: { withinRole: ['message.send'], crossRole: [] } }, cto: { authority: { withinRole: ['message.send'], crossRole: [] } }, coder: { authority: { withinRole: ['message.send'], crossRole: [] } }, }, }, }; const STUB = `(() => { const real = window.fetch.bind(window); window.__reqs = []; window.__answers = []; window.__redirect = []; const redirect = () => ({ type: 'opaqueredirect', ok: false, status: 0, json: async () => { throw new SyntaxError('no body'); } }); window.fetch = async (url, init = {}) => { const u = String(url), path = u.split('?')[0]; window.__reqs.push({ path, redirect: init.redirect ?? null }); if (window.__redirect.includes(path)) return redirect(); if (path === '/api/config' && !location.search.includes('nosend')) { const c = await (await real(url, init)).json(); return new Response(JSON.stringify({ ...c, send: true }), { headers: { 'content-type': 'application/json' } }); } if (path !== '/api/bus/message') return real(url, init); const a = window.__answers.shift(); if (!a) throw new TypeError('no answer queued'); if (a.redirect) return redirect(); return new Response(JSON.stringify(a.body), { status: a.status, headers: { 'content-type': 'application/json' } }); }; })();`; async function setup(machine) { const root = mkdtempSync(join(tmpdir(), 'webui-board-held-')); const store = new Store(root), b0 = new Broker({ store, businesses }); // pm's run has an address; coder's claim has none, which the Board still lists. b0.request(b0.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi', address: 'pm-run' }), { verb: 'role.claim' }); b0.request(b0.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'claude-code' }), { verb: 'role.claim' }); const reader = b0.bindReader({ business: 'demo' }); const bus = views({ call: async (verb, args = {}) => structuredClone(b0.request(reader, { verb, args })) }); const f = await fixture(), b = await browser({ machine }); const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`; await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB }); await b.viewport(1280); const wait = expr => b.evaluate(`(async()=>{for(let i=0;i<100;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,50))}throw new Error('Condition timed out: '+${JSON.stringify(expr)})})()`); const text = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent ?? null`); const shown = sel => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});return !!e && !e.hidden && getComputedStyle(e).display !== 'none' && !!e.getClientRects().length})()`); const reqs = () => b.evaluate('window.__reqs'); const settle = () => new Promise(r => setTimeout(r, 600)); const done = async () => { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); }; return { b, base, wait, text, shown, reqs, settle, done }; } const HELD = 'document.querySelectorAll("#held table tbody tr").length === 2'; const SESSIONS = 'document.querySelector("table.sessions [data-open=\'proj/agent1\']")'; test('the Board: held roles with no machine sessions, a claim with no address, the toggle kept across a reload, and Message addressed to the role', { timeout: 120000 }, async () => { const { b, base, wait, text, shown, reqs, settle, done } = await setup(false); try { // Off by default: the held roles show, the machine scan is never read. await b.navigate(base); await wait(HELD); const rows = await b.evaluate('[...document.querySelectorAll("#held table tbody tr")].map(r => [...r.cells].map(c => c.textContent))'); const byRole = Object.fromEntries(rows.map(r => [r[0], r])); assert.deepEqual(Object.keys(byRole).sort(), ['coder', 'pm']); assert.equal(byRole.coder[3], 'no address'); assert.equal(byRole.pm[3], 'pm-run'); assert.match(byRole.coder[4], /^coder-run/); assert.match(byRole.coder[1], /idle|waiting|working|unknown/); assert.equal(await text('#held-count'), '2'); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#held [data-message-role]")].map(e => e.getAttribute("aria-label")).sort()'), ['Message coder', 'Message pm']); assert.equal(await b.evaluate('document.querySelector("#show-machine").checked'), false); assert.match(await text('#status'), /^Machine sessions hidden\./); assert.equal(await shown('#board-view'), false); assert.equal(await shown('#board-tree'), false); await settle(); assert.equal((await reqs()).some(r => r.path === '/api/board'), false); assert.equal(await b.evaluate(`!!${SESSIONS}`), false); // On: the machine sessions are read and shown, and the choice is kept for the next load. await b.evaluate('document.querySelector("#show-machine").click(), true'); await wait(SESSIONS); assert.equal(await shown('#board-view'), true); assert.equal(await b.evaluate('localStorage.getItem("mosaic-console-board")'), '{"machine":true}'); await b.navigate(`${base}?on`); await wait(SESSIONS); assert.equal(await b.evaluate('document.querySelector("#show-machine").checked'), true); await wait(HELD); // Off again: the sessions go, the held roles stay, and the next load reads no board. await b.evaluate('document.querySelector("#show-machine").click(), true'); await wait(`!${SESSIONS}`); assert.equal(await shown('#board-view'), false); assert.equal(await shown('#held-section'), true); assert.equal(await b.evaluate('localStorage.getItem("mosaic-console-board")'), '{"machine":false}'); await b.navigate(`${base}?off`); await wait(HELD); assert.equal(await b.evaluate('document.querySelector("#show-machine").checked'), false); await settle(); assert.equal((await reqs()).some(r => r.path === '/api/board'), false); // Message opens the send form addressed to that role, with the caret in the message. for (const role of ['coder', 'pm']) { await b.evaluate(`location.hash = '#/', true`); await wait(HELD); await b.evaluate(`document.querySelector('#held [data-message-role="${role}"]').click(), true`); await wait(`location.hash === '#/inbox' && document.querySelector('#send-to')?.value === '${role}' && document.activeElement === document.querySelector('#send-body')`); assert.equal(await shown('#held-section'), false); } // Without send there is no Message action; the roles still show. await b.navigate(`${base}?nosend`); await wait(HELD); assert.equal(await b.evaluate('document.querySelectorAll("#held [data-message-role]").length'), 0); assert.match(await text('#held'), /--send/); // Every Console fetch the page made asked for redirect: 'manual'. assert.deepEqual((await reqs()).filter(r => r.redirect !== 'manual'), []); } finally { await done(); } }); test('a redirect answer on a Board read, a bus read, a conversation read, a board POST, a module read and a send signs the page out: one line, nothing stale, no further reads', { timeout: 120000 }, async () => { const { b, base, wait, text, shown, reqs, settle, done } = await setup(true); const LINE = 'Signed out. Reload the page to sign in.'; const out = async () => { await wait('!document.querySelector("#signed-out").hidden'); assert.equal(await text('#signed-out'), LINE); assert.equal(await b.evaluate('document.activeElement === document.querySelector("#signed-out") || document.activeElement?.closest?.("#send-section") !== null'), true); const page = await b.evaluate('document.body.innerText'); // No failure text from the redirected read either: the footer stays visible when signed out. assert.doesNotMatch(page, /NetworkError|Failed to fetch|did not answer|Board unreachable|[Nn]ot read|last known data/); assert.equal(page.split(LINE).length, 2); // Nothing read before stays on the page as if current. assert.equal(await b.evaluate(`!!${SESSIONS}`), false); assert.equal(await b.evaluate('document.querySelector("#held").children.length'), 0); for (const sel of ['#held-section', '#board-view', '#board-head', '#error']) assert.equal(await shown(sel), false, sel); assert.equal(await b.evaluate('document.querySelector("#refresh").disabled'), true); // No further Console fetch: not on a route change, a Refresh, the toggle or the poll. const n = (await reqs()).length; await b.evaluate(`location.hash = location.hash === '#/agents' ? '#/inbox' : '#/agents', true`); await b.evaluate('document.querySelector("#refresh").click(), document.querySelector("#show-machine").click(), document.querySelector("#show-machine").click(), true'); await settle(); assert.equal((await reqs()).length, n); assert.equal(await b.evaluate('!!document.querySelector("#bus-view h1")'), false); assert.deepEqual((await reqs()).filter(r => r.redirect !== 'manual'), []); }; try { // A Board read: the machine scan's /api/board, after a good first read. await b.navigate(base); await wait(`${SESSIONS} && ${HELD}`); await b.evaluate(`window.__redirect.push('/api/board'), document.querySelector('#refresh').click(), true`); await out(); // A bus read: the held roles' agents read, on a later Refresh. await b.navigate(`${base}?bus`); await wait(`${SESSIONS} && ${HELD}`); assert.equal(await b.evaluate('document.querySelector("#signed-out").hidden'), true); await b.evaluate(`window.__redirect.push('/api/bus/agents'), document.querySelector('#refresh').click(), true`); await out(); // A conversation read: History on a session asks for the catalogue. await b.navigate(`${base}?conv`); await wait(`${SESSIONS} && ${HELD}`); await b.evaluate(`window.__redirect.push('/api/conversations'), document.querySelector('table.sessions [data-history="proj/agent1"]').click(), true`); await out(); // A board POST: Seen in the inspector posts /api/seen. await b.navigate(`${base}?seen`); await wait(`${SESSIONS} && ${HELD}`); await b.evaluate(`document.querySelector('table.sessions [data-open="proj/agent1"]').click(), true`); await wait('document.querySelector("#inspection [data-seen]")'); await b.evaluate(`window.__redirect.push('/api/seen'), document.querySelector('#inspection [data-seen]').click(), true`); await out(); // A module read: the Queue page's /api/queue. await b.navigate(`${base}?queue`); await wait(`${SESSIONS} && ${HELD}`); await b.evaluate(`window.__redirect.push('/api/queue'), location.hash = '#/queue', true`); await out(); // A send: the receipt says it was not sent and keeps the draft; the form stays for the draft. await b.navigate(`${base}?send#/inbox`); await wait('document.querySelector("#send-to option[value=coder]") && document.querySelector("#send-form [data-send]")'); await b.evaluate(`(()=>{const t=document.querySelector('#send-to'),m=document.querySelector('#send-body');t.value='coder';t.dispatchEvent(new Event('input',{bubbles:true}));m.value='Kept draft';m.dispatchEvent(new Event('input',{bubbles:true}));window.__answers.push({redirect:true});document.querySelector('#send-form [data-send]').click();return true})()`); await wait('document.querySelector("#send-section .receipt")'); assert.match(await text('#send-section .receipt'), /^Not sent to coder: the sign-in proxy redirected it, so it never reached the Console; reload the page to sign in again \(sign-in\)\. The draft is kept\./); assert.equal(await b.evaluate('document.querySelector("#send-body").value'), 'Kept draft'); assert.equal(await shown('#send-section'), true); await out(); } finally { await done(); } });