From 9e0996c6d67446862ddc151e49fdac7e57a27cbe Mon Sep 17 00:00:00 2001 From: pasmud Date: Sun, 27 Sep 2026 02:01:44 +0000 Subject: [PATCH] fix(control-pane): replace stale live-region guidance when a poll fails The live region announced the advisory and steering state on every change, but a failed or invalid poll only set the visible status to offline. The live region kept the last known message, so during an outage a screen-reader user could still hear "No steering is required" from a poll that had already failed. That is worse than silence, because stale guidance reads as current. Route announcements through a single helper and have the failure path replace the guidance with a message saying the data is unavailable and the counts are unknown. The helper still suppresses a repeat of the same message, so a continuing outage stays quiet and recovery speaks again. Cover the success then failure and failure then recovery sequences at runtime. Reverting just the failure-path announcement fails the new assertion, and the unmutated view passes all of them. Co-authored-by: CommandCodeBot --- .../lib/control-pane/control-plane-view-ui.js | 30 ++++++++------- tests/lib/control-plane-view-ui.test.js | 37 +++++++++++++++++++ 2 files changed, 54 insertions(+), 13 deletions(-) diff --git a/scripts/lib/control-pane/control-plane-view-ui.js b/scripts/lib/control-pane/control-plane-view-ui.js index ce35ada45..af1a14aa8 100644 --- a/scripts/lib/control-pane/control-plane-view-ui.js +++ b/scripts/lib/control-pane/control-plane-view-ui.js @@ -83,8 +83,8 @@ function renderControlPlaneViewHtml() { var canvas = document.getElementById('c'); var ctx = canvas.getContext('2d'); var view = { tasks: [], lanes: [], pairs: [], events: [], projection: { agents: [] }, thresholds: { ta: 0.35, ra: 0.7 } }; - // Last advisory/steering pair handed to the live region, so a poll that - // changes nothing stays silent. + // Last message handed to the live region, so a poll that changes nothing + // stays silent. var lastSpoken = null; function resize() { @@ -240,6 +240,15 @@ function renderControlPlaneViewHtml() { }); } + // Polling runs every few seconds, so only speak when the advisory and + // steering counts actually move. Repeating an unchanged summary would talk + // over the operator without telling them anything new. + function announce(message) { + if (message === lastSpoken) return; + lastSpoken = message; + document.getElementById('announce').textContent = message; + } + function apply(data) { if (!data || data.schemaVersion !== 'ecc.control-plane.view.v1' || !['tasks', 'lanes', 'pairs', 'events'].every(function (key) { return Array.isArray(data[key]); }) || @@ -259,17 +268,9 @@ function renderControlPlaneViewHtml() { (c.advisories || 0) + ' advisories (' + (c.resolutions || 0) + ' steering)' + (view.inventory && view.inventory.status !== 'ok' ? ' | inventory ' + view.inventory.status : ''); - // Polling runs every few seconds, so only speak when the advisory and - // steering counts actually move. Repeating an unchanged summary would talk - // over the operator without telling them anything new. - var speaker = document.getElementById('announce'); - var spoken = (c.advisories || 0) + '/' + (c.resolutions || 0); - if (spoken !== lastSpoken) { - lastSpoken = spoken; - speaker.textContent = (c.advisories || 0) + ' advisories, ' + - (c.resolutions || 0) + ' steering. ' + - ((c.resolutions || 0) > 0 ? 'Steering is required.' : 'No steering is required.'); - } + announce((c.advisories || 0) + ' advisories, ' + + (c.resolutions || 0) + ' steering. ' + + ((c.resolutions || 0) > 0 ? 'Steering is required.' : 'No steering is required.')); } function poll() { @@ -278,6 +279,9 @@ function renderControlPlaneViewHtml() { return r.json(); }).then(apply).catch(function () { document.getElementById('status').textContent = 'offline'; + // The last guidance is now stale, so replace it rather than leaving the + // live region claiming the airspace is clear. + announce('Control-plane data is unavailable. Advisories and steering are unknown.'); }); } diff --git a/tests/lib/control-plane-view-ui.test.js b/tests/lib/control-plane-view-ui.test.js index ce58a6f04..b8c8d698d 100644 --- a/tests/lib/control-plane-view-ui.test.js +++ b/tests/lib/control-plane-view-ui.test.js @@ -206,6 +206,43 @@ let passed = 0; `a changed poll must announce the new counts, got ${changed.elements.get('announce').textContent}`); passed += 1; + // An outage must not leave the live region holding the last known guidance, + // which would read as a current "airspace is clear" after data stopped. + const outage = await render([ + { ok: true, data: populatedView() }, + { ok: false, data: { ok: false, error: 'snapshot unavailable' } } + ]); + const beforeOutage = outage.elements.get('announce').textContent; + await outage.pollAgain(); + assert.strictEqual(outage.elements.get('status').textContent, 'offline'); + assert.ok(outage.elements.get('announce').textContent !== beforeOutage, + 'a failed poll must not leave the previous guidance in the live region'); + assert.ok(/unavailable/i.test(outage.elements.get('announce').textContent) + && /unknown/i.test(outage.elements.get('announce').textContent), + `an outage must say the counts are unknown, got ${outage.elements.get('announce').textContent}`); + passed += 1; + + // A repeated failure stays silent, but recovering online must speak again. + const writesAfterOutage = outage.writesTo('announce'); + await outage.pollAgain(); + assert.strictEqual(outage.writesTo('announce'), writesAfterOutage, + 'a repeated failure must not re-announce the same outage'); + passed += 1; + + const recovered = await render([ + { ok: true, data: populatedView() }, + { ok: false, data: { ok: false, error: 'snapshot unavailable' } }, + { ok: true, data: populatedView({ counts: { tasks: 3, lanes: 1, agents: 2, advisories: 1, resolutions: 1 } }) } + ]); + await recovered.pollAgain(); + await recovered.pollAgain(); + assert.ok(recovered.elements.get('status').textContent.includes('3 tasks'), + 'a recovered poll must restore the live status'); + assert.ok(recovered.elements.get('announce').textContent.includes('1 advisories') + && recovered.elements.get('announce').textContent.includes('Steering is required.'), + `a recovered poll must announce the restored counts, got ${recovered.elements.get('announce').textContent}`); + passed += 1; + console.log(`Results: Passed: ${passed}, Failed: 0`); })().catch(error => { console.error(error.message);