diff --git a/main.js b/main.js index f4fae94..0299406 100644 --- a/main.js +++ b/main.js @@ -2113,6 +2113,97 @@ ipcMain.handle('inspect-import-files', async (_event, payload) => { }); }); +async function rejectPreparedUploadStart(manager, producerTracker, error, clearRecovery) { + try { manager.cancel(); } catch {} + if (uploadManager === manager) { + uploadManager = null; + globalThis._mhuUploadManagerRef = null; + } + producerTracker.finish(); + if (clearRecovery) { + try { await configStore.saveUploadRecovery(null); } catch (cleanupError) { + debugLog(`upload recovery state could not be cleared after rejected start: ${cleanupError.message}`); + } + } + return { error }; +} + +function preparedUploadStartGate(manager) { + if (uploadManager !== manager) return 'Upload-Start wurde verworfen'; + if (closeFlushRequested) return 'Die Anwendung wird gerade beendet'; + if (configStore.load().globalSettings?.folderMonitor?.paused === true) return 'Automatik ist pausiert'; + return ''; +} + +function observePreparedUploadAcceptance(batchPromise) { + const settled = batchPromise && typeof batchPromise.then === 'function' + ? batchPromise.then( + () => ({ settled: true }), + error => ({ rejected: true, error }) + ) + : Promise.resolve({ settled: true }); + return Promise.race([ + settled, + new Promise(resolve => queueMicrotask(() => resolve({ accepted: true }))) + ]); +} + +function startPreparedUploadBatch({ manager, tasks, producerTracker, recovery, isAutoRetry }) { + return new Promise(resolve => { + setImmediate(() => { + void (async () => { + let gateError = preparedUploadStartGate(manager); + if (gateError) return rejectPreparedUploadStart(manager, producerTracker, gateError, false); + try { + await configStore.saveUploadRecovery(recovery); + } catch { + return rejectPreparedUploadStart(manager, producerTracker, 'Upload-Wiederherstellung konnte nicht vorbereitet werden', true); + } + gateError = preparedUploadStartGate(manager); + if (gateError) return rejectPreparedUploadStart(manager, producerTracker, gateError, true); + _accountCooldowns.releaseExpired(); + const pausedAccounts = _accountCooldowns.activeKeys(); + let batchPromise; + try { + batchPromise = manager.startBatch(tasks, { + primeFailedAccounts: pausedAccounts, + primeOverrides: Array.from(_sessionAccountOverrides.entries()) + }); + } catch { + return rejectPreparedUploadStart(manager, producerTracker, 'Upload konnte nicht gestartet werden', true); + } + const acceptance = await observePreparedUploadAcceptance(batchPromise); + if (acceptance.rejected) { + return rejectPreparedUploadStart(manager, producerTracker, 'Upload konnte nicht gestartet werden', true); + } + if (acceptance.accepted) { + Promise.resolve(batchPromise).catch((err) => { + debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`); + const errorSummary = { + id: 'error', + timestamp: new Date().toISOString(), + total: tasks.length, + succeeded: 0, + failed: tasks.length, + files: [], + error: err ? err.message : 'Unbekannter Fehler' + }; + safeSend('upload-batch-done', errorSummary); + configStore.saveUploadRecovery(null).catch(error => debugLog(`upload recovery state could not be cleared after start failure: ${error.message}`)); + producerTracker.finish(); + if (!isAutoRetry) sendBatchWebhook(errorSummary, 0); + if (uploadManager === manager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; } + }); + } + logMemorySnapshot('batch-start'); + return { started: true }; + })().then(resolve, async () => { + resolve(await rejectPreparedUploadStart(manager, producerTracker, 'Upload konnte nicht gestartet werden', true)); + }); + }); + }); +} + ipcMain.handle('start-upload', async (_event, payload) => { if (configStore.load().globalSettings?.folderMonitor?.paused === true) { return { error: 'Automatik ist pausiert' }; @@ -2191,12 +2282,6 @@ ipcMain.handle('start-upload', async (_event, payload) => { startedAt: new Date().toISOString(), jobIds: tasks.map(task => task.jobId).filter(Boolean) }; - try { await configStore.saveUploadRecovery(recovery); } catch (error) { debugLog(`upload recovery state could not be saved: ${error.message}`); } - if (configStore.load().globalSettings?.folderMonitor?.paused === true) { - if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; } - try { await configStore.saveUploadRecovery(null); } catch (error) { debugLog(`upload recovery state could not be cleared after automation pause: ${error.message}`); } - return { error: 'Automatik ist pausiert' }; - } // Pre-resolve a fallback for every hoster that has one. Lets the upload // manager break out of the retry loop after a single generic failure and @@ -2425,55 +2510,16 @@ ipcMain.handle('start-upload', async (_event, payload) => { else debugLog('batch-done: skipping uploadManager null-out — a newer manager replaced this one mid-await'); }); - // Defer startBatch to next tick so the IPC response is sent first. - // This ensures webContents.send() calls from upload events - // are not interleaved with the handle() response. - setImmediate(() => { - if (uploadManager !== _thisManager) { - debugLog('setImmediate: uploadManager was replaced before startBatch'); - _producerTracker.finish(); - return; - } - if (closeFlushRequested) { - try { _thisManager.cancel(); } catch {} - if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; } - _producerTracker.finish(); - return; - } - if (configStore.load().globalSettings?.folderMonitor?.paused === true) { - try { _thisManager.cancel(); } catch {} - if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; } - _producerTracker.finish(); - return; - } - _accountCooldowns.releaseExpired(); - const pausedAccounts = _accountCooldowns.activeKeys(); - debugLog(`setImmediate: calling startBatch now (priming ${pausedAccounts.length} failed accounts, ${_sessionAccountOverrides.size} overrides from session)`); - _thisManager.startBatch(tasks, { - primeFailedAccounts: pausedAccounts, - primeOverrides: Array.from(_sessionAccountOverrides.entries()) - }).catch((err) => { - debugLog(`startBatch REJECTED: ${err && err.stack ? err.stack : err}`); - const errorSummary = { - id: 'error', - timestamp: new Date().toISOString(), - total: tasks.length, - succeeded: 0, - failed: tasks.length, - files: [], - error: err ? err.message : 'Unbekannter Fehler' - }; - safeSend('upload-batch-done', errorSummary); - configStore.saveUploadRecovery(null).catch(error => debugLog(`upload recovery state could not be cleared after start failure: ${error.message}`)); - _producerTracker.finish(); - if (!isAutoRetry) sendBatchWebhook(errorSummary, 0); - if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; } - }); + const startResult = await startPreparedUploadBatch({ + manager: _thisManager, + tasks, + producerTracker: _producerTracker, + recovery, + isAutoRetry }); - - logMemorySnapshot('batch-start'); - debugLog(`start-upload returning started=true (startBatch deferred to nextTick)`); - return { started: true, taskCount: tasks.length, skippedJobs, sourceCleanupFingerprints }; + if (!startResult.started) return startResult; + debugLog('start-upload returning started=true after startBatch acceptance'); + return { ...startResult, taskCount: tasks.length, skippedJobs, sourceCleanupFingerprints }; }); // Logged at batch boundaries so we can spot memory growth between batches @@ -3190,7 +3236,44 @@ function _sweepOrphanConfigTmps() { } catch {} } -let suppressAutomationStatusEvents = false; +const automationLifecycleQueue = []; +let automationLifecycleRunning = false; +let automationLifecycleGeneration = 0; +let automationStatusSuppressionDepth = 0; + +function drainAutomationLifecycleQueue() { + if (automationLifecycleRunning) return; + const next = automationLifecycleQueue.shift(); + if (!next) return; + automationLifecycleRunning = true; + let result; + try { + result = next.operation(next.generation); + } catch (error) { + result = Promise.reject(error); + } + Promise.resolve(result).then(next.resolve, next.reject).finally(() => { + automationLifecycleRunning = false; + drainAutomationLifecycleQueue(); + }); +} + +function enqueueAutomationLifecycle(operation) { + const generation = ++automationLifecycleGeneration; + return new Promise((resolve, reject) => { + automationLifecycleQueue.push({ generation, operation, resolve, reject }); + drainAutomationLifecycleQueue(); + }); +} + +async function withAutomationStatusSuppressed(operation) { + automationStatusSuppressionDepth++; + try { + return await operation(); + } finally { + automationStatusSuppressionDepth--; + } +} function automationStatusSnapshot() { const settings = configStore.load().globalSettings?.folderMonitor || {}; @@ -3205,9 +3288,10 @@ function automationStatusSnapshot() { }); } -function publishAutomationStatus() { - const snapshot = automationStatusSnapshot(); - if (!suppressAutomationStatusEvents) safeSend('automation:status', snapshot); +function publishAutomationStatus(extra = null, generation = null) { + const snapshot = Object.freeze({ ...automationStatusSnapshot(), ...(extra || {}) }); + const currentGeneration = generation === null || generation === automationLifecycleGeneration; + if (automationStatusSuppressionDepth === 0 && currentGeneration) safeSend('automation:status', snapshot); return snapshot; } @@ -3220,7 +3304,7 @@ function bindFolderMonitorEvents(settings) { folderMonitor.on('error', (err) => { debugLog(`folder-monitor error: ${err.message}`); }); - folderMonitor.on('status', publishAutomationStatus); + folderMonitor.on('status', () => publishAutomationStatus()); folderMonitor.on('initial-scan-complete', async () => { try { const latest = configStore.load(); @@ -3280,45 +3364,45 @@ ipcMain.handle('automation:get-status', () => { return automationStatusSnapshot(); }); -ipcMain.handle('automation:pause-after-active', async () => { - const latest = configStore.load(); - const settings = latest.globalSettings?.folderMonitor || {}; - await configStore.save({ - globalSettings: { - ...latest.globalSettings, - folderMonitor: { ...settings, paused: true, pausedAt: Date.now() } +ipcMain.handle('automation:pause-after-active', () => enqueueAutomationLifecycle(async generation => { + let monitorError = ''; + await withAutomationStatusSuppressed(async () => { + const latest = configStore.load(); + const settings = latest.globalSettings?.folderMonitor || {}; + await configStore.save({ + globalSettings: { + ...latest.globalSettings, + folderMonitor: { ...settings, paused: true, pausedAt: Date.now() } + } + }); + try { + await folderMonitor.pause(); + } catch { + monitorError = 'Ordnerüberwachung konnte nicht pausiert werden'; + } finally { + if (uploadManager) uploadManager.finishAfterActive(); } }); - suppressAutomationStatusEvents = true; - try { - await folderMonitor.pause(); - } finally { - suppressAutomationStatusEvents = false; - } - if (uploadManager) uploadManager.finishAfterActive(); - return publishAutomationStatus(); -}); + return publishAutomationStatus(monitorError ? { monitorError } : null, generation); +})); -ipcMain.handle('automation:resume', async () => { - const latest = configStore.load(); - const settings = latest.globalSettings?.folderMonitor || {}; - const resumedSettings = { ...settings, paused: false, pausedAt: null }; - await configStore.save({ - globalSettings: { - ...latest.globalSettings, - folderMonitor: resumedSettings - } - }); - suppressAutomationStatusEvents = true; - try { +ipcMain.handle('automation:resume', () => enqueueAutomationLifecycle(async generation => { + await withAutomationStatusSuppressed(async () => { + const latest = configStore.load(); + const settings = latest.globalSettings?.folderMonitor || {}; + const resumedSettings = { ...settings, paused: false, pausedAt: null }; + await configStore.save({ + globalSettings: { + ...latest.globalSettings, + folderMonitor: resumedSettings + } + }); if (resumedSettings.enabled && resumedSettings.folderPath) { await resumeFolderMonitor(resumedSettings); } - } finally { - suppressAutomationStatusEvents = false; - } - return publishAutomationStatus(); -}); + }); + return publishAutomationStatus(null, generation); +})); ipcMain.handle('folder-monitor:test-scan', () => { return folderMonitor.scan({ emitFiles: false, trigger: 'test' }); diff --git a/tests/package-build-files.test.js b/tests/package-build-files.test.js index 520b7e5..316c785 100644 --- a/tests/package-build-files.test.js +++ b/tests/package-build-files.test.js @@ -9,6 +9,116 @@ const packageJson = require('../package.json'); const projectRoot = path.join(__dirname, '..'); +function createDeferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +async function flushMicrotasks() { + await Promise.resolve(); + await Promise.resolve(); +} + +async function waitForCondition(predicate) { + for (let attempt = 0; attempt < 50; attempt++) { + if (predicate()) return; + await Promise.resolve(); + } + assert.fail('condition did not become true'); +} + +function createAutomationLifecycleHarness(mainSource) { + const currentMarker = 'let suppressAutomationStatusEvents = false;'; + const queuedMarker = 'const automationLifecycleQueue = [];'; + const blockStart = Math.max(mainSource.indexOf(currentMarker), mainSource.indexOf(queuedMarker)); + const blockEnd = mainSource.indexOf('\n// --- Remote Control ---', blockStart); + assert.notEqual(blockStart, -1, 'automation lifecycle block missing'); + assert.notEqual(blockEnd, -1, 'automation lifecycle block boundary missing'); + const handlers = new Map(); + const order = []; + const sent = []; + const saves = []; + const pauseDeferred = createDeferred(); + const resumeDeferred = createDeferred(); + let publishStatus = () => {}; + let state = { + globalSettings: { + folderMonitor: { + enabled: true, + folderPath: 'C:\\watch', + paused: true, + pausedAt: 1, + queueLimitJobs: 15000, + reconcileIntervalMinutes: 5 + } + } + }; + const folderMonitor = new (require('node:events').EventEmitter)(); + folderMonitor.running = false; + folderMonitor.status = () => ({ running: folderMonitor.running, reachable: true }); + folderMonitor.stop = () => { folderMonitor.running = false; order.push('stop'); }; + folderMonitor.start = () => { folderMonitor.running = true; order.push('start'); publishStatus(); return {}; }; + folderMonitor.pause = () => { + order.push('pause'); + publishStatus(); + return pauseDeferred.promise.then(() => { + folderMonitor.running = false; + publishStatus(); + }); + }; + folderMonitor.resume = () => { + order.push('resume'); + publishStatus(); + return resumeDeferred.promise.then(() => { + folderMonitor.running = true; + publishStatus(); + return { reachable: true }; + }); + }; + folderMonitor.scan = async () => ({ reachable: true }); + const configStore = { + load: () => structuredClone(state), + save: config => { + const deferred = createDeferred(); + const snapshot = structuredClone(config); + saves.push({ paused: snapshot.globalSettings.folderMonitor.paused, deferred }); + order.push(`save:${snapshot.globalSettings.folderMonitor.paused}`); + return deferred.promise.then(() => { state = snapshot; }); + } + }; + const uploadManager = { + finishAfterActive: () => order.push('finish'), + startBatch: () => order.push('startBatch') + }; + const context = { + configStore, + debugLog: () => {}, + dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) }, + folderMonitor, + ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) }, + path, + safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; }, + uploadManager + }; + vm.runInNewContext(mainSource.slice(blockStart, blockEnd), context); + publishStatus = () => context.publishAutomationStatus(); + return { + handlers, + order, + pauseDeferred, + resumeDeferred, + saves, + sent, + state: () => structuredClone(state), + publishStatus + }; +} + test('packages every Electron preload referenced by the main process', () => { assert.ok(packageJson.build.files.includes('preload.js')); assert.ok(packageJson.build.files.includes('preload-drop-target.js')); @@ -277,12 +387,12 @@ test('every batch start and extension IPC fails closed before account and cleanu const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); const gate = /configStore\.load\(\)\.globalSettings\?\.folderMonitor\?\.paused\s*===\s*true/u; const cases = [ - ['debug-test-upload', "ipcMain.handle('debug-test-upload'", "ipcMain.handle('select-folder'", 'fs.writeFileSync', 1], - ['start-upload', "ipcMain.handle('start-upload'", '\n// Logged at batch boundaries', 'makeAccountPicker', 6], - ['add-jobs-to-batch', "ipcMain.handle('add-jobs-to-batch'", "ipcMain.handle('finish-after-active'", 'makeAccountPicker', 2] + ['debug-test-upload', "ipcMain.handle('debug-test-upload'", "ipcMain.handle('select-folder'", 'fs.writeFileSync'], + ['start-upload', "ipcMain.handle('start-upload'", '\n// Logged at batch boundaries', 'makeAccountPicker'], + ['add-jobs-to-batch', "ipcMain.handle('add-jobs-to-batch'", "ipcMain.handle('finish-after-active'", 'makeAccountPicker'] ]; - for (const [channel, startMarker, endMarker, sideEffectMarker, expectedGateCount] of cases) { + for (const [channel, startMarker, endMarker, sideEffectMarker] of cases) { const start = mainSource.indexOf(startMarker); const end = mainSource.indexOf(endMarker, start); assert.notEqual(start, -1, `${channel} handler missing`); @@ -293,13 +403,15 @@ test('every batch start and extension IPC fails closed before account and cleanu assert.notEqual(gateIndex, -1, `${channel} pause gate missing`); assert.notEqual(sideEffectIndex, -1, `${channel} side-effect marker missing`); assert.ok(gateIndex < sideEffectIndex, `${channel} pause gate runs after side effects`); - assert.equal([...handler.matchAll(new RegExp(gate.source, 'gu'))].length, expectedGateCount, `${channel} does not recheck every asynchronous race boundary`); } }); -test('automation pause and resume commit state before lifecycle effects', async () => { +test('automation pause save commits before lifecycle effects and save failure is inert', async () => { const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); - const blockStart = mainSource.indexOf('let suppressAutomationStatusEvents = false;'); + const blockStart = Math.max( + mainSource.indexOf('let suppressAutomationStatusEvents = false;'), + mainSource.indexOf('const automationLifecycleQueue = [];') + ); const blockEnd = mainSource.indexOf('\n// --- Remote Control ---', blockStart); assert.notEqual(blockStart, -1, 'automation lifecycle block missing'); assert.notEqual(blockEnd, -1, 'automation lifecycle block boundary missing'); @@ -380,6 +492,234 @@ test('automation pause and resume commit state before lifecycle effects', async assert.equal(sent[0][1].paused, false); }); +test('automation lifecycle serializes pause then resume so the newer intent wins', async () => { + const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); + const harness = createAutomationLifecycleHarness(mainSource); + + const pause = harness.handlers.get('automation:pause-after-active')(); + const resume = harness.handlers.get('automation:resume')(); + + assert.equal(harness.saves.length, 1); + assert.equal(harness.saves[0].paused, true); + harness.saves[0].deferred.resolve(); + await flushMicrotasks(); + harness.pauseDeferred.resolve(); + await waitForCondition(() => harness.saves.length === 2); + assert.equal(harness.saves.length, 2); + assert.equal(harness.saves[1].paused, false); + harness.saves[1].deferred.resolve(); + await flushMicrotasks(); + harness.resumeDeferred.resolve(); + await Promise.all([pause, resume]); + + assert.deepEqual(harness.order, ['save:true', 'pause', 'finish', 'save:false', 'resume']); + assert.equal(harness.state().globalSettings.folderMonitor.paused, false); + assert.equal(harness.sent.length, 1); + assert.equal(harness.sent[0][1].paused, false); +}); + +test('automation lifecycle serializes resume then pause so the newer intent wins', async () => { + const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); + const harness = createAutomationLifecycleHarness(mainSource); + + const resume = harness.handlers.get('automation:resume')(); + const pause = harness.handlers.get('automation:pause-after-active')(); + + assert.equal(harness.saves.length, 1); + assert.equal(harness.saves[0].paused, false); + harness.saves[0].deferred.resolve(); + await flushMicrotasks(); + harness.resumeDeferred.resolve(); + await waitForCondition(() => harness.saves.length === 2); + assert.equal(harness.saves.length, 2); + assert.equal(harness.saves[1].paused, true); + harness.saves[1].deferred.resolve(); + await flushMicrotasks(); + harness.pauseDeferred.resolve(); + await Promise.all([resume, pause]); + + assert.deepEqual(harness.order, ['save:false', 'resume', 'save:true', 'pause', 'finish']); + assert.equal(harness.state().globalSettings.folderMonitor.paused, true); + assert.equal(harness.sent.length, 1); + assert.equal(harness.sent[0][1].paused, true); +}); + +test('automation status suppression remains active until the serialized operation ends', async () => { + const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); + const harness = createAutomationLifecycleHarness(mainSource); + + const pause = harness.handlers.get('automation:pause-after-active')(); + const resume = harness.handlers.get('automation:resume')(); + harness.saves[0].deferred.resolve(); + await waitForCondition(() => harness.order.includes('pause')); + harness.pauseDeferred.resolve(); + await waitForCondition(() => harness.saves.length === 2); + harness.publishStatus(); + + assert.equal(harness.sent.length, 0); + + harness.saves[1].deferred.resolve(); + await waitForCondition(() => harness.order.includes('resume')); + harness.resumeDeferred.resolve(); + await Promise.all([pause, resume]); + + assert.equal(harness.sent.length, 1); + assert.equal(harness.sent[0][1].paused, false); +}); + +test('automation pause rejection still finishes active uploads and returns a sanitized monitor error', async () => { + const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); + const harness = createAutomationLifecycleHarness(mainSource); + + const pause = harness.handlers.get('automation:pause-after-active')(); + harness.saves[0].deferred.resolve(); + await flushMicrotasks(); + harness.pauseDeferred.reject(new Error('token=secret-value')); + const result = await pause; + + assert.equal(result.paused, true); + assert.equal(result.monitorError, 'Ordnerüberwachung konnte nicht pausiert werden'); + assert.equal(JSON.stringify(result).includes('secret-value'), false); + assert.equal(harness.order.includes('finish'), true); + assert.equal(harness.sent.length, 1); + assert.equal(JSON.stringify(harness.sent[0][1]).includes('secret-value'), false); +}); + +test('prepared upload start waits for the final tick and clears recovery when pause wins', async () => { + const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); + const blockStart = mainSource.indexOf('async function rejectPreparedUploadStart'); + const blockEnd = mainSource.indexOf("\nipcMain.handle('start-upload'", blockStart); + assert.notEqual(blockStart, -1, 'prepared upload start block missing'); + assert.notEqual(blockEnd, -1, 'prepared upload start block boundary missing'); + const ticks = []; + const recoverySave = createDeferred(); + const writes = []; + let paused = false; + let cancelled = 0; + let finished = 0; + let started = 0; + const manager = { + cancel: () => { cancelled++; }, + startBatch: () => { started++; return new Promise(() => {}); } + }; + const context = { + _accountCooldowns: { activeKeys: () => [], releaseExpired: () => {} }, + _sessionAccountOverrides: new Map(), + closeFlushRequested: false, + configStore: { + load: () => ({ globalSettings: { folderMonitor: { paused } } }), + saveUploadRecovery: value => { + writes.push(value); + return value === null ? Promise.resolve() : recoverySave.promise; + } + }, + debugLog: () => {}, + globalThis: {}, + isAllAborted: () => false, + logMemorySnapshot: () => {}, + queueMicrotask, + safeSend: () => {}, + sendBatchWebhook: () => {}, + setImmediate: callback => { ticks.push(callback); }, + uploadManager: manager + }; + vm.runInNewContext(mainSource.slice(blockStart, blockEnd), context); + const producerTracker = { finish: () => { finished++; } }; + const start = context.startPreparedUploadBatch({ + manager, + tasks: [{ jobId: 'job-1' }], + producerTracker, + recovery: { id: 'recovery-1' }, + isAutoRetry: false + }); + let settled = false; + start.then(() => { settled = true; }); + + assert.equal(settled, false); + assert.equal(ticks.length, 1); + ticks.shift()(); + await flushMicrotasks(); + assert.deepEqual(writes, [{ id: 'recovery-1' }]); + paused = true; + recoverySave.resolve(); + const result = await start; + + assert.equal(result.error, 'Automatik ist pausiert'); + assert.equal(Object.hasOwn(result, 'started'), false); + assert.deepEqual(writes, [{ id: 'recovery-1' }, null]); + assert.equal(cancelled, 1); + assert.equal(finished, 1); + assert.equal(started, 0); + assert.equal(context.uploadManager, null); +}); + +test('prepared upload start returns success only after synchronous acceptance and cleans immediate rejection', async () => { + const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); + const blockStart = mainSource.indexOf('async function rejectPreparedUploadStart'); + const blockEnd = mainSource.indexOf("\nipcMain.handle('start-upload'", blockStart); + assert.notEqual(blockStart, -1, 'prepared upload start block missing'); + assert.notEqual(blockEnd, -1, 'prepared upload start block boundary missing'); + + async function run(mode) { + const ticks = []; + const writes = []; + let cancelled = 0; + let finished = 0; + const manager = { cancel: () => { cancelled++; } }; + const context = { + _accountCooldowns: { activeKeys: () => [], releaseExpired: () => {} }, + _sessionAccountOverrides: new Map(), + closeFlushRequested: false, + configStore: { + load: () => ({ globalSettings: { folderMonitor: { paused: false } } }), + saveUploadRecovery: value => { writes.push(value); return Promise.resolve(); } + }, + debugLog: () => {}, + globalThis: {}, + isAllAborted: () => false, + logMemorySnapshot: () => {}, + queueMicrotask, + safeSend: () => {}, + sendBatchWebhook: () => {}, + setImmediate: callback => { ticks.push(callback); }, + uploadManager: manager + }; + vm.runInNewContext(mainSource.slice(blockStart, blockEnd), context); + vm.runInNewContext( + mode === 'rejected' + ? "uploadManager.startBatch = () => Promise.reject(new Error('apiKey=secret-value'));" + : 'uploadManager.startBatch = () => new Promise(() => {});', + context + ); + const promise = context.startPreparedUploadBatch({ + manager, + tasks: [{ jobId: 'job-1' }], + producerTracker: { finish: () => { finished++; } }, + recovery: { id: 'recovery-1' }, + isAutoRetry: false + }); + ticks.shift()(); + const result = await promise; + return { cancelled, context, finished, result, writes }; + } + + const accepted = await run('pending'); + assert.equal(accepted.result.started, true); + assert.equal(Object.hasOwn(accepted.result, 'error'), false); + assert.deepEqual(accepted.writes, [{ id: 'recovery-1' }]); + assert.equal(accepted.cancelled, 0); + assert.equal(accepted.finished, 0); + + const rejected = await run('rejected'); + assert.equal(rejected.result.error, 'Upload konnte nicht gestartet werden'); + assert.equal(Object.hasOwn(rejected.result, 'started'), false); + assert.deepEqual(rejected.writes, [{ id: 'recovery-1' }, null]); + assert.equal(rejected.cancelled, 1); + assert.equal(rejected.finished, 1); + assert.equal(rejected.context.uploadManager, null); + assert.equal(JSON.stringify(rejected.result).includes('secret-value'), false); +}); + test('startup keeps a missing configured folder disconnected without disabling automation', () => { const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); const startupStart = mainSource.indexOf('app.whenReady().then(async () => {');