// Row 76 (#1570): the Inbox's "Message a role" box in a browser. A send passes only with the // proxy's https Origin, which a loopback page can't have, so a script added before the page // loads answers /api/config with send on and answers each POST /api/bus/message from a queue, // recording what the page sent. A role's run is ended in the broker to show a role no longer held. Server-side checks are send.test.mjs's. The roles and the // message trail come from an in-process broker through its reader session. The same script // answers the board's /api/reply from its own queue while one is queued, for the Reply receipt. 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: [] } }, }, }, }; // Answers in order; `hold` waits for window.__release(), `reject` is a network failure, `redirect` // what fetch gives for the sign-in proxy's redirect under redirect: 'manual' (type opaqueredirect, status 0). const STUB = `(() => { const real = window.fetch.bind(window); window.__sends = []; window.__answers = []; window.__replies = []; window.fetch = async (url, init = {}) => { const u = String(url); if (u === '/api/reply' && window.__replies.length) { const a = window.__replies.shift(); return new Response(JSON.stringify(a.body), { status: a.status, headers: { 'content-type': 'application/json' } }); } if (u === '/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 (u !== '/api/bus/message') return real(url, init); window.__sends.push({ method: init.method, headers: init.headers, body: init.body, redirect: init.redirect }); const a = window.__answers.shift(); if (!a) throw new TypeError('no answer queued'); if (a.hold) await new Promise(r => { window.__release = r; }); if (a.reject) throw new TypeError('Failed to fetch'); if (a.redirect) return { type: 'opaqueredirect', ok: false, status: 0, json: async () => { throw new SyntaxError('no body'); } }; return new Response(JSON.stringify(a.body), { status: a.status, headers: { 'content-type': 'application/json' } }); }; })();`; test('the send box: held roles, the draft and caret across a refresh, each receipt, one POST per Send, and the trail link; the board Reply on a 4xx and a 5xx', { timeout: 120000 }, async () => { const root = mkdtempSync(join(tmpdir(), 'webui-send-browser-')); const store = new Store(root), b0 = new Broker({ store, businesses }); for (const role of ['pm', 'coder']) b0.request(b0.bindLaunch({ business: 'demo', role, run: `${role}-run`, harness: 'pi', address: `${role}-run` }), { verb: 'role.claim' }); const human = b0.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true }); const seeded = b0.request(human, { verb: 'message.send', args: { to: 'coder', body: 'Seeded message from the human' } }); 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(); const web = await startServer({ port: 0, board: f.boardURL, bus }), base = `http://127.0.0.1:${web.address().port}/`; 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 value = sel => b.evaluate(`document.querySelector(${JSON.stringify(sel)})?.value ?? null`); const set = (sel, v) => b.evaluate(`(()=>{const e=document.querySelector(${JSON.stringify(sel)});e.value=${JSON.stringify(v)};e.dispatchEvent(new Event('input',{bubbles:true}));return true})()`); const answer = a => b.evaluate(`window.__answers.push(${JSON.stringify(a)}), true`); const send = async () => { await b.evaluate('document.querySelector("#send-form [data-send]").click(), true'); await wait('document.querySelector("#send-section .receipt") && document.querySelector("#send-form [data-send]").getAttribute("aria-disabled") === "false"'); }; const sends = () => b.evaluate('window.__sends'); const receipt = () => text('#send-section .receipt'); const msg = 'Please look at row 76.'; try { await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB }); await b.viewport(1280); // The board's Reply: a 4xx was refused before anything ran, so "Not sent."; a 5xx or a 408 // leaves delivery unknown. None reaches the board, and the draft stays. await b.navigate(base); await wait('document.querySelector("table.sessions [data-open=\'proj/agent1\']")'); await b.evaluate('document.querySelector("table.sessions [data-open=\'proj/agent1\']").click(), true'); await wait('document.querySelector("#reply")'); await b.evaluate('document.querySelector("#reply").focus(), true'); await b.call('Input.insertText', { text: 'reply draft' }); for (const [status, error, said] of [[403, 'refused here', 'failed: refused here. Not sent.'], [502, 'board unreachable', 'failed: board unreachable. Delivery may be unknown; check the seat before sending again.'], [408, 'request timeout', 'failed: request timeout. Delivery may be unknown; check the seat before sending again.']]) { await b.evaluate(`window.__replies.push(${JSON.stringify({ status, body: { error } })}), true`); await b.evaluate('document.querySelector(".reply-form").requestSubmit(), true'); await wait(`document.querySelector("#inspection .receipt")?.textContent === ${JSON.stringify(said)}`); assert.equal(await value('#reply'), 'reply draft'); } assert.equal(f.captures.length, 0); // Without send in /api/config there is no box. await b.navigate(`${base}?nosend#/inbox`); await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Inbox") && !document.querySelector("#bus-view [aria-busy]")'); assert.equal(await b.evaluate('!!document.querySelector("#send-section")'), false); await b.navigate(`${base}#/inbox`); await wait('document.querySelector("#send-form")'); assert.equal(await text('#send-section h2'), 'Message a role'); assert.match(await text('#send-section'), /does not show yet\. The next mosaic talk in a terminal prints it, after sending its own message\./); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#send-to option")].map(o => [o.value, o.textContent])'), [['', 'Choose a held role'], ['coder', 'coder'], ['pm', 'pm']]); // Nothing to send is refused in the page, without a request. await send(); assert.match(await receipt(), /^Not sent to no role: choose a role first/); await set('#send-to', 'coder'); await send(); assert.match(await receipt(), /^Not sent to coder: the message is empty/); assert.deepEqual(await sends(), []); // The draft, focus and caret survive a refresh, which redraws the form. await set('#send-body', msg); await b.evaluate('(()=>{const e=document.querySelector("#send-body");e.dataset.before="1";e.focus();e.setSelectionRange(3,7);document.querySelector("#refresh").click();return true})()'); await wait('!document.querySelector("#send-body")?.dataset.before && !document.querySelector("#bus-view [aria-busy]")'); assert.deepEqual([await value('#send-to'), await value('#send-body')], ['coder', msg]); assert.deepEqual(await b.evaluate('(()=>{const e=document.querySelector("#send-body");return {focus:document.activeElement===e,start:e.selectionStart,end:e.selectionEnd}})()'), { focus: true, start: 3, end: 7 }); // A role that stops being held stays selected, marked, so the draft isn't moved to another role. await set('#send-to', 'pm'); b0.endLaunch({ business: 'demo', run: 'pm-run', reason: 'exited' }); await b.evaluate('(()=>{document.querySelector("#send-body").dataset.before="1";document.querySelector("#refresh").click();return true})()'); await wait('!document.querySelector("#send-body")?.dataset.before && !document.querySelector("#bus-view [aria-busy]")'); assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#send-to option")].map(o => [o.value, o.textContent, o.selected])'), [['', 'Choose a held role', false], ['coder', 'coder', false], ['pm', 'pm (not held now)', true]]); await set('#send-to', 'coder'); // A refusal keeps the draft and says why. await answer({ status: 400, body: { error: 'unknown-role', message: 'not sent: the bus refused it (unknown-role)', sent: false } }); await send(); assert.equal(await receipt(), 'Not sent to coder: the bus refused it (unknown-role). The draft is kept.'); assert.equal(await value('#send-body'), msg); const [first] = await sends(); assert.deepEqual(first, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ to: 'coder', body: msg }), redirect: 'manual' }); // A 503 the server marked not sent is a refusal too; an unknown outcome and a lost answer are unknown. await answer({ status: 503, body: { error: 'no-bus-host', message: 'not sent: the bus refused it (no-bus-host)', sent: false } }); await send(); assert.equal(await receipt(), 'Not sent to coder: the bus refused it (no-bus-host). The draft is kept.'); // A redirect is the sign-in proxy's (the Console never redirects this route): not sent. await answer({ redirect: true }); await send(); assert.equal(await 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 value('#send-body'), msg); // A 408 too: a proxy can answer it after forwarding. for (const a of [{ status: 503, body: { error: 'outcome-unknown', message: 'the message may have been sent', sent: 'unknown' } }, { reject: true }, { status: 200, body: { at: 'x' } }, { status: 408, body: { error: 'request timeout' } }]) { await answer(a); await send(); assert.match(await receipt(), /^Outcome unknown: the message to coder may have been sent\. Nothing was sent again, and the draft is kept\./, JSON.stringify(a)); assert.equal(await value('#send-body'), msg); assert.equal(await b.evaluate('!!document.querySelector("#send-section .receipt a")'), false); } // Each Send was one POST; none was repeated after the unknown ones. await new Promise(r => setTimeout(r, 500)); assert.equal((await sends()).length, 7); // While a send is pending, Send is disabled and a second press sends nothing; text typed // meanwhile is kept when the first one succeeds. await answer({ hold: true, status: 200, body: { id: seeded.id, at: new Date().toISOString() } }); await b.evaluate('document.querySelector("#send-form [data-send]").click(), true'); await wait('document.querySelector("#send-form [data-send]").getAttribute("aria-disabled") === "true"'); assert.equal(await text('#send-form [data-send]'), 'Sending…'); await b.evaluate('document.querySelector("#send-form [data-send]").click(), true'); await set('#send-body', 'A second thought'); assert.equal((await sends()).length, 8); await b.evaluate('window.__release(), true'); await wait('document.querySelector("#send-section .receipt") && document.querySelector("#send-form [data-send]").getAttribute("aria-disabled") === "false"'); assert.match(await receipt(), new RegExp(`^Sent to coder .*, message ${seeded.id.slice(0, 8)}\\. Its trail\\.$`)); assert.equal(await value('#send-body'), 'A second thought'); assert.equal((await sends()).length, 8); // A plain success clears the text and keeps the role. await answer({ status: 200, body: { id: seeded.id, at: new Date().toISOString() } }); await send(); assert.deepEqual([await value('#send-to'), await value('#send-body')], ['coder', '']); assert.equal(JSON.parse((await sends())[8].body).body, 'A second thought'); // The receipt links the message's trail, which the bus reads. assert.equal(await b.evaluate('document.querySelector("#send-section .receipt a").getAttribute("href")'), `#/trail/message/${seeded.id}`); await b.evaluate('document.querySelector("#send-section .receipt a").click(), true'); await wait('document.querySelector("#bus-view h1")?.textContent.startsWith("Trail for message") && !document.querySelector("#bus-view [aria-busy]")'); assert.equal(await text('#bus-view h1'), `Trail for message ${seeded.id.slice(0, 8)}`); assert.match(await text('#bus-view'), /Seeded message from the human/); assert.equal(await b.evaluate('document.querySelector("#bus-view a[href=\'#/inbox\']")?.textContent'), 'Back to the Inbox'); assert.equal(await b.evaluate('document.querySelector("#sections a[aria-current=page]").getAttribute("href")'), '#/inbox'); await b.evaluate(`location.hash = '#/trail/message/no-such-message', true`); await wait('document.querySelector("#bus-view h1")?.textContent === "Not found"'); assert.match(await text('#bus-view'), /No message with id no-such-message\./); assert.equal((await sends()).length, 9); } finally { await b.close(); await close(web); await f.close(); store.close(); rmSync(root, { recursive: true, force: true }); } });