Row 95, round 1. The Board's first section lists each role a run holds
on the bus (from /api/bus/agents), with a Message button on a Console
started with --send. Machine sessions sit behind a "Show machine
sessions" checkbox, off by default, kept in localStorage; while off the
page never reads /api/board. Every Console fetch passes
redirect: 'manual'; the first opaqueredirect signs the page out: timers
stop, reads are dropped, one line says to reload, and the send form
keeps its draft.
Candidate manifest 246c0557 (agents/filbert/work/queue-95/), base
a3f6e9e6, no drift on the manifest paths. Builder Filbert. Approved by
Darkwing (comment 27953). Mutants 30/35 killed, 5 redundant guards.
Sage's gate 2026-10-12T21:52:13Z-21:54:35Z: webui 86/0, control-board
124/0, every scripts/test-*.sh green.
Live on landing: serve.mjs reads src/public on every request with
no-store (decision 123). Mos gave the go (sage-0115).
Co-Authored-By: Claude Opus 5.5 <[email protected]>
352 lines
25 KiB
JavaScript
352 lines
25 KiB
JavaScript
// 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 { BusReadError } from '../src/bus.mjs';
|
|
import { browser } from './browser.mjs';
|
|
import { fixture, close } from './fixture.mjs';
|
|
|
|
const HOSTILE = '<script>window.injected=1</script><img src=x onerror="window.injected=2"> \u001b[31mred \u202eevil';
|
|
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') { window.__reads = (window.__reads || 0) + 1; 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'), /Replies to it show under the receipt\. The Console only shows them and never marks one delivered or read, so the next mosaic talk in a terminal still prints each one\./);
|
|
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);
|
|
// Row 95 (#1589): the redirect also signs the page out. One line says so; the form keeps its
|
|
// draft and receipt; nothing read before stays; no further Console fetch is made, and a Send
|
|
// is refused in the page.
|
|
await wait('!document.querySelector("#signed-out").hidden');
|
|
assert.equal(await text('#signed-out'), 'Signed out. Reload the page to sign in.');
|
|
assert.equal(await b.evaluate('document.body.classList.contains("signed-out")'), true);
|
|
assert.deepEqual(await b.evaluate('[...document.querySelector("#bus-view").children].map(e => e.id)'), ['send-section']);
|
|
assert.doesNotMatch(await b.evaluate('document.body.innerText'), /NetworkError|Failed to fetch|did not answer/);
|
|
assert.equal(await b.evaluate('document.querySelector("#refresh").disabled && document.querySelector("#pause").disabled'), true);
|
|
const reads = await b.evaluate('window.__reads');
|
|
await b.evaluate(`location.hash = '#/tasks', true`);
|
|
await send();
|
|
assert.equal(await receipt(), 'Not sent to coder: signed out; reload the page to sign in again (sign-in). The draft is kept.');
|
|
await new Promise(r => setTimeout(r, 500));
|
|
assert.equal(await b.evaluate('window.__reads'), reads);
|
|
assert.equal((await sends()).length, 3);
|
|
assert.equal(await b.evaluate('!!document.querySelector("#bus-view h1")'), false);
|
|
// Reloading signs in again (here, nothing redirects) and the page works as before.
|
|
await b.navigate(`${base}?again#/inbox`);
|
|
await wait('document.querySelector("#send-to option[value=coder]") && document.querySelector("#send-form [data-send]")');
|
|
assert.equal(await b.evaluate('document.querySelector("#signed-out").hidden'), true);
|
|
await set('#send-to', 'coder'); await set('#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, 4);
|
|
|
|
// 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, 5);
|
|
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, 5);
|
|
|
|
// 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())[5].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, 6);
|
|
} finally {
|
|
await b.close(); await close(web); await f.close(); store.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
// Row 81 (#1575): a sent receipt lists the replies to that message, read with `replies` on each
|
|
// render. The read writes no delivery or read row, so the human's next message.receive
|
|
// (mosaic talk) still gets every reply the page showed.
|
|
test('a sent receipt lists its replies as the page refreshes, oldest first and capped, and marks none delivered or read', { timeout: 120000 }, async () => {
|
|
const root = mkdtempSync(join(tmpdir(), 'webui-replies-browser-'));
|
|
const store = new Store(root), b0 = new Broker({ store, businesses });
|
|
const caps = {};
|
|
for (const role of ['pm', 'coder']) b0.request(caps[role] = 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 sent = b0.request(human, { verb: 'message.send', args: { to: 'coder', body: 'Please look at row 81.' } });
|
|
const other = b0.request(human, { verb: 'message.send', args: { to: 'pm', body: 'Another question' } });
|
|
const reader = b0.bindReader({ business: 'demo' });
|
|
// A broker refusal comes back from the transport as a BusReadError carrying its code. While
|
|
// `hold` is set, a replies read waits for it, counted in `held`.
|
|
let hold = null, held = 0;
|
|
const bus = views({
|
|
call: async (verb, args = {}) => {
|
|
if (verb === 'replies' && hold) { held++; await hold.promise; }
|
|
try { return structuredClone(b0.request(reader, { verb, args })); } catch (err) { throw new BusReadError(err.code); }
|
|
},
|
|
});
|
|
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, tries = 100) => b.evaluate(`(async()=>{for(let i=0;i<${tries};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 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 send = async (id, body) => {
|
|
await set('#send-body', body);
|
|
await b.evaluate(`window.__answers.push(${JSON.stringify({ status: 200, body: { id, at: new Date().toISOString() } })}), true`);
|
|
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 refresh = async () => {
|
|
await b.evaluate('(()=>{document.querySelector("#send-section").dataset.before="1";document.querySelector("#refresh").click();return true})()');
|
|
await wait('!document.querySelector("#send-section")?.dataset.before && !document.querySelector("#bus-view [aria-busy]")');
|
|
};
|
|
const items = () => b.evaluate('[...document.querySelectorAll("#send-section .s1-replies li")].map(li => [...li.querySelectorAll("p")].map(p => p.textContent).concat(li.querySelector("a").getAttribute("href")))');
|
|
const reply = (role, to, body) => b0.request(caps[role], { verb: 'message.send', args: { to: 'human', body, class: 'RESULT', ...(to ? { in_reply_to: to } : {}) } });
|
|
const deliveries = () => store.all('SELECT * FROM deliveries ORDER BY seq').map(d => ({ ...d }));
|
|
const NONE = 'No reply yet. One shows here when the role answers, as the page refreshes.';
|
|
try {
|
|
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: STUB });
|
|
await b.viewport(1280);
|
|
await b.navigate(`${base}#/inbox`);
|
|
await wait('document.querySelector("#send-form")');
|
|
await set('#send-to', 'coder');
|
|
|
|
// Straight after the send there is no reply.
|
|
await send(sent.id, 'Please look at row 81.');
|
|
assert.match(await text('#send-section .receipt'), new RegExp(`^Sent to coder .*, message ${sent.id.slice(0, 8)}\\. Its trail\\.$`));
|
|
assert.match(await text('#send-section'), new RegExp(NONE.replace(/\./g, '\\.')));
|
|
assert.deepEqual(await items(), []);
|
|
|
|
// Replies to the sent message show on Read again, oldest first, as text. A reply to another
|
|
// message and a message with no in_reply_to don't.
|
|
const first = reply('coder', sent.id, `Done: 3 files. ${HOSTILE}`);
|
|
reply('pm', other.id, 'Another answer');
|
|
reply('pm', null, 'fyi');
|
|
const second = reply('pm', sent.id, 'Second look.');
|
|
let before = deliveries();
|
|
await refresh();
|
|
assert.deepEqual(deliveries(), before, 'no delivered or read row');
|
|
assert.equal(await text('#send-section h3'), 'Replies 2');
|
|
const two = await items();
|
|
assert.deepEqual(two.map(([meta, , href]) => [meta.split(' ')[0], / · RESULT · trail$/.test(meta), href]), [['coder', true, `#/trail/message/${first.id}`], ['pm', true, `#/trail/message/${second.id}`]]);
|
|
assert.match(two[0][1], /^Done: 3 files\. <script>window\.injected=1<\/script><img src=x onerror="window\.injected=2">/);
|
|
assert.equal(two[1][1], 'Second look.');
|
|
assert.equal(await b.evaluate('!!window.injected || !!document.querySelector("#send-section script, #send-section img")'), false);
|
|
assert.match(await text('#send-section .receipt'), /^Sent to coder /);
|
|
assert.doesNotMatch(await text('#send-section'), /The first 50 replies/);
|
|
|
|
// The page's own refresh reads them too, with no click.
|
|
const third = reply('coder', sent.id, 'Third.');
|
|
before = deliveries();
|
|
await wait('document.querySelectorAll("#send-section .s1-replies li").length === 3', 300);
|
|
assert.deepEqual((await items()).map(i => i[1]).slice(1), ['Second look.', 'Third.']);
|
|
|
|
// Showing them wrote nothing: mosaic talk's message.receive still gets each one, and once
|
|
// the human has received and read them they are still listed.
|
|
assert.deepEqual(deliveries(), before, 'no delivered or read row');
|
|
const got = b0.request(human, { verb: 'message.receive' });
|
|
assert.deepEqual(got.filter(m => m.in_reply_to === sent.id).map(m => m.id), [first.id, second.id, third.id]);
|
|
for (const m of got) b0.request(human, { verb: 'message.read', args: { id: m.id } });
|
|
await refresh();
|
|
assert.equal((await items()).length, 3);
|
|
|
|
// The broker's cap: 51 replies show the first 50 and say so.
|
|
for (let i = 0; i < 48; i++) reply(i % 2 ? 'pm' : 'coder', sent.id, `reply ${i}`);
|
|
await refresh();
|
|
const capped = await items();
|
|
assert.equal(capped.length, 50);
|
|
assert.deepEqual([capped[0][1].slice(0, 5), capped[3][1], capped[49][1]], ['Done:', 'reply 0', 'reply 46']);
|
|
assert.equal(await text('#send-section h3'), 'Replies 50');
|
|
assert.match(await text('#send-section'), /The first 50 replies show here\./);
|
|
|
|
// A sent id the bus doesn't know: its replies can't be read, which the receipt says; the
|
|
// Inbox stays fresh.
|
|
await send('00000000-0000-4000-8000-000000000000', 'Lost?');
|
|
assert.match(await text('#send-section'), new RegExp(NONE.replace(/\./g, '\\.')));
|
|
await refresh();
|
|
assert.match(await text('#send-section'), /The replies to it could not be read\. Read again to retry; mosaic talk in a terminal prints them\./);
|
|
assert.deepEqual(await items(), []);
|
|
assert.equal(await b.evaluate('!!document.querySelector("#bus-view .s1-stale, #bus-view .banner")'), false);
|
|
assert.match(await text('#send-section .receipt'), /, message 00000000\. Its trail\.$/);
|
|
|
|
// A new send starts its own list.
|
|
await send(sent.id, 'Once more.');
|
|
assert.deepEqual(await items(), []);
|
|
await refresh();
|
|
assert.equal((await items()).length, 50);
|
|
|
|
// A send that finishes while a replies read is out keeps its own list: that read's answer is
|
|
// the earlier message's replies, so it is dropped and the next render reads the new one's.
|
|
let release;
|
|
hold = { promise: new Promise(r => { release = r; }) };
|
|
await b.evaluate('(()=>{document.querySelector("#bus-view h1").dataset.before="1";document.querySelector("#refresh").click();return true})()');
|
|
for (let i = 0; i < 100 && !held; i++) await new Promise(r => setTimeout(r, 50));
|
|
assert.equal(held, 1);
|
|
await send(other.id, 'Another?');
|
|
hold = null; release();
|
|
await wait('!document.querySelector("#bus-view h1")?.dataset.before && !document.querySelector("#bus-view [aria-busy]")');
|
|
assert.match(await text('#send-section .receipt'), new RegExp(`, message ${other.id.slice(0, 8)}\\. Its trail\\.$`));
|
|
assert.deepEqual(await items(), []);
|
|
assert.match(await text('#send-section'), new RegExp(NONE.replace(/\./g, '\\.')));
|
|
await refresh();
|
|
assert.deepEqual((await items()).map(i => i[1]), ['Another answer']);
|
|
} finally {
|
|
await b.close(); await close(web); await f.close(); store.close();
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
});
|