From 4630c8c467148a824984685b371bac2b12a74554 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:45:01 +0200 Subject: [PATCH] Preserve queue recovery and desktop drop integrity Resolve floating drops through Electron native paths, expand folders recursively, correlate terminal outcomes by job ID until final queue persistence succeeds, and keep current queue badges, telemetry, cancellation behavior, and copy-link wording synchronized. --- lib/upload-manager.js | 1 + lib/upload-recovery.js | 54 ++++++++++++++ main.js | 11 ++- preload-drop-target.js | 5 +- renderer/app.js | 112 +++++++++++++++++++----------- renderer/drop-target.html | 16 +++-- renderer/index.html | 1 + scripts/verify-public-release.mjs | 2 + tests/package-build-files.test.js | 32 +++++++++ tests/ui-smoke.js | 108 +++++++++++++++++++++++++--- tests/upload-manager.test.js | 1 + tests/upload-recovery.test.js | 75 ++++++++++++++++++++ 12 files changed, 363 insertions(+), 55 deletions(-) create mode 100644 lib/upload-recovery.js create mode 100644 tests/upload-recovery.test.js diff --git a/lib/upload-manager.js b/lib/upload-manager.js index ee2b367..9520c4a 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -473,6 +473,7 @@ class UploadManager extends EventEmitter { finalStatus = status; const result = { + jobId, hoster: task.hoster, status, error: payload.error || null, diff --git a/lib/upload-recovery.js b/lib/upload-recovery.js new file mode 100644 index 0000000..be3643b --- /dev/null +++ b/lib/upload-recovery.js @@ -0,0 +1,54 @@ +(function exposeUploadRecovery(root, factory) { + const api = factory(); + if (typeof module !== 'undefined' && module.exports) module.exports = api; + if (root) root.UploadRecovery = api; +})(typeof window !== 'undefined' ? window : globalThis, () => { + const terminalStatuses = new Set(['done', 'error', 'skipped', 'aborted']); + + function buildTerminalJobSnapshots(summary) { + const snapshots = new Map(); + for (const file of Array.isArray(summary?.files) ? summary.files : []) { + for (const result of Array.isArray(file?.results) ? file.results : []) { + const jobId = typeof result?.jobId === 'string' ? result.jobId : ''; + const status = typeof result?.status === 'string' ? result.status : ''; + if (!jobId || !terminalStatuses.has(status)) continue; + const uploadResult = result.download_url || result.embed_url || result.file_code + ? { + download_url: result.download_url || null, + embed_url: result.embed_url || null, + file_code: result.file_code || null + } + : null; + snapshots.set(jobId, { + jobId, + status, + error: result.error || null, + failureDetails: result.failureDetails || null, + result: uploadResult + }); + } + } + return Array.from(snapshots.values()); + } + + function getRecoveryOutcome(job, recovery) { + const status = typeof job?.status === 'string' ? job.status : 'preview'; + const jobId = typeof job?.id === 'string' ? job.id : ''; + const terminal = Array.isArray(recovery?.terminalJobs) + ? recovery.terminalJobs.find(entry => entry?.jobId === jobId && terminalStatuses.has(entry.status)) + : null; + if (terminal) { + return { + status: terminal.status, + error: terminal.error || null, + failureDetails: terminal.failureDetails || null, + result: terminal.result || null, + interrupted: false + }; + } + const interruptedIds = new Set(Array.isArray(recovery?.jobIds) ? recovery.jobIds.filter(Boolean) : []); + return { status, interrupted: interruptedIds.has(jobId) && !terminalStatuses.has(status) }; + } + + return { buildTerminalJobSnapshots, getRecoveryOutcome }; +}); diff --git a/main.js b/main.js index 3f0c1e0..0ad87bf 100644 --- a/main.js +++ b/main.js @@ -36,6 +36,7 @@ const stats = require('./lib/stats'); const { createCollectors } = require('./lib/diagnostics-collectors'); const { createAgent } = require('./lib/diagnostics-agent'); const { buildSessionReport, buildSessionReportCsv } = require('./lib/session-report'); +const { buildTerminalJobSnapshots } = require('./lib/upload-recovery'); const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 }); _eventLoopDelay.enable(); @@ -2272,8 +2273,16 @@ ipcMain.handle('start-upload', async (_event, payload) => { 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) + }; + try { await configStore.saveUploadRecovery(recoveryWithTerminalJobs); } catch (error) { debugLog(`upload recovery outcomes could not be saved: ${error.message}`); } const queuePersisted = await requestUploadFinalization(summary, historyPersisted); - try { await configStore.saveUploadRecovery(null); } catch (error) { debugLog(`upload recovery state could not be cleared: ${error.message}`); } + if (queuePersisted) { + try { await configStore.saveUploadRecovery(null); } catch (error) { debugLog(`upload recovery state could not be cleared: ${error.message}`); } + } if (!queuePersisted) debugLog('upload finalization blocked: renderer queue acknowledgement missing'); await sourceCleanup.finishBatch({ historyPersisted, queuePersisted }); _producerTracker.finish(); diff --git a/preload-drop-target.js b/preload-drop-target.js index 3d11d02..8a40dac 100644 --- a/preload-drop-target.js +++ b/preload-drop-target.js @@ -1,5 +1,6 @@ -const { contextBridge, ipcRenderer } = require('electron'); +const { contextBridge, ipcRenderer, webUtils } = require('electron'); contextBridge.exposeInMainWorld('dropTargetApi', { - sendFiles: (paths) => ipcRenderer.send('drop-target:files', paths) + sendFiles: (paths) => ipcRenderer.send('drop-target:files', paths), + getPathForFile: (file) => webUtils.getPathForFile(file) }); diff --git a/renderer/app.js b/renderer/app.js index 8b7eb64..8a96d69 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -540,8 +540,8 @@ async function init() { }); // Drop target window: files dropped on the small floating window - window.api.onDropTargetFiles((paths) => { - addPathsToQueue(paths); + window.api.onDropTargetFiles((entries) => { + addDropTargetEntries(entries).catch(console.error); }); // Remote client count updates (registered once, not per renderSettings call) @@ -1198,39 +1198,44 @@ function restoreQueueStateFromConfig() { .map(file => ({ path: file.path, name: file.name || file.path.split(/[\\/]/).pop(), size: file.size || 0 })) : []; - const interruptedJobIds = new Set(Array.isArray(config?.globalSettings?.uploadRecovery?.jobIds) ? config.globalSettings.uploadRecovery.jobIds : []); + const uploadRecovery = config?.globalSettings?.uploadRecovery || null; const rawJobs = Array.isArray(pending.queueJobs) ? pending.queueJobs .filter(job => job && job.fileName && job.hoster) - .map(job => ({ - id: job.id || `restored-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - uploadId: null, - file: job.file || '', - fileName: job.fileName, - hoster: job.hoster, - status: normalizeRestoredJobStatus(job.status), - bytesUploaded: job.status === 'done' ? (job.bytesTotal || 0) : 0, - bytesTotal: job.bytesTotal || 0, - speedKbs: 0, - elapsed: 0, - remaining: 0, - error: job.error || null, - failureDetails: job.failureDetails || null, - interrupted: interruptedJobIds.size > 0 && !['done', 'error', 'skipped'].includes(job.status), - result: job.result || null, - sourceCleanupMetadataVersion: job.sourceCleanupMetadataVersion === 2 ? 2 : null, - sourceCleanupToken: job.sourceCleanupToken || null, - sourceCleanupRequiredHosters: Array.isArray(job.sourceCleanupRequiredHosters) ? [...job.sourceCleanupRequiredHosters] : [], - sourceCleanupConfirmedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupConfirmedHosters) - ? [...job.sourceCleanupConfirmedHosters] - : [], - sourceCleanupProvisionalHosters: [], - sourceCleanupFingerprint: job.sourceCleanupFingerprint || null, - attempt: 0, - maxAttempts: job.maxAttempts || 0, - link: '', - progress: job.status === 'done' ? 1 : 0 - })) + .map(job => { + const id = job.id || `restored-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const recoveryOutcome = window.UploadRecovery.getRecoveryOutcome({ ...job, id }, uploadRecovery); + const status = normalizeRestoredJobStatus(recoveryOutcome.status); + return { + id, + uploadId: null, + file: job.file || '', + fileName: job.fileName, + hoster: job.hoster, + status, + bytesUploaded: status === 'done' ? (job.bytesTotal || 0) : 0, + bytesTotal: job.bytesTotal || 0, + speedKbs: 0, + elapsed: 0, + remaining: 0, + error: Object.hasOwn(recoveryOutcome, 'error') ? recoveryOutcome.error : (job.error || null), + failureDetails: Object.hasOwn(recoveryOutcome, 'failureDetails') ? recoveryOutcome.failureDetails : (job.failureDetails || null), + interrupted: recoveryOutcome.interrupted === true, + result: Object.hasOwn(recoveryOutcome, 'result') ? recoveryOutcome.result : (job.result || null), + sourceCleanupMetadataVersion: job.sourceCleanupMetadataVersion === 2 ? 2 : null, + sourceCleanupToken: job.sourceCleanupToken || null, + sourceCleanupRequiredHosters: Array.isArray(job.sourceCleanupRequiredHosters) ? [...job.sourceCleanupRequiredHosters] : [], + sourceCleanupConfirmedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupConfirmedHosters) + ? [...job.sourceCleanupConfirmedHosters] + : [], + sourceCleanupProvisionalHosters: [], + sourceCleanupFingerprint: job.sourceCleanupFingerprint || null, + attempt: 0, + maxAttempts: job.maxAttempts || 0, + link: '', + progress: status === 'done' ? 1 : 0 + }; + }) : []; // Deduplicate: keep the job with the best status for each file+hoster pair @@ -1377,6 +1382,23 @@ let _pendingFiles = []; // Files waiting for hoster modal confirmation let _addingDropped = false; +async function addDropTargetEntries(entries) { + const files = []; + for (const entry of Array.isArray(entries) ? entries : []) { + const filePath = typeof entry === 'string' ? entry : entry?.path; + if (!filePath) continue; + if (entry && typeof entry === 'object' && entry.isDirectory) { + try { + const folderFiles = await window.api.resolveFolderFiles(filePath); + if (Array.isArray(folderFiles)) files.push(...folderFiles); + } catch {} + continue; + } + files.push(entry); + } + addPathsToQueue(files); +} + async function addDroppedFiles(fileList) { if (_addingDropped) return; _addingDropped = true; @@ -2305,8 +2327,12 @@ function showContextMenu(x, y) { const n = selectedJobIds.size; const delItem = menu.querySelector('[data-action="delete-selected"]'); if (delItem) delItem.textContent = n > 1 ? `Entfernen (${n})` : 'Entfernen'; + const copyableLinkCount = getSelectedJobLinks().length; const copyItem = menu.querySelector('[data-action="copy-links"]'); - if (copyItem) copyItem.textContent = n > 1 ? `Links kopieren (${n})` : 'Link kopieren'; + if (copyItem) { + copyItem.textContent = copyableLinkCount > 1 ? `Links kopieren (${copyableLinkCount})` : 'Link kopieren'; + copyItem.style.display = copyableLinkCount > 0 ? '' : 'none'; + } menu.querySelectorAll('[data-action="retry-selected"]').forEach(el => { el.textContent = n > 1 ? `Erneut versuchen (${n})` : 'Erneut versuchen'; }); @@ -2364,8 +2390,12 @@ function showRecentContextMenu(row, x, y) { applyRecentSelectionClasses(); } const menu = document.getElementById('recentContextMenu'); + const copyableLinkCount = getSelectedRecentLinks().length; const copyItem = menu.querySelector('[data-action="recent-copy-links"]'); - if (copyItem) copyItem.textContent = selectedRecentIds.size > 1 ? `Links kopieren (${selectedRecentIds.size})` : 'Link kopieren'; + if (copyItem) { + copyItem.textContent = copyableLinkCount > 1 ? `Links kopieren (${copyableLinkCount})` : 'Link kopieren'; + copyItem.style.display = copyableLinkCount > 0 ? '' : 'none'; + } menu.style.display = 'block'; menu.style.left = Math.min(x, window.innerWidth - menu.offsetWidth - 5) + 'px'; menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 5) + 'px'; @@ -2438,11 +2468,15 @@ async function exportAllRecentFiles() { } } -function copySelectedRecentLinks() { - const links = sessionFilesData +function getSelectedRecentLinks() { + return sessionFilesData .filter(r => selectedRecentIds.has(r.order) && !r.isError) .map(r => r.link) .filter(Boolean); +} + +function copySelectedRecentLinks() { + const links = getSelectedRecentLinks(); if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); } } @@ -2923,7 +2957,7 @@ async function completeSourceCleanupFinalization(data) { } return window.api.completeUploadFinalization({ finalizationId: data.finalizationId, - pendingQueue: queueJobs.some((job) => !['done', 'skipped'].includes(job.status)) + pendingQueue: data.historyPersisted !== true || queueJobs.some((job) => !['done', 'skipped'].includes(job.status)) ? buildPersistedQueueState() : null }); @@ -4169,8 +4203,8 @@ function updateStatusBar() { _setRollingUploadMetric('uploadTelemetryConnections', lastUploadStats.activeJobs || 0); _setRollingUploadMetric('uploadTelemetryRemaining', stats.remaining); _setRollingUploadMetric('uploadTelemetryRunning', stats.inProgress); - _setRollingUploadMetric('uploadTelemetryCompleted', _sessionDoneCount); - _setRollingUploadMetric('uploadTelemetryFailed', Math.max(_sessionErrorCount, stats.errors)); + _setRollingUploadMetric('uploadTelemetryCompleted', stats.done); + _setRollingUploadMetric('uploadTelemetryFailed', stats.errors); updateUploadSpeedDisplays(); _setUploadTelemetryText('uploadTelemetryEta', etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'); updateUploadSidebarSummary(stats); diff --git a/renderer/drop-target.html b/renderer/drop-target.html index 399dfd1..532bbd4 100644 --- a/renderer/drop-target.html +++ b/renderer/drop-target.html @@ -79,12 +79,20 @@ e.preventDefault(); e.stopPropagation(); target.classList.remove('drag-over'); - const paths = []; + const files = []; for (const file of e.dataTransfer.files) { - if (file.path) paths.push(file.path); + let filePath = ''; + try { filePath = window.dropTargetApi.getPathForFile(file); } catch {} + if (!filePath) continue; + files.push({ + path: filePath, + name: file.name || '', + size: Number.isFinite(file.size) ? file.size : 0, + isDirectory: file.type === '' && file.size === 0 + }); } - if (paths.length > 0) { - window.dropTargetApi.sendFiles(paths); + if (files.length > 0) { + window.dropTargetApi.sendFiles(files); } }); diff --git a/renderer/index.html b/renderer/index.html index 1213fe5..510ef29 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -682,6 +682,7 @@ + diff --git a/scripts/verify-public-release.mjs b/scripts/verify-public-release.mjs index 1fd2b5c..387964f 100644 --- a/scripts/verify-public-release.mjs +++ b/scripts/verify-public-release.mjs @@ -63,6 +63,7 @@ const sourceFiles = [ 'lib/upload-confirmation.js', 'lib/upload-diagnostics.js', 'lib/upload-manager.js', + 'lib/upload-recovery.js', 'lib/vidmoly-upload.js', 'lib/voe-upload.js', 'lib/webhook-notify.js', @@ -152,6 +153,7 @@ const sourceFiles = [ 'tests/upload-confirmation.test.js', 'tests/upload-diagnostics.test.js', 'tests/upload-manager.test.js', + 'tests/upload-recovery.test.js', 'tests/session-report.test.js', 'tests/validate-credentials.test.js', 'tests/webhook-notify.test.js' diff --git a/tests/package-build-files.test.js b/tests/package-build-files.test.js index 1bd6c17..e223295 100644 --- a/tests/package-build-files.test.js +++ b/tests/package-build-files.test.js @@ -10,6 +10,38 @@ test('packages every Electron preload referenced by the main process', () => { assert.equal(packageJson.build.win.signAndEditExecutable, false); }); +test('floating drop target resolves native paths through Electron webUtils', () => { + let exposedApi = null; + const nativeFile = { name: 'fixture.mkv' }; + const electronMock = { + contextBridge: { + exposeInMainWorld: (_name, api) => { exposedApi = api; } + }, + ipcRenderer: { + send: () => {} + }, + webUtils: { + getPathForFile: file => file === nativeFile ? 'C:\\fixtures\\fixture.mkv' : '' + } + }; + const originalLoad = Module._load; + const preloadPath = require.resolve('../preload-drop-target'); + delete require.cache[preloadPath]; + Module._load = function (request, parent, isMain) { + if (request === 'electron') return electronMock; + return originalLoad.call(this, request, parent, isMain); + }; + try { + require(preloadPath); + } finally { + Module._load = originalLoad; + delete require.cache[preloadPath]; + } + + assert.equal(typeof exposedApi.getPathForFile, 'function'); + assert.equal(exposedApi.getPathForFile(nativeFile), 'C:\\fixtures\\fixture.mkv'); +}); + test('afterPack brands the executable metadata shown by Windows', async () => { let editCall = null; const originalLoad = Module._load; diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js index 09b78fc..2c58785 100644 --- a/tests/ui-smoke.js +++ b/tests/ui-smoke.js @@ -228,7 +228,9 @@ setTimeout(async () => { const liveLanguageSwitch = await wc.executeJavaScript('(() => { const input = document.getElementById("languageInput"); input.value = "de"; input.dispatchEvent(new Event("change", { bubbles: true })); const german = [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(","); input.value = "en"; input.dispatchEvent(new Event("change", { bubbles: true })); const english = [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(","); input.value = "de"; input.dispatchEvent(new Event("change", { bubbles: true })); return [german, english, document.documentElement.lang].join("|"); })()'); check('Language changes apply immediately in both directions', liveLanguageSwitch === 'Upload,Accounts,Einstellungen,Verlauf|Upload,Accounts,Settings,History|de'); const localizedStableMetric = await wc.executeJavaScript(\`(async () => { - _sessionDoneCount = 1234; + const previousJobs = queueJobs; + queueJobs = Array.from({ length: 1234 }, (_, index) => ({ id: 'ui-locale-done-' + index, status: 'done' })); + _queueStatsCache = null; updateStatusBar(); await new Promise(resolve => setTimeout(resolve, 360)); const metric = document.getElementById('uploadTelemetryCompleted'); @@ -236,7 +238,8 @@ setTimeout(async () => { setUiLanguage('en'); const english = [metric?.textContent.trim(), metric?.getAttribute('aria-label')]; setUiLanguage('de'); - _sessionDoneCount = 0; + queueJobs = previousJobs; + _queueStatsCache = null; updateStatusBar(); return { german, english }; })()\`); @@ -371,6 +374,24 @@ setTimeout(async () => { } check('Desktop file drop reaches the upload selection with its native path', desktopDropState?.modal === 'flex' && desktopDropState.paths.length === 1 && desktopDropState.paths[0] === desktopDropFixture); + const floatingDropFolder = fs.mkdtempSync(path.join(app.getPath('temp'), 'mhu-floating-folder-drop-')); + const floatingDropNested = path.join(floatingDropFolder, 'nested'); + const floatingDropFirst = path.join(floatingDropFolder, 'first.mkv'); + const floatingDropSecond = path.join(floatingDropNested, 'second.mp4'); + fs.mkdirSync(floatingDropNested); + fs.writeFileSync(floatingDropFirst, Buffer.from('first floating drop fixture')); + fs.writeFileSync(floatingDropSecond, Buffer.from('second floating drop fixture')); + let floatingFolderDropState = null; + try { + wc.send('drop-target:files', [{ path: floatingDropFolder, name: path.basename(floatingDropFolder), size: 0, isDirectory: true }]); + await waitUntil(() => wc.executeJavaScript('document.getElementById("hosterModal")?.style.display === "flex" && _pendingFiles.length === 2')); + floatingFolderDropState = await wc.executeJavaScript('(() => ({ modal: document.getElementById("hosterModal")?.style.display, paths: _pendingFiles.map(file => file.path).sort() }))()'); + await wc.executeJavaScript('cancelHosterModal()'); + } finally { + fs.rmSync(floatingDropFolder, { recursive: true, force: true }); + } + check('Floating drop target recursively expands folders before hoster selection', floatingFolderDropState?.modal === 'flex' && floatingFolderDropState.paths.join('|') === [floatingDropFirst, floatingDropSecond].sort().join('|')); + const populatedDropFixture = path.join(app.getPath('temp'), 'mhu-populated-drop-' + process.pid + '.mkv'); fs.writeFileSync(populatedDropFixture, Buffer.from('populated queue drop fixture')); await wc.executeJavaScript('(() => { selectedFiles = [{ path: "C:/ui/existing.bin", name: "existing.bin", size: 16 }]; queueJobs = [{ id: "ui-existing-drop-row", file: "C:/ui/existing.bin", fileName: "existing.bin", hoster: "doodstream.com", status: "preview", bytesUploaded: 0, bytesTotal: 16, speedKbs: 0, elapsed: 0, remaining: 0, progress: 0 }]; rebuildJobIndex(); updateUploadView(); renderQueueTable(); })()'); @@ -525,7 +546,7 @@ setTimeout(async () => { speedPair: [document.getElementById('uploadTelemetrySpeed')?.textContent, document.getElementById('uploadSpeedValue')?.textContent].join('|') }; })()\`); - check('Upload telemetry reflects queue and session activity', telemetryUpdate.values === '4|1|2|1|7|2|2 kB/s|00:03'); + check('Upload telemetry reflects current queue activity', telemetryUpdate.values === '4|1|2|1|1|1|2 kB/s|00:03'); check('Changing integer telemetry rolls vertically', telemetryUpdate.rolling === 2 && telemetryUpdate.direction === 'up'); check('Header and sidebar speed update synchronously from the same live sample', telemetryUpdate.speedPair === '2 kB/s|2 kB/s'); const secondSynchronizedSpeed = await wc.executeJavaScript('lastUploadStats = { ...lastUploadStats, globalSpeedKbs: 1536 }; updateStatusBar(); [document.getElementById("uploadTelemetrySpeed")?.textContent, document.getElementById("uploadSpeedValue")?.textContent].join("|")'); @@ -750,6 +771,33 @@ 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); + const queueTelemetryState = await wc.executeJavaScript(\`(async () => { + queueJobs = [ + { id: 'telemetry-done-a', status: 'done' }, + { id: 'telemetry-done-b', status: 'done' }, + { id: 'telemetry-error', status: 'error' }, + { id: 'telemetry-queued', status: 'queued' } + ]; + _sessionDoneCount = 91; + _sessionErrorCount = 92; + _queueStatsCache = null; + updateStatusBar(); + await new Promise(resolve => setTimeout(resolve, 360)); + const result = { + completed: document.getElementById('uploadTelemetryCompleted')?.textContent.trim(), + failed: document.getElementById('uploadTelemetryFailed')?.textContent.trim(), + sidebarDone: document.getElementById('uploadSidebarDoneCount')?.textContent.trim(), + sidebarFailed: document.getElementById('uploadSidebarErrorCount')?.textContent.trim() + }; + queueJobs = []; + _sessionDoneCount = 0; + _sessionErrorCount = 0; + _queueStatsCache = null; + updateStatusBar(); + return result; + })()\`); + check('Lower telemetry and sidebar badges use the same current queue state', queueTelemetryState.completed === '2' && queueTelemetryState.failed === '1' && queueTelemetryState.sidebarDone === '2' && queueTelemetryState.sidebarFailed === '1'); + let releaseSelectedQueueCancel = null; ipcMain.removeHandler('cancel-selected-jobs'); ipcMain.handle('cancel-selected-jobs', () => new Promise(resolve => { releaseSelectedQueueCancel = () => resolve(true); })); @@ -773,14 +821,14 @@ setTimeout(async () => { }); ipcMain.removeHandler('cancel-selected-jobs'); ipcMain.handle('cancel-selected-jobs', () => { selectedQueueCancelCalls++; return true; }); - await wc.executeJavaScript('(() => { uploading = true; queueJobs = ["a", "b", "c"].map(id => ({ id: "ui-delete-all-" + id, file: "C:/ui/delete-all-" + id + ".bin", fileName: "delete-all-" + id + ".bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0 })); selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteAllPromise = handleContextAction("delete-all"); return true; })()'); + await wc.executeJavaScript('(() => { uploading = true; queueJobs = Array.from({ length: 100 }, (_, index) => ({ id: "ui-delete-all-" + index, file: "C:/ui/delete-all-" + index + ".bin", fileName: "delete-all-" + index + ".bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0 })); selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteAllPromise = handleContextAction("delete-all"); return true; })()'); await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"')); await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()'); await waitUntil(() => releaseFullQueueCancel); const fullQueueStillPresent = await wc.executeJavaScript('queueJobs.length'); releaseFullQueueCancel(); const fullQueueAfterCancel = await wc.executeJavaScript('window.__uiDeleteAllPromise.then(() => { delete window.__uiDeleteAllPromise; return { length: queueJobs.length, uploading }; })'); - check('Remove all awaits one batch cancellation instead of issuing one cancellation per queued job', fullQueueStillPresent === 3 && fullQueueAfterCancel.length === 0 && fullQueueAfterCancel.uploading === false && fullQueueCancelCalls === 1 && selectedQueueCancelCalls === 0); + check('Remove all keeps a 100-job cancellation responsive and issues one batch cancellation', fullQueueStillPresent === 100 && fullQueueAfterCancel.length === 0 && fullQueueAfterCancel.uploading === false && fullQueueCancelCalls === 1 && selectedQueueCancelCalls === 0); restoreInitialIpcHandler('cancel-upload'); restoreInitialIpcHandler('cancel-selected-jobs'); @@ -1901,6 +1949,41 @@ setTimeout(async () => { const singleRecentLinkContextLabel = await wc.executeJavaScript('(() => { selectedRecentIds.clear(); const row = document.createElement("tr"); row.dataset.order = "1001"; showRecentContextMenu(row, 8, 8); const label = document.querySelector("#recentContextMenu [data-action=recent-copy-links]")?.textContent?.trim(); hideContextMenu(); return label; })()'); check('Recent upload context menu uses singular copy text for one link', singleRecentLinkContextLabel === 'Link kopieren'); + const copyableLinkContextLabels = await wc.executeJavaScript(\`(() => { + const previousJobs = queueJobs; + const previousRecent = sessionFilesData; + queueJobs = [ + { id: 'copyable-link', status: 'done', result: { download_url: 'https://example.invalid/copyable' } }, + { id: 'missing-link', status: 'done', result: null } + ]; + rebuildJobIndex(); + selectedJobIds.clear(); + selectedJobIds.add('copyable-link'); + selectedJobIds.add('missing-link'); + showContextMenu(8, 8); + const queueLabel = document.querySelector('#contextMenu [data-action=copy-links]')?.textContent?.trim(); + hideContextMenu(); + sessionFilesData = [ + { order: 2001, link: 'https://example.invalid/recent', isError: false }, + { order: 2002, link: '', isError: true } + ]; + selectedRecentIds.clear(); + selectedRecentIds.add(2001); + selectedRecentIds.add(2002); + const row = document.createElement('tr'); + row.dataset.order = '2001'; + showRecentContextMenu(row, 8, 8); + const recentLabel = document.querySelector('#recentContextMenu [data-action=recent-copy-links]')?.textContent?.trim(); + hideContextMenu(); + queueJobs = previousJobs; + sessionFilesData = previousRecent; + selectedJobIds.clear(); + selectedRecentIds.clear(); + rebuildJobIndex(); + return { queueLabel, recentLabel }; + })()\`); + check('Copy-link context labels count only links that can actually be copied', copyableLinkContextLabels.queueLabel === 'Link kopieren' && copyableLinkContextLabels.recentLabel === 'Link kopieren'); + const historySidebarInformation = await wc.executeJavaScript('(() => { const sidebar = document.querySelector("#history-view > .view-sidebar")?.getBoundingClientRect(); const section = document.querySelector("#history-view .view-sidebar-section")?.getBoundingClientRect(); const retention = document.getElementById("historySidebarRetention")?.textContent?.trim(); return Boolean(sidebar && section && section.top >= sidebar.top + sidebar.height * 0.55 && retention === "Alles behalten"); })()'); check('History sidebar shows the active retention in its lower area', historySidebarInformation === true); @@ -2253,7 +2336,9 @@ setTimeout(async () => { check('Rapid main-view switches never paint a blank, duplicate, or overflowing active view', rapidViewStability.length === 12 && invalidViewFrames.length === 0); const languageFrameStability = await wc.executeJavaScript(\`(async () => { - _sessionDoneCount = 1234; + const previousJobs = queueJobs; + queueJobs = Array.from({ length: 1234 }, (_, index) => ({ id: 'ui-language-frame-done-' + index, status: 'done' })); + _queueStatsCache = null; const languages = ['en', 'de', 'en', 'de', 'en', 'de', 'en', 'de', 'en', 'de', 'en', 'de']; const samples = []; for (const language of languages) { @@ -2270,7 +2355,8 @@ setTimeout(async () => { metricLabel: metric?.getAttribute('aria-label') }); } - _sessionDoneCount = 0; + queueJobs = previousJobs; + _queueStatsCache = null; updateStatusBar(); return samples; })()\`); @@ -2286,8 +2372,11 @@ setTimeout(async () => { const metric = document.getElementById('uploadTelemetryCompleted'); const initialRect = metric.getBoundingClientRect(); const frames = []; + const previousJobs = queueJobs; + queueJobs = Array.from({ length: 999 }, (_, index) => ({ id: 'ui-rolling-done-' + index, status: 'done' })); for (let value = 1000; value <= 1020; value++) { - _sessionDoneCount = value; + queueJobs.push({ id: 'ui-rolling-done-' + value, status: 'done' }); + _queueStatsCache = null; updateStatusBar(); await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))); const rect = metric.getBoundingClientRect(); @@ -2301,7 +2390,8 @@ setTimeout(async () => { } await new Promise(resolve => setTimeout(resolve, 360)); const settled = { text: metric.textContent.trim(), label: metric.getAttribute('aria-label'), direction: metric.dataset.direction }; - _sessionDoneCount = 0; + queueJobs = previousJobs; + _queueStatsCache = null; updateStatusBar(); return { initialRect: { width: initialRect.width, height: initialRect.height }, frames, settled }; })()\`); diff --git a/tests/upload-manager.test.js b/tests/upload-manager.test.js index 1650d08..7348cf5 100644 --- a/tests/upload-manager.test.js +++ b/tests/upload-manager.test.js @@ -160,6 +160,7 @@ describe('UploadManager', () => { assert.equal(summary.succeeded, 2); assert.equal(summary.failed, 0); assert.equal(summary.files.length, 2); + assert.ok(summary.files.flatMap(file => file.results).every(result => typeof result.jobId === 'string' && result.jobId.length > 0)); }); it('emits a final idle stats snapshot after a normal batch', async () => { diff --git a/tests/upload-recovery.test.js b/tests/upload-recovery.test.js new file mode 100644 index 0000000..937244d --- /dev/null +++ b/tests/upload-recovery.test.js @@ -0,0 +1,75 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +test('terminal recovery snapshots retain exact job outcomes and canonical links', () => { + const { buildTerminalJobSnapshots } = require('../lib/upload-recovery'); + const snapshots = buildTerminalJobSnapshots({ + files: [{ + name: 'episode.mkv', + results: [ + { jobId: 'done-job', hoster: 'doodstream.com', status: 'done', download_url: 'https://doodstream.com/d/abc123', file_code: 'abc123' }, + { jobId: 'error-job', hoster: 'voe.sx', status: 'error', error: 'rejected', failureDetails: { kind: 'hoster' } }, + { hoster: 'byse.sx', status: 'done', download_url: 'https://byse.sx/d/no-id' } + ] + }] + }); + + assert.deepEqual(snapshots, [ + { + jobId: 'done-job', + status: 'done', + error: null, + failureDetails: null, + result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' } + }, + { + jobId: 'error-job', + status: 'error', + error: 'rejected', + failureDetails: { kind: 'hoster' }, + result: null + } + ]); +}); + +test('recovery markers affect only their exact job IDs and never restart terminal outcomes', () => { + const { getRecoveryOutcome } = require('../lib/upload-recovery'); + const recovery = { + jobIds: ['done-job', 'active-job'], + terminalJobs: [{ + jobId: 'done-job', + status: 'done', + error: null, + failureDetails: null, + result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' } + }] + }; + + assert.deepEqual(getRecoveryOutcome({ id: 'done-job', status: 'preview' }, recovery), { + status: 'done', + error: null, + failureDetails: null, + result: { download_url: 'https://doodstream.com/d/abc123', embed_url: null, file_code: 'abc123' }, + interrupted: false + }); + assert.deepEqual(getRecoveryOutcome({ id: 'active-job', status: 'queued' }, recovery), { status: 'queued', interrupted: true }); + assert.deepEqual(getRecoveryOutcome({ id: 'foreign-job', status: 'queued' }, recovery), { status: 'queued', interrupted: false }); + assert.deepEqual(getRecoveryOutcome({ id: 'already-done', status: 'done' }, recovery), { status: 'done', interrupted: false }); +}); + +test('main and renderer keep recovery evidence until final queue persistence succeeds', () => { + const root = path.join(__dirname, '..'); + const mainSource = fs.readFileSync(path.join(root, 'main.js'), 'utf8'); + 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'")); + + assert.match(batchDone, /buildTerminalJobSnapshots\(summary\)/); + assert.match(batchDone, /if \(queuePersisted\)[\s\S]*saveUploadRecovery\(null\)/); + assert.ok(batchDone.indexOf('saveUploadRecovery(recoveryWithTerminalJobs)') < batchDone.indexOf('requestUploadFinalization(summary, historyPersisted)')); + assert.match(rendererSource, /window\.UploadRecovery\.getRecoveryOutcome/); + assert.match(rendererSource, /data\.historyPersisted !== true/); + assert.ok(indexSource.indexOf('../lib/upload-recovery.js') < indexSource.indexOf('app.js')); +});