diff --git a/lib/source-cleanup-policy.js b/lib/source-cleanup-policy.js index 1e05aae..7750c70 100644 --- a/lib/source-cleanup-policy.js +++ b/lib/source-cleanup-policy.js @@ -2,7 +2,6 @@ 'use strict'; const pathApi = typeof require === 'function' ? require('path') : null; - const protectedStatuses = new Set(['done', 'error', 'aborted', 'skipped']); const metadataVersion = 2; function normalizeFile(file, platform) { @@ -247,7 +246,7 @@ } function removeRequirement(queueJobs, job, platform) { - if (!job || protectedStatuses.has(job.status)) return []; + if (!job || job.status !== 'preview' || job.interrupted) return []; const siblings = relatedJobs(queueJobs, job, platform); const removedHoster = normalizeHoster(job.hoster); for (const sibling of siblings) { diff --git a/main.js b/main.js index 60e95a4..c6088e9 100644 --- a/main.js +++ b/main.js @@ -149,25 +149,184 @@ const startupExternalRevealBindings = createStartupExternalRevealBindings({ getRevealGate: () => startupRevealGate, sendDroppedFiles: paths => safeSend('drop-target:files', paths) }); -const pendingUploadFinalizations = new Map(); + +function createUploadFinalizationCoordinator({ + send, + saveQueue, + schedule = setTimeout, + cancelSchedule = clearTimeout, + createFinalizationId = () => `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + createDeliveryId = () => `delivery-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + timeoutMs = 15000 +}) { + const pending = new Map(); + let rendererGeneration = 0; + let rendererIsReady = false; + + function settle(entry, value) { + if (!entry || entry.settled) return; + entry.settled = true; + if (entry.timer) cancelSchedule(entry.timer); + pending.delete(entry.finalizationId); + entry.resolve(value); + } + + function armTimeout(entry) { + if (entry.timer) cancelSchedule(entry.timer); + entry.timer = schedule(() => settle(entry, false), timeoutMs); + } + + function deliver(entry) { + if (!rendererIsReady || entry.settled || entry.completing || entry.deliveredGeneration === rendererGeneration) return false; + const deliveryId = createDeliveryId(); + const delivered = send({ + summary: entry.summary, + finalizationId: entry.finalizationId, + deliveryId, + historyPersisted: entry.historyPersisted + }); + if (delivered === false) return false; + entry.deliveryId = deliveryId; + entry.deliveredGeneration = rendererGeneration; + return true; + } + + function request(summary, historyPersisted) { + const finalizationId = createFinalizationId(); + return new Promise((resolve) => { + const entry = { + finalizationId, + summary, + historyPersisted: historyPersisted === true, + deliveryId: null, + deliveredGeneration: 0, + completing: false, + settled: false, + timer: null, + resolve + }; + pending.set(finalizationId, entry); + armTimeout(entry); + deliver(entry); + }); + } + + function rendererBlocked() { + rendererIsReady = false; + for (const entry of pending.values()) armTimeout(entry); + } + + function rendererReady() { + if (rendererIsReady) return rendererGeneration; + rendererIsReady = true; + rendererGeneration += 1; + for (const entry of pending.values()) deliver(entry); + return rendererGeneration; + } + + async function complete(payload) { + const entry = payload?.finalizationId && pending.get(payload.finalizationId); + if (!entry || entry.settled || entry.completing || !payload.deliveryId || payload.deliveryId !== entry.deliveryId) return false; + entry.completing = true; + if (payload.ready === false) { + settle(entry, false); + return false; + } + try { + await saveQueue(payload.pendingQueue ?? null); + settle(entry, true); + return true; + } catch { + settle(entry, false); + return false; + } + } + + return { request, rendererBlocked, rendererReady, complete }; +} + +function createUploadFinalizationBarrier({ + appendHistory, + saveRecovery, + requestFinalization, + buildTerminalSnapshots, + now = () => new Date().toISOString(), + onError = () => {} +}) { + async function finalize(summary, recovery) { + let historyPersisted = true; + try { + await appendHistory(summary); + } catch (error) { + historyPersisted = false; + onError('history', error); + } + + const terminalRecovery = { + ...recovery, + settledAt: now(), + terminalJobs: buildTerminalSnapshots(summary) + }; + let terminalRecoveryPersisted = true; + try { + await saveRecovery(terminalRecovery); + } catch (error) { + terminalRecoveryPersisted = false; + onError('recovery', error); + } + + let queuePersisted = false; + try { + queuePersisted = (await requestFinalization(summary, historyPersisted)) === true; + } catch (error) { + onError('queue', error); + } + + let recoveryCleared = false; + if (queuePersisted && terminalRecoveryPersisted) { + try { + await saveRecovery(null); + recoveryCleared = true; + } catch (error) { + onError('recovery-clear', error); + } + } + + return { + historyPersisted, + queuePersisted, + terminalRecoveryPersisted, + recoveryCleared, + terminalRecovery + }; + } + + return { finalize }; +} function requestUploadFinalization(summary, historyPersisted) { - const finalizationId = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; - return new Promise((resolve) => { - const timer = setTimeout(() => { - pendingUploadFinalizations.delete(finalizationId); - resolve(false); - }, 15000); - pendingUploadFinalizations.set(finalizationId, { - resolve(value) { - clearTimeout(timer); - pendingUploadFinalizations.delete(finalizationId); - resolve(value); - } - }); - safeSend('upload-batch-done', { summary, finalizationId, historyPersisted: historyPersisted === true }); - }); + return uploadFinalizationCoordinator.request(summary, historyPersisted); } + +const uploadFinalizationCoordinator = createUploadFinalizationCoordinator({ + send: payload => safeSend('upload-batch-done', payload), + saveQueue: async pendingQueue => { + try { + await configStore.savePendingQueue(pendingQueue); + } catch (error) { + debugLog(`upload finalization queue save failed: ${error.message}`); + throw error; + } + }, + timeoutMs: RENDERER_READY_TIMEOUT_MS * 3 +}); +const uploadFinalizationBarrier = createUploadFinalizationBarrier({ + appendHistory: summary => configStore.appendHistory(summary), + saveRecovery: recovery => configStore.saveUploadRecovery(recovery), + requestFinalization: requestUploadFinalization, + buildTerminalSnapshots: buildTerminalJobSnapshots, + onError: (phase, error) => debugLog(`upload finalization ${phase} failed: ${error.message}`) +}); const activeUploadProducerTrackers = new Set(); const uploadStartReservation = createUploadStartReservation(); const settingsImportGate = createSettingsImportGate(() => !!uploadManager || uploadStartReservation.isActive()); @@ -1520,12 +1679,17 @@ function createWindow() { window: mainWindow, ipcMain, coordinator: startupRecoveryCoordinator, - onDocumentLoadStarted: currentStartupRevealGate.block, + onDocumentLoadStarted: () => { + currentStartupRevealGate.block(); + uploadFinalizationCoordinator.rendererBlocked(); + }, onRendererCrashed: (details) => { + uploadFinalizationCoordinator.rendererBlocked(); _writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details); debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`); }, onReady: () => { + uploadFinalizationCoordinator.rendererReady(); startupExternalRevealBindings.rendererReady(startupWindow.window); closeHandshakeReady = true; }, @@ -2106,11 +2270,21 @@ async function executeReservedUploadStart(payload, startLease) { skipped: 0, files: [] }, skippedJobs); - try { await configStore.appendHistory(skippedSummary); } catch (error) { - debugLog(`appendHistory for skipped jobs failed: ${error.message}`); - } - setImmediate(() => safeSend('upload-batch-done', skippedSummary)); - return { started: true, taskCount: 0, skippedJobs }; + lastSessionSummary = skippedSummary; + const finalization = await uploadFinalizationBarrier.finalize(skippedSummary, { + id: `skipped-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, + startedAt: new Date().toISOString(), + jobIds: skippedJobs.map(job => job.jobId).filter(Boolean) + }); + if (!finalization.queuePersisted) debugLog('upload finalization blocked: renderer queue acknowledgement missing'); + if (!finalization.terminalRecoveryPersisted) debugLog('upload finalization blocked: terminal recovery state was not persisted'); + return { + started: true, + taskCount: 0, + skippedJobs, + finalized: true, + historyPersisted: finalization.historyPersisted + }; } uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config)); @@ -2319,11 +2493,6 @@ async function executeReservedUploadStart(payload, startLease) { const _batchDurationSec = _thisManager && _thisManager.startTime ? Math.round((Date.now() - _thisManager.startTime) / 1000) : 0; - let historyPersisted = true; - try { await configStore.appendHistory(summary); } catch (err) { - historyPersisted = false; - debugLog(`appendHistory failed: ${err.message}`); - } if (_progressFlushTimer) { clearTimeout(_progressFlushTimer); _progressFlushTimer = null; @@ -2332,20 +2501,8 @@ async function executeReservedUploadStart(payload, startLease) { for (const value of _progressByJob.values()) finalProgressBatch.push(value); _progressByJob.clear(); if (finalProgressBatch.length) safeSend('upload-progress-batch', finalProgressBatch); - const recoveryWithTerminalJobs = { - ...recovery, - settledAt: new Date().toISOString(), - terminalJobs: buildTerminalJobSnapshots(summary) - }; - let terminalRecoveryPersisted = true; - try { await configStore.saveUploadRecovery(recoveryWithTerminalJobs); } catch (error) { - terminalRecoveryPersisted = false; - debugLog(`upload recovery outcomes could not be saved: ${error.message}`); - } - const queuePersisted = await requestUploadFinalization(summary, historyPersisted); - if (queuePersisted && terminalRecoveryPersisted) { - try { await configStore.saveUploadRecovery(null); } catch (error) { debugLog(`upload recovery state could not be cleared: ${error.message}`); } - } + const finalization = await uploadFinalizationBarrier.finalize(summary, recovery); + const { historyPersisted, queuePersisted, terminalRecoveryPersisted } = finalization; if (!queuePersisted) debugLog('upload finalization blocked: renderer queue acknowledgement missing'); if (!terminalRecoveryPersisted) debugLog('upload finalization blocked: terminal recovery state was not persisted'); if (hadActiveBatchMutation) debugLog('source cleanup blocked: batch mutation overlapped finalization'); @@ -2394,27 +2551,7 @@ async function executeReservedUploadStart(payload, startLease) { debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`); await batchMutationGate.sealAndDrain(); const errorSummary = buildFailedUploadSummary(tasks, 'Upload konnte nicht gestartet werden'); - let historyPersisted = true; - try { await configStore.appendHistory(errorSummary); } catch (historyError) { - historyPersisted = false; - debugLog(`appendHistory after start failure failed: ${historyError.message}`); - } - const terminalRecovery = { - ...recovery, - settledAt: new Date().toISOString(), - terminalJobs: buildTerminalJobSnapshots(errorSummary) - }; - let terminalRecoveryPersisted = true; - try { await configStore.saveUploadRecovery(terminalRecovery); } catch (recoveryError) { - terminalRecoveryPersisted = false; - debugLog(`upload recovery outcomes could not be saved after start failure: ${recoveryError.message}`); - } - const queuePersisted = await requestUploadFinalization(errorSummary, historyPersisted); - if (queuePersisted && terminalRecoveryPersisted) { - try { await configStore.saveUploadRecovery(null); } catch (clearError) { - debugLog(`upload recovery state could not be cleared after start failure: ${clearError.message}`); - } - } + await uploadFinalizationBarrier.finalize(errorSummary, recovery); _producerTracker.finish(); if (!isAutoRetry) sendBatchWebhook(errorSummary, 0); if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; } @@ -3085,22 +3222,7 @@ ipcMain.handle('save-pending-queue', async (_event, pendingQueue) => { }); ipcMain.handle('complete-upload-finalization', async (_event, payload) => { - const finalizationId = payload && payload.finalizationId; - const pending = finalizationId && pendingUploadFinalizations.get(finalizationId); - if (!pending) return false; - if (payload.ready === false) { - pending.resolve(false); - return false; - } - try { - await configStore.savePendingQueue(payload.pendingQueue ?? null); - pending.resolve(true); - return true; - } catch (error) { - debugLog(`upload finalization queue save failed: ${error.message}`); - pending.resolve(false); - return false; - } + return uploadFinalizationCoordinator.complete(payload); }); ipcMain.handle('save-global-settings', async (_event, globalSettings) => { diff --git a/renderer/app.js b/renderer/app.js index 44fe44e..6bde871 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -2968,6 +2968,11 @@ document.getElementById('contextMenu').addEventListener('click', (e) => { handleContextAction(action); }); +function removeSourceCleanupRequirements(jobs) { + if (!window.SourceCleanupPolicy || !Array.isArray(jobs)) return; + for (const job of jobs) window.SourceCleanupPolicy.removeRequirement(queueJobs, job, 'win32'); +} + async function handleContextAction(action) { _normalizeQueueSelectionToVisible(); if (action === 'start-selected') { @@ -2987,6 +2992,8 @@ async function handleContextAction(action) { return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server'); }); if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds); + const removedJobs = queueJobs.filter(j => selectedJobIds.has(j.id)); + removeSourceCleanupRequirements(removedJobs); const _deletedKeys = []; queueJobs = queueJobs.filter(j => { if (selectedJobIds.has(j.id)) { @@ -3014,6 +3021,7 @@ async function handleContextAction(action) { await window.api.cancelUpload(); uploading = false; } + removeSourceCleanupRequirements(queueJobs); queueJobs.forEach(j => removeJobFromIndex(j)); queueJobs = []; selectedJobIds.clear(); @@ -3036,6 +3044,7 @@ async function handleContextAction(action) { .filter(j => j.hoster === hoster && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server' || j.status === 'preview')) .map(j => j.id); if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds); + removeSourceCleanupRequirements(queueJobs.filter(j => j.hoster === hoster)); // Remove ALL jobs for this hoster queueJobs = queueJobs.filter(j => { if (j.hoster === hoster) { removeJobFromIndex(j); return false; } @@ -3111,12 +3120,19 @@ async function completeSourceCleanupFinalization(data) { const persist = async () => { if (!writesReady) { try { - await window.api.completeUploadFinalization({ finalizationId: data.finalizationId, ready: false }); + await window.api.completeUploadFinalization({ + finalizationId: data.finalizationId, + deliveryId: data.deliveryId, + historyPersisted: data.historyPersisted === true, + ready: false + }); } catch {} return false; } return window.api.completeUploadFinalization({ finalizationId: data.finalizationId, + deliveryId: data.deliveryId, + historyPersisted: data.historyPersisted === true, pendingQueue: data.historyPersisted !== true || queueJobs.some((job) => !['done', 'skipped'].includes(job.status)) ? buildPersistedQueueState({ historyPersisted: data.historyPersisted === true }) : null @@ -3195,7 +3211,7 @@ async function startUpload(opts) { window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); } _markSkippedJobs(result); - persistQueueStateSoon(); + if (result?.finalized !== true) persistQueueStateSoon(); if (result && result.error) { await showAppAlert(getLocalizedErrorDetail(result.error), 'Upload-Start fehlgeschlagen'); @@ -3319,7 +3335,7 @@ async function startSelectedUpload(explicitJobs) { window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); } _markSkippedJobs(result); - persistQueueStateSoon(); + if (result?.finalized !== true) persistQueueStateSoon(); if (result && result.error) { await showAppAlert(getLocalizedErrorDetail(result.error), 'Upload-Start fehlgeschlagen'); diff --git a/tests/source-cleanup-policy.test.js b/tests/source-cleanup-policy.test.js index ab15184..cc302e2 100644 --- a/tests/source-cleanup-policy.test.js +++ b/tests/source-cleanup-policy.test.js @@ -178,6 +178,47 @@ test('markCompleted keeps successful hosters provisional for the current round', } }); +test('removeRequirement never relaxes requirements for started or interrupted jobs', () => { + for (const candidate of [ + { status: 'queued' }, + { status: 'getting-server' }, + { status: 'uploading' }, + { status: 'retrying' }, + { status: 'preview', interrupted: true } + ]) { + const queueJobs = [ + { + id: 'job-complete', + file: 'C:\\Uploads\\Protected.mkv', + hoster: 'doodstream.com', + status: 'done', + sourceCleanupMetadataVersion: 2, + sourceCleanupToken: 'cleanup-protected', + sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'], + sourceCleanupConfirmedHosters: ['doodstream.com'] + }, + { + id: 'job-candidate', + file: 'C:\\Uploads\\Protected.mkv', + hoster: 'voe.sx', + sourceCleanupMetadataVersion: 2, + sourceCleanupToken: 'cleanup-protected', + sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'], + sourceCleanupConfirmedHosters: ['doodstream.com'], + ...candidate + } + ]; + + const touchedJobs = policy.removeRequirement(queueJobs, queueJobs[1], 'win32'); + + assert.deepEqual(touchedJobs, []); + assert.deepEqual(queueJobs.map(job => job.sourceCleanupRequiredHosters), [ + ['doodstream.com', 'voe.sx'], + ['doodstream.com', 'voe.sx'] + ]); + } +}); + test('applyFingerprints attaches Main fingerprints by token and includes them in payloads', () => { const queueJobs = queueFixture(); policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32'); diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js index 5e06137..311d8fe 100644 --- a/tests/ui-smoke.js +++ b/tests/ui-smoke.js @@ -803,6 +803,12 @@ setTimeout(async () => { const removeAllDanger = await wc.executeJavaScript('(() => { const item = document.querySelector("#contextMenu [data-action=delete-all]"); const channels = getComputedStyle(item).color.match(/[0-9.]+/g)?.map(Number) || []; return Boolean(item && channels.length >= 3 && channels[0] > channels[1] * 1.2 && channels[0] > channels[2] * 1.15); })()'); check('Remove all is visually marked as a destructive queue action', removeAllDanger === true); + await wc.executeJavaScript('(() => { queuePersistThrottle.cancel(); queueJobs = [{ id: "ui-cleanup-survivor", file: "C:/ui/cleanup-remove-selected.bin", fileName: "cleanup-remove-selected.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-cleanup-remove-selected", sourceCleanupRequiredHosters: ["doodstream.com", "voe.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-cleanup-remove-preview", file: "C:/ui/cleanup-remove-selected.bin", fileName: "cleanup-remove-selected.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-cleanup-remove-selected", sourceCleanupRequiredHosters: ["doodstream.com", "voe.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"] }]; selectedJobIds.clear(); selectedJobIds.add("ui-cleanup-remove-preview"); rebuildJobIndex(); renderQueueTable(); window.__uiCleanupPreviewRemoval = handleContextAction("delete-selected"); return true; })()'); + await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"')); + await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()'); + const previewCleanupRemoval = await wc.executeJavaScript('window.__uiCleanupPreviewRemoval.then(() => { delete window.__uiCleanupPreviewRemoval; queuePersistThrottle.cancel(); const survivor = queueJobs[0]; const result = { length: queueJobs.length, id: survivor?.id, required: survivor?.sourceCleanupRequiredHosters || [], confirmed: survivor?.sourceCleanupConfirmedHosters || [], finalizing: sourceCleanupFinalizationPending }; queueJobs = []; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); return result; })'); + check('Removing an unstarted destination drops only that cleanup prerequisite', previewCleanupRemoval.length === 1 && previewCleanupRemoval.id === 'ui-cleanup-survivor' && previewCleanupRemoval.required.join('|') === 'doodstream.com' && previewCleanupRemoval.confirmed.join('|') === 'doodstream.com' && previewCleanupRemoval.finalizing === false); + const queueTelemetryState = await wc.executeJavaScript(\`(async () => { queueJobs = [ { id: 'telemetry-done-a', status: 'done' }, @@ -833,14 +839,15 @@ setTimeout(async () => { let releaseSelectedQueueCancel = null; ipcMain.removeHandler('cancel-selected-jobs'); ipcMain.handle('cancel-selected-jobs', () => new Promise(resolve => { releaseSelectedQueueCancel = () => resolve(true); })); - await wc.executeJavaScript('(() => { queueJobs = [{ id: "ui-delete-selected", file: "C:/ui/delete-selected.bin", fileName: "delete-selected.bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0 }]; selectedJobIds.clear(); selectedJobIds.add("ui-delete-selected"); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteSelectedPromise = handleContextAction("delete-selected"); return true; })()'); + await wc.executeJavaScript('(() => { queueJobs = [{ id: "ui-delete-selected-survivor", file: "C:/ui/delete-selected.bin", fileName: "delete-selected.bin", hoster: "doodstream.com", status: "done", bytesUploaded: 100, bytesTotal: 100, progress: 1, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-selected-token", sourceCleanupRequiredHosters: ["doodstream.com", "byse.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-selected", file: "C:/ui/delete-selected.bin", fileName: "delete-selected.bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-selected-token", sourceCleanupRequiredHosters: ["doodstream.com", "byse.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"] }]; selectedJobIds.clear(); selectedJobIds.add("ui-delete-selected"); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteSelectedPromise = handleContextAction("delete-selected"); return true; })()'); await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"')); await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()'); await waitUntil(() => releaseSelectedQueueCancel); const selectedQueueStillPresent = await wc.executeJavaScript('queueJobs.length'); releaseSelectedQueueCancel(); - const selectedQueueAfterCancel = await wc.executeJavaScript('window.__uiDeleteSelectedPromise.then(() => { delete window.__uiDeleteSelectedPromise; return queueJobs.length; })'); - check('Removing selected uploads waits for the main-process cancellation acknowledgement', selectedQueueStillPresent === 1 && selectedQueueAfterCancel === 0); + const selectedQueueAfterCancel = await wc.executeJavaScript('window.__uiDeleteSelectedPromise.then(() => { delete window.__uiDeleteSelectedPromise; const result = { length: queueJobs.length, required: queueJobs[0]?.sourceCleanupRequiredHosters || [] }; queueJobs = []; rebuildJobIndex(); return result; })'); + check('Removing selected uploads waits for the main-process cancellation acknowledgement', selectedQueueStillPresent === 2 && selectedQueueAfterCancel.length === 1); + check('Cancelling a started destination never relaxes cleanup prerequisites', selectedQueueAfterCancel.required.join('|') === 'doodstream.com|byse.sx'); restoreInitialIpcHandler('cancel-selected-jobs'); let releaseFullQueueCancel = null; @@ -864,6 +871,18 @@ setTimeout(async () => { restoreInitialIpcHandler('cancel-upload'); restoreInitialIpcHandler('cancel-selected-jobs'); + await wc.executeJavaScript('(() => { uploading = false; queuePersistThrottle.cancel(); const required = ["doodstream.com", "voe.sx", "byse.sx"]; window.__uiDeleteAllCleanupJobs = [{ id: "ui-delete-all-cleanup-done", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-all-cleanup-voe", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-all-cleanup-byse", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "byse.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }]; queueJobs = window.__uiDeleteAllCleanupJobs; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteAllCleanupPromise = handleContextAction("delete-all"); return true; })()'); + await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"')); + await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()'); + const deleteAllCleanupState = await wc.executeJavaScript('window.__uiDeleteAllCleanupPromise.then(() => { delete window.__uiDeleteAllCleanupPromise; queuePersistThrottle.cancel(); const result = { queueLength: queueJobs.length, required: window.__uiDeleteAllCleanupJobs.map(job => job.sourceCleanupRequiredHosters || []), confirmed: window.__uiDeleteAllCleanupJobs.map(job => job.sourceCleanupConfirmedHosters || []) }; delete window.__uiDeleteAllCleanupJobs; return result; })'); + check('Remove all drops only never-started cleanup destinations without authorizing deletion', deleteAllCleanupState.queueLength === 0 && deleteAllCleanupState.required.every(hosters => hosters.join('|') === 'doodstream.com') && deleteAllCleanupState.confirmed.every(hosters => hosters.join('|') === 'doodstream.com')); + + await wc.executeJavaScript('(() => { const required = ["doodstream.com", "voe.sx", "byse.sx"]; queueJobs = [{ id: "ui-delete-hoster-cleanup-done", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-hoster-cleanup-voe", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-hoster-cleanup-byse", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "byse.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }]; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteHosterCleanupPromise = handleContextAction("delete-hoster:voe.sx"); return true; })()'); + await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"')); + await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()'); + const deleteHosterCleanupState = await wc.executeJavaScript('window.__uiDeleteHosterCleanupPromise.then(() => { delete window.__uiDeleteHosterCleanupPromise; queuePersistThrottle.cancel(); const result = { ids: queueJobs.map(job => job.id), required: queueJobs.map(job => job.sourceCleanupRequiredHosters || []), confirmed: queueJobs.map(job => job.sourceCleanupConfirmedHosters || []) }; queueJobs = []; rebuildJobIndex(); return result; })'); + check('Removing one unstarted host destination preserves every remaining cleanup prerequisite', deleteHosterCleanupState.ids.join('|') === 'ui-delete-hoster-cleanup-done|ui-delete-hoster-cleanup-byse' && deleteHosterCleanupState.required.every(hosters => hosters.join('|') === 'doodstream.com|byse.sx') && deleteHosterCleanupState.confirmed.every(hosters => hosters.join('|') === 'doodstream.com')); + const keyboardTab = await wc.executeJavaScript('document.getElementById("upload-tab").focus(); document.getElementById("upload-tab").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); document.querySelector(".tab.active")?.textContent?.trim() + "|" + document.activeElement?.id'); check('Arrow keys move and activate main tabs', keyboardTab === 'Accounts|accounts-tab'); @@ -2022,11 +2041,11 @@ setTimeout(async () => { sourceCleanupFinalizationPayload = payload; return false; }); - const sourceCleanupRollback = await wc.executeJavaScript('(async () => { queuePersistThrottle.cancel(); await flushConfigWrites(); queueJobs = [{ id: "ui-cleanup-voe", file: "C:/ui/cleanup-round.bin", fileName: "cleanup-round.bin", hoster: "voe.sx", status: "preview", bytesTotal: 10 }, { id: "ui-cleanup-byse", file: "C:/ui/cleanup-round.bin", fileName: "cleanup-round.bin", hoster: "byse.sx", status: "error", bytesTotal: 10 }]; rebuildJobIndex(); window.SourceCleanupPolicy.prepareGroups(queueJobs, queueJobs, () => "ui-cleanup-token", "win32"); queueJobs[0].status = "done"; window.SourceCleanupPolicy.markCompleted(queueJobs, queueJobs[0], "win32"); const before = buildPersistedQueueState(); if (typeof completeSourceCleanupFinalization !== "function") return { available: false, before }; const result = await completeSourceCleanupFinalization({ finalizationId: "ui-cleanup-finalization", historyPersisted: true }); const after = buildPersistedQueueState(); return { available: true, result, before, after }; })()'); + const sourceCleanupRollback = await wc.executeJavaScript('(async () => { queuePersistThrottle.cancel(); await flushConfigWrites(); queueJobs = [{ id: "ui-cleanup-voe", file: "C:/ui/cleanup-round.bin", fileName: "cleanup-round.bin", hoster: "voe.sx", status: "preview", bytesTotal: 10 }, { id: "ui-cleanup-byse", file: "C:/ui/cleanup-round.bin", fileName: "cleanup-round.bin", hoster: "byse.sx", status: "error", bytesTotal: 10 }]; rebuildJobIndex(); window.SourceCleanupPolicy.prepareGroups(queueJobs, queueJobs, () => "ui-cleanup-token", "win32"); queueJobs[0].status = "done"; window.SourceCleanupPolicy.markCompleted(queueJobs, queueJobs[0], "win32"); const before = buildPersistedQueueState(); if (typeof completeSourceCleanupFinalization !== "function") return { available: false, before }; const result = await completeSourceCleanupFinalization({ finalizationId: "ui-cleanup-finalization", deliveryId: "ui-cleanup-delivery", historyPersisted: true }); const after = buildPersistedQueueState(); return { available: true, result, before, after }; })()'); const sourceCleanupBeforeJobs = sourceCleanupRollback.before?.queueJobs || []; const sourceCleanupAfterJobs = sourceCleanupRollback.after?.queueJobs || []; const sourceCleanupPromotedJobs = sourceCleanupFinalizationPayload?.pendingQueue?.queueJobs || []; - const sourceCleanupRollbackOk = sourceCleanupRollback.available === true && sourceCleanupRollback.result === false && sourceCleanupBeforeJobs.length === 2 && sourceCleanupBeforeJobs.every(job => !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupProvisionalHosters') && !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupCompletedHosters') && (job.sourceCleanupConfirmedHosters || []).length === 0) && sourceCleanupPromotedJobs.length === 2 && sourceCleanupPromotedJobs.every(job => (job.sourceCleanupConfirmedHosters || []).join('|') === 'voe.sx') && sourceCleanupAfterJobs.length === 2 && sourceCleanupAfterJobs.every(job => (job.sourceCleanupConfirmedHosters || []).length === 0); + const sourceCleanupRollbackOk = sourceCleanupRollback.available === true && sourceCleanupRollback.result === false && sourceCleanupFinalizationPayload?.deliveryId === 'ui-cleanup-delivery' && sourceCleanupBeforeJobs.length === 2 && sourceCleanupBeforeJobs.every(job => !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupProvisionalHosters') && !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupCompletedHosters') && (job.sourceCleanupConfirmedHosters || []).length === 0) && sourceCleanupPromotedJobs.length === 2 && sourceCleanupPromotedJobs.every(job => (job.sourceCleanupConfirmedHosters || []).join('|') === 'voe.sx') && sourceCleanupAfterJobs.length === 2 && sourceCleanupAfterJobs.every(job => (job.sourceCleanupConfirmedHosters || []).length === 0); check('Final queue persistence promotes only inside the handshake and rolls back failed saves', sourceCleanupRollbackOk); const terminalRecoveryState = await wc.executeJavaScript('(() => { selectedFiles = []; queueJobs = [{ id: "ui-terminal-done", file: "C:/ui/terminal-done.bin", fileName: "terminal-done.bin", hoster: "voe.sx", status: "done", bytesTotal: 41, error: null, result: { download_url: "https://example.invalid/terminal-done", embed_url: "https://example.invalid/embed-terminal-done", file_code: "terminal-code" } }, { id: "ui-terminal-skipped", file: "C:/ui/terminal-skipped.bin", fileName: "terminal-skipped.bin", hoster: "byse.sx", status: "skipped", bytesTotal: 42, error: "Size limit", result: null }]; rebuildJobIndex(); return { normal: buildPersistedQueueState(), recovery: buildPersistedQueueState({ historyPersisted: false }) }; })()'); const terminalRecoveryJobs = terminalRecoveryState.recovery?.queueJobs || []; @@ -2078,6 +2097,34 @@ setTimeout(async () => { check('Live indexed upload IDs retain precedence over legacy visible identity', completionIdentityRaces.liveIndexedUpload.length === 1 && completionIdentityRaces.liveIndexedUpload[0].status === 'done' && completionIdentityRaces.liveIndexedUpload[0].uploadId === 'completion-live-upload' && completionIdentityRaces.liveIndexedUpload[0].link === 'https://example.invalid/live-upload'); check('Legacy completion identity applies only to one unambiguous candidate', completionIdentityRaces.ambiguousLegacy.every(job => job.status !== 'done') && completionIdentityRaces.uniqueLegacy.length === 1 && completionIdentityRaces.uniqueLegacy[0].status === 'done' && completionIdentityRaces.uniqueLegacy[0].link === 'https://example.invalid/unique'); restoreInitialIpcHandler('complete-upload-finalization'); + + await wc.executeJavaScript('queuePersistThrottle.cancel(); flushConfigWrites()'); + const allSkippedOriginalConfig = await wc.executeJavaScript('structuredClone(config)'); + await wc.executeJavaScript('config = { ...config, hosters: { ...(config.hosters || {}), "clouddrop.cc": [] } }; hosterSettings = config.hosterSettings || {}; saveConfigTracked(config)'); + const originalAppendHistoryForSkippedBatch = activeConfigStore.appendHistory.bind(activeConfigStore); + let skippedBatchHistoryFailures = 0; + activeConfigStore.appendHistory = async summary => { + if (String(summary?.id || '').startsWith('skipped-')) { + skippedBatchHistoryFailures++; + throw new Error('injected skipped batch history failure'); + } + return originalAppendHistoryForSkippedBatch(summary); + }; + let allSkippedFinalizationPayload = null; + const initialCompleteUploadFinalization = initialIpcHandlers.get('complete-upload-finalization'); + ipcMain.removeHandler('complete-upload-finalization'); + ipcMain.handle('complete-upload-finalization', async (event, payload) => { + allSkippedFinalizationPayload = structuredClone(payload); + return initialCompleteUploadFinalization(event, payload); + }); + const allSkippedRendererState = await wc.executeJavaScript('(async () => { queuePersistThrottle.cancel(); await flushConfigWrites(); uploading = false; selectedFiles = []; selectedUploadHosters = ["clouddrop.cc"]; queueJobs = [{ id: "ui-all-skipped", file: "C:/ui/all-skipped.bin", fileName: "all-skipped.bin", hoster: "clouddrop.cc", status: "preview", bytesTotal: 64 }]; rebuildJobIndex(); await startUpload(); await new Promise(resolve => setTimeout(resolve, 700)); queuePersistThrottle.flushSync(); await flushConfigWrites(); return { status: queueJobs[0]?.status, uploading }; })()'); + const allSkippedPersistedQueue = activeConfigStore.load().globalSettings?.pendingQueue; + activeConfigStore.appendHistory = originalAppendHistoryForSkippedBatch; + restoreInitialIpcHandler('complete-upload-finalization'); + await wc.executeJavaScript('config = ' + JSON.stringify(allSkippedOriginalConfig) + '; hosterSettings = config.hosterSettings || {}; selectedFiles = []; selectedUploadHosters = []; queueJobs = []; rebuildJobIndex(); queuePersistThrottle.cancel(); saveConfigTracked(config)'); + const allSkippedPayloadJobs = allSkippedFinalizationPayload?.pendingQueue?.queueJobs || []; + const allSkippedPersistedJobs = allSkippedPersistedQueue?.queueJobs || []; + check('A history failure in an all-skipped batch retains the terminal queue through the finalization barrier', skippedBatchHistoryFailures === 1 && allSkippedFinalizationPayload?.historyPersisted === false && typeof allSkippedFinalizationPayload?.deliveryId === 'string' && allSkippedPayloadJobs.length === 1 && allSkippedPayloadJobs[0].id === 'ui-all-skipped' && allSkippedPayloadJobs[0].status === 'skipped' && allSkippedPersistedJobs.length === 1 && allSkippedPersistedJobs[0].id === 'ui-all-skipped' && allSkippedPersistedJobs[0].status === 'skipped' && allSkippedRendererState.status === 'skipped' && allSkippedRendererState.uploading === false); await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")'); console.log('\\n=== History View ==='); diff --git a/tests/upload-finalization-coordinator.test.js b/tests/upload-finalization-coordinator.test.js new file mode 100644 index 0000000..8ea368f --- /dev/null +++ b/tests/upload-finalization-coordinator.test.js @@ -0,0 +1,153 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); + +const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); + +function loadMainFunction(name, nextName) { + const start = mainSource.indexOf(`function ${name}(`); + assert.notEqual(start, -1, `${name} must exist`); + const end = mainSource.indexOf(`\nfunction ${nextName}(`, start + name.length); + assert.notEqual(end, -1, `${nextName} must follow ${name}`); + const context = vm.createContext({}); + vm.runInContext(`${mainSource.slice(start, end)}\nthis.loaded = ${name};`, context); + return context.loaded; +} + +test('terminal finalization is delivered once per ready renderer generation and rejects stale acknowledgements', async () => { + const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier'); + const sent = []; + const savedQueues = []; + const scheduled = new Set(); + let deliverySequence = 0; + const coordinator = createCoordinator({ + send: payload => { + sent.push(JSON.parse(JSON.stringify(payload))); + return true; + }, + saveQueue: async pendingQueue => { + savedQueues.push(JSON.parse(JSON.stringify(pendingQueue))); + }, + schedule: callback => { + const token = { callback }; + scheduled.add(token); + return token; + }, + cancelSchedule: token => scheduled.delete(token), + createFinalizationId: () => 'finalization-1', + createDeliveryId: () => `delivery-${++deliverySequence}`, + timeoutMs: 15000 + }); + + const completion = coordinator.request({ id: 'batch-1', files: [] }, true); + let settled = false; + completion.then(() => { settled = true; }); + + assert.equal(sent.length, 0); + assert.equal(coordinator.rendererReady(), 1); + assert.equal(coordinator.rendererReady(), 1); + assert.equal(sent.length, 1); + assert.equal(sent[0].finalizationId, 'finalization-1'); + assert.equal(sent[0].deliveryId, 'delivery-1'); + assert.equal(sent[0].historyPersisted, true); + + coordinator.rendererBlocked(); + assert.equal(coordinator.rendererReady(), 2); + assert.equal(sent.length, 2); + assert.equal(sent[1].deliveryId, 'delivery-2'); + + const stale = await coordinator.complete({ + finalizationId: 'finalization-1', + deliveryId: 'delivery-1', + pendingQueue: { queueJobs: [{ id: 'stale' }] } + }); + await Promise.resolve(); + + assert.equal(stale, false); + assert.equal(settled, false); + assert.equal(savedQueues.length, 0); + + const accepted = await coordinator.complete({ + finalizationId: 'finalization-1', + deliveryId: 'delivery-2', + pendingQueue: { queueJobs: [{ id: 'terminal' }] } + }); + + assert.equal(accepted, true); + assert.equal(await completion, true); + assert.deepEqual(savedQueues, [{ queueJobs: [{ id: 'terminal' }] }]); + assert.equal(scheduled.size, 0); + + coordinator.rendererBlocked(); + coordinator.rendererReady(); + assert.equal(sent.length, 2); +}); + +test('history failure remains visible to the durable terminal finalization barrier', async () => { + const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization'); + const recoveries = []; + const finalizationCalls = []; + const errors = []; + const barrier = createBarrier({ + appendHistory: async () => { throw new Error('history unavailable'); }, + saveRecovery: async value => { recoveries.push(value === null ? null : JSON.parse(JSON.stringify(value))); }, + requestFinalization: async (summary, historyPersisted) => { + finalizationCalls.push({ summary: JSON.parse(JSON.stringify(summary)), historyPersisted }); + return false; + }, + buildTerminalSnapshots: summary => summary.files[0].results.map(result => ({ jobId: result.jobId, status: result.status })), + now: () => '2026-08-13T12:00:00.000Z', + onError: (phase, error) => errors.push([phase, error.message]) + }); + const summary = { + id: 'skipped-batch', + files: [{ name: 'missing-account.bin', results: [{ jobId: 'skip-1', status: 'skipped' }] }] + }; + + const result = await barrier.finalize(summary, { + id: 'recovery-skipped', + startedAt: '2026-08-13T11:59:59.000Z', + jobIds: ['skip-1'] + }); + + assert.equal(result.historyPersisted, false); + assert.equal(result.queuePersisted, false); + assert.equal(result.terminalRecoveryPersisted, true); + assert.deepEqual(finalizationCalls, [{ summary, historyPersisted: false }]); + assert.deepEqual(recoveries, [{ + id: 'recovery-skipped', + startedAt: '2026-08-13T11:59:59.000Z', + jobIds: ['skip-1'], + settledAt: '2026-08-13T12:00:00.000Z', + terminalJobs: [{ jobId: 'skip-1', status: 'skipped' }] + }]); + assert.deepEqual(errors, [['history', 'history unavailable']]); +}); + +test('terminal recovery clears only after history-independent queue acknowledgement', async () => { + const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization'); + const recoveries = []; + const barrier = createBarrier({ + appendHistory: async () => true, + saveRecovery: async value => { recoveries.push(value === null ? null : JSON.parse(JSON.stringify(value))); }, + requestFinalization: async (_summary, historyPersisted) => historyPersisted, + buildTerminalSnapshots: () => [{ jobId: 'done-1', status: 'done' }], + now: () => '2026-08-13T12:01:00.000Z', + onError: () => {} + }); + + const result = await barrier.finalize({ id: 'done-batch', files: [] }, { + id: 'recovery-done', + startedAt: '2026-08-13T12:00:00.000Z', + jobIds: ['done-1'] + }); + + assert.equal(result.historyPersisted, true); + assert.equal(result.queuePersisted, true); + assert.equal(result.terminalRecoveryPersisted, true); + assert.equal(result.recoveryCleared, true); + assert.equal(recoveries.length, 2); + assert.equal(recoveries[1], null); +}); diff --git a/tests/upload-recovery.test.js b/tests/upload-recovery.test.js index 78836af..6d187c2 100644 --- a/tests/upload-recovery.test.js +++ b/tests/upload-recovery.test.js @@ -79,17 +79,20 @@ test('main and renderer keep recovery evidence until final queue persistence suc const rendererSource = fs.readFileSync(path.join(root, 'renderer', 'app.js'), 'utf8'); const indexSource = fs.readFileSync(path.join(root, 'renderer', 'index.html'), 'utf8'); const batchDone = mainSource.slice(mainSource.indexOf("uploadManager.on('batch-done'"), mainSource.indexOf("ipcMain.handle('cancel-upload'")); + const barrier = mainSource.slice(mainSource.indexOf('function createUploadFinalizationBarrier'), mainSource.indexOf('function requestUploadFinalization')); - assert.match(batchDone, /buildTerminalJobSnapshots\(summary\)/); - assert.match(batchDone, /if \(queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveUploadRecovery\(null\)/); - assert.ok(batchDone.indexOf('saveUploadRecovery(recoveryWithTerminalJobs)') < batchDone.indexOf('requestUploadFinalization(summary, historyPersisted)')); + assert.match(mainSource, /buildTerminalSnapshots: buildTerminalJobSnapshots/); + assert.ok(barrier.indexOf('appendHistory(summary)') < barrier.indexOf('saveRecovery(terminalRecovery)')); + assert.ok(barrier.indexOf('saveRecovery(terminalRecovery)') < barrier.indexOf('requestFinalization(summary, historyPersisted)')); + assert.match(barrier, /if \(queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveRecovery\(null\)/); + assert.match(batchDone, /uploadFinalizationBarrier\.finalize\(summary, recovery\)/); const startFailure = batchDone.slice(batchDone.indexOf('startBatch(tasks')); assert.match(startFailure, /buildFailedUploadSummary\(tasks/); - assert.match(startFailure, /saveUploadRecovery\(terminalRecovery\)/); - assert.match(startFailure, /requestUploadFinalization\(errorSummary, historyPersisted\)/); - assert.match(startFailure, /if \(queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveUploadRecovery\(null\)/); + assert.match(startFailure, /uploadFinalizationBarrier\.finalize\(errorSummary, recovery\)/); const startHandler = mainSource.slice(mainSource.indexOf("ipcMain.handle('start-upload'"), mainSource.indexOf("ipcMain.handle('cancel-upload'")); assert.match(startHandler, /await configStore\.saveUploadRecovery\(recovery\)/); + assert.match(startHandler, /uploadFinalizationBarrier\.finalize\(skippedSummary/); + assert.match(startHandler, /finalized: true/); assert.ok(startHandler.indexOf('sourceCleanup.registerGroups(sourceCleanupGroups)') < startHandler.indexOf('saveUploadRecovery(recovery)')); assert.match(startHandler, /catch \(error\)[\s\S]*return { error: 'Upload-Wiederherstellung konnte nicht gespeichert werden' }/); const addHandler = mainSource.slice(mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'"), mainSource.indexOf("ipcMain.handle('finish-after-active'")); @@ -97,5 +100,6 @@ test('main and renderer keep recovery evidence until final queue persistence suc assert.ok(addHandler.indexOf('saveUploadRecovery(nextRecovery)') < addHandler.indexOf('batchManager.addJobs(tasks)')); assert.match(rendererSource, /window\.UploadRecovery\.getRecoveryOutcome/); assert.match(rendererSource, /data\.historyPersisted !== true/); + assert.match(rendererSource, /deliveryId: data\.deliveryId/); assert.ok(indexSource.indexOf('../lib/upload-recovery.js') < indexSource.indexOf('app.js')); });