From 9adb38de082cdda85924c9bf782617b20b1a76c6 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:05:26 +0200 Subject: [PATCH] feat: persist automation pause state --- lib/config-store.js | 14 ++- main.js | 189 ++++++++++++++++++++++++------ preload.js | 9 ++ tests/config-store.test.js | 55 +++++++++ tests/package-build-files.test.js | 139 ++++++++++++++++++++++ tests/startup-renderer.test.js | 103 ++++++++++++++++ tests/upload-manager.test.js | 35 ++++++ 7 files changed, 506 insertions(+), 38 deletions(-) diff --git a/lib/config-store.js b/lib/config-store.js index 8831d4a..cd90743 100644 --- a/lib/config-store.js +++ b/lib/config-store.js @@ -99,7 +99,11 @@ const DEFAULTS = { skipDuplicates: true, delaySec: 3, autoStart: true, - hosters: [] // pre-selected hosters, empty = ask via modal + hosters: [], // pre-selected hosters, empty = ask via modal + queueLimitJobs: 15000, + reconcileIntervalMinutes: 5, + paused: false, + pausedAt: null }, remote: { enabled: false, @@ -618,6 +622,8 @@ class ConfigStore { const currentGlobalSettings = current.globalSettings || {}; const currentRemote = currentGlobalSettings.remote || {}; const incomingRemote = snapshot.remote || {}; + const currentFolderMonitor = currentGlobalSettings.folderMonitor || {}; + const incomingFolderMonitor = snapshot.folderMonitor || {}; current.globalSettings = { ...snapshot, pendingQueue: currentGlobalSettings.pendingQueue ?? null, @@ -625,6 +631,12 @@ class ConfigStore { lastBrowseDirectory: currentGlobalSettings.lastBrowseDirectory || '', diagnostics: this._clone(currentGlobalSettings.diagnostics || {}), historyRetention: currentGlobalSettings.historyRetention || 'all', + folderMonitor: { + ...currentFolderMonitor, + ...incomingFolderMonitor, + paused: currentFolderMonitor.paused === true, + pausedAt: currentFolderMonitor.pausedAt ?? null + }, remote: { ...incomingRemote, token: incomingRemote.token || currentRemote.token || '' diff --git a/main.js b/main.js index 6d97e90..f4fae94 100644 --- a/main.js +++ b/main.js @@ -473,10 +473,6 @@ function logInfo(a, b) { const s = _split(a, b); debugLog(`[INFO ] ${_ctxTag(s.ctx)}${s.msg}`); } -function logWarn(a, b) { - const s = _split(a, b); - debugLog(`[WARN ] ${_ctxTag(s.ctx)}${s.msg}`); -} function logError(a, b, c) { let ctx, msg, err; if (typeof a === 'string') { ctx = null; msg = a; err = b; } @@ -1690,18 +1686,15 @@ app.whenReady().then(async () => { mainWindow.hide(); }); - // Auto-start folder monitor if enabled try { const launchConfig = configStore.load(); const fm = launchConfig.globalSettings && launchConfig.globalSettings.folderMonitor; - if (fm && fm.enabled && fm.folderPath) { - if (fs.existsSync(fm.folderPath)) { - startFolderMonitor(fm); - } else { - logWarn(`folder-monitor auto-start skipped: path not found (${fm.folderPath})`); - // Persist the disable so the user gets a clean state on next launch - const gs = { ...launchConfig.globalSettings, folderMonitor: { ...fm, enabled: false } }; - configStore.save({ globalSettings: gs }).catch(() => {}); + if (fm && fm.enabled && fm.folderPath && fm.paused !== true) { + startFolderMonitor(fm); + if (!fs.existsSync(fm.folderPath)) { + void folderMonitor.scan({ emitFiles: true, trigger: 'startup' }).catch(error => { + debugLog(`folder-monitor startup scan failed: ${error.message}`); + }); } } } catch (err) { @@ -2026,6 +2019,9 @@ ipcMain.handle('select-files', async () => { // Debug self-test: runs a minimal upload in the main process to verify events work ipcMain.handle('debug-test-upload', async () => { + if (configStore.load().globalSettings?.folderMonitor?.paused === true) { + return { error: 'Automatik ist pausiert' }; + } const testFile = path.join(__dirname, 'test-self-check.txt'); try { fs.writeFileSync(testFile, 'selftest ' + Date.now(), 'utf-8'); @@ -2118,6 +2114,9 @@ ipcMain.handle('inspect-import-files', async (_event, payload) => { }); ipcMain.handle('start-upload', async (_event, payload) => { + if (configStore.load().globalSettings?.folderMonitor?.paused === true) { + return { error: 'Automatik ist pausiert' }; + } if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' }; if (!settingsImportGate.canStartUpload()) return { error: 'Einstellungen werden gerade importiert' }; if (uploadManager) return { error: 'Ein Upload wird bereits ausgeführt oder abgeschlossen' }; @@ -2158,6 +2157,9 @@ ipcMain.handle('start-upload', async (_event, payload) => { if (tasks.length === 0) { await appendUploadPlanAudit(batchPlan, 'start'); + if (configStore.load().globalSettings?.folderMonitor?.paused === true) { + return { error: 'Automatik ist pausiert' }; + } const skippedSummary = stats.mergeSkippedIntoSummary({ id: `skipped-${Date.now()}`, timestamp: new Date().toISOString(), @@ -2179,6 +2181,10 @@ ipcMain.handle('start-upload', async (_event, payload) => { const _thisManager = uploadManager; await appendUploadPlanAudit(batchPlan, 'start'); + if (configStore.load().globalSettings?.folderMonitor?.paused === true) { + if (uploadManager === _thisManager) { uploadManager = null; globalThis._mhuUploadManagerRef = null; } + return { error: 'Automatik ist pausiert' }; + } const recovery = { id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`, @@ -2186,6 +2192,11 @@ ipcMain.handle('start-upload', async (_event, payload) => { 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 @@ -2230,6 +2241,11 @@ ipcMain.handle('start-upload', async (_event, payload) => { } return { error: `Quelldatei-Schutz konnte nicht vorbereitet werden: ${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' }; + } for (const skipped of skippedJobs) sourceCleanup.markSkipped(skipped.jobId); _thisManager.sourceFileCleanup = sourceCleanup; const _producerTracker = trackUploadProducer(_thisManager); @@ -2424,6 +2440,12 @@ ipcMain.handle('start-upload', async (_event, payload) => { _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)`); @@ -2480,6 +2502,9 @@ ipcMain.handle('cancel-selected-jobs', (_event, jobIds) => { }); ipcMain.handle('add-jobs-to-batch', async (_event, payload) => { + if (configStore.load().globalSettings?.folderMonitor?.paused === true) { + return { error: 'Automatik ist pausiert' }; + } if (closeFlushRequested) return { error: 'Die Anwendung wird gerade beendet' }; if (!uploadManager || !uploadManager.running) { return { error: 'Kein Upload aktiv' }; @@ -2498,6 +2523,9 @@ ipcMain.handle('add-jobs-to-batch', async (_event, payload) => { const sourceCleanupFingerprints = batchManager.sourceFileCleanup ? await batchManager.sourceFileCleanup.registerGroups(sourceCleanupGroups) : {}; + if (configStore.load().globalSettings?.folderMonitor?.paused === true) { + return { error: 'Automatik ist pausiert' }; + } if (uploadManager !== batchManager || !batchManager.running) { return { error: 'Kein Upload aktiv' }; } @@ -3162,33 +3190,58 @@ function _sweepOrphanConfigTmps() { } catch {} } -// --- Folder Monitor --- +let suppressAutomationStatusEvents = false; + +function automationStatusSnapshot() { + const settings = configStore.load().globalSettings?.folderMonitor || {}; + return Object.freeze({ + ...folderMonitor.status(), + enabled: settings.enabled === true, + configured: String(settings.folderPath || '').trim().length > 0, + paused: settings.paused === true, + pausedAt: settings.pausedAt ?? null, + queueLimitJobs: settings.queueLimitJobs, + reconcileIntervalMinutes: settings.reconcileIntervalMinutes + }); +} + +function publishAutomationStatus() { + const snapshot = automationStatusSnapshot(); + if (!suppressAutomationStatusEvents) safeSend('automation:status', snapshot); + return snapshot; +} + +function bindFolderMonitorEvents(settings) { + folderMonitor.removeAllListeners(); + folderMonitor.on('new-files', (files) => { + debugLog(`folder-monitor: ${files.length} new file(s)`); + safeSend('folder-monitor:new-files', files); + }); + folderMonitor.on('error', (err) => { + debugLog(`folder-monitor error: ${err.message}`); + }); + folderMonitor.on('status', publishAutomationStatus); + folderMonitor.on('initial-scan-complete', async () => { + try { + const latest = configStore.load(); + const current = latest.globalSettings?.folderMonitor; + if (!current?.includeExisting || path.resolve(current.folderPath || '') !== path.resolve(settings.folderPath || '')) return; + await configStore.save({ + globalSettings: { + ...latest.globalSettings, + folderMonitor: { ...current, includeExisting: false } + } + }); + } catch (error) { + debugLog(`folder-monitor initial scan state failed: ${error.message}`); + } + }); +} + function startFolderMonitor(settings) { try { folderMonitor.stop(); - folderMonitor.removeAllListeners(); - folderMonitor.on('new-files', (files) => { - debugLog(`folder-monitor: ${files.length} new file(s)`); - safeSend('folder-monitor:new-files', files); - }); - folderMonitor.on('error', (err) => { - debugLog(`folder-monitor error: ${err.message}`); - }); - folderMonitor.on('initial-scan-complete', async () => { - try { - const latest = configStore.load(); - const current = latest.globalSettings?.folderMonitor; - if (!current?.includeExisting || path.resolve(current.folderPath || '') !== path.resolve(settings.folderPath || '')) return; - await configStore.save({ - globalSettings: { - ...latest.globalSettings, - folderMonitor: { ...current, includeExisting: false } - } - }); - } catch (error) { - debugLog(`folder-monitor initial scan state failed: ${error.message}`); - } - }); + bindFolderMonitorEvents(settings); const result = folderMonitor.start(settings); debugLog(`folder-monitor started: ${settings.folderPath}`); return result; @@ -3198,7 +3251,17 @@ function startFolderMonitor(settings) { } } +async function resumeFolderMonitor(settings) { + bindFolderMonitorEvents(settings); + const result = await folderMonitor.resume(settings); + debugLog(`folder-monitor resumed: ${settings.folderPath}`); + return result; +} + ipcMain.handle('folder-monitor:start', (_event, settings) => { + if (configStore.load().globalSettings?.folderMonitor?.paused === true) { + return { error: 'Automatik ist pausiert' }; + } const result = startFolderMonitor(settings); return { ok: true, includesExisting: result?.includesExisting === true }; }); @@ -3213,6 +3276,58 @@ ipcMain.handle('folder-monitor:status', () => { return folderMonitor.status(); }); +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() } + } + }); + suppressAutomationStatusEvents = true; + try { + await folderMonitor.pause(); + } finally { + suppressAutomationStatusEvents = false; + } + if (uploadManager) uploadManager.finishAfterActive(); + return publishAutomationStatus(); +}); + +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 { + if (resumedSettings.enabled && resumedSettings.folderPath) { + await resumeFolderMonitor(resumedSettings); + } + } finally { + suppressAutomationStatusEvents = false; + } + return publishAutomationStatus(); +}); + +ipcMain.handle('folder-monitor:test-scan', () => { + return folderMonitor.scan({ emitFiles: false, trigger: 'test' }); +}); + +ipcMain.handle('folder-monitor:reconcile', () => { + return folderMonitor.scan({ emitFiles: true, trigger: 'manual' }); +}); + ipcMain.handle('folder-monitor:select-folder', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory'] diff --git a/preload.js b/preload.js index bd1756c..6fae9a3 100644 --- a/preload.js +++ b/preload.js @@ -90,9 +90,17 @@ contextBridge.exposeInMainWorld('api', { folderMonitorStop: () => ipcRenderer.invoke('folder-monitor:stop'), folderMonitorStatus: () => ipcRenderer.invoke('folder-monitor:status'), folderMonitorSelectFolder: () => ipcRenderer.invoke('folder-monitor:select-folder'), + folderMonitorTestScan: () => ipcRenderer.invoke('folder-monitor:test-scan'), + folderMonitorReconcile: () => ipcRenderer.invoke('folder-monitor:reconcile'), + automationGetStatus: () => ipcRenderer.invoke('automation:get-status'), + automationPauseAfterActive: () => ipcRenderer.invoke('automation:pause-after-active'), + automationResume: () => ipcRenderer.invoke('automation:resume'), onFolderMonitorNewFiles: (callback) => { ipcRenderer.on('folder-monitor:new-files', (_event, data) => callback(data)); }, + onAutomationStatus: (callback) => { + ipcRenderer.on('automation:status', (_event, data) => callback(data)); + }, // Account switched event onAccountSwitched: (callback) => { @@ -176,6 +184,7 @@ contextBridge.exposeInMainWorld('api', { ipcRenderer.removeAllListeners('app:prepare-close'); ipcRenderer.removeAllListeners('shutdown-countdown'); ipcRenderer.removeAllListeners('folder-monitor:new-files'); + ipcRenderer.removeAllListeners('automation:status'); ipcRenderer.removeAllListeners('drop-target:files'); ipcRenderer.removeAllListeners('account-switched'); ipcRenderer.removeAllListeners('session-failed-accounts-changed'); diff --git a/tests/config-store.test.js b/tests/config-store.test.js index 0f4c97d..734b39c 100644 --- a/tests/config-store.test.js +++ b/tests/config-store.test.js @@ -34,6 +34,16 @@ function createStore() { return store; } +function createStoreAt(filePath) { + const configuredStore = new ConfigStore({ + isPackaged: false, + getPath: () => path.dirname(filePath) + }); + configuredStore.filePath = filePath; + configuredStore.historyPath = path.join(path.dirname(filePath), 'electron-history.json'); + return configuredStore; +} + describe('ConfigStore', () => { beforeEach(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-test-')); @@ -97,6 +107,24 @@ describe('ConfigStore', () => { assert.deepEqual(config.history, []); }); + it('automation defaults persist a 15000 job limit and five minute interval', () => { + const settings = store.load().globalSettings.folderMonitor; + + assert.equal(settings.queueLimitJobs, 15000); + assert.equal(settings.reconcileIntervalMinutes, 5); + assert.equal(settings.paused, false); + assert.equal(settings.pausedAt, null); + }); + + it('automation pause survives a save and reload', async () => { + await store.save({ globalSettings: { folderMonitor: { paused: true, pausedAt: 1787712000000 } } }); + + const reloaded = createStoreAt(store.filePath).load(); + + assert.equal(reloaded.globalSettings.folderMonitor.paused, true); + assert.equal(reloaded.globalSettings.folderMonitor.pausedAt, 1787712000000); + }); + it('drops the retired plaintext credential setting from legacy configurations', () => { fs.writeFileSync(store.filePath, JSON.stringify({ hosters: {}, @@ -501,6 +529,33 @@ describe('ConfigStore', () => { assert.deepEqual(config.globalSettings.remote, { enabled: true, port: 9200, token: 'main-token', allowInput: false }); }); + it('renderer settings saves cannot clear the authoritative automation pause', async () => { + const current = store.load(); + await store.save({ + globalSettings: { + ...current.globalSettings, + folderMonitor: { + ...current.globalSettings.folderMonitor, + enabled: true, + folderPath: 'C:\\watch', + paused: true, + pausedAt: 1787712000000 + } + } + }); + + await store.saveRendererGlobalSettings({ + alwaysOnTop: true, + folderMonitor: { paused: false, pausedAt: null } + }); + + const saved = store.load().globalSettings.folderMonitor; + assert.equal(saved.paused, true); + assert.equal(saved.pausedAt, 1787712000000); + assert.equal(saved.enabled, true); + assert.equal(saved.folderPath, 'C:\\watch'); + }); + it('persists the last browse directory across stale renderer settings saves', async () => { const selectedDirectory = path.join(tmpDir, 'selected'); diff --git a/tests/package-build-files.test.js b/tests/package-build-files.test.js index 53da69f..520b7e5 100644 --- a/tests/package-build-files.test.js +++ b/tests/package-build-files.test.js @@ -253,3 +253,142 @@ test('preload exposes account cooldown snapshots and removes their listener duri assert.deepEqual(pushed, { version: 2, accounts: [{ accountId: 'a1' }] }); assert.equal(removed.includes('session-failed-accounts-changed'), true); }); + +test('exposes persistent automation controls and status through narrow IPC boundaries', () => { + const preloadSource = fs.readFileSync(path.join(projectRoot, 'preload.js'), 'utf8'); + const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); + + assert.match(mainSource, /ipcMain\.handle\('automation:get-status'/u); + assert.match(mainSource, /ipcMain\.handle\('automation:pause-after-active'/u); + assert.match(mainSource, /ipcMain\.handle\('automation:resume'/u); + assert.match(mainSource, /ipcMain\.handle\('folder-monitor:test-scan'/u); + assert.match(mainSource, /ipcMain\.handle\('folder-monitor:reconcile'/u); + assert.match(mainSource, /safeSend\('automation:status'/u); + assert.match(preloadSource, /automationGetStatus:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('automation:get-status'\)/u); + assert.match(preloadSource, /automationPauseAfterActive:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('automation:pause-after-active'\)/u); + assert.match(preloadSource, /automationResume:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('automation:resume'\)/u); + assert.match(preloadSource, /folderMonitorTestScan:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('folder-monitor:test-scan'\)/u); + assert.match(preloadSource, /folderMonitorReconcile:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('folder-monitor:reconcile'\)/u); + assert.match(preloadSource, /onAutomationStatus:\s*\(callback\)\s*=>\s*\{[\s\S]*?ipcRenderer\.on\('automation:status'/u); + assert.match(preloadSource, /ipcRenderer\.removeAllListeners\('automation:status'\)/u); +}); + +test('every batch start and extension IPC fails closed before account and cleanup side effects', () => { + 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] + ]; + + for (const [channel, startMarker, endMarker, sideEffectMarker, expectedGateCount] of cases) { + const start = mainSource.indexOf(startMarker); + const end = mainSource.indexOf(endMarker, start); + assert.notEqual(start, -1, `${channel} handler missing`); + assert.notEqual(end, -1, `${channel} handler boundary missing`); + const handler = mainSource.slice(start, end); + const gateIndex = handler.search(gate); + const sideEffectIndex = handler.indexOf(sideEffectMarker); + 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 () => { + const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); + const blockStart = mainSource.indexOf('let suppressAutomationStatusEvents = false;'); + 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 folderMonitor = new (require('node:events').EventEmitter)(); + folderMonitor.running = true; + folderMonitor.status = () => ({ running: folderMonitor.running, paused: !folderMonitor.running, reachable: true }); + folderMonitor.stop = () => { folderMonitor.running = false; order.push('stop'); }; + folderMonitor.start = () => { folderMonitor.running = true; order.push('start'); folderMonitor.emit('status'); return {}; }; + folderMonitor.pause = async () => { folderMonitor.running = false; order.push('pause'); folderMonitor.emit('status'); }; + folderMonitor.resume = async () => { folderMonitor.running = true; order.push('resume'); folderMonitor.emit('status'); return { reachable: true }; }; + folderMonitor.scan = async options => { order.push(`scan:${options.trigger}:${options.emitFiles}`); return { reachable: true }; }; + let state = { + globalSettings: { + folderMonitor: { + enabled: true, + folderPath: 'C:\\watch', + paused: false, + pausedAt: null, + queueLimitJobs: 15000, + reconcileIntervalMinutes: 5 + } + } + }; + let rejectSave = false; + const configStore = { + load: () => structuredClone(state), + save: async config => { + const paused = config.globalSettings.folderMonitor.paused; + order.push(`save:${paused}`); + if (rejectSave) throw new Error('save failed'); + state = structuredClone(config); + } + }; + const uploadManager = { + finishAfterActive: () => order.push('finish'), + startBatch: () => order.push('startBatch') + }; + vm.runInNewContext(mainSource.slice(blockStart, blockEnd), { + 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 + }); + + await handlers.get('automation:pause-after-active')(); + assert.deepEqual(order, ['save:true', 'pause', 'finish']); + assert.equal(sent.length, 1); + assert.equal(sent[0][0], 'automation:status'); + assert.equal(sent[0][1].paused, true); + + order.length = 0; + sent.length = 0; + state.globalSettings.folderMonitor.paused = false; + state.globalSettings.folderMonitor.pausedAt = null; + folderMonitor.running = true; + rejectSave = true; + await assert.rejects(handlers.get('automation:pause-after-active')(), /save failed/u); + assert.deepEqual(order, ['save:true']); + assert.equal(folderMonitor.running, true); + assert.equal(sent.length, 0); + + order.length = 0; + rejectSave = false; + state.globalSettings.folderMonitor.paused = true; + state.globalSettings.folderMonitor.pausedAt = 1; + folderMonitor.running = false; + await handlers.get('automation:resume')(); + assert.deepEqual(order, ['save:false', 'resume']); + assert.equal(order.includes('startBatch'), false); + assert.equal(sent.length, 1); + assert.equal(sent[0][1].paused, 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 () => {'); + const startupEnd = mainSource.indexOf("\napp.on('window-all-closed'", startupStart); + assert.notEqual(startupStart, -1); + assert.notEqual(startupEnd, -1); + const startup = mainSource.slice(startupStart, startupEnd); + + assert.match(startup, /fm\s*&&\s*fm\.enabled\s*&&\s*fm\.folderPath\s*&&\s*fm\.paused\s*!==\s*true[\s\S]*?startFolderMonitor\(fm\)/u); + assert.match(startup, /startFolderMonitor\(fm\);[\s\S]*?!fs\.existsSync\(fm\.folderPath\)[\s\S]*?folderMonitor\.scan\(\{\s*emitFiles:\s*true,\s*trigger:\s*'startup'\s*\}\)/u); + assert.doesNotMatch(startup, /folderMonitor:\s*\{\s*\.\.\.fm,\s*enabled:\s*false\s*\}/u); +}); diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index 445fbe9..f618238 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -1054,6 +1054,109 @@ app.whenReady().then(async () => { } }); +test('persisted automation pause rejects batch starts through hidden real IPC', { skip: process.platform !== 'win32' }, () => { + const projectRoot = path.join(__dirname, '..'); + const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8'); + const startUploadStart = mainSource.indexOf("ipcMain.handle('start-upload'"); + const startUploadEnd = mainSource.indexOf('\n// Logged at batch boundaries', startUploadStart); + const addJobsStart = mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'"); + const addJobsEnd = mainSource.indexOf("\nipcMain.handle('finish-after-active'", addJobsStart); + assert.notEqual(startUploadStart, -1); + assert.notEqual(startUploadEnd, -1); + assert.notEqual(addJobsStart, -1); + assert.notEqual(addJobsEnd, -1); + const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-pause-ipc-')); + const probePath = path.join(probeRoot, 'probe.cjs'); + const preloadPath = path.join(probeRoot, 'preload.cjs'); + const rendererPath = path.join(probeRoot, 'renderer.html'); + const outputPath = path.join(probeRoot, 'result.json'); + const userDataPath = path.join(probeRoot, 'user-data'); + fs.writeFileSync(preloadPath, ` +const { contextBridge, ipcRenderer } = require('electron'); +contextBridge.exposeInMainWorld('automationProbe', { + start: () => ipcRenderer.invoke('start-upload', { files: [], hosters: [], jobs: [] }), + extend: () => ipcRenderer.invoke('add-jobs-to-batch', { jobs: [], sourceCleanupGroups: [] }) +}); +`, 'utf8'); + fs.writeFileSync(rendererPath, ``, 'utf8'); + const productionHandlers = `${mainSource.slice(startUploadStart, startUploadEnd)}\n${mainSource.slice(addJobsStart, addJobsEnd)}`; + const probeSource = ` +const { app, BrowserWindow, ipcMain } = require('electron'); +const fs = require('node:fs'); +const ConfigStore = require(${JSON.stringify(path.join(projectRoot, 'lib', 'config-store.js'))}); +const outputPath = process.env.MHU_AUTOMATION_OUTPUT; +const rendererPath = process.env.MHU_AUTOMATION_RENDERER; +const preloadPath = process.env.MHU_AUTOMATION_PRELOAD; +app.setPath('userData', process.env.MHU_AUTOMATION_USER_DATA); +const configStore = new ConfigStore(app); +let closeFlushRequested = false; +const settingsImportGate = { canStartUpload: () => true }; +let uploadManager = { running: false }; +${productionHandlers} +app.whenReady().then(async () => { + const current = configStore.load(); + await configStore.save({ + globalSettings: { + ...current.globalSettings, + folderMonitor: { ...current.globalSettings.folderMonitor, paused: true, pausedAt: Date.now() } + } + }); + const window = new BrowserWindow({ + show: false, + webPreferences: { contextIsolation: true, nodeIntegration: false, preload: preloadPath } + }); + await window.loadFile(rendererPath); + let result = null; + for (let attempt = 0; attempt < 200; attempt++) { + result = await window.webContents.executeJavaScript('window.__automationPauseResult || null'); + if (result) break; + await new Promise(resolve => setTimeout(resolve, 10)); + } + fs.writeFileSync(outputPath, JSON.stringify({ hidden: window.isVisible() === false, result }), 'utf8'); + window.destroy(); + app.exit(0); +}).catch(error => { + fs.writeFileSync(outputPath, JSON.stringify({ error: error.stack || String(error) }), 'utf8'); + app.exit(1); +}); +`; + fs.writeFileSync(probePath, probeSource, 'utf8'); + try { + const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe'); + const execution = spawnSync(electronPath, [probePath, `--user-data-dir=${userDataPath}`], { + cwd: projectRoot, + env: { + ...process.env, + MHU_AUTOMATION_OUTPUT: outputPath, + MHU_AUTOMATION_RENDERER: rendererPath, + MHU_AUTOMATION_PRELOAD: preloadPath, + MHU_AUTOMATION_USER_DATA: userDataPath + }, + encoding: 'utf8', + windowsHide: true, + timeout: 30000 + }); + assert.equal(execution.status, 0, `${execution.stdout}\n${execution.stderr}`); + const outcome = JSON.parse(fs.readFileSync(outputPath, 'utf8')); + assert.equal(outcome.error, undefined); + assert.deepEqual(outcome, { + hidden: true, + result: { + start: { error: 'Automatik ist pausiert' }, + extend: { error: 'Automatik ist pausiert' } + } + }); + } finally { + fs.rmSync(probeRoot, { recursive: true, force: true }); + } +}); + test('resolveStartupLanguage accepts only the supported persisted language', () => { assert.equal(resolveStartupLanguage({ globalSettings: { language: 'de' } }), 'de'); assert.equal(resolveStartupLanguage({ globalSettings: { language: 'en' } }), 'en'); diff --git a/tests/upload-manager.test.js b/tests/upload-manager.test.js index b10b368..3bb5217 100644 --- a/tests/upload-manager.test.js +++ b/tests/upload-manager.test.js @@ -603,6 +603,41 @@ describe('UploadManager', () => { assert.ok(statuses.some((entry) => entry.jobId === 'job-third' && entry.status === 'done')); }); + it('finishAfterActive completes active work without starting queued work', async () => { + let releaseActive; + const started = []; + mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => { + started.push(filePath); + if (filePath.endsWith('/active.mp4')) { + await new Promise(resolve => { releaseActive = resolve; }); + } + if (onProgress) onProgress(fakeFileSize, fakeFileSize); + return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' }; + }); + const mgr = new UploadManager({ + 'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } + }); + const settled = new Map(); + mgr.on('job-settled', event => settled.set(event.jobId, event.status)); + const batch = mgr.startBatch([ + { jobId: 'active', file: '/test/active.mp4', hoster: 'doodstream.com', apiKey: 'key1' }, + { jobId: 'queued', file: '/test/queued.mp4', hoster: 'doodstream.com', apiKey: 'key1' } + ]); + + for (let attempt = 0; attempt < 50 && !releaseActive; attempt++) { + await new Promise(resolve => setTimeout(resolve, 5)); + } + assert.equal(typeof releaseActive, 'function'); + + mgr.finishAfterActive(); + releaseActive(); + await batch; + + assert.deepEqual(started, ['/test/active.mp4']); + assert.equal(settled.get('active'), 'done'); + assert.equal(settled.get('queued'), 'aborted'); + }); + it('_combineSignals propagates abort from either source', () => { const mgr = new UploadManager({}); const ac1 = new AbortController();