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.
This commit is contained in:
@@ -473,6 +473,7 @@ class UploadManager extends EventEmitter {
|
|||||||
finalStatus = status;
|
finalStatus = status;
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
|
jobId,
|
||||||
hoster: task.hoster,
|
hoster: task.hoster,
|
||||||
status,
|
status,
|
||||||
error: payload.error || null,
|
error: payload.error || null,
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
@@ -36,6 +36,7 @@ const stats = require('./lib/stats');
|
|||||||
const { createCollectors } = require('./lib/diagnostics-collectors');
|
const { createCollectors } = require('./lib/diagnostics-collectors');
|
||||||
const { createAgent } = require('./lib/diagnostics-agent');
|
const { createAgent } = require('./lib/diagnostics-agent');
|
||||||
const { buildSessionReport, buildSessionReportCsv } = require('./lib/session-report');
|
const { buildSessionReport, buildSessionReportCsv } = require('./lib/session-report');
|
||||||
|
const { buildTerminalJobSnapshots } = require('./lib/upload-recovery');
|
||||||
|
|
||||||
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
|
const _eventLoopDelay = monitorEventLoopDelay({ resolution: 10 });
|
||||||
_eventLoopDelay.enable();
|
_eventLoopDelay.enable();
|
||||||
@@ -2272,8 +2273,16 @@ ipcMain.handle('start-upload', async (_event, payload) => {
|
|||||||
for (const value of _progressByJob.values()) finalProgressBatch.push(value);
|
for (const value of _progressByJob.values()) finalProgressBatch.push(value);
|
||||||
_progressByJob.clear();
|
_progressByJob.clear();
|
||||||
if (finalProgressBatch.length) safeSend('upload-progress-batch', finalProgressBatch);
|
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);
|
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');
|
if (!queuePersisted) debugLog('upload finalization blocked: renderer queue acknowledgement missing');
|
||||||
await sourceCleanup.finishBatch({ historyPersisted, queuePersisted });
|
await sourceCleanup.finishBatch({ historyPersisted, queuePersisted });
|
||||||
_producerTracker.finish();
|
_producerTracker.finish();
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const { contextBridge, ipcRenderer } = require('electron');
|
const { contextBridge, ipcRenderer, webUtils } = require('electron');
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld('dropTargetApi', {
|
contextBridge.exposeInMainWorld('dropTargetApi', {
|
||||||
sendFiles: (paths) => ipcRenderer.send('drop-target:files', paths)
|
sendFiles: (paths) => ipcRenderer.send('drop-target:files', paths),
|
||||||
|
getPathForFile: (file) => webUtils.getPathForFile(file)
|
||||||
});
|
});
|
||||||
|
|||||||
+73
-39
@@ -540,8 +540,8 @@ async function init() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Drop target window: files dropped on the small floating window
|
// Drop target window: files dropped on the small floating window
|
||||||
window.api.onDropTargetFiles((paths) => {
|
window.api.onDropTargetFiles((entries) => {
|
||||||
addPathsToQueue(paths);
|
addDropTargetEntries(entries).catch(console.error);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Remote client count updates (registered once, not per renderSettings call)
|
// 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 }))
|
.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)
|
const rawJobs = Array.isArray(pending.queueJobs)
|
||||||
? pending.queueJobs
|
? pending.queueJobs
|
||||||
.filter(job => job && job.fileName && job.hoster)
|
.filter(job => job && job.fileName && job.hoster)
|
||||||
.map(job => ({
|
.map(job => {
|
||||||
id: job.id || `restored-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
const id = job.id || `restored-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
uploadId: null,
|
const recoveryOutcome = window.UploadRecovery.getRecoveryOutcome({ ...job, id }, uploadRecovery);
|
||||||
file: job.file || '',
|
const status = normalizeRestoredJobStatus(recoveryOutcome.status);
|
||||||
fileName: job.fileName,
|
return {
|
||||||
hoster: job.hoster,
|
id,
|
||||||
status: normalizeRestoredJobStatus(job.status),
|
uploadId: null,
|
||||||
bytesUploaded: job.status === 'done' ? (job.bytesTotal || 0) : 0,
|
file: job.file || '',
|
||||||
bytesTotal: job.bytesTotal || 0,
|
fileName: job.fileName,
|
||||||
speedKbs: 0,
|
hoster: job.hoster,
|
||||||
elapsed: 0,
|
status,
|
||||||
remaining: 0,
|
bytesUploaded: status === 'done' ? (job.bytesTotal || 0) : 0,
|
||||||
error: job.error || null,
|
bytesTotal: job.bytesTotal || 0,
|
||||||
failureDetails: job.failureDetails || null,
|
speedKbs: 0,
|
||||||
interrupted: interruptedJobIds.size > 0 && !['done', 'error', 'skipped'].includes(job.status),
|
elapsed: 0,
|
||||||
result: job.result || null,
|
remaining: 0,
|
||||||
sourceCleanupMetadataVersion: job.sourceCleanupMetadataVersion === 2 ? 2 : null,
|
error: Object.hasOwn(recoveryOutcome, 'error') ? recoveryOutcome.error : (job.error || null),
|
||||||
sourceCleanupToken: job.sourceCleanupToken || null,
|
failureDetails: Object.hasOwn(recoveryOutcome, 'failureDetails') ? recoveryOutcome.failureDetails : (job.failureDetails || null),
|
||||||
sourceCleanupRequiredHosters: Array.isArray(job.sourceCleanupRequiredHosters) ? [...job.sourceCleanupRequiredHosters] : [],
|
interrupted: recoveryOutcome.interrupted === true,
|
||||||
sourceCleanupConfirmedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupConfirmedHosters)
|
result: Object.hasOwn(recoveryOutcome, 'result') ? recoveryOutcome.result : (job.result || null),
|
||||||
? [...job.sourceCleanupConfirmedHosters]
|
sourceCleanupMetadataVersion: job.sourceCleanupMetadataVersion === 2 ? 2 : null,
|
||||||
: [],
|
sourceCleanupToken: job.sourceCleanupToken || null,
|
||||||
sourceCleanupProvisionalHosters: [],
|
sourceCleanupRequiredHosters: Array.isArray(job.sourceCleanupRequiredHosters) ? [...job.sourceCleanupRequiredHosters] : [],
|
||||||
sourceCleanupFingerprint: job.sourceCleanupFingerprint || null,
|
sourceCleanupConfirmedHosters: job.sourceCleanupMetadataVersion === 2 && Array.isArray(job.sourceCleanupConfirmedHosters)
|
||||||
attempt: 0,
|
? [...job.sourceCleanupConfirmedHosters]
|
||||||
maxAttempts: job.maxAttempts || 0,
|
: [],
|
||||||
link: '',
|
sourceCleanupProvisionalHosters: [],
|
||||||
progress: job.status === 'done' ? 1 : 0
|
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
|
// 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;
|
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) {
|
async function addDroppedFiles(fileList) {
|
||||||
if (_addingDropped) return;
|
if (_addingDropped) return;
|
||||||
_addingDropped = true;
|
_addingDropped = true;
|
||||||
@@ -2305,8 +2327,12 @@ function showContextMenu(x, y) {
|
|||||||
const n = selectedJobIds.size;
|
const n = selectedJobIds.size;
|
||||||
const delItem = menu.querySelector('[data-action="delete-selected"]');
|
const delItem = menu.querySelector('[data-action="delete-selected"]');
|
||||||
if (delItem) delItem.textContent = n > 1 ? `Entfernen (${n})` : 'Entfernen';
|
if (delItem) delItem.textContent = n > 1 ? `Entfernen (${n})` : 'Entfernen';
|
||||||
|
const copyableLinkCount = getSelectedJobLinks().length;
|
||||||
const copyItem = menu.querySelector('[data-action="copy-links"]');
|
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 => {
|
menu.querySelectorAll('[data-action="retry-selected"]').forEach(el => {
|
||||||
el.textContent = n > 1 ? `Erneut versuchen (${n})` : 'Erneut versuchen';
|
el.textContent = n > 1 ? `Erneut versuchen (${n})` : 'Erneut versuchen';
|
||||||
});
|
});
|
||||||
@@ -2364,8 +2390,12 @@ function showRecentContextMenu(row, x, y) {
|
|||||||
applyRecentSelectionClasses();
|
applyRecentSelectionClasses();
|
||||||
}
|
}
|
||||||
const menu = document.getElementById('recentContextMenu');
|
const menu = document.getElementById('recentContextMenu');
|
||||||
|
const copyableLinkCount = getSelectedRecentLinks().length;
|
||||||
const copyItem = menu.querySelector('[data-action="recent-copy-links"]');
|
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.display = 'block';
|
||||||
menu.style.left = Math.min(x, window.innerWidth - menu.offsetWidth - 5) + 'px';
|
menu.style.left = Math.min(x, window.innerWidth - menu.offsetWidth - 5) + 'px';
|
||||||
menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 5) + 'px';
|
menu.style.top = Math.min(y, window.innerHeight - menu.offsetHeight - 5) + 'px';
|
||||||
@@ -2438,11 +2468,15 @@ async function exportAllRecentFiles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function copySelectedRecentLinks() {
|
function getSelectedRecentLinks() {
|
||||||
const links = sessionFilesData
|
return sessionFilesData
|
||||||
.filter(r => selectedRecentIds.has(r.order) && !r.isError)
|
.filter(r => selectedRecentIds.has(r.order) && !r.isError)
|
||||||
.map(r => r.link)
|
.map(r => r.link)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function copySelectedRecentLinks() {
|
||||||
|
const links = getSelectedRecentLinks();
|
||||||
if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); }
|
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({
|
return window.api.completeUploadFinalization({
|
||||||
finalizationId: data.finalizationId,
|
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()
|
? buildPersistedQueueState()
|
||||||
: null
|
: null
|
||||||
});
|
});
|
||||||
@@ -4169,8 +4203,8 @@ function updateStatusBar() {
|
|||||||
_setRollingUploadMetric('uploadTelemetryConnections', lastUploadStats.activeJobs || 0);
|
_setRollingUploadMetric('uploadTelemetryConnections', lastUploadStats.activeJobs || 0);
|
||||||
_setRollingUploadMetric('uploadTelemetryRemaining', stats.remaining);
|
_setRollingUploadMetric('uploadTelemetryRemaining', stats.remaining);
|
||||||
_setRollingUploadMetric('uploadTelemetryRunning', stats.inProgress);
|
_setRollingUploadMetric('uploadTelemetryRunning', stats.inProgress);
|
||||||
_setRollingUploadMetric('uploadTelemetryCompleted', _sessionDoneCount);
|
_setRollingUploadMetric('uploadTelemetryCompleted', stats.done);
|
||||||
_setRollingUploadMetric('uploadTelemetryFailed', Math.max(_sessionErrorCount, stats.errors));
|
_setRollingUploadMetric('uploadTelemetryFailed', stats.errors);
|
||||||
updateUploadSpeedDisplays();
|
updateUploadSpeedDisplays();
|
||||||
_setUploadTelemetryText('uploadTelemetryEta', etaSeconds > 0 ? formatTime(etaSeconds) : '--:--');
|
_setUploadTelemetryText('uploadTelemetryEta', etaSeconds > 0 ? formatTime(etaSeconds) : '--:--');
|
||||||
updateUploadSidebarSummary(stats);
|
updateUploadSidebarSummary(stats);
|
||||||
|
|||||||
@@ -79,12 +79,20 @@
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
target.classList.remove('drag-over');
|
target.classList.remove('drag-over');
|
||||||
const paths = [];
|
const files = [];
|
||||||
for (const file of e.dataTransfer.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) {
|
if (files.length > 0) {
|
||||||
window.dropTargetApi.sendFiles(paths);
|
window.dropTargetApi.sendFiles(files);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -682,6 +682,7 @@
|
|||||||
<script src="../lib/throttle-timer.js"></script>
|
<script src="../lib/throttle-timer.js"></script>
|
||||||
<script src="../lib/serialized-runner.js"></script>
|
<script src="../lib/serialized-runner.js"></script>
|
||||||
<script src="../lib/speed-history.js"></script>
|
<script src="../lib/speed-history.js"></script>
|
||||||
|
<script src="../lib/upload-recovery.js"></script>
|
||||||
<script src="account-submit.js"></script>
|
<script src="account-submit.js"></script>
|
||||||
<script src="account-status.js"></script>
|
<script src="account-status.js"></script>
|
||||||
<script src="history-status.js"></script>
|
<script src="history-status.js"></script>
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ const sourceFiles = [
|
|||||||
'lib/upload-confirmation.js',
|
'lib/upload-confirmation.js',
|
||||||
'lib/upload-diagnostics.js',
|
'lib/upload-diagnostics.js',
|
||||||
'lib/upload-manager.js',
|
'lib/upload-manager.js',
|
||||||
|
'lib/upload-recovery.js',
|
||||||
'lib/vidmoly-upload.js',
|
'lib/vidmoly-upload.js',
|
||||||
'lib/voe-upload.js',
|
'lib/voe-upload.js',
|
||||||
'lib/webhook-notify.js',
|
'lib/webhook-notify.js',
|
||||||
@@ -152,6 +153,7 @@ const sourceFiles = [
|
|||||||
'tests/upload-confirmation.test.js',
|
'tests/upload-confirmation.test.js',
|
||||||
'tests/upload-diagnostics.test.js',
|
'tests/upload-diagnostics.test.js',
|
||||||
'tests/upload-manager.test.js',
|
'tests/upload-manager.test.js',
|
||||||
|
'tests/upload-recovery.test.js',
|
||||||
'tests/session-report.test.js',
|
'tests/session-report.test.js',
|
||||||
'tests/validate-credentials.test.js',
|
'tests/validate-credentials.test.js',
|
||||||
'tests/webhook-notify.test.js'
|
'tests/webhook-notify.test.js'
|
||||||
|
|||||||
@@ -10,6 +10,38 @@ test('packages every Electron preload referenced by the main process', () => {
|
|||||||
assert.equal(packageJson.build.win.signAndEditExecutable, false);
|
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 () => {
|
test('afterPack brands the executable metadata shown by Windows', async () => {
|
||||||
let editCall = null;
|
let editCall = null;
|
||||||
const originalLoad = Module._load;
|
const originalLoad = Module._load;
|
||||||
|
|||||||
+99
-9
@@ -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("|"); })()');
|
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');
|
check('Language changes apply immediately in both directions', liveLanguageSwitch === 'Upload,Accounts,Einstellungen,Verlauf|Upload,Accounts,Settings,History|de');
|
||||||
const localizedStableMetric = await wc.executeJavaScript(\`(async () => {
|
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();
|
updateStatusBar();
|
||||||
await new Promise(resolve => setTimeout(resolve, 360));
|
await new Promise(resolve => setTimeout(resolve, 360));
|
||||||
const metric = document.getElementById('uploadTelemetryCompleted');
|
const metric = document.getElementById('uploadTelemetryCompleted');
|
||||||
@@ -236,7 +238,8 @@ setTimeout(async () => {
|
|||||||
setUiLanguage('en');
|
setUiLanguage('en');
|
||||||
const english = [metric?.textContent.trim(), metric?.getAttribute('aria-label')];
|
const english = [metric?.textContent.trim(), metric?.getAttribute('aria-label')];
|
||||||
setUiLanguage('de');
|
setUiLanguage('de');
|
||||||
_sessionDoneCount = 0;
|
queueJobs = previousJobs;
|
||||||
|
_queueStatsCache = null;
|
||||||
updateStatusBar();
|
updateStatusBar();
|
||||||
return { german, english };
|
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);
|
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');
|
const populatedDropFixture = path.join(app.getPath('temp'), 'mhu-populated-drop-' + process.pid + '.mkv');
|
||||||
fs.writeFileSync(populatedDropFixture, Buffer.from('populated queue drop fixture'));
|
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(); })()');
|
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('|')
|
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('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');
|
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("|")');
|
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); })()');
|
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);
|
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;
|
let releaseSelectedQueueCancel = null;
|
||||||
ipcMain.removeHandler('cancel-selected-jobs');
|
ipcMain.removeHandler('cancel-selected-jobs');
|
||||||
ipcMain.handle('cancel-selected-jobs', () => new Promise(resolve => { releaseSelectedQueueCancel = () => resolve(true); }));
|
ipcMain.handle('cancel-selected-jobs', () => new Promise(resolve => { releaseSelectedQueueCancel = () => resolve(true); }));
|
||||||
@@ -773,14 +821,14 @@ setTimeout(async () => {
|
|||||||
});
|
});
|
||||||
ipcMain.removeHandler('cancel-selected-jobs');
|
ipcMain.removeHandler('cancel-selected-jobs');
|
||||||
ipcMain.handle('cancel-selected-jobs', () => { selectedQueueCancelCalls++; return true; });
|
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 waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
|
||||||
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
|
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
|
||||||
await waitUntil(() => releaseFullQueueCancel);
|
await waitUntil(() => releaseFullQueueCancel);
|
||||||
const fullQueueStillPresent = await wc.executeJavaScript('queueJobs.length');
|
const fullQueueStillPresent = await wc.executeJavaScript('queueJobs.length');
|
||||||
releaseFullQueueCancel();
|
releaseFullQueueCancel();
|
||||||
const fullQueueAfterCancel = await wc.executeJavaScript('window.__uiDeleteAllPromise.then(() => { delete window.__uiDeleteAllPromise; return { length: queueJobs.length, uploading }; })');
|
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-upload');
|
||||||
restoreInitialIpcHandler('cancel-selected-jobs');
|
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; })()');
|
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');
|
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"); })()');
|
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);
|
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);
|
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 () => {
|
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 languages = ['en', 'de', 'en', 'de', 'en', 'de', 'en', 'de', 'en', 'de', 'en', 'de'];
|
||||||
const samples = [];
|
const samples = [];
|
||||||
for (const language of languages) {
|
for (const language of languages) {
|
||||||
@@ -2270,7 +2355,8 @@ setTimeout(async () => {
|
|||||||
metricLabel: metric?.getAttribute('aria-label')
|
metricLabel: metric?.getAttribute('aria-label')
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
_sessionDoneCount = 0;
|
queueJobs = previousJobs;
|
||||||
|
_queueStatsCache = null;
|
||||||
updateStatusBar();
|
updateStatusBar();
|
||||||
return samples;
|
return samples;
|
||||||
})()\`);
|
})()\`);
|
||||||
@@ -2286,8 +2372,11 @@ setTimeout(async () => {
|
|||||||
const metric = document.getElementById('uploadTelemetryCompleted');
|
const metric = document.getElementById('uploadTelemetryCompleted');
|
||||||
const initialRect = metric.getBoundingClientRect();
|
const initialRect = metric.getBoundingClientRect();
|
||||||
const frames = [];
|
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++) {
|
for (let value = 1000; value <= 1020; value++) {
|
||||||
_sessionDoneCount = value;
|
queueJobs.push({ id: 'ui-rolling-done-' + value, status: 'done' });
|
||||||
|
_queueStatsCache = null;
|
||||||
updateStatusBar();
|
updateStatusBar();
|
||||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
||||||
const rect = metric.getBoundingClientRect();
|
const rect = metric.getBoundingClientRect();
|
||||||
@@ -2301,7 +2390,8 @@ setTimeout(async () => {
|
|||||||
}
|
}
|
||||||
await new Promise(resolve => setTimeout(resolve, 360));
|
await new Promise(resolve => setTimeout(resolve, 360));
|
||||||
const settled = { text: metric.textContent.trim(), label: metric.getAttribute('aria-label'), direction: metric.dataset.direction };
|
const settled = { text: metric.textContent.trim(), label: metric.getAttribute('aria-label'), direction: metric.dataset.direction };
|
||||||
_sessionDoneCount = 0;
|
queueJobs = previousJobs;
|
||||||
|
_queueStatsCache = null;
|
||||||
updateStatusBar();
|
updateStatusBar();
|
||||||
return { initialRect: { width: initialRect.width, height: initialRect.height }, frames, settled };
|
return { initialRect: { width: initialRect.width, height: initialRect.height }, frames, settled };
|
||||||
})()\`);
|
})()\`);
|
||||||
|
|||||||
@@ -160,6 +160,7 @@ describe('UploadManager', () => {
|
|||||||
assert.equal(summary.succeeded, 2);
|
assert.equal(summary.succeeded, 2);
|
||||||
assert.equal(summary.failed, 0);
|
assert.equal(summary.failed, 0);
|
||||||
assert.equal(summary.files.length, 2);
|
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 () => {
|
it('emits a final idle stats snapshot after a normal batch', async () => {
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user