diff --git a/renderer/app.js b/renderer/app.js index 591168d..4a63a09 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -442,14 +442,26 @@ function flattenAutomationHistoryRows(history) { const rows = []; for (const entry of Array.isArray(history) ? history : []) { if (!Array.isArray(entry?.files)) { - rows.push(entry); + const status = String(entry?.status || '').toLowerCase(); + const link = entry?.download_url || entry?.embed_url || entry?.link || entry?.file_code || ''; + if (['done', 'success', 'completed'].includes(status) || link) rows.push({ ...entry, link }); continue; } for (const file of entry.files) { - rows.push({ - path: file?.path || file?.file || '', - fileName: file?.fileName || file?.filename || file?.name || '' - }); + const results = Array.isArray(file?.results) ? file.results : []; + for (const result of results) { + const status = String(result?.status || '').toLowerCase(); + const link = result?.download_url || result?.embed_url || result?.link || result?.file_code || ''; + if (!['done', 'success', 'completed'].includes(status) && !link) continue; + rows.push({ + ...result, + path: file?.path || file?.file || '', + fileName: file?.fileName || file?.filename || file?.name || '', + status, + hoster: result?.hoster || '', + link + }); + } } } return rows; @@ -502,9 +514,16 @@ function createAutomationStatusSnapshot() { async function evaluateAutomationCandidates(files, options = {}) { const source = Array.isArray(files) ? files : []; - const candidates = source.map(normalizeAutomationCandidate); + const normalizedCandidates = source.map(normalizeAutomationCandidate); + const candidateMap = new Map(); + for (const candidate of normalizedCandidates) { + const key = normalizeAutomationPath(candidate.path); + if (key && !candidateMap.has(key)) candidateMap.set(key, candidate); + } + const candidates = [...candidateMap.values()]; const matched = candidates.filter(candidate => candidate.path && candidate.filterMatched); const folderSettings = config.globalSettings?.folderMonitor || {}; + const ownedPendingPaths = new Set((Array.isArray(options.ownedPendingPaths) ? options.ownedPendingPaths : []).map(normalizeAutomationPath)); const selectedHosters = Array.from(new Set((Array.isArray(options.selectedHosters) ? options.selectedHosters : folderSettings.hosters || []) .map(value => String(value || '').trim()) .filter(Boolean))); @@ -520,7 +539,10 @@ async function evaluateAutomationCandidates(files, options = {}) { }); const processedPaths = new Set(processed.processedPaths.map(normalizeAutomationPath)); const unprocessed = matched.filter(candidate => !processedPaths.has(normalizeAutomationPath(candidate.path))); - const inspection = await window.api.inspectImportFiles(unprocessed, []); + const inspection = await window.api.inspectImportFiles( + unprocessed, + _pendingFiles.filter(file => !ownedPendingPaths.has(normalizeAutomationPath(file.path))).map(file => file.path) + ); const metadata = new Map(unprocessed.map(candidate => [normalizeAutomationPath(candidate.path), candidate])); const accepted = (Array.isArray(inspection?.accepted) ? inspection.accepted : []).map(file => ({ ...(metadata.get(normalizeAutomationPath(file?.path)) || {}), @@ -568,6 +590,7 @@ async function evaluateAutomationCandidates(files, options = {}) { queueJobCount: currentJobCount, queueLimitJobs: normalizedSettings.queueLimitJobs, selectedHosters, + ownedPendingPaths: [...ownedPendingPaths], candidates: plannedCandidates, admittedFiles, deferredFiles, @@ -598,7 +621,8 @@ function createAutomationPreviewJob(file, hoster) { result: null, attempt: 0, maxAttempts: 0, - link: '' + link: '', + automationAdmission: true }; } @@ -613,8 +637,10 @@ async function persistAutomationTelemetry(delta) { config.globalSettings = nextSettings; try { await saveGlobalSettingsTracked(nextSettings); - } catch {} - return telemetry; + return { telemetry, warning: '' }; + } catch { + return { telemetry, warning: 'Telemetrie konnte nicht gespeichert werden.' }; + } } async function applyAutomationEvaluation(evaluation) { @@ -626,9 +652,30 @@ async function applyAutomationEvaluation(evaluation) { if (paused && !manualPreview) { return freezeAutomationValue({ admittedFiles: [], deferredFiles: evaluation.candidates || [], paused: true, dryRun: false }); } - const currentPaths = new Set([...queueJobs.map(job => job.file), ...selectedFiles.map(file => file.path)].map(normalizeAutomationPath)); - const candidates = evaluation.candidates.filter(candidate => !currentPaths.has(normalizeAutomationPath(candidate.path))); - if (evaluation.selectedHosters.length === 0) { + const selectedHosters = Array.from(new Set((manualPreview + ? evaluation.selectedHosters + : config.globalSettings?.folderMonitor?.hosters || []) + .map(value => String(value || '').trim()) + .filter(Boolean))); + const replannedCandidates = evaluation.candidates.map(file => { + const eligibleHosters = window.ImportPreflight.getEligibleImportHosters(file, selectedHosters, hosterSettings); + return { + path: file.path, + name: file.name, + size: file.size, + mtimeMs: file.mtimeMs, + eligibleHosters, + eligibleJobCount: eligibleHosters.length + }; + }); + const ownedPendingPaths = new Set(Array.isArray(evaluation.ownedPendingPaths) ? evaluation.ownedPendingPaths : []); + const currentPaths = new Set([ + ...queueJobs.map(job => job.file), + ...selectedFiles.map(file => file.path), + ..._pendingFiles.filter(file => !ownedPendingPaths.has(normalizeAutomationPath(file.path))).map(file => file.path) + ].map(normalizeAutomationPath)); + const candidates = replannedCandidates.filter(candidate => !currentPaths.has(normalizeAutomationPath(candidate.path))); + if (selectedHosters.length === 0) { if (candidates.length > 0) { _pendingFiles.push(...candidates.map(file => ({ path: file.path, name: file.name, size: file.size, mtimeMs: file.mtimeMs }))); mergePendingImportInspection({ @@ -657,11 +704,7 @@ async function applyAutomationEvaluation(evaluation) { if (admittedFiles.length === 0) { return freezeAutomationValue({ admittedFiles: [], deferredFiles, paused, dryRun: false }); } - const filePaths = new Set(admittedFiles.map(file => file.path)); const newJobs = admittedFiles.flatMap(file => file.eligibleHosters.map(hoster => createAutomationPreviewJob(file, hoster))); - selectedUploadHosters = evaluation.selectedHosters.slice(); - clearDedupKeysForPaths(filePaths); - selectedFiles.push(...admittedFiles.map(file => ({ path: file.path, name: file.name, size: file.size }))); queueJobs.push(...newJobs); rebuildJobIndex(); _queueStatsCache = null; @@ -671,35 +714,59 @@ async function applyAutomationEvaluation(evaluation) { updateStatusBar(); updateStatsPanel(); persistQueueStateSoon(true); - await persistAutomationTelemetry({ - detected: evaluation.summary.found, - queued: admittedFiles.length, - skipped: evaluation.summary.alreadyProcessed + evaluation.summary.unavailable, - deferred: deferredFiles.length, - lastDetectedName: admittedFiles.at(-1)?.name || '' - }); const action = paused && manualPreview ? 'queue' : resolveFolderMonitorQueueAction({ autoStart: config.globalSettings?.folderMonitor?.autoStart === true, uploading, healthCheckRunning }); - if (action === 'inject' && !(await isAutomationPaused())) { + if (action === 'inject') { + if (await isAutomationPaused()) { + return freezeAutomationValue({ ok: false, error: 'Automatik ist pausiert', warning: null, admittedFiles: [], deferredFiles, paused: true, dryRun: false }); + } const cleanupPreparation = prepareSourceCleanup(newJobs); try { const result = await window.api.addJobsToBatch({ jobs: newJobs.map(serializeUploadJob), sourceCleanupGroups: cleanupPreparation.groups }); - if (!result?.error) newJobs.forEach(job => { job.status = 'queued'; }); + if (result?.error) { + 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) { + 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 {} + } catch { + return freezeAutomationValue({ ok: false, error: 'Jobs konnten nicht hinzugefügt werden.', warning: null, admittedFiles: [], deferredFiles, paused: false, dryRun: false }); + } renderQueueTable(); persistQueueStateSoon(true); } else if (action === 'start') { - await startUpload(); + const result = await startUpload(); + if (result?.ok === false) { + return freezeAutomationValue({ ok: false, error: result.error, warning: null, admittedFiles: [], deferredFiles, paused: /pausiert/i.test(result.error), dryRun: false }); + } } - return freezeAutomationValue({ admittedFiles, deferredFiles, paused, dryRun: false, plannedJobs: admission.plannedJobs }); + const telemetryResult = await persistAutomationTelemetry({ + detected: evaluation.summary.found, + queued: admittedFiles.length, + skipped: evaluation.summary.alreadyProcessed + evaluation.summary.unavailable, + deferred: deferredFiles.length, + lastDetectedName: admittedFiles.at(-1)?.name || '' + }); + return freezeAutomationValue({ + ok: telemetryResult.warning === '', + error: null, + warning: telemetryResult.warning || null, + admittedFiles, + deferredFiles, + paused, + dryRun: false, + plannedJobs: admission.plannedJobs + }); } async function runFolderMonitorTestScan() { @@ -1433,15 +1500,17 @@ async function applyHosterSelection() { selectedUploadHosters = Array.from(document.querySelectorAll('input[data-hoster-modal]:checked')) .map(input => input.dataset.hosterModal); const pendingFiles = _pendingFiles.slice(); - const automationFiles = pendingFiles.filter(file => _pendingFolderMonitorAutoStart.has(file.path)); - const regularFiles = pendingFiles.filter(file => !_pendingFolderMonitorAutoStart.has(file.path)); + const automationPathKeys = new Set([..._pendingFolderMonitorAutoStart.keys()].map(normalizeAutomationPath)); + const automationFiles = pendingFiles.filter(file => automationPathKeys.has(normalizeAutomationPath(file.path))); + const regularFiles = pendingFiles.filter(file => !automationPathKeys.has(normalizeAutomationPath(file.path))); const admittedFiles = regularFiles.filter(file => window.ImportPreflight .getEligibleImportHosters(file, selectedUploadHosters, hosterSettings).length > 0); const pendingPaths = new Set(admittedFiles.map(f => f.path)); + let regularInjectionFailure = null; if (admittedFiles.length > 0) { selectedFiles.push(...admittedFiles); } - _pendingFiles = []; + _pendingFiles = automationFiles.slice(); clearDedupKeysForPaths(pendingPaths); renderHosterSummary(); if (pendingPaths.size > 0) buildQueuePreview(); @@ -1454,27 +1523,64 @@ async function applyHosterSelection() { jobs: newJobs.map(serializeUploadJob), sourceCleanupGroups: cleanupPreparation.groups }); - if (!result?.error) newJobs.forEach(job => { job.status = 'queued'; }); - _markSkippedJobs(result); - if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); - } catch {} + if (result?.error) { + regularInjectionFailure = sanitizeUploadControlError(result.error, 'Jobs konnten nicht hinzugefügt werden.'); + } else if ((Number(result?.added) || 0) !== newJobs.length) { + regularInjectionFailure = 'Jobs wurden nicht vollständig hinzugefügt.'; + } else { + newJobs.forEach(job => { job.status = 'queued'; }); + _markSkippedJobs(result); + if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints); + } + } catch { + regularInjectionFailure = 'Jobs konnten nicht hinzugefügt werden.'; + } renderQueueTable(); persistQueueStateSoon(true); } } updateUploadView(); persistQueueStateSoon(true); + if (regularInjectionFailure) { + showCopyToast(regularInjectionFailure, 6500); + return { ok: false, error: regularInjectionFailure }; + } const selectedHosters = selectedUploadHosters.slice(); - _pendingFolderMonitorAutoStart.clear(); - _pendingImportInspection = null; - document.getElementById('hosterModal').style.display = 'none'; if (automationFiles.length > 0) { - const evaluation = await evaluateAutomationCandidates(automationFiles, { - dryRun: false, - trigger: 'manual-host', - selectedHosters - }); - await applyAutomationEvaluation(evaluation); + try { + const evaluation = await evaluateAutomationCandidates(automationFiles, { + dryRun: false, + trigger: 'manual-host', + selectedHosters, + ownedPendingPaths: automationFiles.map(file => file.path) + }); + const result = await applyAutomationEvaluation(evaluation); + 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))); + for (const path of [..._pendingFolderMonitorAutoStart.keys()]) { + if (admittedPaths.has(normalizeAutomationPath(path))) _pendingFolderMonitorAutoStart.delete(path); + } + if (_pendingImportInspection) { + _pendingImportInspection = { + ..._pendingImportInspection, + accepted: (_pendingImportInspection.accepted || []).filter(file => !admittedPaths.has(normalizeAutomationPath(file.path))) + }; + } + } catch { + const failure = { ok: false, error: 'Automatische Aufnahme konnte nicht abgeschlossen werden.' }; + showCopyToast(failure.error, 6500); + document.getElementById('hosterModal').style.display = 'flex'; + renderImportPlanSummary(); + return failure; + } + } + if (_pendingFiles.length === 0) { + _pendingImportInspection = null; + document.getElementById('hosterModal').style.display = 'none'; + } else { + document.getElementById('hosterModal').style.display = 'flex'; + renderImportPlanSummary(); } return true; } @@ -1550,6 +1656,7 @@ function restoreQueueStateFromConfig() { sourceCleanupRequiredHosters: Array.isArray(job.sourceCleanupRequiredHosters) ? [...job.sourceCleanupRequiredHosters] : [], sourceCleanupCompletedHosters: Array.isArray(job.sourceCleanupCompletedHosters) ? [...job.sourceCleanupCompletedHosters] : [], sourceCleanupFingerprint: job.sourceCleanupFingerprint || null, + automationAdmission: job.automationAdmission === true, attempt: 0, maxAttempts: job.maxAttempts || 0, link: '', @@ -1576,7 +1683,7 @@ function buildPersistedQueueState() { const selectedFileMap = new Map(selectedFiles.map(file => [file.path, file])); for (const job of persistableJobs) { - if (job.file && !selectedFileMap.has(job.file)) { + if (job.file && job.automationAdmission !== true && !selectedFileMap.has(job.file)) { selectedFileMap.set(job.file, { path: job.file, name: job.fileName, @@ -1627,6 +1734,7 @@ function buildPersistedQueueState() { sourceCleanupRequiredHosters: Array.isArray(job.sourceCleanupRequiredHosters) ? [...job.sourceCleanupRequiredHosters] : [], sourceCleanupCompletedHosters: Array.isArray(job.sourceCleanupCompletedHosters) ? [...job.sourceCleanupCompletedHosters] : [], sourceCleanupFingerprint: job.sourceCleanupFingerprint || null, + automationAdmission: job.automationAdmission === true, maxAttempts: job.maxAttempts || 0 }; }) @@ -1999,7 +2107,7 @@ function suppressPreviewKeysStillSelected(keys) { // Build preview jobs from selected files x selected hosters (before upload starts) function buildQueuePreview() { const hosters = getSelectedHosters(); - queueJobs = queueJobs.filter(j => j.status !== 'preview'); + queueJobs = queueJobs.filter(j => j.status !== 'preview' || j.automationAdmission === true); if (hosters.length > 0) { const existingKeys = new Set(); @@ -3604,6 +3712,24 @@ function serializeUploadJob(job) { }; } +function captureUploadJobStates(jobs) { + return jobs.map(job => [job, { ...job }]); +} + +function restoreUploadJobStates(states) { + for (const [job, state] of states) { + for (const key of Object.keys(job)) { + if (!Object.prototype.hasOwnProperty.call(state, key)) delete job[key]; + } + Object.assign(job, state); + } +} + +function sanitizeUploadControlError(error, fallback) { + const message = String(error?.message || error || ''); + return /pausiert/i.test(message) ? 'Automatik ist pausiert' : fallback; +} + async function startUpload(opts) { if (uploading) return; if (await isAutomationPaused()) return false; @@ -3627,6 +3753,7 @@ async function startUpload(opts) { const jobsToStart = queueJobs.filter((job) => isStartableQueueStatus(job.status)); if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; } + const originalStates = captureUploadJobStates(jobsToStart); try { const cleanupPreparation = prepareSourceCleanup(jobsToStart); @@ -3652,23 +3779,32 @@ async function startUpload(opts) { sourceCleanupGroups: cleanupPreparation.groups }; const result = await window.api.startUpload(uploadPayload); + if (result && result.error) { + restoreUploadJobStates(originalStates); + const error = sanitizeUploadControlError(result.error, 'Upload konnte nicht gestartet werden.'); + 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); } _markSkippedJobs(result); persistQueueStateSoon(); - if (result && result.error) { - await showAppAlert(result.error, 'Upload-Start fehlgeschlagen'); - uploading = false; - updateQueueActionButtons(); - updateStatusBar(); - } + return { ok: true }; } catch (err) { + restoreUploadJobStates(originalStates); uploading = false; + renderQueueTable(); updateQueueActionButtons(); updateStatusBar(); - await showAppAlert(`Upload-Start fehlgeschlagen: ${err.message}`, 'Upload-Start fehlgeschlagen'); + const error = sanitizeUploadControlError(err, 'Upload konnte nicht gestartet werden.'); + await showAppAlert(error, 'Upload-Start fehlgeschlagen'); + return { ok: false, error }; } } @@ -3695,6 +3831,7 @@ async function startSelectedUpload(explicitJobs) { return; } { + const originalStates = captureUploadJobStates(addable); const cleanupPreparation = prepareSourceCleanup(addable); addable.forEach(j => { j.status = 'queued'; j.error = null; j.result = null; @@ -3708,17 +3845,26 @@ async function startSelectedUpload(explicitJobs) { sourceCleanupGroups: cleanupPreparation.groups }); } catch (err) { - showCopyToast(`Jobs konnten nicht hinzugefügt werden: ${err.message}`); - return; + restoreUploadJobStates(originalStates); + renderQueueTable(); + const error = sanitizeUploadControlError(err, 'Jobs konnten nicht hinzugefügt werden.'); + showCopyToast(error); + return { ok: false, error }; } - // If the batch ended between UI-state and IPC call, start a fresh batch immediately if (result && result.error === 'Kein Upload aktiv') { + restoreUploadJobStates(originalStates); uploading = false; updateQueueActionButtons(); updateStatusBar(); - await startSelectedUpload(addable); - return; + return startSelectedUpload(addable); + } + if (result && result.error) { + restoreUploadJobStates(originalStates); + renderQueueTable(); + const error = sanitizeUploadControlError(result.error, 'Jobs konnten nicht hinzugefügt werden.'); + showCopyToast(error); + return { ok: false, error }; } _markSkippedJobs(result); if (result?.sourceCleanupFingerprints && window.SourceCleanupPolicy) { @@ -3742,7 +3888,7 @@ async function startSelectedUpload(explicitJobs) { } else { showCopyToast('Keine Jobs hinzugefuegt'); } - return; + return { ok: true, added }; } } uploading = true; // set immediately to prevent double-click race @@ -3751,6 +3897,7 @@ async function startSelectedUpload(explicitJobs) { const hosters = getSelectedHosters(); const jobsToStart = scopedJobs.filter(job => isStartableQueueStatus(job.status)); if (jobsToStart.length === 0) { uploading = false; updateQueueActionButtons(); return; } + const originalStates = captureUploadJobStates(jobsToStart); try { const cleanupPreparation = prepareSourceCleanup(jobsToStart); @@ -3773,23 +3920,32 @@ async function startSelectedUpload(explicitJobs) { sourceCleanupGroups: cleanupPreparation.groups }; const result = await window.api.startUpload(uploadPayload); + if (result && result.error) { + restoreUploadJobStates(originalStates); + const error = sanitizeUploadControlError(result.error, 'Upload konnte nicht gestartet werden.'); + 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); } _markSkippedJobs(result); persistQueueStateSoon(); - if (result && result.error) { - await showAppAlert(result.error, 'Upload-Start fehlgeschlagen'); - uploading = false; - updateQueueActionButtons(); - updateStatusBar(); - } + return { ok: true }; } catch (err) { + restoreUploadJobStates(originalStates); uploading = false; + renderQueueTable(); updateQueueActionButtons(); updateStatusBar(); - await showAppAlert(`Upload-Start fehlgeschlagen: ${err.message}`, 'Upload-Start fehlgeschlagen'); + const error = sanitizeUploadControlError(err, 'Upload konnte nicht gestartet werden.'); + await showAppAlert(error, 'Upload-Start fehlgeschlagen'); + return { ok: false, error }; } } @@ -4463,7 +4619,7 @@ function moveSelectedJobs(direction) { function syncSelectedFilesFromQueue() { const fileMap = new Map(); queueJobs - .filter((job) => !['done', 'skipped', 'aborted'].includes(job.status)) + .filter((job) => job.automationAdmission !== true && !['done', 'skipped', 'aborted'].includes(job.status)) .forEach((job) => { if (!job.file || fileMap.has(job.file)) return; fileMap.set(job.file, { diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index 6ce72ba..0bc5640 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -68,6 +68,11 @@ let automationProbe = { history: [], uploadLog: [], paused: false, + historyError: '', + addResult: null, + addError: '', + startResult: null, + saveSettingsError: '', dryScan: { files: [], reachable: true, trigger: 'test' }, readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 }, mutationCalls: [], @@ -159,6 +164,11 @@ contextBridge.exposeInMainWorld('api', { history: Array.isArray(value.history) ? value.history : [], uploadLog: Array.isArray(value.uploadLog) ? value.uploadLog : [], paused: value.paused === true, + historyError: String(value.historyError || ''), + addResult: value.addResult || null, + addError: String(value.addError || ''), + startResult: value.startResult || null, + 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 }, mutationCalls: [], @@ -174,23 +184,36 @@ contextBridge.exposeInMainWorld('api', { savedSettings: automationProbe.savedSettings.map(value => JSON.parse(JSON.stringify(value))) }; }, - inspectImportFiles(entries) { + inspectImportFiles(entries, existingPaths) { automationProbe.readCalls.inspect++; const candidates = Array.isArray(entries) ? entries : []; - const unavailable = candidates.filter(entry => entry?.unavailable).map(entry => ({ ...entry, reason: 'unreadable' })); - const accepted = candidates.filter(entry => !entry?.unavailable).map(entry => ({ ...entry })); + const normalize = value => String(value || '').replace(/\\\\/g, '/').toLowerCase(); + const seen = new Set((Array.isArray(existingPaths) ? existingPaths : []).map(normalize)); + const duplicates = []; + const unique = []; + for (const entry of candidates) { + const key = normalize(entry?.path); + if (seen.has(key)) duplicates.push({ ...entry }); + else { + seen.add(key); + unique.push(entry); + } + } + const unavailable = unique.filter(entry => entry?.unavailable).map(entry => ({ ...entry, reason: 'unreadable' })); + const accepted = unique.filter(entry => !entry?.unavailable).map(entry => ({ ...entry })); return Promise.resolve({ candidateCount: candidates.length, - duplicateCount: 0, + duplicateCount: duplicates.length, unavailableCount: unavailable.length, acceptedCount: accepted.length, accepted, - duplicates: [], + duplicates, unavailable }); }, getHistory() { automationProbe.readCalls.history++; + if (automationProbe.historyError) return Promise.reject(new Error(automationProbe.historyError)); return Promise.resolve(automationProbe.history); }, readOwnUploadLog() { @@ -213,6 +236,7 @@ contextBridge.exposeInMainWorld('api', { saveGlobalSettings(value) { automationProbe.savedSettings.push(value); automationProbe.mutationCalls.push(['settings']); + if (automationProbe.saveSettingsError) return Promise.reject(new Error(automationProbe.saveSettingsError)); return Promise.resolve(true); }, savePendingQueue(payload) { @@ -223,11 +247,12 @@ contextBridge.exposeInMainWorld('api', { addJobsToBatch(payload) { folderMonitorProbeCalls.push(['inject', payload?.jobs?.length || 0]); automationProbe.mutationCalls.push(['inject', payload?.jobs?.length || 0]); - return Promise.resolve({ added: payload?.jobs?.length || 0 }); + if (automationProbe.addError) return Promise.reject(new Error(automationProbe.addError)); + return Promise.resolve(automationProbe.addResult || { added: payload?.jobs?.length || 0 }); }, startUpload(payload) { automationProbe.mutationCalls.push(['start', payload?.jobs?.length || 0]); - return Promise.resolve({ started: true }); + return Promise.resolve(automationProbe.startResult || { started: true }); }, getFolderMonitorProbeCalls() { return folderMonitorProbeCalls; } }); @@ -536,6 +561,142 @@ contextBridge.exposeInMainWorld('api', { summary: manualTestPreview.summary, reads: manualTestProbe.readCalls }; + const historyCandidates = [ + { path: 'C:\\\\history\\\\success.mkv', name: 'success.mkv', size: 1 }, + { path: 'C:\\\\history\\\\link-success.mkv', name: 'link-success.mkv', size: 1 }, + { path: 'C:\\\\history\\\\error.mkv', name: 'error.mkv', size: 1 }, + { path: 'C:\\\\history\\\\aborted.mkv', name: 'aborted.mkv', size: 1 }, + { path: 'C:\\\\history\\\\skipped.mkv', name: 'skipped.mkv', size: 1 }, + { path: 'C:\\\\history\\\\all-failed.mkv', name: 'all-failed.mkv', size: 1 }, + { path: 'C:\\\\history-a\\\\same-name.mkv', name: 'same-name.mkv', size: 1 }, + { path: 'D:\\\\history-b\\\\same-name.mkv', name: 'same-name.mkv', size: 1 } + ]; + config.globalSettings.folderMonitor = { + enabled: true, + folderPath: 'C:\\\\history', + hosters: ['doodstream.com'], + autoStart: false, + queueLimitJobs: 15000, + paused: false + }; + hosterSettings = {}; + selectedFiles = []; + queueJobs = []; + rebuildJobIndex(); + window.api.configureAutomationProbe({ + paused: false, + history: [{ + id: 'evidence', + files: [ + { path: historyCandidates[0].path, name: historyCandidates[0].name, results: [{ hoster: 'doodstream.com', status: 'done' }] }, + { path: historyCandidates[1].path, name: historyCandidates[1].name, results: [{ hoster: 'doodstream.com', status: 'error', download_url: 'https://example.test/link' }] }, + { path: historyCandidates[2].path, name: historyCandidates[2].name, results: [{ hoster: 'doodstream.com', status: 'error' }] }, + { path: historyCandidates[3].path, name: historyCandidates[3].name, results: [{ hoster: 'doodstream.com', status: 'aborted' }] }, + { path: historyCandidates[4].path, name: historyCandidates[4].name, results: [{ hoster: 'doodstream.com', status: 'skipped' }] }, + { path: historyCandidates[5].path, name: historyCandidates[5].name, results: [{ hoster: 'doodstream.com', status: 'error' }, { hoster: 'voe.sx', status: 'aborted' }] }, + { name: 'same-name.mkv', results: [{ hoster: 'doodstream.com', status: 'done' }] } + ] + }] + }); + const historyEvaluation = await evaluateAutomationCandidates(historyCandidates, { dryRun: true, trigger: 'test' }); + const historyEvidence = { + alreadyProcessed: historyEvaluation.summary.alreadyProcessed, + acceptedNames: historyEvaluation.candidates.map(file => file.name).sort(), + resultingJobs: historyEvaluation.summary.resultingJobs + }; + config.globalSettings.folderMonitor = { + enabled: true, + folderPath: 'C:\\\\pending', + hosters: [], + autoStart: false, + queueLimitJobs: 15000, + paused: false + }; + selectedFiles = []; + queueJobs = []; + _pendingFiles = [{ path: 'C:\\\\pending\\\\existing.mkv', name: 'existing.mkv', size: 1 }]; + _pendingImportInspection = { candidateCount: 1, duplicateCount: 0, unavailableCount: 0, accepted: _pendingFiles.slice() }; + _pendingFolderMonitorAutoStart.clear(); + rebuildJobIndex(); + window.api.configureAutomationProbe({ paused: false }); + const pendingEvaluation = await evaluateAutomationCandidates([ + { path: 'c:\\\\PENDING\\\\existing.mkv', name: 'existing.mkv', size: 1 }, + { path: 'C:\\\\pending\\\\new.mkv', name: 'new.mkv', size: 1 }, + { path: 'c:\\\\PENDING\\\\new.mkv', name: 'new.mkv', size: 1 } + ], { dryRun: true, trigger: 'test' }); + _pendingFiles = []; + _pendingImportInspection = null; + _pendingFolderMonitorAutoStart.clear(); + const overlapping = { path: 'C:\\\\pending\\\\overlap.mkv', name: 'overlap.mkv', size: 1 }; + await Promise.all([ + handleFolderMonitorFiles([overlapping, { ...overlapping, path: 'c:\\\\PENDING\\\\overlap.mkv' }]), + handleFolderMonitorFiles([{ ...overlapping }]) + ]); + const pendingDedup = { + evaluatedNames: pendingEvaluation.candidates.map(file => file.name), + pendingPaths: _pendingFiles.map(file => normalizeAutomationPath(file.path)), + pendingAccepted: _pendingImportInspection?.accepted?.length || 0, + markerPaths: [..._pendingFolderMonitorAutoStart.keys()].map(normalizeAutomationPath) + }; + const prepareManualHostFailure = ({ full, historyError }) => { + const file = { path: 'C:\\\\manual-host\\\\pending.mkv', name: 'pending.mkv', size: 1, mtimeMs: 1 }; + config.globalSettings.folderMonitor = { + enabled: true, + folderPath: 'C:\\\\manual-host', + hosters: [], + autoStart: false, + queueLimitJobs: full ? 1 : 15000, + paused: false + }; + hosterSettings = {}; + selectedFiles = []; + selectedUploadHosters = []; + queueJobs = full ? [{ id: 'full', file: 'C:\\\\queue\\\\full.mkv', fileName: 'full.mkv', hoster: 'doodstream.com', status: 'queued', bytesTotal: 1 }] : []; + _pendingFiles = [file]; + _pendingImportInspection = { candidateCount: 1, duplicateCount: 0, unavailableCount: 0, accepted: [file] }; + _pendingImportInspections = 0; + _pendingFolderMonitorAutoStart.clear(); + _pendingFolderMonitorAutoStart.set(file.path, false); + rebuildJobIndex(); + const input = document.createElement('input'); + input.type = 'checkbox'; + input.dataset.hosterModal = 'doodstream.com'; + input.checked = true; + document.getElementById('hosterModalList').replaceChildren(input); + document.getElementById('hosterModal').style.display = 'flex'; + window.api.configureAutomationProbe({ paused: false, historyError }); + }; + const captureManualHostFailure = (result, thrown) => ({ + result, + thrown, + pendingNames: _pendingFiles.map(file => file.name), + markerNames: [..._pendingFolderMonitorAutoStart.keys()].map(path => path.split('\\\\').pop()), + inspectionAccepted: _pendingImportInspection?.accepted?.length || 0, + modalOpen: document.getElementById('hosterModal').style.display === 'flex' + }); + prepareManualHostFailure({ full: false, historyError: 'token=secret-value' }); + let readFailureResult = null; + let readFailureThrown = null; + try { + readFailureResult = await applyHosterSelection(); + } catch (error) { + readFailureThrown = error.message || String(error); + } + const readFailure = captureManualHostFailure(readFailureResult, readFailureThrown); + prepareManualHostFailure({ full: true, historyError: '' }); + let applyFailureResult = null; + let applyFailureThrown = null; + try { + applyFailureResult = await applyHosterSelection(); + } catch (error) { + applyFailureThrown = error.message || String(error); + } + const applyFailure = captureManualHostFailure(applyFailureResult, applyFailureThrown); + const manualHostTransactional = { + readFailure, + applyFailure, + secretExposed: JSON.stringify({ readFailure, applyFailure }).includes('secret-value') + }; const configureAtomicState = currentCount => { config.globalSettings.folderMonitor = { @@ -551,8 +712,8 @@ contextBridge.exposeInMainWorld('api', { 'doodstream.com': { maxSizeMb: 2 }, 'voe.sx': { maxSizeMb: 2 } }; - selectedUploadHosters = []; - selectedFiles = [{ path: 'C:\\\\manual\\\\unplanned.mkv', name: 'unplanned.mkv', size: 1 }]; + selectedUploadHosters = ['clouddrop.cc']; + selectedFiles = []; queueJobs = Array.from({ length: currentCount }, (_, index) => ({ id: 'capacity-' + index, file: 'C:\\\\capacity\\\\existing-' + index + '.mkv', @@ -576,13 +737,21 @@ contextBridge.exposeInMainWorld('api', { configureAtomicState(14998); const atomicEvaluation = await evaluateAutomationCandidates(atomicCandidates, { dryRun: false, trigger: 'watcher' }); const atomicResult = await applyAutomationEvaluation(atomicEvaluation); + const selectedHostersAfterApply = selectedUploadHosters.slice(); + const manualSelectionFilesAfterApply = selectedFiles.map(file => file.name); + const plannedHostsBeforeRebuild = queueJobs.filter(job => job.file === atomicCandidates[1].path).map(job => job.hoster).sort(); + selectedUploadHosters = ['clouddrop.cc']; + updateUploadView(); const atomic = { newQueueFiles: [...new Set(queueJobs.filter(job => job.file === atomicCandidates[0].path || job.file === atomicCandidates[1].path).map(job => job.fileName))], admittedFiles: atomicResult.admittedFiles.map(file => file.name), 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, + hostsAfterRebuild: queueJobs.filter(job => job.file === atomicCandidates[1].path).map(job => job.hoster).sort() }; const statusSnapshot = createAutomationStatusSnapshot(); const status = { @@ -593,6 +762,30 @@ contextBridge.exposeInMainWorld('api', { frozen: Object.isFrozen(statusSnapshot) && Object.isFrozen(statusSnapshot.telemetry) }; + configureAtomicState(0); + selectedFiles = []; + selectedUploadHosters = ['clouddrop.cc']; + config.globalSettings.folderMonitor.hosters = ['doodstream.com', 'voe.sx']; + hosterSettings = {}; + const persistedFile = { path: 'C:\\\\persisted\\\\automation.mkv', name: 'automation.mkv', size: 1, mtimeMs: 1 }; + const persistedEvaluation = await evaluateAutomationCandidates([persistedFile], { dryRun: false, trigger: 'watcher' }); + await applyAutomationEvaluation(persistedEvaluation); + const pendingSnapshot = buildPersistedQueueState(); + config.globalSettings.pendingQueue = pendingSnapshot; + selectedFiles = []; + selectedUploadHosters = []; + queueJobs = []; + rebuildJobIndex(); + restoreQueueStateFromConfig(); + syncSelectedFilesFromQueue(); + selectedUploadHosters = ['clouddrop.cc']; + updateUploadView(); + const persistedQueueExactness = { + restoredSelectedFiles: selectedFiles.map(file => file.name), + automationMarkers: queueJobs.filter(job => job.file === persistedFile.path && job.automationAdmission === true).length, + hostsAfterRebuild: queueJobs.filter(job => job.file === persistedFile.path).map(job => job.hoster).sort() + }; + configureAtomicState(14994); const staleEvaluation = await evaluateAutomationCandidates(atomicCandidates, { dryRun: false, trigger: 'watcher' }); queueJobs.push(...Array.from({ length: 4 }, (_, index) => ({ @@ -611,6 +804,147 @@ contextBridge.exposeInMainWorld('api', { newQueueFiles: [...new Set(queueJobs.filter(job => job.file === atomicCandidates[0].path || job.file === atomicCandidates[1].path).map(job => job.fileName))] }; + configureAtomicState(0); + selectedUploadHosters = ['clouddrop.cc']; + selectedFiles = []; + config.globalSettings.folderMonitor.hosters = ['doodstream.com', 'voe.sx']; + hosterSettings = {}; + const changingFile = { path: 'C:\\\\watch\\\\changing.mkv', name: 'changing.mkv', size: 3 * 1024 * 1024, mtimeMs: 1 }; + const changingEvaluation = await evaluateAutomationCandidates([changingFile], { dryRun: false, trigger: 'watcher' }); + config.globalSettings.folderMonitor.hosters = ['byse.sx', 'vidmoly.me']; + hosterSettings = { 'byse.sx': { maxSizeMb: 2 } }; + const changingResult = await applyAutomationEvaluation(changingEvaluation); + const watcherHosts = queueJobs.filter(job => job.file === changingFile.path).map(job => job.hoster).sort(); + const immutableEvaluatedHosts = changingEvaluation.candidates[0].eligibleHosters.slice().sort(); + configureAtomicState(0); + selectedFiles = []; + config.globalSettings.folderMonitor.hosters = []; + hosterSettings = {}; + const manualChangingEvaluation = await evaluateAutomationCandidates([changingFile], { + dryRun: false, + trigger: 'manual-host', + selectedHosters: ['doodstream.com', 'voe.sx'] + }); + hosterSettings = { + 'doodstream.com': { maxSizeMb: 2 }, + 'voe.sx': { maxSizeMb: 2 } + }; + const manualChangingResult = await applyAutomationEvaluation(manualChangingEvaluation); + const replannedEligibility = { + watcherAdmitted: changingResult.admittedFiles.map(file => file.name), + watcherHosts, + immutableEvaluatedHosts, + manualAdmitted: manualChangingResult.admittedFiles.map(file => file.name), + manualJobs: queueJobs.filter(job => job.file === changingFile.path).length + }; + + const makePauseRaceJob = name => ({ + id: 'pause-race-' + name, + file: 'C:\\\\pause-race\\\\' + name, + fileName: name, + hoster: 'doodstream.com', + status: 'preview', + bytesUploaded: 0, + bytesTotal: 1, + speedKbs: 0, + elapsed: 0, + remaining: 0, + error: null, + result: null, + progress: 0, + uploadId: null + }); + configureAtomicState(0); + selectedFiles = []; + selectedUploadHosters = ['doodstream.com']; + const startRaceJob = makePauseRaceJob('start-paused.mkv'); + queueJobs = [startRaceJob]; + rebuildJobIndex(); + window.api.configureAutomationProbe({ paused: false, startResult: { error: 'Automatik ist pausiert' } }); + const originalShowAppAlertForPause = showAppAlert; + showAppAlert = async () => {}; + const startRaceResult = await startUpload(); + showAppAlert = originalShowAppAlertForPause; + const startRace = { result: startRaceResult, status: startRaceJob.status, uploading }; + const addRaceJob = makePauseRaceJob('add-paused.mkv'); + queueJobs = [addRaceJob]; + uploading = true; + rebuildJobIndex(); + window.api.configureAutomationProbe({ paused: false, addResult: { error: 'Automatik ist pausiert', added: 0 } }); + const addRaceResult = await startSelectedUpload([addRaceJob]); + const addRace = { result: addRaceResult, status: addRaceJob.status, uploading }; + uploading = false; + const selectedStartRaceJob = makePauseRaceJob('selected-start-paused.mkv'); + queueJobs = [selectedStartRaceJob]; + uploading = false; + rebuildJobIndex(); + window.api.configureAutomationProbe({ paused: false, startResult: { error: 'Automatik ist pausiert' } }); + showAppAlert = async () => {}; + const selectedStartRaceResult = await startSelectedUpload([selectedStartRaceJob]); + showAppAlert = originalShowAppAlertForPause; + const selectedStartRace = { result: selectedStartRaceResult, status: selectedStartRaceJob.status, uploading }; + const manualRaceFile = { path: 'C:\\\\manual-race\\\\manual-paused.mkv', name: 'manual-paused.mkv', size: 1 }; + queueJobs = []; + selectedFiles = []; + uploading = true; + _pendingFiles = [manualRaceFile]; + _pendingImportInspection = { candidateCount: 1, duplicateCount: 0, unavailableCount: 0, accepted: [manualRaceFile] }; + _pendingImportInspections = 0; + _pendingFolderMonitorAutoStart.clear(); + rebuildJobIndex(); + const manualRaceInput = document.createElement('input'); + manualRaceInput.type = 'checkbox'; + manualRaceInput.dataset.hosterModal = 'doodstream.com'; + manualRaceInput.checked = true; + document.getElementById('hosterModalList').replaceChildren(manualRaceInput); + document.getElementById('hosterModal').style.display = 'flex'; + window.api.configureAutomationProbe({ paused: false, addResult: { error: 'Automatik ist pausiert', added: 0 } }); + const manualRaceResult = await applyHosterSelection(); + const manualRace = { + result: manualRaceResult, + status: queueJobs.find(job => job.file === manualRaceFile.path)?.status || null, + uploading + }; + uploading = false; + const mainPauseResponses = { startRace, addRace, selectedStartRace, manualRace }; + + const runInjectionCase = async (name, probe) => { + 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 = true; + window.api.configureAutomationProbe({ paused: false, ...probe }); + const file = { path: 'C:\\\\injection\\\\' + name, name, size: 1, mtimeMs: 1 }; + const evaluation = await evaluateAutomationCandidates([file], { dryRun: false, trigger: 'watcher' }); + const result = await applyAutomationEvaluation(evaluation); + const state = await window.api.getAutomationProbeState(); + const response = { + ok: result.ok, + error: result.error || null, + warning: result.warning || null, + admitted: result.admittedFiles.map(entry => entry.name), + status: queueJobs.find(job => job.file === file.path)?.status || null, + queuedTelemetry: config.globalSettings.folderMonitor.telemetry.queued, + telemetrySaveAttempted: state.mutationCalls.some(call => call[0] === 'settings') + }; + uploading = false; + return response; + }; + const pausedInjection = await runInjectionCase('paused.mkv', { addResult: { error: 'Automatik ist pausiert', added: 0 } }); + const unconfirmedInjection = await runInjectionCase('unconfirmed.mkv', { addResult: { added: 0 } }); + const exceptionInjection = await runInjectionCase('exception.mkv', { addError: 'token=secret-value' }); + const telemetryFailure = await runInjectionCase('telemetry.mkv', { saveSettingsError: 'token=telemetry-secret' }); + const injectionOutcomes = { + pausedInjection, + unconfirmedInjection, + exceptionInjection, + telemetryFailure, + secretExposed: JSON.stringify({ pausedInjection, unconfirmedInjection, exceptionInjection, telemetryFailure }).includes('secret') + }; + configureAtomicState(0); config.globalSettings.folderMonitor.paused = true; window.api.configureAutomationProbe({ paused: true }); @@ -651,7 +985,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, atomic, status, stale, paused }; + return { dry, manualTest, historyEvidence, pendingDedup, manualHostTransactional, atomic, status, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, injectionOutcomes, paused }; })()`; const onlineBackupBehaviorScript = `(async () => { const ids = { @@ -1051,13 +1385,46 @@ app.whenReady().then(async () => { }, reads: { history: 1, uploadLog: 1, inspect: 1, status: 0, testScan: 1, reconcile: 0 } }); + assert.deepEqual(result.automationPipeline.historyEvidence, { + alreadyProcessed: 2, + acceptedNames: ['aborted.mkv', 'all-failed.mkv', 'error.mkv', 'same-name.mkv', 'same-name.mkv', 'skipped.mkv'], + resultingJobs: 6 + }); + assert.deepEqual(result.automationPipeline.pendingDedup, { + evaluatedNames: ['new.mkv'], + pendingPaths: ['c:/pending/overlap.mkv'], + pendingAccepted: 1, + markerPaths: ['c:/pending/overlap.mkv'] + }); + assert.deepEqual(result.automationPipeline.manualHostTransactional, { + readFailure: { + result: { ok: false, error: 'Automatische Aufnahme konnte nicht abgeschlossen werden.' }, + thrown: null, + pendingNames: ['pending.mkv'], + markerNames: ['pending.mkv'], + inspectionAccepted: 1, + modalOpen: true + }, + applyFailure: { + result: { ok: false, error: 'Automatische Aufnahme konnte nicht abgeschlossen werden.' }, + thrown: null, + pendingNames: ['pending.mkv'], + markerNames: ['pending.mkv'], + inspectionAccepted: 1, + modalOpen: true + }, + secretExposed: false + }); assert.deepEqual(result.automationPipeline.atomic, { newQueueFiles: ['b.mkv'], admittedFiles: ['b.mkv'], deferred: 1, queued: 1, currentJobCount: 15000, - unplannedJobs: 0 + selectedHostersAfterApply: ['clouddrop.cc'], + manualSelectionFilesAfterApply: [], + plannedHostsBeforeRebuild: ['byse.sx', 'vidmoly.me'], + hostsAfterRebuild: ['byse.sx', 'vidmoly.me'] }); assert.deepEqual(result.automationPipeline.status, { state: 'queue-limited', @@ -1066,11 +1433,84 @@ app.whenReady().then(async () => { queueLimited: true, frozen: true }); + assert.deepEqual(result.automationPipeline.persistedQueueExactness, { + restoredSelectedFiles: [], + automationMarkers: 2, + hostsAfterRebuild: ['doodstream.com', 'voe.sx'] + }); assert.deepEqual(result.automationPipeline.stale, { plannedBeforeApply: ['a.mkv', 'b.mkv'], admittedAfterApply: ['b.mkv'], newQueueFiles: ['b.mkv'] }); + assert.deepEqual(result.automationPipeline.replannedEligibility, { + watcherAdmitted: ['changing.mkv'], + watcherHosts: ['vidmoly.me'], + immutableEvaluatedHosts: ['doodstream.com', 'voe.sx'], + manualAdmitted: [], + manualJobs: 0 + }); + assert.deepEqual(result.automationPipeline.mainPauseResponses, { + startRace: { + result: { ok: false, error: 'Automatik ist pausiert' }, + status: 'preview', + uploading: false + }, + addRace: { + result: { ok: false, error: 'Automatik ist pausiert' }, + status: 'preview', + uploading: true + }, + selectedStartRace: { + result: { ok: false, error: 'Automatik ist pausiert' }, + status: 'preview', + uploading: false + }, + manualRace: { + result: { ok: false, error: 'Automatik ist pausiert' }, + status: 'preview', + uploading: true + } + }); + assert.deepEqual(result.automationPipeline.injectionOutcomes, { + pausedInjection: { + ok: false, + error: 'Automatik ist pausiert', + warning: null, + admitted: [], + status: 'preview', + queuedTelemetry: 0, + telemetrySaveAttempted: false + }, + unconfirmedInjection: { + ok: false, + error: 'Jobs wurden nicht vollständig hinzugefügt.', + warning: null, + admitted: [], + status: 'preview', + queuedTelemetry: 0, + telemetrySaveAttempted: false + }, + exceptionInjection: { + ok: false, + error: 'Jobs konnten nicht hinzugefügt werden.', + warning: null, + admitted: [], + status: 'preview', + queuedTelemetry: 0, + telemetrySaveAttempted: false + }, + telemetryFailure: { + ok: false, + error: null, + warning: 'Telemetrie konnte nicht gespeichert werden.', + admitted: ['telemetry.mkv'], + status: 'queued', + queuedTelemetry: 1, + telemetrySaveAttempted: true + }, + secretExposed: false + }); assert.deepEqual(result.automationPipeline.paused, { uploading: false, statuses: {