fix(control-pane): report a failed live refresh instead of discarding it

The 15-second refresh caught every error with `load().catch(() => {})`,
so a control pane that had lost its server kept showing the last
snapshot as if it were current. A failed refresh now shows the error box
with "Live refresh failed. The data below is from <time>." and the
error, and the next successful refresh clears it, as load() already
clears the box.

The new test runs the served page script in a vm with a stand-in for the
browser APIs it uses, fails a refresh, and checks both transitions.

Fixes #3176
This commit is contained in:
L4XB
2026-09-24 23:25:13 +02:00
parent e482e57941
commit 0ae2fa06a4
2 changed files with 121 additions and 1 deletions
+11 -1
View File
@@ -436,6 +436,15 @@ function renderControlPaneHtml() {
target.textContent = '';
}
// The board keeps the last snapshot on screen, so a failed refresh has to
// say that the data is no longer live, and since when.
function showRefreshFailure(error) {
const since = state.loadedAt
? ' The data below is from ' + state.loadedAt.toLocaleTimeString() + '.'
: '';
showError('#app', 'Live refresh failed.' + since + '\\n' + formatError(error));
}
async function readJsonResponse(response) {
let payload;
try {
@@ -642,6 +651,7 @@ function renderControlPaneHtml() {
...action,
executable: snapshot.execution.allowActions && action.executable
})));
state.loadedAt = new Date();
clearError('#app');
}
@@ -682,7 +692,7 @@ function renderControlPaneHtml() {
// Live board: refresh on a gentle interval; pause while a prompt/tab is hidden.
setInterval(() => {
if (document.hidden) return;
load().catch(() => {});
load().catch(showRefreshFailure);
}, 15000);
load().catch(error => showError('#app', error));
+110
View File
@@ -0,0 +1,110 @@
/**
* Tests for the browser script the local ECC2 control pane serves.
*/
const assert = require('assert');
const path = require('path');
const vm = require('vm');
const { buildControlPaneSnapshot } = require('../../scripts/lib/control-pane/state');
const { renderControlPaneHtml } = require('../../scripts/lib/control-pane/ui');
async function test(name, fn) {
try {
await fn();
console.log(` PASS ${name}`);
return true;
} catch (error) {
console.log(` FAIL ${name}`);
console.log(` Error: ${error.message}`);
return false;
}
}
function inlineScript(html) {
const start = html.indexOf('<script>') + '<script>'.length;
return html.slice(start, html.lastIndexOf('</script>'));
}
// Runs the page script against a stand-in for the few browser APIs it uses:
// elements looked up by selector, fetch, and a setInterval whose callback the
// test fires itself.
function openPage(snapshot) {
const elements = new Map();
const element = selector => {
if (!elements.has(selector)) {
elements.set(selector, {
hidden: selector === '#app',
textContent: '',
innerHTML: '',
value: '',
dataset: {},
addEventListener() {}
});
}
return elements.get(selector);
};
const page = { online: true, refresh: null, element };
vm.runInNewContext(inlineScript(renderControlPaneHtml()), {
document: { hidden: false, querySelector: element, querySelectorAll: () => [] },
window: { location: { href: 'http://127.0.0.1:8765/' } },
URL,
Intl,
console,
fetch: async () => {
if (!page.online) throw new TypeError('Failed to fetch');
return { ok: true, status: 200, statusText: 'OK', json: async () => snapshot };
},
setInterval: callback => {
page.refresh = callback;
}
});
return page;
}
const settle = () => new Promise(resolve => setImmediate(resolve));
async function runTests() {
console.log('\n=== Testing control-pane UI ===\n');
let passed = 0;
let failed = 0;
const snapshot = JSON.parse(
JSON.stringify(
await buildControlPaneSnapshot({
dbPath: path.join(__dirname, 'no-such-ecc2.db'),
repoRoot: path.join(__dirname, '..', '..'),
query: ''
})
)
);
if (
await test('a failed live refresh is reported, and cleared by the next one that succeeds', async () => {
const page = openPage(snapshot);
await settle();
assert.strictEqual(page.element('#app').hidden, true, 'the first load succeeds');
page.online = false;
page.refresh();
await settle();
const box = page.element('#app');
assert.strictEqual(box.hidden, false, 'the failure is shown');
assert.match(box.textContent, /Live refresh failed\. The data below is from /);
assert.match(box.textContent, /Failed to fetch/);
page.online = true;
page.refresh();
await settle();
assert.strictEqual(page.element('#app').hidden, true, 'a successful refresh clears it');
})
)
passed++;
else failed++;
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
process.exit(failed > 0 ? 1 : 0);
}
runTests();