diff --git a/scripts/lib/control-pane/ui.js b/scripts/lib/control-pane/ui.js index 690c8d981..8168a448e 100644 --- a/scripts/lib/control-pane/ui.js +++ b/scripts/lib/control-pane/ui.js @@ -439,17 +439,24 @@ function renderControlPaneHtml() { // 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. let loadedAt = null; - // Loads are numbered as they start. The board follows the newest load that - // has finished, with its data or with its failure, so a load that finishes - // after a newer one leaves the board alone. + // Loads are numbered as they start and finish in any order. The board shows + // the newest data any load brought, and the error box the outcome of the + // newest load that has finished, so a load that finishes late can neither + // replace newer data nor overrule a newer outcome. let loadsStarted = 0; let newestFinished = 0; + let shownLoad = 0; + let failure = null; function showRefreshFailure(error) { const since = loadedAt ? ' The data below is from ' + loadedAt.toLocaleString() + '.' : ''; showError('#app', 'Live refresh failed.' + since + '\\n' + formatError(error)); } + function showFailure() { + if (failure.live) showRefreshFailure(failure.error); + else showError('#app', failure.error); + } async function readJsonResponse(response) { let payload; @@ -639,14 +646,37 @@ function renderControlPaneHtml() { } } - async function load() { + async function load(live = false) { const id = ++loadsStarted; - const url = new URL('/api/snapshot', window.location.href); - if (state.query) url.searchParams.set('query', state.query); - const response = await fetch(url); - const snapshot = await readJsonResponse(response); - if (id < newestFinished) return; + let snapshot; + try { + const url = new URL('/api/snapshot', window.location.href); + if (state.query) url.searchParams.set('query', state.query); + const response = await fetch(url); + snapshot = await readJsonResponse(response); + } catch (error) { + if (id < newestFinished) return; + newestFinished = id; + failure = { error, live }; + showFailure(); + return; + } + if (id > shownLoad) { + shownLoad = id; + render(snapshot); + } + if (id < newestFinished) { + // A newer load failed first. This data is still the newest on the + // board, so the failure stays, dated by it. + if (failure && id === shownLoad) showFailure(); + return; + } newestFinished = id; + failure = null; + clearError('#app'); + } + + function render(snapshot) { $('#query').value = snapshot.knowledge.query || state.query; $('#db-path').textContent = snapshot.database.exists ? snapshot.dbPath : 'database missing'; state.allowActions = Boolean(snapshot.execution.allowActions); @@ -661,7 +691,6 @@ function renderControlPaneHtml() { executable: snapshot.execution.allowActions && action.executable }))); loadedAt = new Date(); - clearError('#app'); } $('#query-form').addEventListener('submit', event => { @@ -701,14 +730,7 @@ function renderControlPaneHtml() { // Live board: refresh on a gentle interval; pause while a prompt/tab is hidden. setInterval(() => { if (document.hidden) return; - const refresh = load(); - // load() numbers itself before its first await. - const id = loadsStarted; - refresh.catch(error => { - if (id < newestFinished) return; - newestFinished = id; - showRefreshFailure(error); - }); + load(true).catch(error => showError('#app', error)); }, 15000); load().catch(error => showError('#app', error)); diff --git a/tests/lib/control-pane-ui.test.js b/tests/lib/control-pane-ui.test.js index 7e8f73e63..92a0622a5 100644 --- a/tests/lib/control-pane-ui.test.js +++ b/tests/lib/control-pane-ui.test.js @@ -41,7 +41,9 @@ class PageDate extends Date { // elements looked up by selector, fetch, a fixed clock, and a setInterval // whose callback the test fires itself. While `hold` is set, a fetch waits in // `pending` until the test settles it, with the page's snapshot or another one. -function openPage(snapshot) { +// With `hold`, the first load is held too. Listeners are kept per element, so a +// test can press a button. +function openPage(snapshot, { hold = false } = {}) { const elements = new Map(); const element = selector => { if (!elements.has(selector)) { @@ -51,12 +53,15 @@ function openPage(snapshot) { innerHTML: '', value: '', dataset: {}, - addEventListener() {} + listeners: {}, + addEventListener(type, listener) { + this.listeners[type] = listener; + } }); } return elements.get(selector); }; - const page = { online: true, hold: false, pending: [], refresh: null, element }; + const page = { online: true, hold, pending: [], refresh: null, element }; vm.runInNewContext(inlineScript(renderControlPaneHtml()), { document: { hidden: false, querySelector: element, querySelectorAll: () => [] }, window: { location: { href: 'http://127.0.0.1:8765/' } }, @@ -219,6 +224,63 @@ async function runTests() { passed++; else failed++; + if ( + await test('the first snapshot still shows when a live refresh fails before it arrives', async () => { + const page = openPage(snapshot, { hold: true }); + const answer = query => ({ ...snapshot, knowledge: { ...snapshot.knowledge, query } }); + + page.refresh(); + page.pending[1].fail(); + await settle(); + page.pending[0].succeed(answer('first')); + await settle(); + assert.strictEqual(page.element('#query').value, 'first', 'the pane is not left empty'); + const box = page.element('#app'); + assert.strictEqual(box.hidden, false, 'the newer failure stays up'); + assert.match(box.textContent, /Live refresh failed\. The data below is from /); + }) + ) + passed++; + else failed++; + + if ( + await test('a manual refresh that fails after a newer load succeeded is not shown', async () => { + const page = openPage(snapshot); + await settle(); + + page.hold = true; + page.element('#refresh').listeners.click(); + page.refresh(); + page.pending[1].succeed(); + await settle(); + page.pending[0].fail(); + await settle(); + assert.strictEqual(page.element('#app').hidden, true, 'the newer success decides the board'); + }) + ) + passed++; + else failed++; + + if ( + await test('an older load that succeeds after a newer manual refresh failed leaves the failure up', async () => { + const page = openPage(snapshot); + await settle(); + + page.hold = true; + page.refresh(); + page.element('#refresh').listeners.click(); + page.pending[1].fail(); + await settle(); + page.pending[0].succeed(); + await settle(); + const box = page.element('#app'); + assert.strictEqual(box.hidden, false, 'the newer failure decides the board'); + assert.match(box.textContent, /Failed to fetch/); + }) + ) + passed++; + else failed++; + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`); process.exit(failed > 0 ? 1 : 0); }