bus, cli, webui: every message to the human, read state, Messages page (#1588)
Row 94, round 2. Rocko built it, Dewey approved it (comment 28104) on manifest 53b01ea9 (16 paths, basee918a915). The bus keeps a messages view with read state, where delivered but not read stays read:false. The CLI gains messages and follow, and inbox leaves out messages already read while inbox --json stays byte-compatible. The Console gets a Messages page and an unread count in the nav. Mos gave the go under decision 123 (public/ files). Sage gate on645489e4plus the patch, 12:52:14Z to 12:55:44Z: bus 115, cli 129, webui 104, all fail 0. Every scripts/test-*.sh exited 0 (auth 15, conductor 17, config 24, discord 66, extension-package 18, foundation 44, queue 27, release 14, task 98). Manifest 16/16 before and after. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -276,7 +276,7 @@ delivery may be unknown: inspect the seat before sending again.
|
||||
## Inbox, tasks, agents and trails (slice 1 S5, #1522)
|
||||
|
||||
The Console sidebar adds Inbox, Tasks and Agents next to the control board,
|
||||
which stays at `#/`. Routes: `#/inbox`, `#/inbox/<decision>`, `#/tasks`,
|
||||
which stays at `#/`. Routes: `#/inbox`, `#/inbox/<decision>`, `#/messages`, `#/tasks`,
|
||||
`#/tasks/<vikunja:project/task>`, `#/agents`, `#/trail/task/<ref>`,
|
||||
`#/trail/decision/<id>` and `#/trail/message/<id>` (row 76), with `?kind=` filters on a trail. The design
|
||||
note is `agents/dewey/work/wui/SLICE1-VIEWS.md`.
|
||||
@@ -466,7 +466,8 @@ session reader or transport is implemented here. The session reader is
|
||||
|
||||
GET `/api/bus/inbox`, `/api/bus/tasks`, `/api/bus/agents`,
|
||||
`/api/bus/trail?subject=<id>`, `/api/bus/decisions?list=roles|recent`,
|
||||
`/api/bus/credentials` and `/api/bus/replies?id=<message id>` are the bus
|
||||
`/api/bus/credentials`, `/api/bus/replies?id=<message id>` and
|
||||
`/api/bus/messages?limit=50&after=<message id>` are the bus
|
||||
reads (`src/bus.mjs`). Any other method is 405. The subject and the id must
|
||||
match the broker's identifier rule, and the list must be `roles` or
|
||||
`recent`, else 400. `replies` (row 81, #1575) is the messages whose
|
||||
@@ -633,3 +634,28 @@ say-so, not a test result. Keep #1507 open pending that ruling.
|
||||
Rollback requires only stopping the WebUI and using the unchanged board at 7331.
|
||||
Revert the scoped WebUI commit to remove it; there is no data migration to undo.
|
||||
Seen and reply actions already taken belong to the board and are not rolled back.
|
||||
|
||||
## Messages (row 94, #1588)
|
||||
|
||||
The Messages page shows every message addressed to the human, newest first,
|
||||
including unsolicited messages and late replies. Each lists sender, class,
|
||||
time, read state, literal body and a trail link. It polls at the existing
|
||||
10-second cadence; pause and refresh work as on the other pages. Older
|
||||
messages and Newest messages links page through the history. The nav count
|
||||
is the whole feed's unread count, including messages beyond the visible
|
||||
page. If that count cannot be read, it is hidden; a bus refusal clears kept
|
||||
data as on the other pages. Reading this page marks nothing delivered or
|
||||
read. `mosaic follow` or `mosaic talk --follow` prints messages and marks
|
||||
them read in the terminal. The Console does not consume them.
|
||||
|
||||
The messages HTTP read accepts only limit (1–200, default 50) and after
|
||||
(a message id cursor); malformed, duplicate or unknown query keys refuse
|
||||
400 invalid-request. It returns rows plus total, unread, next and the
|
||||
Console read time at. Older read routes retain their rows/at response.
|
||||
The broker owns business scoping and cursor authorization.
|
||||
|
||||
`messages.test.mjs` exercises the route's paging/refusals and existing
|
||||
route response shapes, then the page against a temporary broker with
|
||||
a scheduled poll accelerated in the browser test. It proves literal
|
||||
content, late unsolicited arrival, pagination, full unread count and
|
||||
unchanged delivery receipts. No live bus data or service is used.
|
||||
|
||||
@@ -39,3 +39,6 @@ table.s1-table tbody th{font-weight:600;text-transform:none;letter-spacing:0;col
|
||||
@media(max-width:699px){.s1-trow{grid-template-columns:3.6em minmax(0,1fr)}.s1-ttext{grid-column:1/-1}}
|
||||
@media(max-width:959px){.s1-sections{display:flex;flex-wrap:wrap;flex-basis:100%;gap:6px}.s1-section{border-color:var(--border);border-radius:99px;padding:5px 12px}}
|
||||
@media (forced-colors:active){.s1-section[aria-current=page]{outline:2px solid Highlight}.s1-trow::before{background:CanvasText}}
|
||||
|
||||
/* Human message bodies keep their line breaks and wrap long literal text. */
|
||||
#message-list .s1-message { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
|
||||
@@ -34,12 +34,12 @@
|
||||
async function fetchRead(verb, subject) {
|
||||
if (out()) throw signedOut();
|
||||
let res, body;
|
||||
try { res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store', redirect: 'manual' }); }
|
||||
try { res = await fetch(`/api/bus/${verb}${subject === undefined ? '' : `?${verb === 'replies' ? 'id' : verb === 'messages' ? 'after' : 'subject'}=${enc(subject)}`}`, { cache: 'no-store', redirect: 'manual' }); }
|
||||
catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||||
if (res.type === 'opaqueredirect') { signOut(); throw signedOut(); }
|
||||
try { body = await res.json(); } catch { throw new ReadError('unreachable', 'The Console server did not answer.', 0); }
|
||||
if (!res.ok || !body || !Array.isArray(body.rows)) throw new ReadError(typeof body?.error === 'string' ? body.error : 'invalid-response', typeof body?.message === 'string' ? body.message : `HTTP ${res.status}`, res.status);
|
||||
return { rows: body.rows, at: body.at };
|
||||
return { ...body, rows: body.rows, at: body.at };
|
||||
}
|
||||
// Row 54 (#1543): the queue, business and settings reads (src/reads.mjs), GET only. Their
|
||||
// error carries the module's own message, which the server has already redacted.
|
||||
@@ -62,6 +62,7 @@
|
||||
if (live) { r = await fetchRead(verb, subject); last.set(k, r); }
|
||||
else if (!(r = last.get(k))) throw new ReadError('not-read', 'not read before');
|
||||
used.push(r.at);
|
||||
if (verb === 'messages') { messageCount = r.unread; return r; }
|
||||
return r.rows;
|
||||
};
|
||||
get.api = async path => {
|
||||
@@ -235,9 +236,9 @@
|
||||
const user = u => `user ${esc(u)}`;
|
||||
|
||||
// ---------------------------------------------------------------- views
|
||||
let inboxCount = null;
|
||||
let inboxCount = null, messageCount = null;
|
||||
function sections(route) {
|
||||
const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/settings', 'Settings', '', 'settings']];
|
||||
const items = [['/', 'Board', '', 'board'], ['/inbox', 'Inbox', inboxCount ? `<span class="count" aria-label="${inboxCount.open} open for you">${inboxCount.open}</span>${inboxCount.blocking ? ` ${blockChip(`${inboxCount.blocking} blocking`)}` : ''}` : '', 'inbox'], ['/messages', 'Messages', messageCount === null ? '' : `<span class="count" aria-label="${messageCount} unread messages">${messageCount}</span>`, 'inbox'], ['/tasks', 'Tasks', '', 'tasks'], ['/agents', 'Agents', '', 'agents'], ['/queue', 'Queue', '', 'queue'], ['/business', 'Business', '', 'business'], ['/settings', 'Settings', '', 'settings']];
|
||||
$('sections').innerHTML = items.map(([h, l, extra, icon]) => {
|
||||
const cur = h === '/' ? route === '/' : route.startsWith(h) || (h === '/tasks' && route.startsWith('/trail/task')) || (h === '/inbox' && (route.startsWith('/trail/decision') || route.startsWith('/trail/message')));
|
||||
return `<li><a href="#${h}" class="s1-section"${cur ? ' aria-current="page"' : ''}><svg class="i" aria-hidden="true"><use href="/icons.svg#i-${icon}"/></svg>${l}${extra ? ` ${extra}` : ''}</a></li>`;
|
||||
@@ -312,6 +313,17 @@
|
||||
<h2 id="b-for-you">For you <span class="count">${rows.length}</span></h2>${body}
|
||||
<p class="small muted">Not shown here yet: ${gap('Decisions routed to roles')} ${gap('Closed decisions')} ${gap('Seen and DM state per row')} Open a decision for its seen, DM and closing history.</p>`;
|
||||
}
|
||||
async function messagesView(get, q) {
|
||||
const after = q.get('after');
|
||||
if (after !== null && !SAFE.test(after)) return notFound('The message cursor is not a message id.');
|
||||
const page = await get('messages', after === null ? undefined : after);
|
||||
const body = page.rows.length ? `<ul id="message-list" class="waiting s1-replies">${page.rows.map(m =>
|
||||
`<li class="wait-item"><p class="small muted">${txt(m.from_role)} · ${txt(m.class)} · ${when(m.at)} · ${m.read ? 'Read' : 'Unread'} · <a href="#/trail/message/${enc(m.id)}">trail</a></p><p class="s1-message">${txt(m.body)}</p></li>`).join('')}</ul>`
|
||||
: empty('No messages to you.', 'Messages appear here as the bus is read.');
|
||||
return `${head('Messages', `${readNote()} Every message to you, newest first. Reading here marks nothing read.`)}
|
||||
<p class="small muted">${page.total} messages · ${page.unread} unread</p>${body}
|
||||
<nav aria-label="Message pages">${after ? '<a href="#/messages">Newest messages</a> · ' : ''}${page.next ? `<a data-older href="#/messages?after=${enc(page.next)}">Older messages</a>` : ''}</nav>`;
|
||||
}
|
||||
async function decisionView(get, id) {
|
||||
const open = (await get('inbox')).find(r => r.id === id) || null, d = await decision(get, id, open);
|
||||
if (!d) return notFound(`No decision with id ${txt(id)}.`);
|
||||
@@ -621,7 +633,7 @@
|
||||
// A refusal fails closed: no kept data, the code in mono. Any other failure keeps the last read, labelled with its time.
|
||||
const refusal = err => err.status === 403 || ['not-configured', 'no-bus-host', 'queue-refused'].includes(err.code);
|
||||
// After a refusal nothing read before is shown anywhere: not in a view, the palette or the Inbox count.
|
||||
function forget() { last.clear(); inboxCount = null; }
|
||||
function forget() { last.clear(); inboxCount = null; messageCount = null; }
|
||||
const failedView = err => `${head(failTitle(err))}<div class="banner ${refusal(err) ? 'ref' : 'err'}" role="alert"><p>${refusal(err) ? `<b>refused: <code>${txt(err.code)}</code>.</b> ` : ''}${why(err)} Console shows nothing rather than a guess. Nothing was written.</p>${retry}</div>${empty('Nothing to show.', refusal(err) ? 'The view stays empty until the problem above is fixed.' : err.module ? 'Read again.' : 'Read again, or come back when the bus answers.')}`;
|
||||
const staleBanner = err => `<div class="banner err s1-stale" role="alert"><p><b>The last read failed.</b> ${why(err)} This is what was read before, ${when(readAt)}. Nothing was changed.</p>${retry}</div>`;
|
||||
// Freshness line in the command bar for bus pages.
|
||||
@@ -642,6 +654,7 @@
|
||||
const [a, b, c] = parts, n = parts.length;
|
||||
if (a === 'inbox' && n === 1) return inboxView(get);
|
||||
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b) : notFound(`${txt(b)} is not a decision id.`);
|
||||
if (a === 'messages' && n === 1) return messagesView(get, q);
|
||||
if (a === 'tasks' && n === 1) return tasksView(get);
|
||||
if (a === 'tasks' && n === 2) return TASK.test(b) ? taskView(get, b) : notFound(`${txt(b)} is not a task reference.`);
|
||||
if (a === 'agents' && n === 1) return agentsView(get);
|
||||
@@ -690,15 +703,17 @@
|
||||
// The board page keeps its own title; the Inbox count still follows the bus.
|
||||
if (shown !== null) { shown = null; $('bus-view').innerHTML = ''; document.title = 'Mosaic Console'; if (navigated) $('main').focus(); }
|
||||
// The held roles (row 95) are read with it. A refusal of either forgets both, as on the bus pages.
|
||||
const [inbox, agents] = await Promise.allSettled([fetchRead('inbox'), fetchRead('agents')]);
|
||||
const failed = [inbox, agents].filter(s => s.status === 'rejected').map(s => s.reason);
|
||||
const [inbox, agents, messages] = await Promise.allSettled([fetchRead('inbox'), fetchRead('agents'), fetchRead('messages')]);
|
||||
const failed = [inbox, agents, messages].filter(s => s.status === 'rejected').map(s => s.reason);
|
||||
if (failed.some(err => err.code === 'signed-out')) return;
|
||||
if (inbox.status === 'fulfilled') last.set('inbox', inbox.value);
|
||||
if (agents.status === 'fulfilled') last.set('agents', agents.value);
|
||||
if (messages.status === 'fulfilled') last.set('messages', messages.value);
|
||||
const refused = failed.find(refusal);
|
||||
if (refused) forget();
|
||||
if (g !== gen) return;
|
||||
if (inbox.status === 'fulfilled' && !refused) countInbox(inbox.value.rows);
|
||||
if (messages.status === 'fulfilled' && !refused) messageCount = messages.value.unread;
|
||||
paintHeld(refused || (agents.status === 'rejected' ? agents.reason : null));
|
||||
sections(path); schedule();
|
||||
return;
|
||||
@@ -706,7 +721,12 @@
|
||||
if (navigated || shown !== route) { $('bus-view').innerHTML = loadingView(SOURCES[path.split('/')[1]]); }
|
||||
let html, state = 'fresh';
|
||||
const live = reader(true);
|
||||
try { html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
|
||||
try {
|
||||
if (path !== '/messages') {
|
||||
try { await live('messages'); }
|
||||
catch (err) { messageCount = null; if (err.status === 403 || err.code === 'signed-out') throw err; }
|
||||
}
|
||||
html = await view(live, path, new URLSearchParams(query)); readAt = live.oldest(); }
|
||||
catch (err) {
|
||||
if (err.code === 'signed-out') return;
|
||||
const kept = reader(false);
|
||||
@@ -771,7 +791,7 @@
|
||||
// Ctrl+K opens it. It lists the views, and the decisions and tasks from the last inbox and
|
||||
// tasks reads (opening it reads both again, GET only). Enter goes; Esc closes and puts focus
|
||||
// back where it was. It only navigates.
|
||||
const VIEWS = [['#/', 'Board'], ['#/inbox', 'Inbox'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/settings', 'Settings']];
|
||||
const VIEWS = [['#/', 'Board'], ['#/inbox', 'Inbox'], ['#/messages', 'Messages'], ['#/tasks', 'Tasks'], ['#/agents', 'Agents'], ['#/queue', 'Queue'], ['#/business', 'Business'], ['#/settings', 'Settings']];
|
||||
let pkAll = [], pkShown = [], pkAt = 0, pkBack = null;
|
||||
function pkItems() {
|
||||
const decisions = (last.get('inbox')?.rows || []).map(d => ({ href: `#/inbox/${enc(d.id)}`, label: first(d.question), group: `decision ${short(d.id)}` }));
|
||||
|
||||
@@ -49,7 +49,7 @@ async function body(req, max = 4096) {
|
||||
// Row 57 (#1546) adds `decisions`, also a read, for the Inbox tabs. Row 60
|
||||
// (#1549) adds `credentials`: what the bus host loaded, metadata only. Row 81
|
||||
// (#1575) adds `replies`: the messages answering one message, for the send receipt.
|
||||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies'];
|
||||
const BUS_VERBS = ['inbox', 'tasks', 'agents', 'trail', 'decisions', 'credentials', 'replies', 'messages'];
|
||||
const DECISION_LISTS = ['roles', 'recent'];
|
||||
async function busRead(res, bus, verb, search) {
|
||||
let arg;
|
||||
@@ -65,6 +65,25 @@ async function busRead(res, bus, verb, search) {
|
||||
arg = new URLSearchParams(search).get('id');
|
||||
if (!subjectOk(arg)) return json(res, 400, { error: 'invalid-request', message: 'id must be a message id' });
|
||||
}
|
||||
if (verb === 'messages') {
|
||||
const query = new URLSearchParams(search), args = {};
|
||||
const invalid = [...query.keys()].some(k => !['limit', 'after'].includes(k) || query.getAll(k).length !== 1);
|
||||
if (invalid || (query.has('limit') && (!/^[1-9]\d*$/.test(query.get('limit')) || Number(query.get('limit')) > 200)) ||
|
||||
(query.has('after') && !subjectOk(query.get('after'))))
|
||||
return json(res, 400, { error: 'invalid-request', message: 'messages takes limit (1–200) and an after message id' });
|
||||
if (query.has('limit')) args.limit = Number(query.get('limit'));
|
||||
if (query.has('after')) args.after = query.get('after');
|
||||
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||||
try {
|
||||
const page = await bus.messages(args);
|
||||
if (!page || !Array.isArray(page.messages)) throw new BusReadError('invalid-response');
|
||||
const { messages: rows, ...metadata } = page;
|
||||
return json(res, 200, { rows, ...metadata, at: new Date().toISOString() });
|
||||
} catch (err) {
|
||||
const code = err instanceof BusReadError ? err.code : 'invalid-response';
|
||||
return json(res, busStatus(code), { error: code, message: err instanceof BusReadError ? err.message : code });
|
||||
}
|
||||
}
|
||||
if (!bus) return json(res, 503, { error: 'not-configured', message: 'the Console has no bus configured' });
|
||||
try {
|
||||
const rows = await bus[verb](arg);
|
||||
|
||||
@@ -316,7 +316,7 @@ test('S5 pages in a browser: real broker rows, inert text, Copy, routes, failure
|
||||
assert.deepEqual(requests.filter(([m]) => m !== 'GET'), []);
|
||||
assert.ok(requests.some(([, u]) => u.startsWith('/api/bus/trail?subject=')));
|
||||
assert.ok(requests.some(([, u]) => u === '/api/task-fields'));
|
||||
assert.deepEqual([...new Set(s.calls)].sort(), ['agents', 'inbox', 'tasks', 'trail']);
|
||||
assert.deepEqual([...new Set(s.calls)].sort(), ['agents', 'inbox', 'messages', 'tasks', 'trail']);
|
||||
|
||||
// A Console with no bus configured says so, and its board still works.
|
||||
await b.navigate(`${f.base}/#/inbox`); await wait('document.querySelector("#bus-view h1")?.textContent==="No bus to read"');
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {broker} from '../../cli/tests/helpers.mjs';
|
||||
import {views} from '../../bus/src/views.mjs';
|
||||
import {startServer} from '../src/serve.mjs';
|
||||
import {close} from './fixture.mjs';
|
||||
import {browser} from './browser.mjs';
|
||||
import {BusReadError} from '../src/bus.mjs';
|
||||
|
||||
function seed(t){
|
||||
const s=broker(t),call=(verb,args={})=>s.b.request(s.reader,{verb,args});
|
||||
const bus=views({call:async(...args)=>structuredClone(call(...args))});
|
||||
const send=body=>s.b.request(s.coder,{verb:'message.send',args:{to:'human',body,class:'INFO'}}).id;
|
||||
return {...s,bus,send};
|
||||
}
|
||||
test('messages HTTP read pages and preserves metadata; bad arguments refuse before a read; other reads unchanged',async t=>{
|
||||
const s=seed(t),a=s.send('first'),b=s.send('second');
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:s.bus});t.after(()=>close(web));
|
||||
const url=`http://127.0.0.1:${web.address().port}/api/bus/`;
|
||||
const get=async path=>{const res=await fetch(url+path);return {status:res.status,body:await res.json()}};
|
||||
const page=await get('messages?limit=1');assert.equal(page.status,200);
|
||||
assert.deepEqual(page.body.rows.map(m=>m.id),[b]); assert.equal(page.body.total,2);assert.equal(page.body.unread,2);assert.equal(page.body.next,b);
|
||||
const next=await get(`messages?after=${b}&limit=1`);assert.deepEqual(next.body.rows.map(m=>m.id),[a]);assert.equal(next.body.next,null);
|
||||
for(const query of ['limit=0','limit=201','limit=1.5','limit=no','limit=','after=','after=%3Cb%3E','limit=2&limit=3','after=x&after=y','extra=1']){
|
||||
const res=await get('messages?'+query);assert.equal(res.status,400,query);assert.equal(res.body.error,'invalid-request');
|
||||
}
|
||||
for(const verb of ['inbox','tasks','agents','replies?id='+a,'decisions?list=recent','credentials','trail?subject='+a]){
|
||||
const res=await get(verb);assert.equal(res.status,200,verb);assert.deepEqual(Object.keys(res.body).sort(),['at','rows']);
|
||||
const [name,query='']=verb.split('?'),params=new URLSearchParams(query);
|
||||
const args=Object.fromEntries(params);
|
||||
assert.deepEqual(res.body.rows,structuredClone(s.b.request(s.reader,{verb:name,args})),verb);
|
||||
assert.match(res.body.at,/^\d{4}-.*Z$/);
|
||||
}
|
||||
});
|
||||
test('Console Messages lists late unsolicited messages on polling with sender class time read state and trail; nav count includes beyond the page; reads never deliver', {timeout:45000},async t=>{
|
||||
const s=seed(t),early=s.send('early');
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus:s.bus});
|
||||
const b=await browser({machine:false});
|
||||
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,25))}throw Error('condition timed out')})()`);
|
||||
try{
|
||||
// Reach a real scheduled poll without making a test wait ten seconds.
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:'const originalTimeout=window.setTimeout; window.setTimeout=(fn,ms,...args)=>originalTimeout(fn,ms===10000?100:ms,...args);'});
|
||||
await b.navigate(`http://127.0.0.1:${web.address().port}/#/messages`);
|
||||
await wait('document.querySelector("#message-list")');
|
||||
const late=s.send('<img src=x onerror="window.injected=1"> late unsolicited');
|
||||
const before=s.store.all('SELECT * FROM deliveries');
|
||||
await wait('document.querySelector("#message-list")?.textContent.includes("late unsolicited")');
|
||||
assert.deepEqual(s.store.all('SELECT * FROM deliveries'),before);
|
||||
const items=await b.evaluate('[...document.querySelectorAll("#message-list li")].map(e=>({text:e.textContent,href:e.querySelector("a").getAttribute("href")}))');
|
||||
assert.match(items[0].text,/coder.*INFO.*Unread/s);assert.match(await b.evaluate('document.querySelector("#message-list time").getAttribute("datetime")'), /^\d{4}-.*Z$/);
|
||||
await b.viewport(400,900);
|
||||
assert.equal(await b.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth'),true);
|
||||
if(process.env.ROCKO_MESSAGES_SHOT) await b.screenshot(process.env.ROCKO_MESSAGES_SHOT);
|
||||
assert.equal(items[0].href,`#/trail/message/${late}`);assert.equal(items[1].href,`#/trail/message/${early}`);
|
||||
assert.equal(await b.evaluate('!!window.injected || !!document.querySelector("#message-list img")'),false);
|
||||
for(let i=0;i<52;i++)s.send(`backlog ${i}`);
|
||||
await wait(`document.querySelector('#sections a[href="#/messages"]')?.textContent.includes("54")`);
|
||||
await wait('document.querySelectorAll("#message-list li").length===50');
|
||||
assert.ok(await b.evaluate('!!document.querySelector("#bus-view a[data-older]")'));
|
||||
await b.evaluate('document.querySelector("#bus-view a[data-older]").click(),true');
|
||||
await wait('document.querySelectorAll("#message-list li").length===4');
|
||||
assert.match(await b.evaluate('document.querySelector("#message-list").textContent'),/late unsolicited/);
|
||||
const got=s.b.request(s.human,{verb:'message.receive'});
|
||||
for(const m of got)s.b.request(s.human,{verb:'message.read',args:{id:m.id}});
|
||||
await wait('document.querySelector("#message-list")?.textContent.includes("Read") && !document.querySelector("#message-list")?.textContent.includes("Unread")');
|
||||
assert.equal(await b.evaluate(`document.querySelector('#sections a[href="#/messages"] .count').textContent`),'0');
|
||||
}finally{await b.close();await close(web)}
|
||||
});
|
||||
|
||||
|
||||
test('Messages nav count polls on Tasks and Board and clears on a Board refusal; messages 403 refuses local pages', {timeout:45000}, async t=>{
|
||||
const s=seed(t);s.send('one');s.send('two');
|
||||
let messagesFailure=null;
|
||||
const bus={...s.bus,messages:async args=>{if(messagesFailure)throw new BusReadError(messagesFailure);return s.bus.messages(args);}};
|
||||
const at=()=>new Date().toISOString(), localReads=[];
|
||||
const reads={
|
||||
queue:async()=>{localReads.push('queue');return {rows:[],notes:[],at:at()};},
|
||||
business:async()=>{localReads.push('business');return {business:{id:'demo',human:'jason',arbiters:{},projects:[],vars:{},instances:[],actions:[],credentials:[]},at:at()};},
|
||||
settings:async()=>{localReads.push('settings');return {release:'0.0.1',business:'demo',notifier:{binding:null},at:at()};},
|
||||
};
|
||||
const web=await startServer({port:0,board:'http://127.0.0.1:9',bus,reads});
|
||||
const b=await browser({machine:false});
|
||||
const wait=expr=>b.evaluate(`(async()=>{for(let i=0;i<200;i++){if(${expr})return true;await new Promise(r=>setTimeout(r,25))}throw Error('condition timed out: '+${JSON.stringify(expr)})})()`);
|
||||
const badge=`document.querySelector('#sections a[href="#/messages"] .count')`;
|
||||
try{
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument',{source:'const originalTimeout=window.setTimeout; window.setTimeout=(fn,ms,...args)=>originalTimeout(fn,ms===10000?100:ms,...args);'});
|
||||
await b.navigate(`http://127.0.0.1:${web.address().port}/#/tasks`);
|
||||
await wait(`document.querySelector('#bus-view h1')?.textContent==='Tasks' && ${badge}?.textContent==='2'`);
|
||||
s.send('three');await wait(`${badge}?.textContent==='3'`);
|
||||
await b.evaluate('location.hash="#/",true');
|
||||
await wait(`document.querySelector('#bus-view').hidden && document.querySelector('#held')?.textContent.includes('coder') && ${badge}?.textContent==='3'`);
|
||||
s.send('four');await wait(`${badge}?.textContent==='4'`);
|
||||
messagesFailure='human-required';
|
||||
await wait(`!${badge} && document.querySelector('#held')?.textContent.includes('refused')`);
|
||||
messagesFailure=null;await wait(`${badge}?.textContent==='4'`);
|
||||
for(const [path,title] of [['queue','Queue'],['business','Business demo'],['settings','Settings']]){
|
||||
await b.evaluate(`location.hash=${JSON.stringify('#/'+path)},true`);
|
||||
await wait(`document.querySelector('#bus-view h1')?.textContent===${JSON.stringify(title)} && ${badge}?.textContent==='4'`);
|
||||
const before=localReads.length;
|
||||
messagesFailure='human-required';
|
||||
await wait(`!!document.querySelector('#bus-view .banner.ref') && !${badge}`);
|
||||
assert.match(await b.evaluate('document.querySelector("#bus-view h1").textContent'),/Bus refused the read/);
|
||||
assert.equal(localReads.length,before,'a refused messages pre-read prevents the local read');
|
||||
messagesFailure=null;
|
||||
await wait(`document.querySelector('#bus-view h1')?.textContent===${JSON.stringify(title)} && ${badge}?.textContent==='4'`);
|
||||
}
|
||||
}finally{await b.close();await close(web)}
|
||||
});
|
||||
@@ -263,7 +263,7 @@ test('shell in a browser: System default, seeded states, 400px, focus and the Ct
|
||||
await flat(); await shot(`bus-empty-${h1.toLowerCase()}-400`);
|
||||
}
|
||||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#fresh-board")).display'), 'none');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 7);
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 8);
|
||||
for (const sel of ['#fresh', '#session-count']) assert.equal(await b.evaluate(`getComputedStyle(document.querySelector(${JSON.stringify(sel)})).fontVariantNumeric`), 'tabular-nums', sel);
|
||||
// Before any copy the toast is an empty live region still in the accessibility tree, out of sight.
|
||||
assert.deepEqual(await b.evaluate('(()=>{const t=document.querySelector("#toast"),c=getComputedStyle(t),r=t.getBoundingClientRect();return [c.display!=="none",c.visibility,t.textContent,r.width<=1&&r.height<=1]})()'), [true, 'visible', '', true]);
|
||||
@@ -459,7 +459,7 @@ test('five states on Board, Inbox, Tasks, Agents and Trail', { timeout: 180000 }
|
||||
}
|
||||
|
||||
// After a refusal the palette lists only the views: no kept decision or task.
|
||||
const views = ['#/', '#/inbox', '#/tasks', '#/agents', '#/queue', '#/business', '#/settings'];
|
||||
const views = ['#/', '#/inbox', '#/messages', '#/tasks', '#/agents', '#/queue', '#/business', '#/settings'];
|
||||
const palette = '[...document.querySelectorAll("#cmdk-list [data-href]")].map(e=>e.dataset.href)';
|
||||
await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1');
|
||||
assert.match(await text('#sections'), /Inbox 1 1 blocking/);
|
||||
|
||||
@@ -56,7 +56,7 @@ async function page(b, base) {
|
||||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: PROBE });
|
||||
await b.viewport(400, 900);
|
||||
await b.navigate(base);
|
||||
await wait('document.querySelectorAll("#sections a").length===7');
|
||||
await wait('document.querySelectorAll("#sections a").length===8');
|
||||
return { wait, text, count, flat, refresh, go };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user