From 2eaa30c0a832addf17de07421b889a886fdc0251 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Sun, 16 Aug 2026 23:25:42 +0200 Subject: [PATCH] fix: reload authoritative host health history Refresh host health from the persisted retention-filtered history after batch completion, allowing the existing generation guard to reject stale responses and removing the competing local merge path. --- lib/stats.js | 18 ----- renderer/app.js | 15 +--- tests/stats.test.js | 45 +++++++----- tests/ui-smoke.js | 174 ++++++++++++++++++++++++++++---------------- 4 files changed, 139 insertions(+), 113 deletions(-) diff --git a/lib/stats.js b/lib/stats.js index 21b6641..3408848 100644 --- a/lib/stats.js +++ b/lib/stats.js @@ -39,23 +39,6 @@ return out; } - function mergeHosterHealthHistory(history, batch) { - if (!Array.isArray(history)) return history; - if (!batch?.id) return [...history, batch]; - const merged = []; - let replaced = false; - for (const existing of history) { - if (existing?.id === batch.id) { - if (!replaced) merged.push(batch); - replaced = true; - } else { - merged.push(existing); - } - } - if (!replaced) merged.push(batch); - return merged; - } - function summarizeHosterHealth(history, options = {}) { const out = {}; const hosters = options.hosters && typeof options.hosters === 'object' ? options.hosters : {}; @@ -297,7 +280,6 @@ const api = { summarizePerHoster, - mergeHosterHealthHistory, summarizeHosterHealth, classifyErrorCategory, summarizeBatchErrors, diff --git a/renderer/app.js b/renderer/app.js index fbc18b6..9f701ef 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -3612,22 +3612,9 @@ function _handleProgressImpl(data) { persistQueueStateSoon(); } -function recordHosterHealthBatch(summary) { - if (!summary || typeof summary !== 'object' || !Array.isArray(summary.files)) return; - const current = window._historyForStats; - if (!Array.isArray(current)) { - if (current === null) loadHistory(); - return; - } - window._historyForStats = window.Stats.mergeHosterHealthHistory(current, summary); - _invalidateHosterLifetimeCache(); - renderHosterHealthOverview(); -} - function handleBatchDone(summary, options = {}) { uploading = false; applySummaryResults(summary, options.historyPersisted !== false); - if (options.historyPersisted !== false) recordHosterHealthBatch(summary); _deletedJobIds.clear(); // Free memory — stale IDs no longer needed after batch completes // Prune session-stats sets to current queue contents. Without this, IDs // of jobs that were removed from queueJobs (via removeFromQueueOnDone @@ -3661,7 +3648,7 @@ function handleBatchDone(summary, options = {}) { // the user actually switches to it — skips an IPC + full table rebuild per // batch-done when the user is watching the upload view. _historyDirty = true; - if (_isHistoryTabActive()) loadHistory(); + if (options.historyPersisted !== false || _isHistoryTabActive()) loadHistory(); const removeOnDone = config.globalSettings && config.globalSettings.removeFromQueueOnDone; if (options.historyPersisted !== false && removeOnDone) { diff --git a/tests/stats.test.js b/tests/stats.test.js index 760cf70..39fac44 100644 --- a/tests/stats.test.js +++ b/tests/stats.test.js @@ -1,6 +1,5 @@ const test = require('node:test'); const assert = require('node:assert'); -const stats = require('../lib/stats'); const { summarizePerHoster, summarizeHosterHealth, @@ -8,7 +7,7 @@ const { summarizeBatchErrors, isRetryableCategory, mergeSkippedIntoSummary -} = stats; +} = require('../lib/stats'); function makeBatch(timestamp, results) { return { @@ -201,8 +200,7 @@ test('summarizeHosterHealth excludes disabled accounts from every problem-state }); }); -test('mergeHosterHealthHistory preserves the full snapshot and lets the summarizer choose the newest 50 batches', () => { - assert.strictEqual(typeof stats.mergeHosterHealthHistory, 'function'); +test('summarizeHosterHealth selects the newest batches without mutating the full loaded snapshot', () => { const now = new Date('2026-08-16T12:00:00.000Z'); const history = Array.from({ length: 60 }, (_, index) => ({ ...makeBatch(now.getTime() - (index + 1) * 60 * 60 * 1000, [{ hoster: 'voe.sx', status: 'error' }]), @@ -213,16 +211,11 @@ test('mergeHosterHealthHistory preserves the full snapshot and lets the summariz id: 'completed' }; - const merged = stats.mergeHosterHealthHistory(history, completed); - const replacement = { ...completed, total: 2 }; - const deduplicated = stats.mergeHosterHealthHistory(merged, replacement); - const summary = summarizeHosterHealth(deduplicated, { now })['voe.sx']; + history.push(completed); + const loadedOrder = [...history]; + const summary = summarizeHosterHealth(history, { now })['voe.sx']; - assert.strictEqual(merged.length, 61); - assert.deepStrictEqual(merged.slice(0, 60), history); - assert.strictEqual(merged[60], completed); - assert.strictEqual(deduplicated.length, 61); - assert.strictEqual(deduplicated[60], replacement); + assert.deepStrictEqual(history, loadedOrder); assert.deepStrictEqual({ sampleSize: summary.sampleSize, successful: summary.successful, @@ -234,12 +227,28 @@ test('mergeHosterHealthHistory preserves the full snapshot and lets the summariz }); }); -test('mergeHosterHealthHistory preserves null and undefined loading states', () => { - assert.strictEqual(typeof stats.mergeHosterHealthHistory, 'function'); - const completed = makeBatch(Date.parse('2026-08-16T12:00:00.000Z'), [{ hoster: 'voe.sx', status: 'done' }]); +test('summarizeHosterHealth preserves account health while history is unavailable', () => { + const options = { + hosters: { + 'voe.sx': [{ id: 'unavailable-history', enabled: true, authType: 'api', apiKey: 'key' }] + }, + accountStatuses: { + 'unavailable-history': { status: 'error' } + } + }; - assert.strictEqual(stats.mergeHosterHealthHistory(null, completed), null); - assert.strictEqual(stats.mergeHosterHealthHistory(undefined, completed), undefined); + for (const history of [null, undefined]) { + const summary = summarizeHosterHealth(history, options)['voe.sx']; + assert.deepStrictEqual({ + sampleSize: summary.sampleSize, + configuredAccounts: summary.configuredAccounts, + accountProblems: summary.accountProblems + }, { + sampleSize: 0, + configuredAccounts: 1, + accountProblems: 1 + }); + } }); test('summarizeHosterHealth excludes invalid and future timestamps from every time statistic', () => { diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js index 0df7324..028c7b2 100644 --- a/tests/ui-smoke.js +++ b/tests/ui-smoke.js @@ -993,18 +993,6 @@ setTimeout(async () => { unchecked: document.querySelector('[data-hoster-health-row="byse.sx"] [data-health="accounts"]')?.textContent.trim() }; setUiLanguage('de'); - window._historyForStats = []; - _invalidateHosterLifetimeCache(); - handleBatchDone({ - id: 'health-completed-batch', - timestamp: new Date().toISOString(), - total: 1, - succeeded: 1, - failed: 0, - skipped: 0, - files: [{ name: 'completed.bin', size: 2048, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 2 }] }] - }, { historyPersisted: true, deferPersistence: true }); - const completedSample = document.querySelector('[data-hoster-health-row="voe.sx"] [data-health="sample"]')?.textContent.trim(); config = previousConfig; accountStatuses = previousStatuses; _sessionFailedKeys = previousSessionFailedKeys; @@ -1012,12 +1000,11 @@ setTimeout(async () => { else delete window._historyForStats; _invalidateHosterLifetimeCache(); renderAccounts(); - return { empty, german, english, completedSample }; + return { empty, german, english }; })()\`); check('Host health overview renders clean empty and unchecked account states', hosterHealthStates.empty === 'Noch keine Hoster-Daten.' && hosterHealthStates.german.unchecked === 'Nicht geprüft'); check('Host health overview renders counts, existing-rate semantics, effective historical throughput, recent failures, and account problems', hosterHealthStates.german.sample === '3' && hosterHealthStates.german.outcomes === '1 / 1 / 1' && hosterHealthStates.german.rate === '50 %' && hosterHealthStates.german.throughput === '512 kB/s' && hosterHealthStates.german.lastSuccess !== 'Nie' && hosterHealthStates.german.recentFailures === '1' && hosterHealthStates.german.accountProblems === '1'); check('Host health overview switches fully to English without a renderer restart', hosterHealthStates.english.title === 'Host health' && hosterHealthStates.english.throughput === 'Effective historical throughput' && hosterHealthStates.english.unchecked === 'Not checked'); - check('A completed upload batch immediately refreshes host health history', hosterHealthStates.completedSample === '1'); const hosterHealthRegressionStates = await wc.executeJavaScript(\`(() => { const previousConfig = config; @@ -1025,7 +1012,6 @@ setTimeout(async () => { const previousSessionFailedKeys = _sessionFailedKeys; const hadHistory = Object.hasOwn(window, '_historyForStats'); const previousHistory = window._historyForStats; - const originalLoadHistory = loadHistory; let states; try { config = { ...config, hosters: Object.fromEntries(HOSTERS.map(name => [name, []])) }; @@ -1063,51 +1049,8 @@ setTimeout(async () => { accountProblems: invalidRow?.querySelector('[data-health="accounts"]')?.textContent.trim() }; - const now = Date.now(); - const loaded = Array.from({ length: 60 }, (_, index) => ({ - id: 'health-loaded-' + index, - timestamp: new Date(now - (index + 1) * 60 * 60 * 1000).toISOString(), - files: [{ name: 'loaded-' + index + '.bin', size: 1024, results: [{ hoster: 'voe.sx', status: 'error' }] }] - })); - window._historyForStats = loaded; - const completed = { - id: 'health-merged-completed', - timestamp: new Date(now).toISOString(), - total: 1, - files: [{ name: 'completed.bin', size: 1024, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 1 }] }] - }; - recordHosterHealthBatch(completed); - const replacement = { ...completed, total: 2 }; - recordHosterHealthBatch(replacement); - const mergedRow = document.querySelector('[data-hoster-health-row="voe.sx"]'); - const merged = { - historyLength: window._historyForStats.length, - loadedOrderPreserved: loaded.every((batch, index) => window._historyForStats[index] === batch), - completedIndex: window._historyForStats.indexOf(replacement), - completedCount: window._historyForStats.filter(batch => batch?.id === replacement.id).length, - sample: mergedRow?.querySelector('[data-health="sample"]')?.textContent.trim(), - outcomes: mergedRow?.querySelector('[data-health="outcomes"]')?.textContent.trim(), - recentFailures: mergedRow?.querySelector('[data-health="recent-failures"]')?.textContent.trim() - }; - - let historyReloads = 0; - loadHistory = async () => { historyReloads++; }; - window._historyForStats = null; - recordHosterHealthBatch({ ...completed, id: 'health-null-completed' }); - const nullState = { - preserved: window._historyForStats === null, - reloads: historyReloads - }; - delete window._historyForStats; - const reloadsBeforeUndefined = historyReloads; - recordHosterHealthBatch({ ...completed, id: 'health-undefined-completed' }); - const undefinedState = { - preserved: !Object.hasOwn(window, '_historyForStats') && window._historyForStats === undefined, - reloads: historyReloads - reloadsBeforeUndefined - }; - states = { invalidAndDisabled, merged, nullState, undefinedState }; + states = { invalidAndDisabled }; } finally { - loadHistory = originalLoadHistory; config = previousConfig; accountStatuses = previousStatuses; _sessionFailedKeys = previousSessionFailedKeys; @@ -1119,10 +1062,115 @@ setTimeout(async () => { return states; })()\`); check('Host health excludes disabled accounts and invalid or future batches from rendered problem and time statistics', hosterHealthRegressionStates.invalidAndDisabled.sample === '0' && hosterHealthRegressionStates.invalidAndDisabled.outcomes === '0 / 0 / 0' && hosterHealthRegressionStates.invalidAndDisabled.lastSuccess === 'Nie' && hosterHealthRegressionStates.invalidAndDisabled.recentFailures === '0' && hosterHealthRegressionStates.invalidAndDisabled.accountProblems === '0'); - check('Completed batches preserve full history while the renderer samples 50 and counts all seven-day failures', hosterHealthRegressionStates.merged.historyLength === 61 && hosterHealthRegressionStates.merged.loadedOrderPreserved === true && hosterHealthRegressionStates.merged.completedIndex === 60 && hosterHealthRegressionStates.merged.sample === '50' && hosterHealthRegressionStates.merged.outcomes === '1 / 49 / 0' && hosterHealthRegressionStates.merged.recentFailures === '60'); - check('Completed batch history merging deduplicates by batch ID without moving the loaded snapshot', hosterHealthRegressionStates.merged.completedCount === 1); - check('Completed batches preserve null history failures and trigger a reload', hosterHealthRegressionStates.nullState.preserved === true && hosterHealthRegressionStates.nullState.reloads === 1); - check('Completed batches preserve undefined initial history loading without starting a second load', hosterHealthRegressionStates.undefinedState.preserved === true && hosterHealthRegressionStates.undefinedState.reloads === 0); + + const healthHistoryResolvers = []; + ipcMain.removeHandler('get-history'); + ipcMain.handle('get-history', () => new Promise(resolve => { healthHistoryResolvers.push(resolve); })); + const healthBeforeReload = await wc.executeJavaScript(\`(() => { + window.__healthReloadPrevious = { + config, + accountStatuses, + sessionFailedKeys: _sessionFailedKeys, + hadHistory: Object.hasOwn(window, '_historyForStats'), + history: window._historyForStats, + historyEverLoaded: _historyEverLoaded, + historyDirty: _historyDirty + }; + config = { ...config, hosters: Object.fromEntries(HOSTERS.map(name => [name, []])) }; + accountStatuses = {}; + _sessionFailedKeys = new Set(); + window._historyForStats = [{ + id: 'health-before-batch', + timestamp: new Date(Date.now() - 60000).toISOString(), + files: [{ name: 'before.bin', size: 1024, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 1 }] }] + }]; + _invalidateHosterLifetimeCache(); + renderAccounts(); + window.__healthStaleReload = loadHistory(); + const row = document.querySelector('[data-hoster-health-row="voe.sx"]'); + return { + sample: row?.querySelector('[data-health="sample"]')?.textContent.trim(), + outcomes: row?.querySelector('[data-health="outcomes"]')?.textContent.trim() + }; + })()\`); + await waitUntil(() => healthHistoryResolvers.length === 1); + const healthPendingReload = await wc.executeJavaScript(\`(() => { + handleBatchDone({ + id: 'health-pruned-completed', + timestamp: new Date().toISOString(), + total: 1, + succeeded: 1, + failed: 0, + skipped: 0, + files: [{ name: 'pruned.bin', size: 2048, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 1 }] }] + }, { historyPersisted: true, deferPersistence: true }); + const row = document.querySelector('[data-hoster-health-row="voe.sx"]'); + return { + sample: row?.querySelector('[data-health="sample"]')?.textContent.trim(), + outcomes: row?.querySelector('[data-health="outcomes"]')?.textContent.trim(), + ids: window._historyForStats.map(batch => batch.id) + }; + })()\`); + const healthPostBatchReloadStarted = Boolean(await waitUntil(() => healthHistoryResolvers.length === 2, 750)); + const retainedHealthHistory = [{ + id: 'health-retained-authoritative', + timestamp: new Date().toISOString(), + files: [{ name: 'retained.bin', size: 1024, results: [{ hoster: 'voe.sx', status: 'error' }] }] + }]; + const staleHealthHistory = [{ + id: 'health-stale-before-batch', + timestamp: new Date().toISOString(), + files: [{ name: 'stale.bin', size: 1024, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 1 }] }] + }, { + id: 'health-pruned-completed', + timestamp: new Date().toISOString(), + files: [{ name: 'pruned.bin', size: 2048, results: [{ hoster: 'voe.sx', status: 'done', durationSec: 1 }] }] + }]; + if (healthPostBatchReloadStarted) { + healthHistoryResolvers[1](retainedHealthHistory); + await waitUntil(() => wc.executeJavaScript('window._historyForStats?.[0]?.id === "health-retained-authoritative"')); + } else { + healthHistoryResolvers[0](staleHealthHistory); + await wc.executeJavaScript('window.__healthStaleReload'); + } + const healthAfterAuthoritativeReload = await wc.executeJavaScript(\`(() => { + const row = document.querySelector('[data-hoster-health-row="voe.sx"]'); + return { + sample: row?.querySelector('[data-health="sample"]')?.textContent.trim(), + outcomes: row?.querySelector('[data-health="outcomes"]')?.textContent.trim(), + ids: window._historyForStats.map(batch => batch.id) + }; + })()\`); + if (healthPostBatchReloadStarted) { + healthHistoryResolvers[0](staleHealthHistory); + await wc.executeJavaScript('window.__healthStaleReload'); + } + const healthAfterStaleReload = await wc.executeJavaScript(\`(() => { + const row = document.querySelector('[data-hoster-health-row="voe.sx"]'); + return { + sample: row?.querySelector('[data-health="sample"]')?.textContent.trim(), + outcomes: row?.querySelector('[data-health="outcomes"]')?.textContent.trim(), + ids: window._historyForStats.map(batch => batch.id) + }; + })()\`); + restoreInitialIpcHandler('get-history'); + await wc.executeJavaScript(\`(() => { + const previous = window.__healthReloadPrevious; + config = previous.config; + accountStatuses = previous.accountStatuses; + _sessionFailedKeys = previous.sessionFailedKeys; + _historyEverLoaded = previous.historyEverLoaded; + _historyDirty = previous.historyDirty; + if (previous.hadHistory) window._historyForStats = previous.history; + else delete window._historyForStats; + delete window.__healthReloadPrevious; + delete window.__healthStaleReload; + _invalidateHosterLifetimeCache(); + renderAccounts(); + })()\`); + check('A persisted batch starts a fresh Health reload without replacing existing values with a loading or local state', healthPostBatchReloadStarted && healthBeforeReload.sample === '1' && healthBeforeReload.outcomes === '1 / 0 / 0' && healthPendingReload.sample === '1' && healthPendingReload.outcomes === '1 / 0 / 0' && healthPendingReload.ids.join('|') === 'health-before-batch'); + check('The authoritative post-batch reload removes batches already pruned by retention from Health', healthAfterAuthoritativeReload.sample === '1' && healthAfterAuthoritativeReload.outcomes === '0 / 1 / 0' && healthAfterAuthoritativeReload.ids.join('|') === 'health-retained-authoritative'); + check('A delayed older History response cannot overwrite the newest Health snapshot', healthAfterStaleReload.sample === '1' && healthAfterStaleReload.outcomes === '0 / 1 / 0' && healthAfterStaleReload.ids.join('|') === 'health-retained-authoritative'); await captureVisual('02-accounts.png');