From f63717cb5440145822290fa37decca01677536da Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:39:39 +0200 Subject: [PATCH] fix: preserve automation rollback contracts --- renderer/app.js | 119 +++++++++++++-- tests/startup-renderer.test.js | 255 ++++++++++++++++++++++++++++++++- 2 files changed, 358 insertions(+), 16 deletions(-) diff --git a/renderer/app.js b/renderer/app.js index 4a63a09..3db1b5e 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -730,16 +730,19 @@ async function applyAutomationEvaluation(evaluation) { sourceCleanupGroups: cleanupPreparation.groups }); if (result?.error) { + restoreSourceCleanupStates(cleanupPreparation.rollbackStates); const error = sanitizeUploadControlError(result.error, 'Jobs konnten nicht hinzugefügt werden.'); return freezeAutomationValue({ ok: false, error, warning: null, admittedFiles: [], deferredFiles, paused: /pausiert/i.test(error), dryRun: false }); } if ((Number(result?.added) || 0) !== newJobs.length) { + restoreSourceCleanupStates(cleanupPreparation.rollbackStates); return freezeAutomationValue({ ok: false, error: 'Jobs wurden nicht vollständig hinzugefügt.', warning: null, admittedFiles: [], deferredFiles, paused: false, dryRun: false }); } newJobs.forEach(job => { job.status = 'queued'; }); _markSkippedJobs(result); if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); } catch { + restoreSourceCleanupStates(cleanupPreparation.rollbackStates); return freezeAutomationValue({ ok: false, error: 'Jobs konnten nicht hinzugefügt werden.', warning: null, admittedFiles: [], deferredFiles, paused: false, dryRun: false }); } renderQueueTable(); @@ -779,10 +782,19 @@ window.applyAutomationEvaluation = applyAutomationEvaluation; window.createAutomationStatusSnapshot = createAutomationStatusSnapshot; window.runFolderMonitorTestScan = runFolderMonitorTestScan; +function surfaceAutomationOutcome(result) { + if (result?.ok !== false) return ''; + const message = result.warning || result.error || 'Automatische Aufnahme konnte nicht abgeschlossen werden.'; + showCopyToast(message, 6500); + return message; +} + async function handleFolderMonitorFiles(files) { window.api.debugLog('folder-monitor: received ' + files.length + ' file(s)'); const evaluation = await evaluateAutomationCandidates(files, { dryRun: false, trigger: 'watcher' }); - return applyAutomationEvaluation(evaluation); + const result = await applyAutomationEvaluation(evaluation); + surfaceAutomationOutcome(result); + return result; } // --- Init --- @@ -1535,6 +1547,7 @@ async function applyHosterSelection() { } catch { regularInjectionFailure = 'Jobs konnten nicht hinzugefügt werden.'; } + if (regularInjectionFailure) restoreSourceCleanupStates(cleanupPreparation.rollbackStates); renderQueueTable(); persistQueueStateSoon(true); } @@ -1546,6 +1559,7 @@ async function applyHosterSelection() { return { ok: false, error: regularInjectionFailure }; } const selectedHosters = selectedUploadHosters.slice(); + let automationOutcome = null; if (automationFiles.length > 0) { try { const evaluation = await evaluateAutomationCandidates(automationFiles, { @@ -1555,6 +1569,7 @@ async function applyHosterSelection() { ownedPendingPaths: automationFiles.map(file => file.path) }); const result = await applyAutomationEvaluation(evaluation); + automationOutcome = result; const admittedPaths = new Set((result?.admittedFiles || []).map(file => normalizeAutomationPath(file.path))); if (result?.error || admittedPaths.size === 0) throw new Error('manual-host-apply-failed'); _pendingFiles = _pendingFiles.filter(file => !admittedPaths.has(normalizeAutomationPath(file.path))); @@ -1582,6 +1597,10 @@ async function applyHosterSelection() { document.getElementById('hosterModal').style.display = 'flex'; renderImportPlanSummary(); } + if (automationOutcome?.warning) { + surfaceAutomationOutcome(automationOutcome); + return { ok: false, warning: automationOutcome.warning, error: null }; + } return true; } @@ -2108,6 +2127,10 @@ function suppressPreviewKeysStillSelected(keys) { function buildQueuePreview() { const hosters = getSelectedHosters(); queueJobs = queueJobs.filter(j => j.status !== 'preview' || j.automationAdmission === true); + const normalizedSettings = automationSettings(); + let availableSlots = normalizedSettings.queueLimitJobs === 0 + ? null + : Math.max(0, normalizedSettings.queueLimitJobs - window.AutomationControl.countAutomaticQueueJobs(queueJobs)); if (hosters.length > 0) { const existingKeys = new Set(); @@ -2116,10 +2139,14 @@ function buildQueuePreview() { } for (const file of selectedFiles) { - for (const hoster of hosters) { - if (!window.ImportPreflight.isImportPairEligible(file, hoster, hosterSettings)) continue; + const eligibleHosters = hosters.filter(hoster => { + if (!window.ImportPreflight.isImportPairEligible(file, hoster, hosterSettings)) return false; + const key = `${file.path}|${hoster}`; + return !existingKeys.has(key) && !_completedUploadKeys.has(key) && !_suppressedPreviewKeys.has(key); + }); + if (availableSlots !== null && eligibleHosters.length > availableSlots) continue; + for (const hoster of eligibleHosters) { const key = `${file.path}|${hoster}`; - if (!existingKeys.has(key) && !_completedUploadKeys.has(key) && !_suppressedPreviewKeys.has(key)) { const job = { id: `preview-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, file: file.path, fileName: file.name, hoster, @@ -2129,8 +2156,8 @@ function buildQueuePreview() { }; queueJobs.push(job); existingKeys.add(key); - } } + if (availableSlots !== null) availableSlots -= eligibleHosters.length; } } @@ -3694,9 +3721,41 @@ function getSelectedJobLinks() { } // --- Upload --- +const sourceCleanupFields = [ + 'sourceCleanupToken', + 'sourceCleanupRequiredHosters', + 'sourceCleanupCompletedHosters', + 'sourceCleanupFingerprint' +]; + +function cloneSourceCleanupValue(value) { + if (!value || typeof value !== 'object') return value; + return structuredClone(value); +} + +function captureSourceCleanupStates(jobs) { + const paths = new Set((Array.isArray(jobs) ? jobs : []).map(job => normalizeAutomationPath(job?.file)).filter(Boolean)); + return queueJobs + .filter(job => paths.has(normalizeAutomationPath(job?.file))) + .map(job => [job, Object.fromEntries(sourceCleanupFields.map(field => [field, { + present: Object.prototype.hasOwnProperty.call(job, field), + value: cloneSourceCleanupValue(job[field]) + }]))]); +} + +function restoreSourceCleanupStates(states) { + for (const [job, fields] of states || []) { + for (const field of sourceCleanupFields) { + if (!fields[field].present) delete job[field]; + else job[field] = cloneSourceCleanupValue(fields[field].value); + } + } +} + function prepareSourceCleanup(jobs) { - if (!config.globalSettings?.deleteSourceAfterSuccessfulUpload || !window.SourceCleanupPolicy) return { groups: [] }; - return window.SourceCleanupPolicy.prepareGroups(queueJobs, jobs, () => window.crypto.randomUUID(), 'win32'); + const rollbackStates = captureSourceCleanupStates(jobs); + if (!config.globalSettings?.deleteSourceAfterSuccessfulUpload || !window.SourceCleanupPolicy) return { groups: [], rollbackStates }; + return { ...window.SourceCleanupPolicy.prepareGroups(queueJobs, jobs, () => window.crypto.randomUUID(), 'win32'), rollbackStates }; } function serializeUploadJob(job) { @@ -3731,8 +3790,8 @@ function sanitizeUploadControlError(error, fallback) { } async function startUpload(opts) { - if (uploading) return; - if (await isAutomationPaused()) return false; + if (uploading) return { ok: false, error: 'Upload läuft bereits.' }; + if (await isAutomationPaused()) return { ok: false, error: 'Automatik ist pausiert' }; if (!(opts && opts._restoredAutoStart)) cancelStartupQueueAutoStart(); if (!(opts && opts._autoRetry)) _cancelAutoRetry(true); else _cancelAutoRetry(false); @@ -3746,17 +3805,22 @@ async function startUpload(opts) { await showAppAlert('Bitte mindestens einen Hoster auswählen.'); uploading = false; updateQueueActionButtons(); - return; + return { ok: false, error: 'Bitte mindestens einen Hoster auswählen.' }; } buildQueuePreview(); } const jobsToStart = queueJobs.filter((job) => isStartableQueueStatus(job.status)); - if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; } + if (jobsToStart.length === 0) { + uploading = false; + updateQueueActionButtons(); + return { ok: false, error: 'Keine startbaren Jobs vorhanden.' }; + } const originalStates = captureUploadJobStates(jobsToStart); + let cleanupPreparation = { groups: [], rollbackStates: [] }; try { - const cleanupPreparation = prepareSourceCleanup(jobsToStart); + cleanupPreparation = prepareSourceCleanup(jobsToStart); jobsToStart.forEach(j => { j.status = 'queued'; j.error = null; @@ -3780,6 +3844,7 @@ async function startUpload(opts) { }; const result = await window.api.startUpload(uploadPayload); if (result && result.error) { + restoreSourceCleanupStates(cleanupPreparation.rollbackStates); restoreUploadJobStates(originalStates); const error = sanitizeUploadControlError(result.error, 'Upload konnte nicht gestartet werden.'); uploading = false; @@ -3789,6 +3854,17 @@ async function startUpload(opts) { await showAppAlert(error, 'Upload-Start fehlgeschlagen'); return { ok: false, error }; } + if (result?.started !== true) { + restoreSourceCleanupStates(cleanupPreparation.rollbackStates); + restoreUploadJobStates(originalStates); + const error = 'Upload wurde nicht bestätigt.'; + uploading = false; + renderQueueTable(); + updateQueueActionButtons(); + updateStatusBar(); + await showAppAlert(error, 'Upload-Start fehlgeschlagen'); + return { ok: false, error }; + } if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) { window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); } @@ -3797,6 +3873,7 @@ async function startUpload(opts) { return { ok: true }; } catch (err) { + restoreSourceCleanupStates(cleanupPreparation.rollbackStates); restoreUploadJobStates(originalStates); uploading = false; renderQueueTable(); @@ -3821,7 +3898,7 @@ function _markSkippedJobs(result) { } async function startSelectedUpload(explicitJobs) { - if (await isAutomationPaused()) return false; + if (await isAutomationPaused()) return { ok: false, error: 'Automatik ist pausiert' }; const scopedJobs = Array.isArray(explicitJobs) ? explicitJobs : _getVisibleSelectedQueueJobs(); if (uploading) { _hydrateMissingJobSizes(); @@ -3845,6 +3922,7 @@ async function startSelectedUpload(explicitJobs) { sourceCleanupGroups: cleanupPreparation.groups }); } catch (err) { + restoreSourceCleanupStates(cleanupPreparation.rollbackStates); restoreUploadJobStates(originalStates); renderQueueTable(); const error = sanitizeUploadControlError(err, 'Jobs konnten nicht hinzugefügt werden.'); @@ -3853,6 +3931,7 @@ async function startSelectedUpload(explicitJobs) { } if (result && result.error === 'Kein Upload aktiv') { + restoreSourceCleanupStates(cleanupPreparation.rollbackStates); restoreUploadJobStates(originalStates); uploading = false; updateQueueActionButtons(); @@ -3860,12 +3939,21 @@ async function startSelectedUpload(explicitJobs) { return startSelectedUpload(addable); } if (result && result.error) { + restoreSourceCleanupStates(cleanupPreparation.rollbackStates); restoreUploadJobStates(originalStates); renderQueueTable(); const error = sanitizeUploadControlError(result.error, 'Jobs konnten nicht hinzugefügt werden.'); showCopyToast(error); return { ok: false, error }; } + if ((Number(result?.added) || 0) !== addable.length) { + restoreSourceCleanupStates(cleanupPreparation.rollbackStates); + restoreUploadJobStates(originalStates); + renderQueueTable(); + const error = 'Jobs wurden nicht vollständig hinzugefügt.'; + showCopyToast(error); + return { ok: false, error }; + } _markSkippedJobs(result); if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) { window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); @@ -3898,9 +3986,10 @@ async function startSelectedUpload(explicitJobs) { const jobsToStart = scopedJobs.filter(job => isStartableQueueStatus(job.status)); if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; } const originalStates = captureUploadJobStates(jobsToStart); + let cleanupPreparation = { groups: [], rollbackStates: [] }; try { - const cleanupPreparation = prepareSourceCleanup(jobsToStart); + cleanupPreparation = prepareSourceCleanup(jobsToStart); jobsToStart.forEach(j => { j.status = 'queued'; j.error = null; @@ -3921,6 +4010,7 @@ async function startSelectedUpload(explicitJobs) { }; const result = await window.api.startUpload(uploadPayload); if (result && result.error) { + restoreSourceCleanupStates(cleanupPreparation.rollbackStates); restoreUploadJobStates(originalStates); const error = sanitizeUploadControlError(result.error, 'Upload konnte nicht gestartet werden.'); uploading = false; @@ -3938,6 +4028,7 @@ async function startSelectedUpload(explicitJobs) { return { ok: true }; } catch (err) { + restoreSourceCleanupStates(cleanupPreparation.rollbackStates); restoreUploadJobStates(originalStates); uploading = false; renderQueueTable(); diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index 0bc5640..20da481 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -68,10 +68,12 @@ let automationProbe = { history: [], uploadLog: [], paused: false, + automationStatusSequence: [], historyError: '', addResult: null, addError: '', startResult: null, + startError: '', saveSettingsError: '', dryScan: { files: [], reachable: true, trigger: 'test' }, readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 }, @@ -164,10 +166,12 @@ contextBridge.exposeInMainWorld('api', { history: Array.isArray(value.history) ? value.history : [], uploadLog: Array.isArray(value.uploadLog) ? value.uploadLog : [], paused: value.paused === true, + automationStatusSequence: Array.isArray(value.automationStatusSequence) ? value.automationStatusSequence.map(entry => ({ ...entry })) : [], historyError: String(value.historyError || ''), addResult: value.addResult || null, addError: String(value.addError || ''), startResult: value.startResult || null, + startError: String(value.startError || ''), saveSettingsError: String(value.saveSettingsError || ''), dryScan: value.dryScan || { files: [], reachable: true, trigger: 'test' }, readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 }, @@ -222,6 +226,7 @@ contextBridge.exposeInMainWorld('api', { }, automationGetStatus() { automationProbe.readCalls.status++; + if (automationProbe.automationStatusSequence.length > 0) return Promise.resolve(automationProbe.automationStatusSequence.shift()); return Promise.resolve({ paused: automationProbe.paused }); }, folderMonitorTestScan() { @@ -252,6 +257,7 @@ contextBridge.exposeInMainWorld('api', { }, startUpload(payload) { automationProbe.mutationCalls.push(['start', payload?.jobs?.length || 0]); + if (automationProbe.startError) return Promise.reject(new Error(automationProbe.startError)); return Promise.resolve(automationProbe.startResult || { started: true }); }, getFolderMonitorProbeCalls() { return folderMonitorProbeCalls; } @@ -735,6 +741,8 @@ contextBridge.exposeInMainWorld('api', { { path: 'C:\\\\watch\\\\b.mkv', name: 'b.mkv', size: 3 * 1024 * 1024, mtimeMs: 2, filterMatched: true } ]; configureAtomicState(14998); + selectedFiles = [{ path: 'C:\\\\manual\\\\unplanned.mkv', name: 'unplanned.mkv', size: 1 }]; + selectedUploadHosters = ['clouddrop.cc']; const atomicEvaluation = await evaluateAutomationCandidates(atomicCandidates, { dryRun: false, trigger: 'watcher' }); const atomicResult = await applyAutomationEvaluation(atomicEvaluation); const selectedHostersAfterApply = selectedUploadHosters.slice(); @@ -748,6 +756,7 @@ contextBridge.exposeInMainWorld('api', { deferred: config.globalSettings.folderMonitor.telemetry.deferred, queued: config.globalSettings.folderMonitor.telemetry.queued, currentJobCount: window.AutomationControl.countAutomaticQueueJobs(queueJobs), + unplannedJobs: queueJobs.filter(job => job.fileName === 'unplanned.mkv').length, selectedHostersAfterApply, manualSelectionFilesAfterApply, plannedHostsBeforeRebuild, @@ -908,6 +917,174 @@ contextBridge.exposeInMainWorld('api', { uploading = false; const mainPauseResponses = { startRace, addRace, selectedStartRace, manualRace }; + const cleanupState = job => ({ + token: job.sourceCleanupToken, + required: clone(job.sourceCleanupRequiredHosters), + completed: clone(job.sourceCleanupCompletedHosters), + fingerprint: clone(job.sourceCleanupFingerprint) + }); + const runCleanupRollbackCase = async (name, probe) => { + configureAtomicState(0); + config.globalSettings.deleteSourceAfterSuccessfulUpload = true; + const filePath = 'C:\\\\cleanup\\\\' + name + '.mkv'; + const target = { + ...makePauseRaceJob(name + '.mkv'), + id: 'cleanup-target-' + name, + file: filePath, + sourceCleanupToken: 'preview-token-' + name, + sourceCleanupRequiredHosters: ['doodstream.com'], + sourceCleanupCompletedHosters: [], + sourceCleanupFingerprint: { size: 11, mtimeMs: 22 } + }; + const sibling = { + ...makePauseRaceJob(name + '-done.mkv'), + id: 'cleanup-sibling-' + name, + file: filePath, + fileName: name + '.mkv', + hoster: 'voe.sx', + status: 'done', + sourceCleanupToken: 'done-token-' + name, + sourceCleanupRequiredHosters: ['voe.sx'], + sourceCleanupCompletedHosters: ['voe.sx'], + sourceCleanupFingerprint: { size: 33, mtimeMs: 44 } + }; + queueJobs = [target, sibling]; + uploading = true; + rebuildJobIndex(); + window.api.configureAutomationProbe({ paused: false, ...probe }); + const before = JSON.stringify([cleanupState(target), cleanupState(sibling)]); + const result = await startSelectedUpload([target]); + const after = JSON.stringify([cleanupState(target), cleanupState(sibling)]); + uploading = false; + return { + cleanupByteIdentical: after === before, + targetStatus: target.status, + siblingStatus: sibling.status, + result + }; + }; + const cleanupRollback = { + paused: await runCleanupRollbackCase('paused', { addResult: { error: 'Automatik ist pausiert', added: 0 } }), + error: await runCleanupRollbackCase('error', { addResult: { error: 'token=secret-value', added: 0 } }), + exception: await runCleanupRollbackCase('exception', { addError: 'token=secret-value' }), + unconfirmed: await runCleanupRollbackCase('unconfirmed', { addResult: { added: 0 } }) + }; + + configureAtomicState(0); + selectedFiles = []; + config.globalSettings.folderMonitor.autoStart = true; + config.globalSettings.folderMonitor.hosters = ['doodstream.com']; + config.globalSettings.folderMonitor.telemetry = { dateKey: new Date().toLocaleDateString('en-CA'), detected: 0, queued: 0, skipped: 0, deferred: 0 }; + hosterSettings = {}; + uploading = false; + window.api.configureAutomationProbe({ + paused: false, + automationStatusSequence: [{ paused: false }, { paused: true }] + }); + const betweenFile = { path: 'C:\\\\between\\\\paused.mkv', name: 'paused.mkv', size: 1, mtimeMs: 1 }; + const betweenEvaluation = await evaluateAutomationCandidates([betweenFile], { dryRun: false, trigger: 'watcher' }); + const betweenResult = await applyAutomationEvaluation(betweenEvaluation); + const betweenProbe = await window.api.getAutomationProbeState(); + const pauseBetweenApplyAndStart = { + result: { + ok: betweenResult.ok, + error: betweenResult.error || null, + warning: betweenResult.warning || null, + admitted: betweenResult.admittedFiles.map(file => file.name) + }, + status: queueJobs.find(job => job.file === betweenFile.path)?.status || null, + queuedTelemetry: config.globalSettings.folderMonitor.telemetry.queued, + startCalls: betweenProbe.mutationCalls.filter(call => call[0] === 'start').length + }; + const runStartAcceptanceCase = async (name, probe) => { + configureAtomicState(0); + selectedFiles = []; + selectedUploadHosters = ['doodstream.com']; + const job = makePauseRaceJob(name + '.mkv'); + queueJobs = [job]; + uploading = false; + rebuildJobIndex(); + window.api.configureAutomationProbe({ paused: false, ...probe }); + const originalAlert = showAppAlert; + showAppAlert = async () => {}; + const result = await startUpload(); + showAppAlert = originalAlert; + return { result, status: job.status, uploading }; + }; + const startAcceptance = { + accepted: await runStartAcceptanceCase('accepted', { startResult: { started: true } }), + unconfirmed: await runStartAcceptanceCase('unconfirmed', { startResult: { started: false } }), + exception: await runStartAcceptanceCase('exception', { startError: 'token=secret-value' }) + }; + + const originalShowCopyToastForAutomation = showCopyToast; + const feedbackMessages = []; + showCopyToast = message => { feedbackMessages.push(String(message)); }; + configureAtomicState(0); + selectedFiles = []; + config.globalSettings.folderMonitor.hosters = ['doodstream.com']; + config.globalSettings.folderMonitor.autoStart = false; + hosterSettings = {}; + window.api.configureAutomationProbe({ paused: false, saveSettingsError: 'token=telemetry-secret' }); + const watcherWarningResult = await handleFolderMonitorFiles([ + { path: 'C:\\\\feedback\\\\watcher-warning.mkv', name: 'watcher-warning.mkv', size: 1, mtimeMs: 1 } + ]); + const watcherWarningFeedback = feedbackMessages.splice(0); + failedConfigWriteOperations.length = 0; + configureAtomicState(0); + selectedFiles = []; + config.globalSettings.folderMonitor.hosters = ['doodstream.com']; + config.globalSettings.folderMonitor.autoStart = true; + hosterSettings = {}; + uploading = true; + window.api.configureAutomationProbe({ paused: false, addResult: { error: 'token=secret-value', added: 0 } }); + const watcherErrorResult = await handleFolderMonitorFiles([ + { path: 'C:\\\\feedback\\\\watcher-error.mkv', name: 'watcher-error.mkv', size: 1, mtimeMs: 1 } + ]); + const watcherErrorFeedback = feedbackMessages.splice(0); + uploading = false; + configureAtomicState(0); + selectedFiles = []; + config.globalSettings.folderMonitor.hosters = []; + config.globalSettings.folderMonitor.autoStart = false; + hosterSettings = {}; + const modalWarningFile = { path: 'C:\\\\feedback\\\\modal-warning.mkv', name: 'modal-warning.mkv', size: 1, mtimeMs: 1 }; + _pendingFiles = [modalWarningFile]; + _pendingImportInspection = { candidateCount: 1, duplicateCount: 0, unavailableCount: 0, accepted: [modalWarningFile] }; + _pendingImportInspections = 0; + _pendingFolderMonitorAutoStart.clear(); + _pendingFolderMonitorAutoStart.set(modalWarningFile.path, false); + const modalWarningInput = document.createElement('input'); + modalWarningInput.type = 'checkbox'; + modalWarningInput.dataset.hosterModal = 'doodstream.com'; + modalWarningInput.checked = true; + document.getElementById('hosterModalList').replaceChildren(modalWarningInput); + document.getElementById('hosterModal').style.display = 'flex'; + window.api.configureAutomationProbe({ paused: false, saveSettingsError: 'token=telemetry-secret' }); + const modalWarningResult = await applyHosterSelection(); + const modalWarningFeedback = feedbackMessages.splice(0); + failedConfigWriteOperations.length = 0; + showCopyToast = originalShowCopyToastForAutomation; + const fulfilledFeedback = { + watcherWarning: { + result: { ok: watcherWarningResult.ok, warning: watcherWarningResult.warning || null, error: watcherWarningResult.error || null }, + feedback: watcherWarningFeedback + }, + watcherError: { + result: { ok: watcherErrorResult.ok, warning: watcherErrorResult.warning || null, error: watcherErrorResult.error || null }, + feedback: watcherErrorFeedback + }, + modalWarning: { + result: modalWarningResult, + feedback: modalWarningFeedback, + pending: _pendingFiles.length, + markers: _pendingFolderMonitorAutoStart.size, + modalOpen: document.getElementById('hosterModal').style.display === 'flex', + queueStatus: queueJobs.find(job => job.file === modalWarningFile.path)?.status || null + }, + secretExposed: JSON.stringify({ watcherWarningFeedback, watcherErrorFeedback, modalWarningFeedback, modalWarningResult }).includes('secret') + }; + const runInjectionCase = async (name, probe) => { configureAtomicState(0); selectedFiles = []; @@ -985,7 +1162,7 @@ contextBridge.exposeInMainWorld('api', { startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length, injectCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'inject').length }; - return { dry, manualTest, historyEvidence, pendingDedup, manualHostTransactional, atomic, status, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, injectionOutcomes, paused }; + return { dry, manualTest, historyEvidence, pendingDedup, manualHostTransactional, atomic, status, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused }; })()`; const onlineBackupBehaviorScript = `(async () => { const ids = { @@ -1421,8 +1598,9 @@ app.whenReady().then(async () => { deferred: 1, queued: 1, currentJobCount: 15000, + unplannedJobs: 0, selectedHostersAfterApply: ['clouddrop.cc'], - manualSelectionFilesAfterApply: [], + manualSelectionFilesAfterApply: ['unplanned.mkv'], plannedHostsBeforeRebuild: ['byse.sx', 'vidmoly.me'], hostsAfterRebuild: ['byse.sx', 'vidmoly.me'] }); @@ -1472,6 +1650,79 @@ app.whenReady().then(async () => { uploading: true } }); + assert.deepEqual(result.automationPipeline.cleanupRollback, { + paused: { + cleanupByteIdentical: true, + targetStatus: 'preview', + siblingStatus: 'done', + result: { ok: false, error: 'Automatik ist pausiert' } + }, + error: { + cleanupByteIdentical: true, + targetStatus: 'preview', + siblingStatus: 'done', + result: { ok: false, error: 'Jobs konnten nicht hinzugefügt werden.' } + }, + exception: { + cleanupByteIdentical: true, + targetStatus: 'preview', + siblingStatus: 'done', + result: { ok: false, error: 'Jobs konnten nicht hinzugefügt werden.' } + }, + unconfirmed: { + cleanupByteIdentical: true, + targetStatus: 'preview', + siblingStatus: 'done', + result: { ok: false, error: 'Jobs wurden nicht vollständig hinzugefügt.' } + } + }); + assert.deepEqual(result.automationPipeline.pauseBetweenApplyAndStart, { + result: { + ok: false, + error: 'Automatik ist pausiert', + warning: null, + admitted: [] + }, + status: 'preview', + queuedTelemetry: 0, + startCalls: 0 + }); + assert.deepEqual(result.automationPipeline.startAcceptance, { + accepted: { + result: { ok: true }, + status: 'queued', + uploading: true + }, + unconfirmed: { + result: { ok: false, error: 'Upload wurde nicht bestätigt.' }, + status: 'preview', + uploading: false + }, + exception: { + result: { ok: false, error: 'Upload konnte nicht gestartet werden.' }, + status: 'preview', + uploading: false + } + }); + assert.deepEqual(result.automationPipeline.fulfilledFeedback, { + watcherWarning: { + result: { ok: false, warning: 'Telemetrie konnte nicht gespeichert werden.', error: null }, + feedback: ['Telemetrie konnte nicht gespeichert werden.'] + }, + watcherError: { + result: { ok: false, warning: null, error: 'Jobs konnten nicht hinzugefügt werden.' }, + feedback: ['Jobs konnten nicht hinzugefügt werden.'] + }, + modalWarning: { + result: { ok: false, warning: 'Telemetrie konnte nicht gespeichert werden.', error: null }, + feedback: ['Telemetrie konnte nicht gespeichert werden.'], + pending: 0, + markers: 0, + modalOpen: false, + queueStatus: 'preview' + }, + secretExposed: false + }); assert.deepEqual(result.automationPipeline.injectionOutcomes, { pausedInjection: { ok: false,