387 lines
30 KiB
Diff
387 lines
30 KiB
Diff
--- a/packages/webui/README.md
|
||
+++ b/packages/webui/README.md
|
||
@@ -91,7 +91,9 @@
|
||
open sections and the copy status survive a refresh.
|
||
- A failed read keeps the last good answer for that read and says it is old,
|
||
with the time it was read. A refusal (403, `not-configured` or `no-bus-host`)
|
||
- never shows kept rows (row 53). A page never read, or refused, shows the failure instead,
|
||
+ never shows kept rows (row 53), and it drops every kept bus read: the Inbox
|
||
+ count and the palette's decisions and tasks go too, until a read succeeds.
|
||
+ A page never read, or refused, shows the failure instead,
|
||
titled "Bus refused the read" (403: the bus refused the Console's read, for
|
||
example `human-required` from a Console started inside an agent run), "No bus to read" (no system
|
||
config, or no bus host and no `--business`) or "The read failed".
|
||
@@ -120,12 +122,16 @@
|
||
and tasks; arrows move, Enter opens, Esc closes and returns focus. Arrow
|
||
keys also move between task links in the bus tables.
|
||
- Copy reports in a toast. If the clipboard is unavailable, the toast stays
|
||
- until dismissed and the command is selected for Ctrl+C.
|
||
-- The command bar shows when the board was scanned and the bus read, and
|
||
- says when the last attempt failed. A board refusal hides the board rather
|
||
- than showing the last scan.
|
||
+ until dismissed and the command is selected for Ctrl+C. When no toast shows,
|
||
+ the toast is an empty live region, visually hidden but still in the
|
||
+ accessibility tree, so the first copy is announced.
|
||
+- The command bar shows, in UTC, when the board was scanned and the bus read,
|
||
+ and says when the last attempt failed. A board refusal drops the last scan
|
||
+ and everything drawn from it: the table, the inspector, the project tree,
|
||
+ the counts, the footer and the scan line all say the board was not read.
|
||
- From 760px the footer is a one-line status bar and the side column fits
|
||
- between it and the command bar. Below 760px the section list wraps.
|
||
+ between it and the command bar. Below 760px the section list is one strip
|
||
+ that scrolls inside itself.
|
||
|
||
## Data and boundaries
|
||
|
||
@@ -204,11 +210,15 @@
|
||
|
||
Shell tests (`shell.test.mjs`) check the copies, the font hashes and the kept
|
||
strings, and in the browser at 400px: System mode under an emulated dark and
|
||
-light OS, the empty, stale and refused states of the board and the bus views,
|
||
-not found, the palette, the toast, keyboard and visible focus, no sideways
|
||
+light OS, the empty, stale and refused states of the board and the bus views
|
||
+(a board refusal with the inspector open), not found, the palette, the toast,
|
||
+keyboard and visible focus, the section strip at 360 and 400px, no sideways
|
||
scroll, no script error and no request other than GET. A second test walks
|
||
the Board, Inbox, Tasks, Agents and a decision trail through loading (a held
|
||
-read), normal, a failed read over kept rows, and a refusal.
|
||
+read), normal, a failed read over kept rows, and a refusal by the bus
|
||
+(`human-required`) and for want of one (`no-bus-host`). It then checks that a
|
||
+refusal empties the palette and the Inbox count, from a view, from the
|
||
+palette's own reads and from the board page, and that palette labels are text.
|
||
|
||
No root CI workflow is configured for this package. These local tests are not a
|
||
claim of CI, deployment, live-seat delivery or user acceptance.
|
||
--- a/packages/webui/src/public/app.js
|
||
+++ b/packages/webui/src/public/app.js
|
||
@@ -282,8 +282,8 @@
|
||
// Freshness line in the command bar: when the board last scanned, and warning words when the last refresh failed.
|
||
function fresh() {
|
||
const at = Date.parse(data?.generatedAt), ago = Number.isFinite(at) ? age(Math.max(0, Math.round((Date.now() - at) / 1000))) : null;
|
||
- if (!data) { $('fresh-board').innerHTML = failed ? 'Board <span class="warn">not read: the read failed</span>' : 'Board not read yet'; return; }
|
||
- const scan = `Board scanned <b>${esc(Number.isFinite(at) ? new Date(at).toLocaleTimeString([], { hour12: false }) : 'unknown')}</b>${ago ? ` (${ago} ago)` : ''}`;
|
||
+ if (!data) { $('fresh-board').innerHTML = failed === 'refused' ? 'Board <span class="warn">not read: refused</span>' : failed ? 'Board <span class="warn">not read: the read failed</span>' : 'Board not read yet'; return; }
|
||
+ const scan = `Board scanned <b>${esc(Number.isFinite(at) ? `${new Date(at).toISOString().slice(11, 19)} UTC` : 'unknown')}</b>${ago ? ` (${ago} ago)` : ''}`;
|
||
$('fresh-board').innerHTML = failed ? `${scan} · <span class="warn">stale: the last refresh failed</span>` : scan;
|
||
}
|
||
setInterval(() => { if (data) fresh(); }, 5000);
|
||
@@ -300,10 +300,20 @@
|
||
for (const id of awaiting.keys()) { const r = row(id); if (r && !pending(r)) awaiting.delete(id); }
|
||
render();
|
||
}
|
||
- // A refusal (403) fails closed: the last data is hidden, not shown as current. Any other failure keeps the last data with its scan time.
|
||
+ // A refusal (403) fails closed: the last data is dropped, not kept or shown as current, and so is
|
||
+ // everything drawn from it: the inspector, the project tree, the counts and the footer. Any other
|
||
+ // failure keeps the last data with its scan time.
|
||
function error(err) {
|
||
- failed = true; $('error').hidden = false;
|
||
const refused = err.status === 403;
|
||
+ failed = refused ? 'refused' : true; $('error').hidden = false;
|
||
+ if (refused) {
|
||
+ data = null; selected = null; project = null;
|
||
+ $('projects').innerHTML = '<p class="muted">Not read: the board refused the read.</p>';
|
||
+ for (const id of ['waiting-count', 'seen-count', 'session-count']) $(id).textContent = '–';
|
||
+ $('footer').textContent = 'Board not read: refused';
|
||
+ $('status').textContent = 'Not read: the board refused the read';
|
||
+ inspect(); convForm();
|
||
+ }
|
||
$('error').className = refused ? 'banner ref' : 'banner err';
|
||
$('board-refused').hidden = !refused; document.body.classList.toggle('board-refused', refused);
|
||
$('error').innerHTML = refused
|
||
--- a/packages/webui/src/public/bus.js
|
||
+++ b/packages/webui/src/public/bus.js
|
||
@@ -369,12 +369,14 @@
|
||
const failTitle = err => err.status === 403 ? 'Bus refused the read' : ['not-configured', 'no-bus-host'].includes(err.code) ? 'No bus to read' : 'The read failed';
|
||
// 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'].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; }
|
||
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.' : '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.
|
||
const stamp = iso => { const t = Date.parse(iso); return Number.isFinite(t) ? `${new Date(t).toISOString().slice(11, 19)} UTC` : 'unknown'; };
|
||
function freshBus(state) {
|
||
- $('fresh-bus').innerHTML = state === 'failed' ? 'Bus <span class="warn">not read: the read failed</span>'
|
||
+ $('fresh-bus').innerHTML = state === 'refused' ? 'Bus <span class="warn">not read: refused</span>' : state === 'failed' ? 'Bus <span class="warn">not read: the read failed</span>'
|
||
: `Bus read <b>${esc(stamp(readAt))}</b>${readAt ? ` (${esc(ago(readAt))})` : ''}${state === 'stale' ? ' · <span class="warn">stale: the last read failed</span>' : ''}`;
|
||
}
|
||
|
||
@@ -420,7 +422,8 @@
|
||
if (board) {
|
||
// 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(); }
|
||
- try { const r = await fetchRead('inbox'); last.set('inbox', r); if (g === gen) { countInbox(r.rows); sections(path); } } catch {}
|
||
+ try { const r = await fetchRead('inbox'); last.set('inbox', r); if (g === gen) { countInbox(r.rows); sections(path); } }
|
||
+ catch (err) { if (refusal(err)) { forget(); if (g === gen) sections(path); } }
|
||
if (g === gen) schedule();
|
||
return;
|
||
}
|
||
@@ -431,7 +434,8 @@
|
||
catch (err) {
|
||
const kept = reader(false);
|
||
try { if (refusal(err)) throw err; html = await view(kept, path, new URLSearchParams(query)); readAt = kept.oldest(); html = staleBanner(err) + html; state = 'stale'; }
|
||
- catch { html = failedView(err); state = 'failed'; }
|
||
+ catch { html = failedView(err); state = refusal(err) ? 'refused' : 'failed'; }
|
||
+ if (refusal(err)) forget();
|
||
}
|
||
if (g !== gen) return;
|
||
freshBus(state);
|
||
@@ -506,7 +510,10 @@
|
||
if (d.open) return;
|
||
pkBack = document.activeElement; pkAt = 0; $('cmdk-input').value = '';
|
||
pkItems(); pkFill(); d.showModal(); $('cmdk-input').focus();
|
||
- Promise.allSettled(['inbox', 'tasks'].map(async v => last.set(v, await fetchRead(v)))).then(() => { if (d.open) { pkItems(); pkFill(); } });
|
||
+ Promise.allSettled(['inbox', 'tasks'].map(async v => last.set(v, await fetchRead(v)))).then(rs => {
|
||
+ if (rs.some(r => r.status === 'rejected' && refusal(r.reason))) { forget(); sections(routeOf().split('?')[0]); }
|
||
+ if (d.open) { pkItems(); pkFill(); }
|
||
+ });
|
||
}
|
||
function pkGo(href) {
|
||
pkBack = null; $('cmdk-dialog').close();
|
||
--- a/packages/webui/src/public/shell.css
|
||
+++ b/packages/webui/src/public/shell.css
|
||
@@ -101,8 +101,9 @@
|
||
body.on-bus #board-refused{display:none}
|
||
|
||
/* Toast: one line, polite, bottom centre. */
|
||
-.toast{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);display:none;align-items:center;gap:10px;background:var(--strong);color:var(--surface);padding:6px 8px 6px 14px;border-radius:var(--r);font-size:13px;z-index:60;max-width:calc(100% - 24px);box-shadow:var(--shadow)}
|
||
-.toast.on{display:flex}
|
||
+.toast{position:fixed;left:50%;bottom:16px;transform:translateX(-50%);display:flex;align-items:center;gap:10px;background:var(--strong);color:var(--surface);padding:6px 8px 6px 14px;border-radius:var(--r);font-size:13px;z-index:60;max-width:calc(100% - 24px);box-shadow:var(--shadow)}
|
||
+/* Off, the toast stays in the accessibility tree, empty and visually hidden, so the first copy is announced. */
|
||
+.toast:not(.on){width:1px;height:1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;box-shadow:none}
|
||
.toast span{min-width:0;overflow-wrap:anywhere}
|
||
.toast.err{background:var(--danger)}
|
||
.toast button{font:inherit;font-weight:600;border:1px solid currentColor;background:none;color:inherit;border-radius:4px;padding:1px 8px;cursor:pointer}
|
||
@@ -164,8 +165,9 @@
|
||
.cmd-right{margin-left:0;flex:1 1 100%;flex-wrap:nowrap;min-width:0}
|
||
.cmd-right label{flex:1 1 0;min-width:0}
|
||
.cmd-right .btn{flex:none}
|
||
- /* The section list wraps rather than scrolling, so a blocking count never hides a section. */
|
||
- .s1-sections{display:flex;flex-wrap:wrap;overflow-x:visible;flex-basis:100%;gap:2px;padding-bottom:2px}
|
||
+ /* The section list is one strip that scrolls inside itself (IMPLEMENTING.md), so more sections never add rows. */
|
||
+ .s1-sections{display:flex;flex-wrap:nowrap;overflow-x:auto;flex-basis:100%;min-width:0;max-width:100%;gap:2px;padding-bottom:2px;scrollbar-width:thin}
|
||
+ .s1-sections>li{flex:none}
|
||
.s1-section{border-radius:var(--r);padding:5px 8px;white-space:nowrap;flex:none}
|
||
.inspector{inset:0;top:0;width:100%;z-index:40;box-shadow:none}
|
||
}
|
||
--- a/packages/webui/tests/shell.test.mjs
|
||
+++ b/packages/webui/tests/shell.test.mjs
|
||
@@ -35,17 +35,21 @@
|
||
});
|
||
|
||
test('shipped strings are unchanged, and the web keeps its write boundary', () => {
|
||
- // Markup in the source (<code>, <b>) is not part of the string.
|
||
+ // Markup in the source (<code>, <b>) is not part of the string. Each string is counted in the
|
||
+ // file that ships it, so dropping one of two copies fails too.
|
||
const src = ['app.js', 'bus.js', 'index.html'].map(f => read(f).toString()).join('\n');
|
||
- const plain = src.replace(/<\/?(code|b)>/g, '');
|
||
- for (const s of [
|
||
- 'Copied: ${text}',
|
||
- 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.',
|
||
- 'Decisions are answered in a terminal with mosaic decide; Console only shows them.',
|
||
- 'No task ${txt(ref)} in this business.', 'No decision with id ${txt(id)}.', '${txt(b)} is not a decision id.', '${txt(b)} is not a task reference.',
|
||
- 'No page at this address.', 'Nothing is waiting on you.', 'No tasks in this business yet.', 'Tasks appear after the PM creates them or the poller reads them from Vikunja.',
|
||
- 'No open tasks.', 'No role is held right now.', 'Nothing of this kind in the trail.', 'Console shows nothing rather than a guess. Nothing was written.',
|
||
- ]) assert.ok(plain.includes(s), s);
|
||
+ const plain = Object.fromEntries(['app.js', 'bus.js'].map(f => [f, read(f).toString().replace(/<\/?(code|b)>/g, '')]));
|
||
+ for (const [file, s, n = 1] of [
|
||
+ ['bus.js', 'Copied: ${text}'],
|
||
+ ['bus.js', 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.'],
|
||
+ ['bus.js', 'Decisions are answered in a terminal with mosaic decide; Console only shows them.'],
|
||
+ ['bus.js', 'No task ${txt(ref)} in this business.', 2], ['bus.js', 'No decision with id ${txt(id)}.'],
|
||
+ ['bus.js', '${txt(b)} is not a decision id.'], ['bus.js', '${txt(b)} is not a task reference.'],
|
||
+ ['bus.js', 'No page at this address.'], ['app.js', 'Nothing is waiting on you.'], ['bus.js', 'Nothing is waiting on you.'],
|
||
+ ['bus.js', 'No tasks in this business yet.'], ['bus.js', 'Tasks appear after the PM creates them or the poller reads them from Vikunja.'],
|
||
+ ['bus.js', 'No open tasks.'], ['bus.js', 'No role is held right now.'], ['bus.js', 'Nothing of this kind in the trail.'],
|
||
+ ['bus.js', 'Console shows nothing rather than a guess. Nothing was written.'],
|
||
+ ]) assert.equal(plain[file].split(s).length - 1, n, `${file}: ${s}`);
|
||
// No Resolve control and no bus write: the only POSTs are the board's Seen and reply.
|
||
assert.doesNotMatch(src, /<button[^>]*>[^<]*(Resolve|Decide|Approve)/i);
|
||
assert.doesNotMatch(read('bus.js').toString(), /method:\s*['"](POST|PUT|PATCH|DELETE)/);
|
||
@@ -121,7 +125,7 @@
|
||
// Board, empty: what would appear and where it comes from.
|
||
assert.match(await text('#sessions .empty'), /No sessions match these filters\.Sessions appear here when the board scan finds/);
|
||
assert.match(await text('#waiting .empty'), /Nothing is waiting on you\./);
|
||
- assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d/);
|
||
+ assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/);
|
||
await flat(); await shot('board-empty-400');
|
||
|
||
// Board, a failed read over kept data: the data stays, labelled with its scan time, and the bar says stale.
|
||
@@ -135,16 +139,30 @@
|
||
assert.match(await text('#fresh-board'), /stale: the last refresh failed/);
|
||
await flat(); await shot('board-stale-400');
|
||
|
||
- // Board, a refusal: fails closed, the kept rows are hidden, not shown as current.
|
||
+ // Board, a refusal: fails closed. The kept rows are hidden, and so is everything drawn from
|
||
+ // them: the open inspector, the project tree's counts and the footer.
|
||
+ await b.evaluate('document.querySelector("table.sessions [data-open]").click()'); await wait('!document.querySelector("#inspector").hidden');
|
||
+ assert.equal(await text('#footer'), 'waiting 1 · working 1');
|
||
+ assert.equal(await b.evaluate('document.querySelectorAll("#projects .count").length'), 2);
|
||
brd.refuse(); await refresh();
|
||
assert.equal(await b.evaluate('document.querySelector("#error").className'), 'banner ref');
|
||
assert.match(await text('#error'), /The board refused the read\. refused: board-refused/);
|
||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||
assert.equal(await b.evaluate('document.querySelector("#board-refused").hidden'), false);
|
||
+ assert.equal(await b.evaluate('document.querySelector("#inspector").hidden'), true);
|
||
+ assert.equal(await b.evaluate('document.body.classList.contains("has-inspector")'), false);
|
||
+ assert.equal(await b.evaluate('document.querySelectorAll("#projects .count, #projects [data-project]").length'), 0);
|
||
+ assert.equal(await text('#footer'), 'Board not read: refused');
|
||
+ assert.equal(await text('#fresh-board'), 'Board not read: refused');
|
||
+ assert.doesNotMatch(await text('#status'), /Scanned/);
|
||
await flat(); await shot('board-refused-400');
|
||
brd.one(); await refresh();
|
||
assert.equal(await b.evaluate('document.querySelector("#error").hidden'), true);
|
||
assert.notEqual(await b.evaluate('getComputedStyle(document.querySelector("#board-view")).display'), 'none');
|
||
+ assert.equal(await text('#footer'), 'waiting 1 · working 1');
|
||
+ // A good read after a failed one drops the stale words.
|
||
+ brd.fail(); await refresh(); assert.match(await text('#fresh-board'), /stale/);
|
||
+ brd.one(); await refresh(); assert.match(await text('#fresh-board'), /^Board scanned \d\d:\d\d:\d\d UTC/); assert.doesNotMatch(await text('#fresh-board'), /stale/);
|
||
|
||
// Keyboard on the board: down moves between rows, Enter opens, Esc closes and returns focus.
|
||
await b.evaluate('document.querySelector("table.sessions [data-open]").focus()');
|
||
@@ -159,12 +177,16 @@
|
||
await b.evaluate('document.querySelector(".skip").focus();document.querySelector(".skip").blur()');
|
||
for (const sel of ['#cmdk', '#sections a', '#refresh']) {
|
||
await b.evaluate(`document.querySelector(${JSON.stringify(sel)}).focus()`);
|
||
- const ring = await b.evaluate(`(()=>{const c=getComputedStyle(document.activeElement);return [c.outlineStyle, parseFloat(c.outlineWidth)]})()`);
|
||
- assert.notEqual(ring[0], 'none', `${sel} focus ring`); assert.ok(ring[1] >= 2, `${sel} outline ${ring[1]}px`);
|
||
+ const ring = await b.evaluate(`(()=>{const c=getComputedStyle(document.activeElement);return [c.outlineStyle, parseFloat(c.outlineWidth), parseFloat(c.outlineOffset)]})()`);
|
||
+ assert.notEqual(ring[0], 'none', `${sel} focus ring`); assert.ok(ring[1] >= 2, `${sel} outline ${ring[1]}px`); assert.equal(ring[2], 2, `${sel} outline offset`);
|
||
}
|
||
|
||
// Bus pages, empty: each says what would appear and where it comes from.
|
||
- for (const [hash, h1, words] of [['#/inbox', 'Inbox', /Nothing is waiting on you\./], ['#/tasks', 'Tasks', /No tasks in this business yet\./], ['#/agents', 'Agents', /No role is held right now\./]]) {
|
||
+ for (const [hash, h1, words] of [
|
||
+ ['#/inbox', 'Inbox', /^Nothing is waiting on you\.A decision shows here when an agent raises one routed to you through the bus\.$/],
|
||
+ ['#/tasks', 'Tasks', /^No tasks in this business yet\.Tasks appear after the PM creates them or the poller reads them from Vikunja\.$/],
|
||
+ ['#/agents', 'Agents', /^No role is held right now\.A role shows here while a run holds its claim on the bus\.$/],
|
||
+ ]) {
|
||
await go(hash, h1);
|
||
assert.match(await text('#bus-view .empty'), words, hash);
|
||
assert.match(await text('#fresh-bus'), /^Bus read \d\d:\d\d:\d\d UTC/, hash);
|
||
@@ -172,11 +194,23 @@
|
||
}
|
||
assert.equal(await b.evaluate('getComputedStyle(document.querySelector("#fresh-board")).display'), 'none');
|
||
assert.equal(await b.evaluate('document.querySelectorAll("#sections svg use").length'), 4);
|
||
+ 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]);
|
||
+ // Below 760px the section list is one strip that scrolls inside itself: every link on one
|
||
+ // line, no label wrapped, and still no sideways scroll on the page. 360px, then back to 400px.
|
||
+ for (const w of [360, 400]) {
|
||
+ await b.viewport(w, 900);
|
||
+ assert.equal(await b.evaluate('new Set([...document.querySelectorAll("#sections a")].map(a=>a.offsetTop)).size'), 1, `one row at ${w}px`);
|
||
+ assert.match(await b.evaluate('getComputedStyle(document.querySelector("#sections")).overflowX'), /^(auto|scroll)$/, `strip scrolls at ${w}px`);
|
||
+ assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#sections a")].filter(a=>a.getClientRects().length>1||a.scrollWidth>a.clientWidth+1).map(a=>a.textContent)'), [], `no wrapped label at ${w}px`);
|
||
+ await flat();
|
||
+ }
|
||
|
||
// Bus, a failed read over kept data: the kept rows stay under a stale banner; the bar says stale.
|
||
s.rows.tasks = [{ task_ref: 'vikunja:32/7', fields: { title: 'Kept task', bucket: 2, done: false, assignees: [] }, source: 'self' }, { task_ref: 'vikunja:32/8', fields: { title: 'Second task', bucket: 2, done: false, assignees: [] }, source: 'self' }];
|
||
await go('#/trail/task/vikunja%3A32%2F7', 'Trail for task #7');
|
||
- assert.match(await text('#bus-view .empty'), /Nothing of this kind in the trail\./);
|
||
+ assert.match(await text('#bus-view .empty'), /^Nothing of this kind in the trail\.Every step the stack takes on this subject writes a trail row\.$/);
|
||
await flat(); await shot('bus-empty-trail-400');
|
||
await go('#/tasks', 'Tasks');
|
||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view td.s1-title a").length'), 2);
|
||
@@ -197,7 +231,7 @@
|
||
assert.equal(await b.evaluate('document.querySelector("#bus-view .banner.ref code").textContent'), 'human-required');
|
||
assert.equal(await b.evaluate('document.querySelectorAll("#bus-view td.s1-title a").length'), 0);
|
||
assert.match(await text('#bus-view .empty'), /Nothing to show\./);
|
||
- assert.match(await text('#fresh-bus'), /not read: the read failed/);
|
||
+ assert.equal(await text('#fresh-bus'), 'Bus not read: refused');
|
||
await flat(); await shot('bus-refused-400');
|
||
s.fail(null);
|
||
|
||
@@ -209,6 +243,8 @@
|
||
// Ctrl+K: opens on the views and the kept tasks, filters, Enter goes, Esc restores focus.
|
||
await go('#/inbox', 'Inbox');
|
||
await b.evaluate('document.querySelector("#refresh").focus()');
|
||
+ await b.key('k', 'KeyK', 3); await new Promise(r => setTimeout(r, 200));
|
||
+ assert.equal(await b.evaluate('document.querySelector("#cmdk-dialog").open'), false, 'Ctrl+Alt+K opens nothing');
|
||
await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
|
||
assert.equal(await b.evaluate('document.activeElement.id'), 'cmdk-input');
|
||
await wait('[...document.querySelectorAll("#cmdk-list [data-href]")].some(e=>e.dataset.href==="#/tasks/vikunja%3A32%2F7")');
|
||
@@ -251,6 +287,8 @@
|
||
await wait('document.querySelector("#toast").classList.contains("err")');
|
||
assert.equal(await text('#toast span'), 'The clipboard is unavailable. The command is selected; press Ctrl+C to copy it.');
|
||
assert.equal(await b.evaluate('getSelection().toString()'), `mosaic decide ${'d'.repeat(32)} yes`);
|
||
+ await new Promise(r => setTimeout(r, 3000));
|
||
+ assert.equal(await b.evaluate('document.querySelector("#toast").className'), 'toast on err', 'the error toast stays until dismissed');
|
||
await b.evaluate('document.querySelector("#toast [data-dismiss]").click()');
|
||
assert.equal(await b.evaluate('document.querySelector("#toast").classList.contains("on")'), false);
|
||
|
||
@@ -293,6 +331,7 @@
|
||
const count = sel => b.evaluate(`document.querySelectorAll(${JSON.stringify(sel)}).length`);
|
||
const flat = async () => assert.equal(await b.evaluate('document.documentElement.scrollWidth<=document.documentElement.clientWidth'), true, 'sideways scroll at 400px');
|
||
const refresh = async () => { await b.evaluate('document.querySelector("#refresh").click()'); await wait('!document.querySelector("#refresh").disabled'); };
|
||
+ const go = async (hash, h1) => { await b.evaluate(`location.hash=${JSON.stringify(hash)}`); await wait(`document.querySelector("#bus-view h1")?.textContent.startsWith(${JSON.stringify(h1)}) && !document.querySelector("#bus-view [aria-busy]")`); };
|
||
try {
|
||
await b.call('Page.addScriptToEvaluateOnNewDocument', { source: 'window.errors=[];addEventListener("error",e=>errors.push(e.message));addEventListener("unhandledrejection",e=>errors.push(String(e.reason)));window.requests=[];const f=window.fetch;window.fetch=(u,o={})=>{requests.push([(o.method||"GET").toUpperCase(),String(u)]);return f(u,o)};' });
|
||
await b.viewport(400, 900);
|
||
@@ -332,17 +371,55 @@
|
||
assert.equal(await count(rows), n, `${hash} keeps its rows`);
|
||
assert.match(await text('#fresh-bus'), /stale: the last read failed/, hash);
|
||
await flat(); await shot(`${name}-stale-400`);
|
||
- // A refusal: no kept rows, the code shown, nothing current.
|
||
- s.fail('human-required'); await refresh();
|
||
- await wait('!!document.querySelector("#bus-view .banner.ref")');
|
||
- assert.equal(await text('#bus-view .banner.ref code'), 'human-required', hash);
|
||
- assert.equal(await count(rows), 0, `${hash} hides kept rows on a refusal`);
|
||
- assert.match(await text('#bus-view .empty'), /Nothing to show\./, hash);
|
||
- await flat(); await shot(`${name}-refused-400`);
|
||
- s.fail(null); await refresh();
|
||
- await wait(`!document.querySelector("#bus-view .banner") && document.querySelectorAll(${JSON.stringify(rows)}).length===${n}`);
|
||
+ // A refusal, by the bus (403) or for want of one (no-bus-host): no kept rows, the code
|
||
+ // shown, nothing current, and no count in the section list.
|
||
+ for (const code of ['human-required', 'no-bus-host']) {
|
||
+ s.fail(code); await refresh();
|
||
+ await wait('!!document.querySelector("#bus-view .banner.ref")');
|
||
+ assert.equal(await text('#bus-view .banner.ref code'), code, hash);
|
||
+ assert.equal(await count(rows), 0, `${hash} hides kept rows on a ${code} refusal`);
|
||
+ assert.match(await text('#bus-view .empty'), /Nothing to show\./, hash);
|
||
+ assert.equal(await text('#fresh-bus'), 'Bus not read: refused', hash);
|
||
+ assert.equal(await count('#sections .count, #sections .tag-block'), 0, `${hash} drops the Inbox count on a ${code} refusal`);
|
||
+ if (code === 'human-required') { await flat(); await shot(`${name}-refused-400`); }
|
||
+ s.fail(null); await refresh();
|
||
+ await wait(`!document.querySelector("#bus-view .banner") && document.querySelectorAll(${JSON.stringify(rows)}).length===${n}`);
|
||
+ }
|
||
}
|
||
|
||
+ // After a refusal the palette lists only the views: no kept decision or task.
|
||
+ const views = ['#/', '#/inbox', '#/tasks', '#/agents'];
|
||
+ 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/);
|
||
+ s.fail('human-required'); await refresh(); await wait('!!document.querySelector("#bus-view .banner.ref")');
|
||
+ await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
|
||
+ assert.deepEqual(await b.evaluate(palette), views);
|
||
+ await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
|
||
+ s.fail(null);
|
||
+ // The palette's own reads: kept rows show until a refused refetch, then go, with the count.
|
||
+ await go('#/tasks', 'Tasks'); await b.evaluate('location.hash="#/inbox"'); await wait('document.querySelectorAll("#sections .count").length===1');
|
||
+ await b.key('k', 'KeyK', 2); await wait(`${palette}.some(h=>h.startsWith("#/tasks/"))`);
|
||
+ await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
|
||
+ s.fail('human-required');
|
||
+ await b.key('k', 'KeyK', 2); await wait(`document.querySelector("#cmdk-dialog").open && ${palette}.length===${views.length} && !document.querySelector("#sections .count")`);
|
||
+ assert.deepEqual(await b.evaluate(palette), views);
|
||
+ await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
|
||
+ // Palette labels are bus text: markup in a question or a task title shows as text.
|
||
+ s.fail(null);
|
||
+ s.rows.inbox = [{ ...s.rows.inbox[0], question: '<img src=x onerror="errors.push(1)" id="pk-q">Push?' }];
|
||
+ s.rows.tasks = [{ ...s.rows.tasks[0], fields: { ...s.rows.tasks[0].fields, title: '<b id="pk-t">Bold</b> task' } }, s.rows.tasks[1]];
|
||
+ await b.key('k', 'KeyK', 2); await wait(`${palette}.length===${views.length + 3}`);
|
||
+ assert.equal(await count('#cmdk-list #pk-q, #cmdk-list #pk-t, #cmdk-list img, #cmdk-list b'), 0);
|
||
+ assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#cmdk-list [data-href] span:first-child")].slice(4).map(e=>e.textContent)'), ['<img src=x onerror="errors.push(1)" id="pk-q">Push?', '#7 <b id="pk-t">Bold</b> task', '#8 Second task']);
|
||
+ await b.key('Escape'); await wait('!document.querySelector("#cmdk-dialog").open');
|
||
+ // The board page's Inbox read: a refusal there drops the count too.
|
||
+ s.fail(null); await refresh(); await wait('document.querySelectorAll("#sections .count").length===1');
|
||
+ s.fail('no-bus-host'); await b.evaluate('location.hash="#/"'); await wait('!document.querySelector("#sections .count")');
|
||
+ await b.key('k', 'KeyK', 2); await wait('document.querySelector("#cmdk-dialog").open');
|
||
+ assert.deepEqual(await b.evaluate(palette), views);
|
||
+ await b.key('Escape'); s.fail(null);
|
||
+
|
||
assert.deepEqual(await b.evaluate('window.errors'), []);
|
||
assert.deepEqual(await b.evaluate('window.requests.filter(([m])=>m!=="GET")'), []);
|
||
} finally { await b.close(); await close(web); await close(brd.server); }
|