webui: a Seen click during a Board read waits for the read and posts, and the row 165 test loads the page instead of reloading from an evaluate (row 172, #1670)

The click handler returned on busy, which refresh() holds for the whole /api/board read, so a click during a
poll did nothing and the sign-out test waited 5 s for a sign-out that was never asked for. The row 165 test's
location.reload() from an evaluate let the next wait start on the old document, and the reload killed it.
This commit is contained in:
jason.woltje committed 2026-10-19 15:34:00 -05:00
1 parent 7f38a2f549
commit be10e0c838
3 files changed
+58 -16

No files matched your search

+18 -9
View File
@@ -6,7 +6,7 @@
const drafts = new Map(), receipts = new Map(), sending = new Set();
// Delivered replies with no newer answer yet, by row: the board's sentAt. Page memory only.
const awaiting = new Map();
let data = null, project = null, selected = null, returnFocus = null, paused = false, busy = false, timer;
let data = null, project = null, selected = null, returnFocus = null, paused = false, busy = false, writing = false, reading = null, timer;
let board = 'the configured board', palette = 'harbor', mode = 'system', failed = false;
// Show machine sessions (#1589): off by default, kept with the page's settings. While it is off the
// page doesn't read /api/board at all; the held roles above come from the bus (bus.js).
@@ -361,8 +361,11 @@
busDown();
if (busy || !machine || out()) return;
busy = true; $('refresh').disabled = true;
try { try { const value = await api('/api/board'); if (machine) accept(value); } catch (err) { if (machine) error(err); } await pollConversation(); }
finally { busy = false; $('refresh').disabled = out(); schedule(); }
reading = (async () => {
try { try { const value = await api('/api/board'); if (machine) accept(value); } catch (err) { if (machine) error(err); } await pollConversation(); }
finally { busy = false; $('refresh').disabled = out(); schedule(); }
})().finally(() => { reading = null; });
await reading;
}
// With the toggle off, nothing from the scan stays on the page: the next time it goes on, the board
// starts from a fresh read rather than showing an old scan as current.
@@ -402,12 +405,18 @@
const projectButton = e.target.closest('[data-project]');
if (projectButton) { if (conv) closeConversation(); project = projectButton.dataset.project || null; render(); return; }
const seen = e.target.closest('[data-seen]');
if (!seen || busy) return;
const r = row(seen.dataset.seen); if (!r) return;
busy = true; seen.disabled = true;
try { accept(await api('/api/seen', { project: r.project, agent: r.agent, lastActivity: r.lastActivity, seen: !r.seen })); announce(`${r.agent} ${r.seen ? 'returned to waiting' : 'marked seen'}`); }
catch (err) { error(err); seen.disabled = false; }
finally { busy = false; schedule(); }
if (!seen || writing) return;
// A Board read in flight (reading) holds busy. The click waits for that read and posts on the row it
// drew; dropping it left the click doing nothing (row 172). A second click while one waits or posts is dropped.
writing = true; seen.disabled = true;
try {
while (reading) await reading;
const r = row(seen.dataset.seen); if (!r) { seen.disabled = false; return; }
busy = true;
try { accept(await api('/api/seen', { project: r.project, agent: r.agent, lastActivity: r.lastActivity, seen: !r.seen })); announce(`${r.agent} ${r.seen ? 'returned to waiting' : 'marked seen'}`); }
catch (err) { error(err); seen.disabled = false; }
finally { busy = false; schedule(); }
} finally { writing = false; }
});
document.addEventListener('input', e => { if (e.target.id === 'reply') drafts.set(selected, e.target.value); if (e.target.id === 'conv-reply' && conv) drafts.set(conv.row, e.target.value); });
document.addEventListener('submit', async e => {
+36 -5
View File
@@ -6,10 +6,11 @@
// makes, answers /api/config with send on (unless the URL has ?nosend), answers POST
// /api/bus/message from a queue, and answers any path in window.__redirect the way fetch does
// under redirect: 'manual' (type opaqueredirect, status 0). It also records every 10 s poll timer
// the page arms (window.__polls, 'board' for app.js and 'bus' for bus.js). The second test has the
// timers fire after 150 ms, so its "no further request" check spans real poll ticks, and both
// tests assert on the timers themselves: a guard that only the fetch behind it backs up would
// otherwise change nothing the page sends.
// the page arms (window.__polls, 'board' for app.js and 'bus' for bus.js). The sign-out test has the
// timers fire after 150 ms, so its "no further request" check spans real poll ticks, and it and the
// Board test assert on the timers themselves: a guard that only the fetch behind it backs up would
// otherwise change nothing the page sends. A path set in window.__hold has its next fetch held until
// the test releases it (window.__held), so a read can be kept in flight across a click.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync } from 'node:fs';
@@ -35,7 +36,7 @@ const businesses = {
const stub = fast => `(() => {
const real = window.fetch.bind(window);
window.__reqs = []; window.__answers = []; window.__redirect = []; window.__polls = [];
window.__reqs = []; window.__answers = []; window.__redirect = []; window.__polls = []; window.__hold = null; window.__held = [];
// app.js and bus.js both poll with a 10000 delay. A timer is filed by the script that armed it, read
// from the call stack, not from the shape of the callback, which either script may change.
const timeout = window.setTimeout.bind(window);
@@ -48,6 +49,7 @@ const stub = fast => `(() => {
window.fetch = async (url, init = {}) => {
const u = String(url), path = u.split('?')[0];
window.__reqs.push({ path, redirect: init.redirect ?? null });
if (window.__hold === path) { window.__hold = null; await new Promise(r => window.__held.push(r)); }
if (window.__redirect.includes(path)) return redirect();
if (path === '/api/config' && !location.search.includes('nosend')) {
const c = await (await real(url, init)).json();
@@ -160,6 +162,35 @@ test('the Board: held roles with no machine sessions, a claim with no address, t
} finally { await done(); }
});
// Row 172: under the gate's load the sign-out test's Seen click landed while a 150 ms poll's Board read
// was in flight, and the page dropped it, so /api/seen was never sent and the sign-out never came.
test('a Seen click while a Board read is in flight waits for that read, then posts', { timeout: 120000 }, async () => {
const { b, base, wait, reqs, done } = await setup(true);
const paths = async () => (await reqs()).map(r => r.path);
try {
await b.navigate(base);
await wait(`${SESSIONS} && ${HELD}`);
await b.evaluate(`document.querySelector('table.sessions [data-open="proj/agent1"]').click(), true`);
await wait('document.querySelector("#inspection [data-seen]")');
// Refresh starts a Board read and the stub holds its answer, so the read is in flight at the click.
await b.evaluate(`window.__hold = '/api/board', document.querySelector('#refresh').click(), true`);
await wait('window.__held.length === 1');
await b.evaluate(`document.querySelector('#inspection [data-seen]').click(), true`);
await new Promise(r => setTimeout(r, 200));
assert.equal((await paths()).includes('/api/seen'), false);
const read = (await paths()).lastIndexOf('/api/board');
// Released, the read draws a fresh Seen button and the click's POST goes out; the stub holds that too.
// A second click while it is held is dropped: one POST, not a second toggle.
await b.evaluate(`window.__hold = '/api/seen', window.__held.splice(0).forEach(r => r()), true`);
await wait('window.__held.length === 1');
await b.evaluate(`document.querySelector('#inspection [data-seen]').click(), true`);
await new Promise(r => setTimeout(r, 200));
await b.evaluate('window.__held.splice(0).forEach(r => r()), true');
await wait('document.querySelector("#seen-count").textContent === "1"');
assert.deepEqual((await paths()).slice(read + 1).filter(p => p === '/api/seen' || p === '/api/board'), ['/api/seen']);
} finally { await done(); }
});
test('a redirect answer on a Board read, a bus read, a conversation read, a board POST, a module read and a send signs the page out: one line, nothing stale, no further reads', { timeout: 120000 }, async () => {
// The poll timers fire after 150 ms here, so the 600 ms settle below spans several ticks.
const { b, base, wait, text, shown, reqs, polls, settle, done } = await setup(true, true);
@@ -87,9 +87,11 @@ test('row 165 Settings: role instances read, add with a restart named, change wi
assert.equal(await b.evaluate('document.querySelectorAll("#instances-table tbody tr").length'),3);
// An unread view is one section's warning, not the page's empty state, and offers no form.
// The same URL with a hash would be a same-document navigation, so reload the page.
// The same URL with a hash would be a same-document navigation, so load it with another query. A
// location.reload() from evaluate returned before the reload committed; the next wait then ran on the
// old document and the reload killed it (row 172: "Inspected target navigated or closed" under load).
current=null;
await b.evaluate('location.reload();true');
await b.navigate(`http://127.0.0.1:${port}/?send=1&read=2#/settings`);
await wait('!!document.querySelector("#instances-title")?.parentElement.querySelector(".warn")');
assert.equal(await b.evaluate('document.querySelector("#instances-title").parentElement.querySelector(".warn").textContent'),'The role instances could not be read, so there is nothing to add or change. Read again to retry; they come from the bus host with your identity.');
assert.equal(await b.evaluate('document.querySelectorAll("#instances-title ~ .empty, .instance-form").length'),0);