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')); +});