Harden terminal upload finalization recovery

This commit is contained in:
Sucukdeluxe
2026-08-14 00:07:13 +02:00
parent e681338631
commit 28094414a8
7 changed files with 476 additions and 94 deletions
+41
View File
@@ -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', () => {
const queueJobs = queueFixture();
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
+52 -5
View File
@@ -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); })()');
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 () => {
queueJobs = [
{ id: 'telemetry-done-a', status: 'done' },
@@ -833,14 +839,15 @@ setTimeout(async () => {
let releaseSelectedQueueCancel = null;
ipcMain.removeHandler('cancel-selected-jobs');
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 wc.executeJavaScript('document.getElementById("appAlertConfirmBtn").click()');
await waitUntil(() => releaseSelectedQueueCancel);
const selectedQueueStillPresent = await wc.executeJavaScript('queueJobs.length');
releaseSelectedQueueCancel();
const selectedQueueAfterCancel = await wc.executeJavaScript('window.__uiDeleteSelectedPromise.then(() => { delete window.__uiDeleteSelectedPromise; return queueJobs.length; })');
check('Removing selected uploads waits for the main-process cancellation acknowledgement', selectedQueueStillPresent === 1 && selectedQueueAfterCancel === 0);
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 === 2 && selectedQueueAfterCancel.length === 1);
check('Cancelling a started destination never relaxes cleanup prerequisites', selectedQueueAfterCancel.required.join('|') === 'doodstream.com|byse.sx');
restoreInitialIpcHandler('cancel-selected-jobs');
let releaseFullQueueCancel = null;
@@ -864,6 +871,18 @@ setTimeout(async () => {
restoreInitialIpcHandler('cancel-upload');
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');
check('Arrow keys move and activate main tabs', keyboardTab === 'Accounts|accounts-tab');
@@ -2022,11 +2041,11 @@ setTimeout(async () => {
sourceCleanupFinalizationPayload = payload;
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 sourceCleanupAfterJobs = sourceCleanupRollback.after?.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);
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 || [];
@@ -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('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');
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")');
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);
});
+10 -6
View File
@@ -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 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 barrier = mainSource.slice(mainSource.indexOf('function createUploadFinalizationBarrier'), mainSource.indexOf('function requestUploadFinalization'));
assert.match(batchDone, /buildTerminalJobSnapshots\(summary\)/);
assert.match(batchDone, /if \(queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveUploadRecovery\(null\)/);
assert.ok(batchDone.indexOf('saveUploadRecovery(recoveryWithTerminalJobs)') < batchDone.indexOf('requestUploadFinalization(summary, historyPersisted)'));
assert.match(mainSource, /buildTerminalSnapshots: buildTerminalJobSnapshots/);
assert.ok(barrier.indexOf('appendHistory(summary)') < barrier.indexOf('saveRecovery(terminalRecovery)'));
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'));
assert.match(startFailure, /buildFailedUploadSummary\(tasks/);
assert.match(startFailure, /saveUploadRecovery\(terminalRecovery\)/);
assert.match(startFailure, /requestUploadFinalization\(errorSummary, historyPersisted\)/);
assert.match(startFailure, /if \(queuePersisted && terminalRecoveryPersisted\)[\s\S]*saveUploadRecovery\(null\)/);
assert.match(startFailure, /uploadFinalizationBarrier\.finalize\(errorSummary, recovery\)/);
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, /uploadFinalizationBarrier\.finalize\(skippedSummary/);
assert.match(startHandler, /finalized: true/);
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' }/);
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.match(rendererSource, /window\.UploadRecovery\.getRecoveryOutcome/);
assert.match(rendererSource, /data\.historyPersisted !== true/);
assert.match(rendererSource, /deliveryId: data\.deliveryId/);
assert.ok(indexSource.indexOf('../lib/upload-recovery.js') < indexSource.indexOf('app.js'));
});