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;
|
||||
|
||||
const result = {
|
||||
jobId,
|
||||
hoster: task.hoster,
|
||||
status,
|
||||
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 { 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);
|
||||
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();
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
|
||||
+54
-20
@@ -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,26 +1198,30 @@ 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)}`,
|
||||
.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: normalizeRestoredJobStatus(job.status),
|
||||
bytesUploaded: job.status === 'done' ? (job.bytesTotal || 0) : 0,
|
||||
status,
|
||||
bytesUploaded: 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,
|
||||
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] : [],
|
||||
@@ -1229,8 +1233,9 @@ function restoreQueueStateFromConfig() {
|
||||
attempt: 0,
|
||||
maxAttempts: job.maxAttempts || 0,
|
||||
link: '',
|
||||
progress: job.status === 'done' ? 1 : 0
|
||||
}))
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -682,6 +682,7 @@
|
||||
<script src="../lib/throttle-timer.js"></script>
|
||||
<script src="../lib/serialized-runner.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-status.js"></script>
|
||||
<script src="history-status.js"></script>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
+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("|"); })()');
|
||||
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 };
|
||||
})()\`);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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