Harden terminal upload finalization recovery
This commit is contained in:
@@ -2,7 +2,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
const pathApi = typeof require === 'function' ? require('path') : null;
|
const pathApi = typeof require === 'function' ? require('path') : null;
|
||||||
const protectedStatuses = new Set(['done', 'error', 'aborted', 'skipped']);
|
|
||||||
const metadataVersion = 2;
|
const metadataVersion = 2;
|
||||||
|
|
||||||
function normalizeFile(file, platform) {
|
function normalizeFile(file, platform) {
|
||||||
@@ -247,7 +246,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeRequirement(queueJobs, job, platform) {
|
function removeRequirement(queueJobs, job, platform) {
|
||||||
if (!job || protectedStatuses.has(job.status)) return [];
|
if (!job || job.status !== 'preview' || job.interrupted) return [];
|
||||||
const siblings = relatedJobs(queueJobs, job, platform);
|
const siblings = relatedJobs(queueJobs, job, platform);
|
||||||
const removedHoster = normalizeHoster(job.hoster);
|
const removedHoster = normalizeHoster(job.hoster);
|
||||||
for (const sibling of siblings) {
|
for (const sibling of siblings) {
|
||||||
|
|||||||
@@ -149,25 +149,184 @@ const startupExternalRevealBindings = createStartupExternalRevealBindings({
|
|||||||
getRevealGate: () => startupRevealGate,
|
getRevealGate: () => startupRevealGate,
|
||||||
sendDroppedFiles: paths => safeSend('drop-target:files', paths)
|
sendDroppedFiles: paths => safeSend('drop-target:files', paths)
|
||||||
});
|
});
|
||||||
const pendingUploadFinalizations = new Map();
|
|
||||||
|
function createUploadFinalizationCoordinator({
|
||||||
|
send,
|
||||||
|
saveQueue,
|
||||||
|
schedule = setTimeout,
|
||||||
|
cancelSchedule = clearTimeout,
|
||||||
|
createFinalizationId = () => `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||||
|
createDeliveryId = () => `delivery-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||||
|
timeoutMs = 15000
|
||||||
|
}) {
|
||||||
|
const pending = new Map();
|
||||||
|
let rendererGeneration = 0;
|
||||||
|
let rendererIsReady = false;
|
||||||
|
|
||||||
|
function settle(entry, value) {
|
||||||
|
if (!entry || entry.settled) return;
|
||||||
|
entry.settled = true;
|
||||||
|
if (entry.timer) cancelSchedule(entry.timer);
|
||||||
|
pending.delete(entry.finalizationId);
|
||||||
|
entry.resolve(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function armTimeout(entry) {
|
||||||
|
if (entry.timer) cancelSchedule(entry.timer);
|
||||||
|
entry.timer = schedule(() => settle(entry, false), timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function deliver(entry) {
|
||||||
|
if (!rendererIsReady || entry.settled || entry.completing || entry.deliveredGeneration === rendererGeneration) return false;
|
||||||
|
const deliveryId = createDeliveryId();
|
||||||
|
const delivered = send({
|
||||||
|
summary: entry.summary,
|
||||||
|
finalizationId: entry.finalizationId,
|
||||||
|
deliveryId,
|
||||||
|
historyPersisted: entry.historyPersisted
|
||||||
|
});
|
||||||
|
if (delivered === false) return false;
|
||||||
|
entry.deliveryId = deliveryId;
|
||||||
|
entry.deliveredGeneration = rendererGeneration;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function request(summary, historyPersisted) {
|
||||||
|
const finalizationId = createFinalizationId();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const entry = {
|
||||||
|
finalizationId,
|
||||||
|
summary,
|
||||||
|
historyPersisted: historyPersisted === true,
|
||||||
|
deliveryId: null,
|
||||||
|
deliveredGeneration: 0,
|
||||||
|
completing: false,
|
||||||
|
settled: false,
|
||||||
|
timer: null,
|
||||||
|
resolve
|
||||||
|
};
|
||||||
|
pending.set(finalizationId, entry);
|
||||||
|
armTimeout(entry);
|
||||||
|
deliver(entry);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function rendererBlocked() {
|
||||||
|
rendererIsReady = false;
|
||||||
|
for (const entry of pending.values()) armTimeout(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rendererReady() {
|
||||||
|
if (rendererIsReady) return rendererGeneration;
|
||||||
|
rendererIsReady = true;
|
||||||
|
rendererGeneration += 1;
|
||||||
|
for (const entry of pending.values()) deliver(entry);
|
||||||
|
return rendererGeneration;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function complete(payload) {
|
||||||
|
const entry = payload?.finalizationId && pending.get(payload.finalizationId);
|
||||||
|
if (!entry || entry.settled || entry.completing || !payload.deliveryId || payload.deliveryId !== entry.deliveryId) return false;
|
||||||
|
entry.completing = true;
|
||||||
|
if (payload.ready === false) {
|
||||||
|
settle(entry, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await saveQueue(payload.pendingQueue ?? null);
|
||||||
|
settle(entry, true);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
settle(entry, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { request, rendererBlocked, rendererReady, complete };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUploadFinalizationBarrier({
|
||||||
|
appendHistory,
|
||||||
|
saveRecovery,
|
||||||
|
requestFinalization,
|
||||||
|
buildTerminalSnapshots,
|
||||||
|
now = () => new Date().toISOString(),
|
||||||
|
onError = () => {}
|
||||||
|
}) {
|
||||||
|
async function finalize(summary, recovery) {
|
||||||
|
let historyPersisted = true;
|
||||||
|
try {
|
||||||
|
await appendHistory(summary);
|
||||||
|
} catch (error) {
|
||||||
|
historyPersisted = false;
|
||||||
|
onError('history', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const terminalRecovery = {
|
||||||
|
...recovery,
|
||||||
|
settledAt: now(),
|
||||||
|
terminalJobs: buildTerminalSnapshots(summary)
|
||||||
|
};
|
||||||
|
let terminalRecoveryPersisted = true;
|
||||||
|
try {
|
||||||
|
await saveRecovery(terminalRecovery);
|
||||||
|
} catch (error) {
|
||||||
|
terminalRecoveryPersisted = false;
|
||||||
|
onError('recovery', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let queuePersisted = false;
|
||||||
|
try {
|
||||||
|
queuePersisted = (await requestFinalization(summary, historyPersisted)) === true;
|
||||||
|
} catch (error) {
|
||||||
|
onError('queue', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let recoveryCleared = false;
|
||||||
|
if (queuePersisted && terminalRecoveryPersisted) {
|
||||||
|
try {
|
||||||
|
await saveRecovery(null);
|
||||||
|
recoveryCleared = true;
|
||||||
|
} catch (error) {
|
||||||
|
onError('recovery-clear', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
historyPersisted,
|
||||||
|
queuePersisted,
|
||||||
|
terminalRecoveryPersisted,
|
||||||
|
recoveryCleared,
|
||||||
|
terminalRecovery
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { finalize };
|
||||||
|
}
|
||||||
|
|
||||||
function requestUploadFinalization(summary, historyPersisted) {
|
function requestUploadFinalization(summary, historyPersisted) {
|
||||||
const finalizationId = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
return uploadFinalizationCoordinator.request(summary, historyPersisted);
|
||||||
return new Promise((resolve) => {
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
pendingUploadFinalizations.delete(finalizationId);
|
|
||||||
resolve(false);
|
|
||||||
}, 15000);
|
|
||||||
pendingUploadFinalizations.set(finalizationId, {
|
|
||||||
resolve(value) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
pendingUploadFinalizations.delete(finalizationId);
|
|
||||||
resolve(value);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
safeSend('upload-batch-done', { summary, finalizationId, historyPersisted: historyPersisted === true });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadFinalizationCoordinator = createUploadFinalizationCoordinator({
|
||||||
|
send: payload => safeSend('upload-batch-done', payload),
|
||||||
|
saveQueue: async pendingQueue => {
|
||||||
|
try {
|
||||||
|
await configStore.savePendingQueue(pendingQueue);
|
||||||
|
} catch (error) {
|
||||||
|
debugLog(`upload finalization queue save failed: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
timeoutMs: RENDERER_READY_TIMEOUT_MS * 3
|
||||||
|
});
|
||||||
|
const uploadFinalizationBarrier = createUploadFinalizationBarrier({
|
||||||
|
appendHistory: summary => configStore.appendHistory(summary),
|
||||||
|
saveRecovery: recovery => configStore.saveUploadRecovery(recovery),
|
||||||
|
requestFinalization: requestUploadFinalization,
|
||||||
|
buildTerminalSnapshots: buildTerminalJobSnapshots,
|
||||||
|
onError: (phase, error) => debugLog(`upload finalization ${phase} failed: ${error.message}`)
|
||||||
|
});
|
||||||
const activeUploadProducerTrackers = new Set();
|
const activeUploadProducerTrackers = new Set();
|
||||||
const uploadStartReservation = createUploadStartReservation();
|
const uploadStartReservation = createUploadStartReservation();
|
||||||
const settingsImportGate = createSettingsImportGate(() => !!uploadManager || uploadStartReservation.isActive());
|
const settingsImportGate = createSettingsImportGate(() => !!uploadManager || uploadStartReservation.isActive());
|
||||||
@@ -1520,12 +1679,17 @@ function createWindow() {
|
|||||||
window: mainWindow,
|
window: mainWindow,
|
||||||
ipcMain,
|
ipcMain,
|
||||||
coordinator: startupRecoveryCoordinator,
|
coordinator: startupRecoveryCoordinator,
|
||||||
onDocumentLoadStarted: currentStartupRevealGate.block,
|
onDocumentLoadStarted: () => {
|
||||||
|
currentStartupRevealGate.block();
|
||||||
|
uploadFinalizationCoordinator.rendererBlocked();
|
||||||
|
},
|
||||||
onRendererCrashed: (details) => {
|
onRendererCrashed: (details) => {
|
||||||
|
uploadFinalizationCoordinator.rendererBlocked();
|
||||||
_writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details);
|
_writeCrashLog('RENDER PROCESS GONE', new Error(details.reason || 'unknown'), details);
|
||||||
debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`);
|
debugLog(`RENDER PROCESS GONE: reason=${details.reason} exitCode=${details.exitCode}`);
|
||||||
},
|
},
|
||||||
onReady: () => {
|
onReady: () => {
|
||||||
|
uploadFinalizationCoordinator.rendererReady();
|
||||||
startupExternalRevealBindings.rendererReady(startupWindow.window);
|
startupExternalRevealBindings.rendererReady(startupWindow.window);
|
||||||
closeHandshakeReady = true;
|
closeHandshakeReady = true;
|
||||||
},
|
},
|
||||||
@@ -2106,11 +2270,21 @@ async function executeReservedUploadStart(payload, startLease) {
|
|||||||
skipped: 0,
|
skipped: 0,
|
||||||
files: []
|
files: []
|
||||||
}, skippedJobs);
|
}, skippedJobs);
|
||||||
try { await configStore.appendHistory(skippedSummary); } catch (error) {
|
lastSessionSummary = skippedSummary;
|
||||||
debugLog(`appendHistory for skipped jobs failed: ${error.message}`);
|
const finalization = await uploadFinalizationBarrier.finalize(skippedSummary, {
|
||||||
}
|
id: `skipped-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||||
setImmediate(() => safeSend('upload-batch-done', skippedSummary));
|
startedAt: new Date().toISOString(),
|
||||||
return { started: true, taskCount: 0, skippedJobs };
|
jobIds: skippedJobs.map(job => job.jobId).filter(Boolean)
|
||||||
|
});
|
||||||
|
if (!finalization.queuePersisted) debugLog('upload finalization blocked: renderer queue acknowledgement missing');
|
||||||
|
if (!finalization.terminalRecoveryPersisted) debugLog('upload finalization blocked: terminal recovery state was not persisted');
|
||||||
|
return {
|
||||||
|
started: true,
|
||||||
|
taskCount: 0,
|
||||||
|
skippedJobs,
|
||||||
|
finalized: true,
|
||||||
|
historyPersisted: finalization.historyPersisted
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config));
|
uploadManager = new UploadManager(config.hosterSettings || {}, config.globalSettings || {}, buildAccountPools(config));
|
||||||
@@ -2319,11 +2493,6 @@ async function executeReservedUploadStart(payload, startLease) {
|
|||||||
const _batchDurationSec = _thisManager && _thisManager.startTime
|
const _batchDurationSec = _thisManager && _thisManager.startTime
|
||||||
? Math.round((Date.now() - _thisManager.startTime) / 1000)
|
? Math.round((Date.now() - _thisManager.startTime) / 1000)
|
||||||
: 0;
|
: 0;
|
||||||
let historyPersisted = true;
|
|
||||||
try { await configStore.appendHistory(summary); } catch (err) {
|
|
||||||
historyPersisted = false;
|
|
||||||
debugLog(`appendHistory failed: ${err.message}`);
|
|
||||||
}
|
|
||||||
if (_progressFlushTimer) {
|
if (_progressFlushTimer) {
|
||||||
clearTimeout(_progressFlushTimer);
|
clearTimeout(_progressFlushTimer);
|
||||||
_progressFlushTimer = null;
|
_progressFlushTimer = null;
|
||||||
@@ -2332,20 +2501,8 @@ async function executeReservedUploadStart(payload, startLease) {
|
|||||||
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 = {
|
const finalization = await uploadFinalizationBarrier.finalize(summary, recovery);
|
||||||
...recovery,
|
const { historyPersisted, queuePersisted, terminalRecoveryPersisted } = finalization;
|
||||||
settledAt: new Date().toISOString(),
|
|
||||||
terminalJobs: buildTerminalJobSnapshots(summary)
|
|
||||||
};
|
|
||||||
let terminalRecoveryPersisted = true;
|
|
||||||
try { await configStore.saveUploadRecovery(recoveryWithTerminalJobs); } catch (error) {
|
|
||||||
terminalRecoveryPersisted = false;
|
|
||||||
debugLog(`upload recovery outcomes could not be saved: ${error.message}`);
|
|
||||||
}
|
|
||||||
const queuePersisted = await requestUploadFinalization(summary, historyPersisted);
|
|
||||||
if (queuePersisted && terminalRecoveryPersisted) {
|
|
||||||
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');
|
||||||
if (!terminalRecoveryPersisted) debugLog('upload finalization blocked: terminal recovery state was not persisted');
|
if (!terminalRecoveryPersisted) debugLog('upload finalization blocked: terminal recovery state was not persisted');
|
||||||
if (hadActiveBatchMutation) debugLog('source cleanup blocked: batch mutation overlapped finalization');
|
if (hadActiveBatchMutation) debugLog('source cleanup blocked: batch mutation overlapped finalization');
|
||||||
@@ -2394,27 +2551,7 @@ async function executeReservedUploadStart(payload, startLease) {
|
|||||||
debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`);
|
debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`);
|
||||||
await batchMutationGate.sealAndDrain();
|
await batchMutationGate.sealAndDrain();
|
||||||
const errorSummary = buildFailedUploadSummary(tasks, 'Upload konnte nicht gestartet werden');
|
const errorSummary = buildFailedUploadSummary(tasks, 'Upload konnte nicht gestartet werden');
|
||||||
let historyPersisted = true;
|
await uploadFinalizationBarrier.finalize(errorSummary, recovery);
|
||||||
try { await configStore.appendHistory(errorSummary); } catch (historyError) {
|
|
||||||
historyPersisted = false;
|
|
||||||
debugLog(`appendHistory after start failure failed: ${historyError.message}`);
|
|
||||||
}
|
|
||||||
const terminalRecovery = {
|
|
||||||
...recovery,
|
|
||||||
settledAt: new Date().toISOString(),
|
|
||||||
terminalJobs: buildTerminalJobSnapshots(errorSummary)
|
|
||||||
};
|
|
||||||
let terminalRecoveryPersisted = true;
|
|
||||||
try { await configStore.saveUploadRecovery(terminalRecovery); } catch (recoveryError) {
|
|
||||||
terminalRecoveryPersisted = false;
|
|
||||||
debugLog(`upload recovery outcomes could not be saved after start failure: ${recoveryError.message}`);
|
|
||||||
}
|
|
||||||
const queuePersisted = await requestUploadFinalization(errorSummary, historyPersisted);
|
|
||||||
if (queuePersisted && terminalRecoveryPersisted) {
|
|
||||||
try { await configStore.saveUploadRecovery(null); } catch (clearError) {
|
|
||||||
debugLog(`upload recovery state could not be cleared after start failure: ${clearError.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_producerTracker.finish();
|
_producerTracker.finish();
|
||||||
if (!isAutoRetry) sendBatchWebhook(errorSummary, 0);
|
if (!isAutoRetry) sendBatchWebhook(errorSummary, 0);
|
||||||
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
|
if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; }
|
||||||
@@ -3085,22 +3222,7 @@ ipcMain.handle('save-pending-queue', async (_event, pendingQueue) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('complete-upload-finalization', async (_event, payload) => {
|
ipcMain.handle('complete-upload-finalization', async (_event, payload) => {
|
||||||
const finalizationId = payload && payload.finalizationId;
|
return uploadFinalizationCoordinator.complete(payload);
|
||||||
const pending = finalizationId && pendingUploadFinalizations.get(finalizationId);
|
|
||||||
if (!pending) return false;
|
|
||||||
if (payload.ready === false) {
|
|
||||||
pending.resolve(false);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await configStore.savePendingQueue(payload.pendingQueue ?? null);
|
|
||||||
pending.resolve(true);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
debugLog(`upload finalization queue save failed: ${error.message}`);
|
|
||||||
pending.resolve(false);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('save-global-settings', async (_event, globalSettings) => {
|
ipcMain.handle('save-global-settings', async (_event, globalSettings) => {
|
||||||
|
|||||||
+19
-3
@@ -2968,6 +2968,11 @@ document.getElementById('contextMenu').addEventListener('click', (e) => {
|
|||||||
handleContextAction(action);
|
handleContextAction(action);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function removeSourceCleanupRequirements(jobs) {
|
||||||
|
if (!window.SourceCleanupPolicy || !Array.isArray(jobs)) return;
|
||||||
|
for (const job of jobs) window.SourceCleanupPolicy.removeRequirement(queueJobs, job, 'win32');
|
||||||
|
}
|
||||||
|
|
||||||
async function handleContextAction(action) {
|
async function handleContextAction(action) {
|
||||||
_normalizeQueueSelectionToVisible();
|
_normalizeQueueSelectionToVisible();
|
||||||
if (action === 'start-selected') {
|
if (action === 'start-selected') {
|
||||||
@@ -2987,6 +2992,8 @@ async function handleContextAction(action) {
|
|||||||
return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server');
|
return j && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server');
|
||||||
});
|
});
|
||||||
if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds);
|
if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds);
|
||||||
|
const removedJobs = queueJobs.filter(j => selectedJobIds.has(j.id));
|
||||||
|
removeSourceCleanupRequirements(removedJobs);
|
||||||
const _deletedKeys = [];
|
const _deletedKeys = [];
|
||||||
queueJobs = queueJobs.filter(j => {
|
queueJobs = queueJobs.filter(j => {
|
||||||
if (selectedJobIds.has(j.id)) {
|
if (selectedJobIds.has(j.id)) {
|
||||||
@@ -3014,6 +3021,7 @@ async function handleContextAction(action) {
|
|||||||
await window.api.cancelUpload();
|
await window.api.cancelUpload();
|
||||||
uploading = false;
|
uploading = false;
|
||||||
}
|
}
|
||||||
|
removeSourceCleanupRequirements(queueJobs);
|
||||||
queueJobs.forEach(j => removeJobFromIndex(j));
|
queueJobs.forEach(j => removeJobFromIndex(j));
|
||||||
queueJobs = [];
|
queueJobs = [];
|
||||||
selectedJobIds.clear();
|
selectedJobIds.clear();
|
||||||
@@ -3036,6 +3044,7 @@ async function handleContextAction(action) {
|
|||||||
.filter(j => j.hoster === hoster && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server' || j.status === 'preview'))
|
.filter(j => j.hoster === hoster && (j.status === 'uploading' || j.status === 'queued' || j.status === 'retrying' || j.status === 'getting-server' || j.status === 'preview'))
|
||||||
.map(j => j.id);
|
.map(j => j.id);
|
||||||
if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds);
|
if (activeIds.length > 0) await window.api.cancelSelectedJobs(activeIds);
|
||||||
|
removeSourceCleanupRequirements(queueJobs.filter(j => j.hoster === hoster));
|
||||||
// Remove ALL jobs for this hoster
|
// Remove ALL jobs for this hoster
|
||||||
queueJobs = queueJobs.filter(j => {
|
queueJobs = queueJobs.filter(j => {
|
||||||
if (j.hoster === hoster) { removeJobFromIndex(j); return false; }
|
if (j.hoster === hoster) { removeJobFromIndex(j); return false; }
|
||||||
@@ -3111,12 +3120,19 @@ async function completeSourceCleanupFinalization(data) {
|
|||||||
const persist = async () => {
|
const persist = async () => {
|
||||||
if (!writesReady) {
|
if (!writesReady) {
|
||||||
try {
|
try {
|
||||||
await window.api.completeUploadFinalization({ finalizationId: data.finalizationId, ready: false });
|
await window.api.completeUploadFinalization({
|
||||||
|
finalizationId: data.finalizationId,
|
||||||
|
deliveryId: data.deliveryId,
|
||||||
|
historyPersisted: data.historyPersisted === true,
|
||||||
|
ready: false
|
||||||
|
});
|
||||||
} catch {}
|
} catch {}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return window.api.completeUploadFinalization({
|
return window.api.completeUploadFinalization({
|
||||||
finalizationId: data.finalizationId,
|
finalizationId: data.finalizationId,
|
||||||
|
deliveryId: data.deliveryId,
|
||||||
|
historyPersisted: data.historyPersisted === true,
|
||||||
pendingQueue: data.historyPersisted !== true || queueJobs.some((job) => !['done', 'skipped'].includes(job.status))
|
pendingQueue: data.historyPersisted !== true || queueJobs.some((job) => !['done', 'skipped'].includes(job.status))
|
||||||
? buildPersistedQueueState({ historyPersisted: data.historyPersisted === true })
|
? buildPersistedQueueState({ historyPersisted: data.historyPersisted === true })
|
||||||
: null
|
: null
|
||||||
@@ -3195,7 +3211,7 @@ async function startUpload(opts) {
|
|||||||
window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints);
|
window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints);
|
||||||
}
|
}
|
||||||
_markSkippedJobs(result);
|
_markSkippedJobs(result);
|
||||||
persistQueueStateSoon();
|
if (result?.finalized !== true) persistQueueStateSoon();
|
||||||
|
|
||||||
if (result && result.error) {
|
if (result && result.error) {
|
||||||
await showAppAlert(getLocalizedErrorDetail(result.error), 'Upload-Start fehlgeschlagen');
|
await showAppAlert(getLocalizedErrorDetail(result.error), 'Upload-Start fehlgeschlagen');
|
||||||
@@ -3319,7 +3335,7 @@ async function startSelectedUpload(explicitJobs) {
|
|||||||
window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints);
|
window.SourceCleanupPolicy.applyFingerprints(queueJobs, result.sourceCleanupFingerprints);
|
||||||
}
|
}
|
||||||
_markSkippedJobs(result);
|
_markSkippedJobs(result);
|
||||||
persistQueueStateSoon();
|
if (result?.finalized !== true) persistQueueStateSoon();
|
||||||
|
|
||||||
if (result && result.error) {
|
if (result && result.error) {
|
||||||
await showAppAlert(getLocalizedErrorDetail(result.error), 'Upload-Start fehlgeschlagen');
|
await showAppAlert(getLocalizedErrorDetail(result.error), 'Upload-Start fehlgeschlagen');
|
||||||
|
|||||||
@@ -178,6 +178,47 @@ test('markCompleted keeps successful hosters provisional for the current round',
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('removeRequirement never relaxes requirements for started or interrupted jobs', () => {
|
||||||
|
for (const candidate of [
|
||||||
|
{ status: 'queued' },
|
||||||
|
{ status: 'getting-server' },
|
||||||
|
{ status: 'uploading' },
|
||||||
|
{ status: 'retrying' },
|
||||||
|
{ status: 'preview', interrupted: true }
|
||||||
|
]) {
|
||||||
|
const queueJobs = [
|
||||||
|
{
|
||||||
|
id: 'job-complete',
|
||||||
|
file: 'C:\\Uploads\\Protected.mkv',
|
||||||
|
hoster: 'doodstream.com',
|
||||||
|
status: 'done',
|
||||||
|
sourceCleanupMetadataVersion: 2,
|
||||||
|
sourceCleanupToken: 'cleanup-protected',
|
||||||
|
sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'],
|
||||||
|
sourceCleanupConfirmedHosters: ['doodstream.com']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'job-candidate',
|
||||||
|
file: 'C:\\Uploads\\Protected.mkv',
|
||||||
|
hoster: 'voe.sx',
|
||||||
|
sourceCleanupMetadataVersion: 2,
|
||||||
|
sourceCleanupToken: 'cleanup-protected',
|
||||||
|
sourceCleanupRequiredHosters: ['doodstream.com', 'voe.sx'],
|
||||||
|
sourceCleanupConfirmedHosters: ['doodstream.com'],
|
||||||
|
...candidate
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const touchedJobs = policy.removeRequirement(queueJobs, queueJobs[1], 'win32');
|
||||||
|
|
||||||
|
assert.deepEqual(touchedJobs, []);
|
||||||
|
assert.deepEqual(queueJobs.map(job => job.sourceCleanupRequiredHosters), [
|
||||||
|
['doodstream.com', 'voe.sx'],
|
||||||
|
['doodstream.com', 'voe.sx']
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('applyFingerprints attaches Main fingerprints by token and includes them in payloads', () => {
|
test('applyFingerprints attaches Main fingerprints by token and includes them in payloads', () => {
|
||||||
const queueJobs = queueFixture();
|
const queueJobs = queueFixture();
|
||||||
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
|
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
|
||||||
|
|||||||
+52
-5
@@ -803,6 +803,12 @@ 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);
|
||||||
|
|
||||||
|
await wc.executeJavaScript('(() => { queuePersistThrottle.cancel(); queueJobs = [{ id: "ui-cleanup-survivor", file: "C:/ui/cleanup-remove-selected.bin", fileName: "cleanup-remove-selected.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-cleanup-remove-selected", sourceCleanupRequiredHosters: ["doodstream.com", "voe.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-cleanup-remove-preview", file: "C:/ui/cleanup-remove-selected.bin", fileName: "cleanup-remove-selected.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-cleanup-remove-selected", sourceCleanupRequiredHosters: ["doodstream.com", "voe.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"] }]; selectedJobIds.clear(); selectedJobIds.add("ui-cleanup-remove-preview"); rebuildJobIndex(); renderQueueTable(); window.__uiCleanupPreviewRemoval = handleContextAction("delete-selected"); return true; })()');
|
||||||
|
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
|
||||||
|
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
|
||||||
|
const previewCleanupRemoval = await wc.executeJavaScript('window.__uiCleanupPreviewRemoval.then(() => { delete window.__uiCleanupPreviewRemoval; queuePersistThrottle.cancel(); const survivor = queueJobs[0]; const result = { length: queueJobs.length, id: survivor?.id, required: survivor?.sourceCleanupRequiredHosters || [], confirmed: survivor?.sourceCleanupConfirmedHosters || [], finalizing: sourceCleanupFinalizationPending }; queueJobs = []; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); return result; })');
|
||||||
|
check('Removing an unstarted destination drops only that cleanup prerequisite', previewCleanupRemoval.length === 1 && previewCleanupRemoval.id === 'ui-cleanup-survivor' && previewCleanupRemoval.required.join('|') === 'doodstream.com' && previewCleanupRemoval.confirmed.join('|') === 'doodstream.com' && previewCleanupRemoval.finalizing === false);
|
||||||
|
|
||||||
const queueTelemetryState = await wc.executeJavaScript(\`(async () => {
|
const queueTelemetryState = await wc.executeJavaScript(\`(async () => {
|
||||||
queueJobs = [
|
queueJobs = [
|
||||||
{ id: 'telemetry-done-a', status: 'done' },
|
{ id: 'telemetry-done-a', status: 'done' },
|
||||||
@@ -833,14 +839,15 @@ setTimeout(async () => {
|
|||||||
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); }));
|
||||||
await wc.executeJavaScript('(() => { queueJobs = [{ id: "ui-delete-selected", file: "C:/ui/delete-selected.bin", fileName: "delete-selected.bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0 }]; selectedJobIds.clear(); selectedJobIds.add("ui-delete-selected"); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteSelectedPromise = handleContextAction("delete-selected"); return true; })()');
|
await wc.executeJavaScript('(() => { queueJobs = [{ id: "ui-delete-selected-survivor", file: "C:/ui/delete-selected.bin", fileName: "delete-selected.bin", hoster: "doodstream.com", status: "done", bytesUploaded: 100, bytesTotal: 100, progress: 1, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-selected-token", sourceCleanupRequiredHosters: ["doodstream.com", "byse.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-selected", file: "C:/ui/delete-selected.bin", fileName: "delete-selected.bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-selected-token", sourceCleanupRequiredHosters: ["doodstream.com", "byse.sx"], sourceCleanupConfirmedHosters: ["doodstream.com"] }]; selectedJobIds.clear(); selectedJobIds.add("ui-delete-selected"); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteSelectedPromise = handleContextAction("delete-selected"); 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(() => releaseSelectedQueueCancel);
|
await waitUntil(() => releaseSelectedQueueCancel);
|
||||||
const selectedQueueStillPresent = await wc.executeJavaScript('queueJobs.length');
|
const selectedQueueStillPresent = await wc.executeJavaScript('queueJobs.length');
|
||||||
releaseSelectedQueueCancel();
|
releaseSelectedQueueCancel();
|
||||||
const selectedQueueAfterCancel = await wc.executeJavaScript('window.__uiDeleteSelectedPromise.then(() => { delete window.__uiDeleteSelectedPromise; return queueJobs.length; })');
|
const selectedQueueAfterCancel = await wc.executeJavaScript('window.__uiDeleteSelectedPromise.then(() => { delete window.__uiDeleteSelectedPromise; const result = { length: queueJobs.length, required: queueJobs[0]?.sourceCleanupRequiredHosters || [] }; queueJobs = []; rebuildJobIndex(); return result; })');
|
||||||
check('Removing selected uploads waits for the main-process cancellation acknowledgement', selectedQueueStillPresent === 1 && selectedQueueAfterCancel === 0);
|
check('Removing selected uploads waits for the main-process cancellation acknowledgement', selectedQueueStillPresent === 2 && selectedQueueAfterCancel.length === 1);
|
||||||
|
check('Cancelling a started destination never relaxes cleanup prerequisites', selectedQueueAfterCancel.required.join('|') === 'doodstream.com|byse.sx');
|
||||||
restoreInitialIpcHandler('cancel-selected-jobs');
|
restoreInitialIpcHandler('cancel-selected-jobs');
|
||||||
|
|
||||||
let releaseFullQueueCancel = null;
|
let releaseFullQueueCancel = null;
|
||||||
@@ -864,6 +871,18 @@ setTimeout(async () => {
|
|||||||
restoreInitialIpcHandler('cancel-upload');
|
restoreInitialIpcHandler('cancel-upload');
|
||||||
restoreInitialIpcHandler('cancel-selected-jobs');
|
restoreInitialIpcHandler('cancel-selected-jobs');
|
||||||
|
|
||||||
|
await wc.executeJavaScript('(() => { uploading = false; queuePersistThrottle.cancel(); const required = ["doodstream.com", "voe.sx", "byse.sx"]; window.__uiDeleteAllCleanupJobs = [{ id: "ui-delete-all-cleanup-done", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-all-cleanup-voe", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-all-cleanup-byse", file: "C:/ui/delete-all-cleanup.bin", fileName: "delete-all-cleanup.bin", hoster: "byse.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-all-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }]; queueJobs = window.__uiDeleteAllCleanupJobs; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteAllCleanupPromise = handleContextAction("delete-all"); return true; })()');
|
||||||
|
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
|
||||||
|
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
|
||||||
|
const deleteAllCleanupState = await wc.executeJavaScript('window.__uiDeleteAllCleanupPromise.then(() => { delete window.__uiDeleteAllCleanupPromise; queuePersistThrottle.cancel(); const result = { queueLength: queueJobs.length, required: window.__uiDeleteAllCleanupJobs.map(job => job.sourceCleanupRequiredHosters || []), confirmed: window.__uiDeleteAllCleanupJobs.map(job => job.sourceCleanupConfirmedHosters || []) }; delete window.__uiDeleteAllCleanupJobs; return result; })');
|
||||||
|
check('Remove all drops only never-started cleanup destinations without authorizing deletion', deleteAllCleanupState.queueLength === 0 && deleteAllCleanupState.required.every(hosters => hosters.join('|') === 'doodstream.com') && deleteAllCleanupState.confirmed.every(hosters => hosters.join('|') === 'doodstream.com'));
|
||||||
|
|
||||||
|
await wc.executeJavaScript('(() => { const required = ["doodstream.com", "voe.sx", "byse.sx"]; queueJobs = [{ id: "ui-delete-hoster-cleanup-done", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "doodstream.com", status: "done", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-hoster-cleanup-voe", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "voe.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }, { id: "ui-delete-hoster-cleanup-byse", file: "C:/ui/delete-hoster-cleanup.bin", fileName: "delete-hoster-cleanup.bin", hoster: "byse.sx", status: "preview", bytesTotal: 100, sourceCleanupMetadataVersion: 2, sourceCleanupToken: "ui-delete-hoster-cleanup-token", sourceCleanupRequiredHosters: [...required], sourceCleanupConfirmedHosters: ["doodstream.com"] }]; selectedJobIds.clear(); rebuildJobIndex(); renderQueueTable(); window.__uiDeleteHosterCleanupPromise = handleContextAction("delete-hoster:voe.sx"); return true; })()');
|
||||||
|
await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal").style.display === "flex"'));
|
||||||
|
await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
|
||||||
|
const deleteHosterCleanupState = await wc.executeJavaScript('window.__uiDeleteHosterCleanupPromise.then(() => { delete window.__uiDeleteHosterCleanupPromise; queuePersistThrottle.cancel(); const result = { ids: queueJobs.map(job => job.id), required: queueJobs.map(job => job.sourceCleanupRequiredHosters || []), confirmed: queueJobs.map(job => job.sourceCleanupConfirmedHosters || []) }; queueJobs = []; rebuildJobIndex(); return result; })');
|
||||||
|
check('Removing one unstarted host destination preserves every remaining cleanup prerequisite', deleteHosterCleanupState.ids.join('|') === 'ui-delete-hoster-cleanup-done|ui-delete-hoster-cleanup-byse' && deleteHosterCleanupState.required.every(hosters => hosters.join('|') === 'doodstream.com|byse.sx') && deleteHosterCleanupState.confirmed.every(hosters => hosters.join('|') === 'doodstream.com'));
|
||||||
|
|
||||||
const keyboardTab = await wc.executeJavaScript('document.getElementById("upload-tab").focus(); document.getElementById("upload-tab").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); document.querySelector(".tab.active")?.textContent?.trim() + "|" + document.activeElement?.id');
|
const keyboardTab = await wc.executeJavaScript('document.getElementById("upload-tab").focus(); document.getElementById("upload-tab").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); document.querySelector(".tab.active")?.textContent?.trim() + "|" + document.activeElement?.id');
|
||||||
check('Arrow keys move and activate main tabs', keyboardTab === 'Accounts|accounts-tab');
|
check('Arrow keys move and activate main tabs', keyboardTab === 'Accounts|accounts-tab');
|
||||||
|
|
||||||
@@ -2022,11 +2041,11 @@ setTimeout(async () => {
|
|||||||
sourceCleanupFinalizationPayload = payload;
|
sourceCleanupFinalizationPayload = payload;
|
||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
const sourceCleanupRollback = await wc.executeJavaScript('(async () => { queuePersistThrottle.cancel(); await flushConfigWrites(); queueJobs = [{ id: "ui-cleanup-voe", file: "C:/ui/cleanup-round.bin", fileName: "cleanup-round.bin", hoster: "voe.sx", status: "preview", bytesTotal: 10 }, { id: "ui-cleanup-byse", file: "C:/ui/cleanup-round.bin", fileName: "cleanup-round.bin", hoster: "byse.sx", status: "error", bytesTotal: 10 }]; rebuildJobIndex(); window.SourceCleanupPolicy.prepareGroups(queueJobs, queueJobs, () => "ui-cleanup-token", "win32"); queueJobs[0].status = "done"; window.SourceCleanupPolicy.markCompleted(queueJobs, queueJobs[0], "win32"); const before = buildPersistedQueueState(); if (typeof completeSourceCleanupFinalization !== "function") return { available: false, before }; const result = await completeSourceCleanupFinalization({ finalizationId: "ui-cleanup-finalization", historyPersisted: true }); const after = buildPersistedQueueState(); return { available: true, result, before, after }; })()');
|
const sourceCleanupRollback = await wc.executeJavaScript('(async () => { queuePersistThrottle.cancel(); await flushConfigWrites(); queueJobs = [{ id: "ui-cleanup-voe", file: "C:/ui/cleanup-round.bin", fileName: "cleanup-round.bin", hoster: "voe.sx", status: "preview", bytesTotal: 10 }, { id: "ui-cleanup-byse", file: "C:/ui/cleanup-round.bin", fileName: "cleanup-round.bin", hoster: "byse.sx", status: "error", bytesTotal: 10 }]; rebuildJobIndex(); window.SourceCleanupPolicy.prepareGroups(queueJobs, queueJobs, () => "ui-cleanup-token", "win32"); queueJobs[0].status = "done"; window.SourceCleanupPolicy.markCompleted(queueJobs, queueJobs[0], "win32"); const before = buildPersistedQueueState(); if (typeof completeSourceCleanupFinalization !== "function") return { available: false, before }; const result = await completeSourceCleanupFinalization({ finalizationId: "ui-cleanup-finalization", deliveryId: "ui-cleanup-delivery", historyPersisted: true }); const after = buildPersistedQueueState(); return { available: true, result, before, after }; })()');
|
||||||
const sourceCleanupBeforeJobs = sourceCleanupRollback.before?.queueJobs || [];
|
const sourceCleanupBeforeJobs = sourceCleanupRollback.before?.queueJobs || [];
|
||||||
const sourceCleanupAfterJobs = sourceCleanupRollback.after?.queueJobs || [];
|
const sourceCleanupAfterJobs = sourceCleanupRollback.after?.queueJobs || [];
|
||||||
const sourceCleanupPromotedJobs = sourceCleanupFinalizationPayload?.pendingQueue?.queueJobs || [];
|
const sourceCleanupPromotedJobs = sourceCleanupFinalizationPayload?.pendingQueue?.queueJobs || [];
|
||||||
const sourceCleanupRollbackOk = sourceCleanupRollback.available === true && sourceCleanupRollback.result === false && sourceCleanupBeforeJobs.length === 2 && sourceCleanupBeforeJobs.every(job => !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupProvisionalHosters') && !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupCompletedHosters') && (job.sourceCleanupConfirmedHosters || []).length === 0) && sourceCleanupPromotedJobs.length === 2 && sourceCleanupPromotedJobs.every(job => (job.sourceCleanupConfirmedHosters || []).join('|') === 'voe.sx') && sourceCleanupAfterJobs.length === 2 && sourceCleanupAfterJobs.every(job => (job.sourceCleanupConfirmedHosters || []).length === 0);
|
const sourceCleanupRollbackOk = sourceCleanupRollback.available === true && sourceCleanupRollback.result === false && sourceCleanupFinalizationPayload?.deliveryId === 'ui-cleanup-delivery' && sourceCleanupBeforeJobs.length === 2 && sourceCleanupBeforeJobs.every(job => !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupProvisionalHosters') && !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupCompletedHosters') && (job.sourceCleanupConfirmedHosters || []).length === 0) && sourceCleanupPromotedJobs.length === 2 && sourceCleanupPromotedJobs.every(job => (job.sourceCleanupConfirmedHosters || []).join('|') === 'voe.sx') && sourceCleanupAfterJobs.length === 2 && sourceCleanupAfterJobs.every(job => (job.sourceCleanupConfirmedHosters || []).length === 0);
|
||||||
check('Final queue persistence promotes only inside the handshake and rolls back failed saves', sourceCleanupRollbackOk);
|
check('Final queue persistence promotes only inside the handshake and rolls back failed saves', sourceCleanupRollbackOk);
|
||||||
const terminalRecoveryState = await wc.executeJavaScript('(() => { selectedFiles = []; queueJobs = [{ id: "ui-terminal-done", file: "C:/ui/terminal-done.bin", fileName: "terminal-done.bin", hoster: "voe.sx", status: "done", bytesTotal: 41, error: null, result: { download_url: "https://example.invalid/terminal-done", embed_url: "https://example.invalid/embed-terminal-done", file_code: "terminal-code" } }, { id: "ui-terminal-skipped", file: "C:/ui/terminal-skipped.bin", fileName: "terminal-skipped.bin", hoster: "byse.sx", status: "skipped", bytesTotal: 42, error: "Size limit", result: null }]; rebuildJobIndex(); return { normal: buildPersistedQueueState(), recovery: buildPersistedQueueState({ historyPersisted: false }) }; })()');
|
const terminalRecoveryState = await wc.executeJavaScript('(() => { selectedFiles = []; queueJobs = [{ id: "ui-terminal-done", file: "C:/ui/terminal-done.bin", fileName: "terminal-done.bin", hoster: "voe.sx", status: "done", bytesTotal: 41, error: null, result: { download_url: "https://example.invalid/terminal-done", embed_url: "https://example.invalid/embed-terminal-done", file_code: "terminal-code" } }, { id: "ui-terminal-skipped", file: "C:/ui/terminal-skipped.bin", fileName: "terminal-skipped.bin", hoster: "byse.sx", status: "skipped", bytesTotal: 42, error: "Size limit", result: null }]; rebuildJobIndex(); return { normal: buildPersistedQueueState(), recovery: buildPersistedQueueState({ historyPersisted: false }) }; })()');
|
||||||
const terminalRecoveryJobs = terminalRecoveryState.recovery?.queueJobs || [];
|
const terminalRecoveryJobs = terminalRecoveryState.recovery?.queueJobs || [];
|
||||||
@@ -2078,6 +2097,34 @@ setTimeout(async () => {
|
|||||||
check('Live indexed upload IDs retain precedence over legacy visible identity', completionIdentityRaces.liveIndexedUpload.length === 1 && completionIdentityRaces.liveIndexedUpload[0].status === 'done' && completionIdentityRaces.liveIndexedUpload[0].uploadId === 'completion-live-upload' && completionIdentityRaces.liveIndexedUpload[0].link === 'https://example.invalid/live-upload');
|
check('Live indexed upload IDs retain precedence over legacy visible identity', completionIdentityRaces.liveIndexedUpload.length === 1 && completionIdentityRaces.liveIndexedUpload[0].status === 'done' && completionIdentityRaces.liveIndexedUpload[0].uploadId === 'completion-live-upload' && completionIdentityRaces.liveIndexedUpload[0].link === 'https://example.invalid/live-upload');
|
||||||
check('Legacy completion identity applies only to one unambiguous candidate', completionIdentityRaces.ambiguousLegacy.every(job => job.status !== 'done') && completionIdentityRaces.uniqueLegacy.length === 1 && completionIdentityRaces.uniqueLegacy[0].status === 'done' && completionIdentityRaces.uniqueLegacy[0].link === 'https://example.invalid/unique');
|
check('Legacy completion identity applies only to one unambiguous candidate', completionIdentityRaces.ambiguousLegacy.every(job => job.status !== 'done') && completionIdentityRaces.uniqueLegacy.length === 1 && completionIdentityRaces.uniqueLegacy[0].status === 'done' && completionIdentityRaces.uniqueLegacy[0].link === 'https://example.invalid/unique');
|
||||||
restoreInitialIpcHandler('complete-upload-finalization');
|
restoreInitialIpcHandler('complete-upload-finalization');
|
||||||
|
|
||||||
|
await wc.executeJavaScript('queuePersistThrottle.cancel(); flushConfigWrites()');
|
||||||
|
const allSkippedOriginalConfig = await wc.executeJavaScript('structuredClone(config)');
|
||||||
|
await wc.executeJavaScript('config = { ...config, hosters: { ...(config.hosters || {}), "clouddrop.cc": [] } }; hosterSettings = config.hosterSettings || {}; saveConfigTracked(config)');
|
||||||
|
const originalAppendHistoryForSkippedBatch = activeConfigStore.appendHistory.bind(activeConfigStore);
|
||||||
|
let skippedBatchHistoryFailures = 0;
|
||||||
|
activeConfigStore.appendHistory = async summary => {
|
||||||
|
if (String(summary?.id || '').startsWith('skipped-')) {
|
||||||
|
skippedBatchHistoryFailures++;
|
||||||
|
throw new Error('injected skipped batch history failure');
|
||||||
|
}
|
||||||
|
return originalAppendHistoryForSkippedBatch(summary);
|
||||||
|
};
|
||||||
|
let allSkippedFinalizationPayload = null;
|
||||||
|
const initialCompleteUploadFinalization = initialIpcHandlers.get('complete-upload-finalization');
|
||||||
|
ipcMain.removeHandler('complete-upload-finalization');
|
||||||
|
ipcMain.handle('complete-upload-finalization', async (event, payload) => {
|
||||||
|
allSkippedFinalizationPayload = structuredClone(payload);
|
||||||
|
return initialCompleteUploadFinalization(event, payload);
|
||||||
|
});
|
||||||
|
const allSkippedRendererState = await wc.executeJavaScript('(async () => { queuePersistThrottle.cancel(); await flushConfigWrites(); uploading = false; selectedFiles = []; selectedUploadHosters = ["clouddrop.cc"]; queueJobs = [{ id: "ui-all-skipped", file: "C:/ui/all-skipped.bin", fileName: "all-skipped.bin", hoster: "clouddrop.cc", status: "preview", bytesTotal: 64 }]; rebuildJobIndex(); await startUpload(); await new Promise(resolve => setTimeout(resolve, 700)); queuePersistThrottle.flushSync(); await flushConfigWrites(); return { status: queueJobs[0]?.status, uploading }; })()');
|
||||||
|
const allSkippedPersistedQueue = activeConfigStore.load().globalSettings?.pendingQueue;
|
||||||
|
activeConfigStore.appendHistory = originalAppendHistoryForSkippedBatch;
|
||||||
|
restoreInitialIpcHandler('complete-upload-finalization');
|
||||||
|
await wc.executeJavaScript('config = ' + JSON.stringify(allSkippedOriginalConfig) + '; hosterSettings = config.hosterSettings || {}; selectedFiles = []; selectedUploadHosters = []; queueJobs = []; rebuildJobIndex(); queuePersistThrottle.cancel(); saveConfigTracked(config)');
|
||||||
|
const allSkippedPayloadJobs = allSkippedFinalizationPayload?.pendingQueue?.queueJobs || [];
|
||||||
|
const allSkippedPersistedJobs = allSkippedPersistedQueue?.queueJobs || [];
|
||||||
|
check('A history failure in an all-skipped batch retains the terminal queue through the finalization barrier', skippedBatchHistoryFailures === 1 && allSkippedFinalizationPayload?.historyPersisted === false && typeof allSkippedFinalizationPayload?.deliveryId === 'string' && allSkippedPayloadJobs.length === 1 && allSkippedPayloadJobs[0].id === 'ui-all-skipped' && allSkippedPayloadJobs[0].status === 'skipped' && allSkippedPersistedJobs.length === 1 && allSkippedPersistedJobs[0].id === 'ui-all-skipped' && allSkippedPersistedJobs[0].status === 'skipped' && allSkippedRendererState.status === 'skipped' && allSkippedRendererState.uploading === false);
|
||||||
await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")');
|
await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")');
|
||||||
|
|
||||||
console.log('\\n=== History View ===');
|
console.log('\\n=== History View ===');
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const vm = require('node:vm');
|
||||||
|
|
||||||
|
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||||
|
|
||||||
|
function loadMainFunction(name, nextName) {
|
||||||
|
const start = mainSource.indexOf(`function ${name}(`);
|
||||||
|
assert.notEqual(start, -1, `${name} must exist`);
|
||||||
|
const end = mainSource.indexOf(`\nfunction ${nextName}(`, start + name.length);
|
||||||
|
assert.notEqual(end, -1, `${nextName} must follow ${name}`);
|
||||||
|
const context = vm.createContext({});
|
||||||
|
vm.runInContext(`${mainSource.slice(start, end)}\nthis.loaded = ${name};`, context);
|
||||||
|
return context.loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('terminal finalization is delivered once per ready renderer generation and rejects stale acknowledgements', async () => {
|
||||||
|
const createCoordinator = loadMainFunction('createUploadFinalizationCoordinator', 'createUploadFinalizationBarrier');
|
||||||
|
const sent = [];
|
||||||
|
const savedQueues = [];
|
||||||
|
const scheduled = new Set();
|
||||||
|
let deliverySequence = 0;
|
||||||
|
const coordinator = createCoordinator({
|
||||||
|
send: payload => {
|
||||||
|
sent.push(JSON.parse(JSON.stringify(payload)));
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
saveQueue: async pendingQueue => {
|
||||||
|
savedQueues.push(JSON.parse(JSON.stringify(pendingQueue)));
|
||||||
|
},
|
||||||
|
schedule: callback => {
|
||||||
|
const token = { callback };
|
||||||
|
scheduled.add(token);
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
cancelSchedule: token => scheduled.delete(token),
|
||||||
|
createFinalizationId: () => 'finalization-1',
|
||||||
|
createDeliveryId: () => `delivery-${++deliverySequence}`,
|
||||||
|
timeoutMs: 15000
|
||||||
|
});
|
||||||
|
|
||||||
|
const completion = coordinator.request({ id: 'batch-1', files: [] }, true);
|
||||||
|
let settled = false;
|
||||||
|
completion.then(() => { settled = true; });
|
||||||
|
|
||||||
|
assert.equal(sent.length, 0);
|
||||||
|
assert.equal(coordinator.rendererReady(), 1);
|
||||||
|
assert.equal(coordinator.rendererReady(), 1);
|
||||||
|
assert.equal(sent.length, 1);
|
||||||
|
assert.equal(sent[0].finalizationId, 'finalization-1');
|
||||||
|
assert.equal(sent[0].deliveryId, 'delivery-1');
|
||||||
|
assert.equal(sent[0].historyPersisted, true);
|
||||||
|
|
||||||
|
coordinator.rendererBlocked();
|
||||||
|
assert.equal(coordinator.rendererReady(), 2);
|
||||||
|
assert.equal(sent.length, 2);
|
||||||
|
assert.equal(sent[1].deliveryId, 'delivery-2');
|
||||||
|
|
||||||
|
const stale = await coordinator.complete({
|
||||||
|
finalizationId: 'finalization-1',
|
||||||
|
deliveryId: 'delivery-1',
|
||||||
|
pendingQueue: { queueJobs: [{ id: 'stale' }] }
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
assert.equal(stale, false);
|
||||||
|
assert.equal(settled, false);
|
||||||
|
assert.equal(savedQueues.length, 0);
|
||||||
|
|
||||||
|
const accepted = await coordinator.complete({
|
||||||
|
finalizationId: 'finalization-1',
|
||||||
|
deliveryId: 'delivery-2',
|
||||||
|
pendingQueue: { queueJobs: [{ id: 'terminal' }] }
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(accepted, true);
|
||||||
|
assert.equal(await completion, true);
|
||||||
|
assert.deepEqual(savedQueues, [{ queueJobs: [{ id: 'terminal' }] }]);
|
||||||
|
assert.equal(scheduled.size, 0);
|
||||||
|
|
||||||
|
coordinator.rendererBlocked();
|
||||||
|
coordinator.rendererReady();
|
||||||
|
assert.equal(sent.length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('history failure remains visible to the durable terminal finalization barrier', async () => {
|
||||||
|
const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization');
|
||||||
|
const recoveries = [];
|
||||||
|
const finalizationCalls = [];
|
||||||
|
const errors = [];
|
||||||
|
const barrier = createBarrier({
|
||||||
|
appendHistory: async () => { throw new Error('history unavailable'); },
|
||||||
|
saveRecovery: async value => { recoveries.push(value === null ? null : JSON.parse(JSON.stringify(value))); },
|
||||||
|
requestFinalization: async (summary, historyPersisted) => {
|
||||||
|
finalizationCalls.push({ summary: JSON.parse(JSON.stringify(summary)), historyPersisted });
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
buildTerminalSnapshots: summary => summary.files[0].results.map(result => ({ jobId: result.jobId, status: result.status })),
|
||||||
|
now: () => '2026-08-13T12:00:00.000Z',
|
||||||
|
onError: (phase, error) => errors.push([phase, error.message])
|
||||||
|
});
|
||||||
|
const summary = {
|
||||||
|
id: 'skipped-batch',
|
||||||
|
files: [{ name: 'missing-account.bin', results: [{ jobId: 'skip-1', status: 'skipped' }] }]
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await barrier.finalize(summary, {
|
||||||
|
id: 'recovery-skipped',
|
||||||
|
startedAt: '2026-08-13T11:59:59.000Z',
|
||||||
|
jobIds: ['skip-1']
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.historyPersisted, false);
|
||||||
|
assert.equal(result.queuePersisted, false);
|
||||||
|
assert.equal(result.terminalRecoveryPersisted, true);
|
||||||
|
assert.deepEqual(finalizationCalls, [{ summary, historyPersisted: false }]);
|
||||||
|
assert.deepEqual(recoveries, [{
|
||||||
|
id: 'recovery-skipped',
|
||||||
|
startedAt: '2026-08-13T11:59:59.000Z',
|
||||||
|
jobIds: ['skip-1'],
|
||||||
|
settledAt: '2026-08-13T12:00:00.000Z',
|
||||||
|
terminalJobs: [{ jobId: 'skip-1', status: 'skipped' }]
|
||||||
|
}]);
|
||||||
|
assert.deepEqual(errors, [['history', 'history unavailable']]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('terminal recovery clears only after history-independent queue acknowledgement', async () => {
|
||||||
|
const createBarrier = loadMainFunction('createUploadFinalizationBarrier', 'requestUploadFinalization');
|
||||||
|
const recoveries = [];
|
||||||
|
const barrier = createBarrier({
|
||||||
|
appendHistory: async () => true,
|
||||||
|
saveRecovery: async value => { recoveries.push(value === null ? null : JSON.parse(JSON.stringify(value))); },
|
||||||
|
requestFinalization: async (_summary, historyPersisted) => historyPersisted,
|
||||||
|
buildTerminalSnapshots: () => [{ jobId: 'done-1', status: 'done' }],
|
||||||
|
now: () => '2026-08-13T12:01:00.000Z',
|
||||||
|
onError: () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await barrier.finalize({ id: 'done-batch', files: [] }, {
|
||||||
|
id: 'recovery-done',
|
||||||
|
startedAt: '2026-08-13T12:00:00.000Z',
|
||||||
|
jobIds: ['done-1']
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.equal(result.historyPersisted, true);
|
||||||
|
assert.equal(result.queuePersisted, true);
|
||||||
|
assert.equal(result.terminalRecoveryPersisted, true);
|
||||||
|
assert.equal(result.recoveryCleared, true);
|
||||||
|
assert.equal(recoveries.length, 2);
|
||||||
|
assert.equal(recoveries[1], null);
|
||||||
|
});
|
||||||
@@ -79,17 +79,20 @@ test('main and renderer keep recovery evidence until final queue persistence suc
|
|||||||
const rendererSource = fs.readFileSync(path.join(root, 'renderer', 'app.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 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'"));
|
const batchDone = mainSource.slice(mainSource.indexOf("uploadManager.on('batch-done'"), mainSource.indexOf("ipcMain.handle('cancel-upload'"));
|
||||||
|
const barrier = mainSource.slice(mainSource.indexOf('function createUploadFinalizationBarrier'), mainSource.indexOf('function requestUploadFinalization'));
|
||||||
|
|
||||||
assert.match(batchDone, /buildTerminalJobSnapshots\(summary\)/);
|
assert.match(mainSource, /buildTerminalSnapshots: buildTerminalJobSnapshots/);
|
||||||
assert.match(batchDone, /if \(queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveUploadRecovery\(null\)/);
|
assert.ok(barrier.indexOf('appendHistory(summary)') < barrier.indexOf('saveRecovery(terminalRecovery)'));
|
||||||
assert.ok(batchDone.indexOf('saveUploadRecovery(recoveryWithTerminalJobs)') < batchDone.indexOf('requestUploadFinalization(summary, historyPersisted)'));
|
assert.ok(barrier.indexOf('saveRecovery(terminalRecovery)') < barrier.indexOf('requestFinalization(summary, historyPersisted)'));
|
||||||
|
assert.match(barrier, /if \(queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveRecovery\(null\)/);
|
||||||
|
assert.match(batchDone, /uploadFinalizationBarrier\.finalize\(summary, recovery\)/);
|
||||||
const startFailure = batchDone.slice(batchDone.indexOf('startBatch(tasks'));
|
const startFailure = batchDone.slice(batchDone.indexOf('startBatch(tasks'));
|
||||||
assert.match(startFailure, /buildFailedUploadSummary\(tasks/);
|
assert.match(startFailure, /buildFailedUploadSummary\(tasks/);
|
||||||
assert.match(startFailure, /saveUploadRecovery\(terminalRecovery\)/);
|
assert.match(startFailure, /uploadFinalizationBarrier\.finalize\(errorSummary, recovery\)/);
|
||||||
assert.match(startFailure, /requestUploadFinalization\(errorSummary, historyPersisted\)/);
|
|
||||||
assert.match(startFailure, /if \(queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveUploadRecovery\(null\)/);
|
|
||||||
const startHandler = mainSource.slice(mainSource.indexOf("ipcMain.handle('start-upload'"), mainSource.indexOf("ipcMain.handle('cancel-upload'"));
|
const startHandler = mainSource.slice(mainSource.indexOf("ipcMain.handle('start-upload'"), mainSource.indexOf("ipcMain.handle('cancel-upload'"));
|
||||||
assert.match(startHandler, /await configStore\.saveUploadRecovery\(recovery\)/);
|
assert.match(startHandler, /await configStore\.saveUploadRecovery\(recovery\)/);
|
||||||
|
assert.match(startHandler, /uploadFinalizationBarrier\.finalize\(skippedSummary/);
|
||||||
|
assert.match(startHandler, /finalized: true/);
|
||||||
assert.ok(startHandler.indexOf('sourceCleanup.registerGroups(sourceCleanupGroups)') < startHandler.indexOf('saveUploadRecovery(recovery)'));
|
assert.ok(startHandler.indexOf('sourceCleanup.registerGroups(sourceCleanupGroups)') < startHandler.indexOf('saveUploadRecovery(recovery)'));
|
||||||
assert.match(startHandler, /catch \(error\)[\s\S]*return { error: 'Upload-Wiederherstellung konnte nicht gespeichert werden' }/);
|
assert.match(startHandler, /catch \(error\)[\s\S]*return { error: 'Upload-Wiederherstellung konnte nicht gespeichert werden' }/);
|
||||||
const addHandler = mainSource.slice(mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'"), mainSource.indexOf("ipcMain.handle('finish-after-active'"));
|
const addHandler = mainSource.slice(mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'"), mainSource.indexOf("ipcMain.handle('finish-after-active'"));
|
||||||
@@ -97,5 +100,6 @@ test('main and renderer keep recovery evidence until final queue persistence suc
|
|||||||
assert.ok(addHandler.indexOf('saveUploadRecovery(nextRecovery)') < addHandler.indexOf('batchManager.addJobs(tasks)'));
|
assert.ok(addHandler.indexOf('saveUploadRecovery(nextRecovery)') < addHandler.indexOf('batchManager.addJobs(tasks)'));
|
||||||
assert.match(rendererSource, /window\.UploadRecovery\.getRecoveryOutcome/);
|
assert.match(rendererSource, /window\.UploadRecovery\.getRecoveryOutcome/);
|
||||||
assert.match(rendererSource, /data\.historyPersisted !== true/);
|
assert.match(rendererSource, /data\.historyPersisted !== true/);
|
||||||
|
assert.match(rendererSource, /deliveryId: data\.deliveryId/);
|
||||||
assert.ok(indexSource.indexOf('../lib/upload-recovery.js') < indexSource.indexOf('app.js'));
|
assert.ok(indexSource.indexOf('../lib/upload-recovery.js') < indexSource.indexOf('app.js'));
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user