154 lines
11 KiB
Diff
154 lines
11 KiB
Diff
commit 15c3d8ca161c11fc2d0031900f0e01f8bef725ec
|
|
Author: Jason Woltje <[email protected]>
|
|
Date: Mon Oct 19 15:05:20 2026 -0500
|
|
|
|
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.
|
|
|
|
diff --git a/packages/webui/src/public/app.js b/packages/webui/src/public/app.js
|
|
index 3bdc61243..89dbd749d 100644
|
|
--- a/packages/webui/src/public/app.js
|
|
+++ b/packages/webui/src/public/app.js
|
|
@@ -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 => {
|
|
diff --git a/packages/webui/tests/board-held.test.mjs b/packages/webui/tests/board-held.test.mjs
|
|
index 6815a4bf2..588024b3a 100644
|
|
--- a/packages/webui/tests/board-held.test.mjs
|
|
+++ b/packages/webui/tests/board-held.test.mjs
|
|
@@ -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);
|
|
diff --git a/packages/webui/tests/instances-browser.test.mjs b/packages/webui/tests/instances-browser.test.mjs
|
|
index e58e2d2cd..09cbe9077 100644
|
|
--- a/packages/webui/tests/instances-browser.test.mjs
|
|
+++ b/packages/webui/tests/instances-browser.test.mjs
|
|
@@ -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);
|