Files
Multi-Hoster-Upload/tests/package-build-files.test.js
T
Sucukdeluxe 5a369f7598 fix: eliminate upload queue stalls under automation load
Move atomic configuration writes off the Electron main event loop and preserve concurrent queue and folder monitor state.

Make finish-and-pause interrupt queued admission waits without aborting active uploads, then resume only persistently marked automation jobs across active, idle, restart, and finalization races.

Reuse bounded automation evidence snapshots, refresh them during long drains, and cover high-load persistence, pause, resume, and hidden Electron behavior with regression tests.
2026-08-27 07:15:39 +02:00

1087 lines
47 KiB
JavaScript

const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const Module = require('node:module');
const path = require('node:path');
const vm = require('node:vm');
const { pathToFileURL } = require('node:url');
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 onHandlers = new Map();
const order = [];
const sent = [];
const saves = [];
const pauseDeferred = createDeferred();
const resumeDeferred = createDeferred();
const configuredSettings = [];
const startedSettings = [];
let scanError = null;
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, startedAt: 100, nextReconcileAt: 200 });
folderMonitor.stop = () => { folderMonitor.running = false; order.push('stop'); };
folderMonitor.configure = settings => {
configuredSettings.push(structuredClone(settings));
folderMonitor.running = false;
order.push('configure');
publishStatus();
return { includesExisting: false, paused: true };
};
folderMonitor.start = settings => {
startedSettings.push(structuredClone(settings));
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 options => {
order.push(`scan:${options.trigger}:${options.emitFiles}`);
if (scanError) throw scanError;
return { reachable: true, trigger: options.trigger };
};
const configStore = {
load: () => structuredClone(state),
saveFolderMonitorRuntimeState: folderMonitor => {
const deferred = createDeferred();
const snapshot = structuredClone({
...state,
globalSettings: {
...state.globalSettings,
folderMonitor: {
...state.globalSettings.folderMonitor,
...folderMonitor
}
}
});
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'),
resumeAfterActive: () => order.push('resume-manager'),
startBatch: () => order.push('startBatch')
};
const webContents = {};
const context = {
configStore,
debugLog: () => {},
dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
folderMonitor,
folderMonitorLifecycleGeneration: 0,
folderMonitorRendererGeneration: 1,
folderMonitorRendererReady: false,
folderMonitorRendererReadyGeneration: null,
folderMonitorStartupReconcile: null,
folderMonitorStartupReconcilePending: false,
folderMonitorStartupReconcileStarted: false,
ipcMain: {
handle: (channel, handler) => handlers.set(channel, handler),
on: (channel, handler) => onHandlers.set(channel, handler)
},
mainWindow: { isDestroyed: () => false, webContents },
normalizeAutomationSettings: require('../lib/automation-control').normalizeAutomationSettings,
path,
quitTeardownStarted: false,
safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; },
uploadManager
};
vm.runInNewContext(mainSource.slice(blockStart, blockEnd), context);
const readyStart = mainSource.indexOf("ipcMain.on('folder-monitor:renderer-ready'");
const readyEnd = mainSource.indexOf("\nipcMain.on('app:close-preparation-started'", readyStart);
assert.notEqual(readyStart, -1, 'folder monitor renderer ready handler missing');
assert.notEqual(readyEnd, -1, 'folder monitor renderer ready handler boundary missing');
vm.runInNewContext(mainSource.slice(readyStart, readyEnd), context);
publishStatus = () => context.publishAutomationStatus();
return {
handlers,
configuredSettings,
context,
order,
onHandlers,
pauseDeferred,
resumeDeferred,
saves,
sent,
setScanError(error) {
scanError = error;
},
setFolderMonitorState(value) {
state.globalSettings.folderMonitor = { ...state.globalSettings.folderMonitor, ...value };
},
startedSettings,
state: () => structuredClone(state),
webContents,
publishStatus
};
}
function createAddJobsToBatchHarness(mainSource) {
const blockStart = mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'");
const blockEnd = mainSource.indexOf("\nipcMain.handle('finish-after-active'", blockStart);
assert.notEqual(blockStart, -1, 'add-jobs-to-batch handler missing');
assert.notEqual(blockEnd, -1, 'add-jobs-to-batch handler boundary missing');
const handlers = new Map();
const effects = [];
const uploadManager = {
running: true,
isStoppingAfterActive: () => true,
sourceFileCleanup: {
registerGroups: async () => { effects.push('sourceCleanup'); return {}; },
markSkipped: () => effects.push('markSkipped')
},
addJobs: tasks => { effects.push('addJobs'); return { added: tasks.length, alreadyInBatchJobIds: [] }; }
};
const config = { globalSettings: { folderMonitor: { paused: false } } };
vm.runInNewContext(mainSource.slice(blockStart, blockEnd), {
appendUploadPlanAudit: async () => effects.push('audit'),
buildUploadTasksFromJobs: (_config, jobs) => {
effects.push('buildUploadTasks');
return jobs.map(job => ({ ...job, jobId: job.id }));
},
closeFlushRequested: false,
configStore: { load: () => config },
debugLog: () => effects.push('debugLog'),
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
makeAccountPicker: () => { effects.push('makeAccountPicker'); return {}; },
persistRotation: () => effects.push('persistRotation'),
summarizeBatchPlan: value => value,
uploadManager
});
return { effects, handlers };
}
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'));
assert.ok(packageJson.build.files.includes('lib/**/*'));
assert.equal(packageJson.build.win.signAndEditExecutable, false);
});
test('exposes managed online backup operations 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(preloadSource, /listManagedOnlineBackups:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('online-backup:list-managed'\)/u);
assert.match(preloadSource, /createManagedOnlineBackup:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('online-backup:create-managed'\)/u);
assert.match(preloadSource, /copyManagedOnlineBackup:\s*\(id\)\s*=>\s*ipcRenderer\.invoke\('online-backup:copy-managed',\s*id\)/u);
assert.match(preloadSource, /deleteManagedOnlineBackup:\s*\(id\)\s*=>\s*ipcRenderer\.invoke\('online-backup:delete-managed',\s*id\)/u);
assert.match(preloadSource, /restoreOnlineBackup:\s*\(key\)\s*=>\s*ipcRenderer\.invoke\('online-backup:restore',\s*key\)/u);
assert.deepEqual(
[...preloadSource.matchAll(/^\s{2}(\w*OnlineBackups?):/gmu)].map((match) => match[1]),
['listManagedOnlineBackups', 'createManagedOnlineBackup', 'copyManagedOnlineBackup', 'deleteManagedOnlineBackup', 'restoreOnlineBackup']
);
assert.doesNotMatch(preloadSource, /^\s{2}createOnlineBackup:/mu);
assert.doesNotMatch(preloadSource, /ipcRenderer\.invoke\('online-backup:create'\)/u);
assert.match(mainSource, /path\.join\(app\.getPath\('userData'\),\s*'online-backup-keys\.json'\)/u);
assert.match(mainSource, /Buffer\.from\(id,\s*'base64url'\)/u);
assert.match(mainSource, /decoded\.toString\('base64url'\)\s*!==\s*id/u);
assert.doesNotMatch(mainSource, /ipcMain\.handle\('online-backup:create'/u);
assert.match(mainSource, /ipcMain\.handle\('online-backup:list-managed',[\s\S]*?onlineBackupManager\.listManaged\(\)/u);
assert.match(mainSource, /ipcMain\.handle\('online-backup:create-managed',[\s\S]*?onlineBackupManager\.createManaged\(\)/u);
assert.match(mainSource, /ipcMain\.handle\('online-backup:copy-managed',[\s\S]*?onlineBackupManager\.copyManaged\(/u);
assert.match(mainSource, /ipcMain\.handle\('online-backup:delete-managed',[\s\S]*?onlineBackupManager\.deleteManaged\(/u);
});
test('managed online backup handlers reject every sender outside the local main frame', async () => {
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
const boundaryStart = mainSource.indexOf('const ONLINE_BACKUP_RENDERER_URL');
const boundaryEnd = mainSource.indexOf('\nfunction clearCloseFlushTimer', boundaryStart);
const handlersStart = mainSource.indexOf("ipcMain.handle('online-backup:list-managed'");
const handlersEnd = mainSource.indexOf("\nipcMain.handle('online-backup:restore'", handlersStart);
assert.notEqual(boundaryStart, -1, 'central online backup sender boundary is missing');
assert.notEqual(boundaryEnd, -1, 'central online backup sender boundary is incomplete');
assert.notEqual(handlersStart, -1, 'managed online backup handlers are missing');
assert.notEqual(handlersEnd, -1, 'managed online backup handler block is incomplete');
const expectedUrl = `${pathToFileURL(path.join(projectRoot, 'renderer', 'index.html')).href}?language=de&version=2.1.31`;
const state = {
frameUrl: expectedUrl,
currentUrl: expectedUrl,
windowDestroyed: false,
webContentsDestroyed: false
};
const mainFrame = {};
Object.defineProperty(mainFrame, 'url', { get: () => state.frameUrl });
const webContents = {
mainFrame,
getURL: () => state.currentUrl,
isDestroyed: () => state.webContentsDestroyed
};
const mainWindow = {
webContents,
isDestroyed: () => state.windowDestroyed
};
const managerCalls = [];
const onlineBackupManager = {
listManaged: () => { managerCalls.push('list'); return { ok: true, entries: [] }; },
createManaged: () => { managerCalls.push('create'); return { ok: true, entry: { id: 'AAAAAAAAAAAAAAAAAAAAAA' } }; },
copyManaged: (id) => { managerCalls.push(`copy:${id}`); return { ok: true }; },
deleteManaged: (id) => { managerCalls.push(`delete:${id}`); return { ok: true, removedId: id }; }
};
const handlers = new Map();
const context = {
Buffer,
URL,
__dirname: projectRoot,
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
mainWindow,
onlineBackupManager,
path,
pathToFileURL
};
vm.runInNewContext(
`${mainSource.slice(boundaryStart, boundaryEnd)}\n${mainSource.slice(handlersStart, handlersEnd)}`,
context
);
const id = 'AAAAAAAAAAAAAAAAAAAAAA';
const invokeAll = async (event) => Promise.all([
handlers.get('online-backup:list-managed')(event),
handlers.get('online-backup:create-managed')(event),
handlers.get('online-backup:copy-managed')(event, id),
handlers.get('online-backup:delete-managed')(event, id)
].map(result => Promise.resolve(result)));
const legitimateEvent = { sender: webContents, senderFrame: mainFrame };
const legitimateResults = await invokeAll(legitimateEvent);
assert.deepEqual(managerCalls, ['list', 'create', `copy:${id}`, `delete:${id}`]);
assert.equal(legitimateResults.every(result => result.ok === true), true);
assert.equal(JSON.stringify(legitimateResults).includes('MHU2-'), false);
const foreignSender = { mainFrame: { url: expectedUrl }, getURL: () => expectedUrl, isDestroyed: () => false };
const cases = [
['foreign sender', { sender: foreignSender, senderFrame: foreignSender.mainFrame }, () => {}],
['destroyed main window', legitimateEvent, () => { state.windowDestroyed = true; }],
['destroyed sender', legitimateEvent, () => { state.webContentsDestroyed = true; }],
['subframe', { sender: webContents, senderFrame: { url: expectedUrl } }, () => {}],
['remote URL', legitimateEvent, () => { state.frameUrl = 'https://example.invalid/renderer/index.html'; state.currentUrl = state.frameUrl; }],
['different local file', legitimateEvent, () => { state.frameUrl = pathToFileURL(path.join(projectRoot, 'renderer', 'drop-target.html')).href; state.currentUrl = state.frameUrl; }]
];
for (const [name, event, arrange] of cases) {
state.frameUrl = expectedUrl;
state.currentUrl = expectedUrl;
state.windowDestroyed = false;
state.webContentsDestroyed = false;
managerCalls.length = 0;
arrange();
const results = await invokeAll(event);
assert.equal(managerCalls.length, 0, `${name} reached the manager`);
assert.equal(results.every(result => result.ok === false), true, `${name} was not rejected`);
assert.equal(JSON.stringify(results).includes('MHU2-'), false, `${name} leaked a complete key`);
}
});
test('afterPack brands the executable metadata shown by Windows', async () => {
let editCall = null;
const originalLoad = Module._load;
const afterPackPath = require.resolve('../scripts/afterPack.cjs');
delete require.cache[afterPackPath];
Module._load = function (request, parent, isMain) {
if (request === 'rcedit') {
return async (exePath, options) => { editCall = { exePath, options }; };
}
return originalLoad.call(this, request, parent, isMain);
};
try {
const afterPack = require(afterPackPath);
await afterPack({
appOutDir: 'C:\\release',
packager: { appInfo: { productFilename: 'Multi-Hoster-Upload', version: '9.8.7' } }
});
} finally {
Module._load = originalLoad;
delete require.cache[afterPackPath];
}
assert.equal(editCall.exePath, path.join('C:\\release', 'Multi-Hoster-Upload.exe'));
assert.equal(editCall.options['file-version'], '9.8.7');
assert.equal(editCall.options['product-version'], '9.8.7');
assert.deepEqual(editCall.options['version-string'], {
CompanyName: 'Sucukdeluxe',
FileDescription: 'Multi Hoster Uploader',
InternalName: 'Multi-Hoster-Upload',
OriginalFilename: 'Multi-Hoster-Upload.exe',
ProductName: 'Multi Hoster Uploader'
});
});
test('close readiness is signaled only after the renderer explicitly finishes initialization', () => {
const listeners = new Map();
const sent = [];
let exposedApi = null;
const electronMock = {
contextBridge: {
exposeInMainWorld: (_name, api) => { exposedApi = api; }
},
ipcRenderer: {
invoke: () => Promise.resolve(),
on: (channel, listener) => { listeners.set(channel, listener); },
send: (...args) => { sent.push(args); },
removeAllListeners: () => {}
},
webUtils: {
getPathForFile: () => ''
}
};
const originalLoad = Module._load;
const preloadPath = require.resolve('../preload');
delete require.cache[preloadPath];
Module._load = function (request, parent, isMain) {
if (request === 'electron') return electronMock;
return originalLoad.call(this, request, parent, isMain);
};
try {
require(preloadPath);
} finally {
Module._load = originalLoad;
}
let closeAttempt = null;
exposedApi.onPrepareClose(attempt => { closeAttempt = attempt; });
assert.deepEqual(sent, []);
listeners.get('app:prepare-close')({}, 7);
assert.equal(closeAttempt, 7);
assert.deepEqual(sent, [['app:close-preparation-started', 7]]);
exposedApi.signalCloseHandshakeReady();
assert.deepEqual(sent, [
['app:close-preparation-started', 7],
['app:close-handshake-ready']
]);
});
test('folder monitor readiness is emitted once and only after the candidate listener exists', () => {
const order = [];
const listeners = new Map();
let exposedApi = null;
const electronMock = {
contextBridge: {
exposeInMainWorld: (_name, api) => { exposedApi = api; }
},
ipcRenderer: {
invoke: () => Promise.resolve(),
on: (channel, listener) => {
listeners.set(channel, listener);
order.push(['listen', channel]);
},
send: (...args) => { order.push(['send', ...args]); },
removeAllListeners: () => {}
},
webUtils: {
getPathForFile: () => ''
}
};
const originalLoad = Module._load;
const preloadPath = require.resolve('../preload');
delete require.cache[preloadPath];
Module._load = function (request, parent, isMain) {
if (request === 'electron') return electronMock;
return originalLoad.call(this, request, parent, isMain);
};
try {
require(preloadPath);
} finally {
Module._load = originalLoad;
}
exposedApi.signalFolderMonitorReady();
let received = null;
exposedApi.onFolderMonitorNewFiles(files => { received = files; });
exposedApi.signalFolderMonitorReady();
listeners.get('folder-monitor:renderer-generation')({}, 4);
exposedApi.signalFolderMonitorReady();
listeners.get('folder-monitor:renderer-generation')({}, 4);
listeners.get('folder-monitor:renderer-generation')({}, 3);
listeners.get('folder-monitor:renderer-generation')({}, 5);
listeners.get('folder-monitor:new-files')({}, [{ path: 'C:\\watch\\ready.mkv' }]);
assert.deepEqual(order, [
['listen', 'folder-monitor:renderer-generation'],
['listen', 'folder-monitor:new-files'],
['send', 'folder-monitor:renderer-ready', 4],
['send', 'folder-monitor:renderer-ready', 5]
]);
assert.deepEqual(received, [{ path: 'C:\\watch\\ready.mkv' }]);
});
test('stopped and resumed monitor generations cannot release a deferred startup reconcile', async () => {
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
const harness = createAutomationLifecycleHarness(mainSource);
const settings = {
enabled: true,
folderPath: 'C:\\watch',
paused: false,
pausedAt: null,
queueLimitJobs: 15000,
reconcileIntervalMinutes: 5
};
harness.setFolderMonitorState({ paused: false, pausedAt: null });
await harness.context.startFolderMonitor(settings, { deferStartupReconcile: true });
const pause = harness.handlers.get('automation:pause-after-active')();
await waitForCondition(() => harness.saves.length === 1);
harness.saves[0].deferred.resolve();
await waitForCondition(() => harness.order.includes('pause'));
harness.pauseDeferred.resolve();
await pause;
harness.handlers.get('folder-monitor:stop')();
harness.onHandlers.get('folder-monitor:renderer-ready')({ sender: harness.webContents }, 1);
harness.resumeDeferred.resolve();
const resume = harness.handlers.get('automation:resume')();
await waitForCondition(() => harness.saves.length === 2);
harness.saves[1].deferred.resolve();
await resume;
harness.context.folderMonitorRendererGeneration = 2;
harness.context.folderMonitorRendererReadyGeneration = null;
harness.context.folderMonitorRendererReady = false;
harness.onHandlers.get('folder-monitor:renderer-ready')({ sender: harness.webContents }, 1);
harness.onHandlers.get('folder-monitor:renderer-ready')({ sender: harness.webContents }, 2);
assert.deepEqual(harness.order.filter(entry => entry.startsWith('scan:')), ['scan:resume:true']);
});
test('a nondeferred monitor restart supersedes pending startup work and each generation releases at most once', async () => {
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
const settings = {
enabled: true,
folderPath: 'C:\\watch',
paused: false,
pausedAt: null,
queueLimitJobs: 15000,
reconcileIntervalMinutes: 5
};
const restarted = createAutomationLifecycleHarness(mainSource);
restarted.setFolderMonitorState({ paused: false, pausedAt: null });
await restarted.context.startFolderMonitor(settings, { deferStartupReconcile: true });
await restarted.context.startFolderMonitor(settings);
restarted.onHandlers.get('folder-monitor:renderer-ready')({ sender: restarted.webContents }, 1);
restarted.onHandlers.get('folder-monitor:renderer-ready')({ sender: restarted.webContents }, 1);
assert.deepEqual(restarted.order.filter(entry => entry.startsWith('scan:')), ['scan:startup:true']);
const deferred = createAutomationLifecycleHarness(mainSource);
deferred.setFolderMonitorState({ paused: false, pausedAt: null });
await deferred.context.startFolderMonitor(settings, { deferStartupReconcile: true });
deferred.onHandlers.get('folder-monitor:renderer-ready')({ sender: {} }, 1);
assert.deepEqual(deferred.order.filter(entry => entry.startsWith('scan:')), []);
deferred.onHandlers.get('folder-monitor:renderer-ready')({ sender: deferred.webContents }, 1);
deferred.onHandlers.get('folder-monitor:renderer-ready')({ sender: deferred.webContents }, 1);
assert.deepEqual(deferred.order.filter(entry => entry.startsWith('scan:')), ['scan:startup:true']);
});
test('preload exposes account cooldown snapshots and removes their listener during cleanup', async () => {
const listeners = new Map();
const invocations = [];
const removed = [];
let exposedApi = null;
const electronMock = {
contextBridge: {
exposeInMainWorld: (_name, api) => { exposedApi = api; }
},
ipcRenderer: {
invoke: (...args) => { invocations.push(args); return Promise.resolve({ version: 2, accounts: [] }); },
on: (channel, listener) => { listeners.set(channel, listener); },
send: () => {},
removeAllListeners: channel => { removed.push(channel); }
},
webUtils: {
getPathForFile: () => ''
}
};
const originalLoad = Module._load;
const preloadPath = require.resolve('../preload');
delete require.cache[preloadPath];
Module._load = function (request, parent, isMain) {
if (request === 'electron') return electronMock;
return originalLoad.call(this, request, parent, isMain);
};
try {
require(preloadPath);
} finally {
Module._load = originalLoad;
}
const snapshot = await exposedApi.getSessionFailedAccountStates();
let pushed = null;
exposedApi.onSessionFailedAccountsChanged(value => { pushed = value; });
listeners.get('session-failed-accounts-changed')({}, { version: 2, accounts: [{ accountId: 'a1' }] });
exposedApi.removeAllListeners();
assert.deepEqual(snapshot, { version: 2, accounts: [] });
assert.deepEqual(invocations, [['get-session-failed-account-states']]);
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'],
['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] 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`);
}
});
test('add-jobs-to-batch rejects a stopping manager before account and cleanup side effects', async () => {
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
const harness = createAddJobsToBatchHarness(mainSource);
const result = await harness.handlers.get('add-jobs-to-batch')(null, {
jobs: [{ id: 'job-1', file: 'C:\\watch\\video.mp4', hoster: 'doodstream.com' }],
sourceCleanupGroups: [{ id: 'group-1' }]
});
assert.deepEqual({ ...result }, { error: 'Warteschlange angehalten' });
assert.deepEqual(harness.effects, []);
});
test('start-upload waits for a finalizing manager to release before deciding availability', async () => {
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
const helperStart = mainSource.indexOf('async function waitForUploadManagerRelease');
const helperEnd = mainSource.indexOf('\nfunction requestUploadFinalization', helperStart);
const handlerStart = mainSource.indexOf("ipcMain.handle('start-upload'");
const configLoad = mainSource.indexOf(' const config = configStore.load();', handlerStart);
const waitCall = mainSource.indexOf('await waitForUploadManagerRelease(existingManager)', handlerStart);
assert.notEqual(helperStart, -1);
assert.notEqual(helperEnd, -1);
assert.notEqual(handlerStart, -1);
assert.ok(waitCall > handlerStart && waitCall < configLoad);
const context = { manager: { running: false }, setTimeout, Date };
vm.runInNewContext(`
let uploadManager = manager;
${mainSource.slice(helperStart, helperEnd)}
globalThis.waitForRelease = timeout => waitForUploadManagerRelease(manager, timeout);
globalThis.release = () => { uploadManager = null; };
`, context);
const released = context.waitForRelease(500);
setTimeout(context.release, 20);
assert.equal(await released, true);
vm.runInNewContext('uploadManager = manager;', context);
assert.equal(await context.waitForRelease(10), false);
});
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 = 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');
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.configure = () => { folderMonitor.running = false; order.push('configure'); return { paused: true }; };
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),
saveFolderMonitorRuntimeState: async folderMonitor => {
const paused = folderMonitor.paused;
order.push(`save:${paused}`);
if (rejectSave) throw new Error('save failed');
state.globalSettings.folderMonitor = {
...state.globalSettings.folderMonitor,
...structuredClone(folderMonitor)
};
}
};
const uploadManager = {
finishAfterActive: () => order.push('finish'),
resumeAfterActive: () => order.push('resume-manager'),
startBatch: () => order.push('startBatch')
};
vm.runInNewContext(mainSource.slice(blockStart, blockEnd), {
configStore,
debugLog: () => {},
dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
folderMonitor,
folderMonitorLifecycleGeneration: 0,
folderMonitorRendererGeneration: 1,
folderMonitorRendererReadyGeneration: null,
folderMonitorStartupReconcile: null,
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
normalizeAutomationSettings: require('../lib/automation-control').normalizeAutomationSettings,
path,
quitTeardownStarted: false,
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, ['resume', 'save:false', 'resume-manager', 'scan:resume:true']);
assert.equal(order.includes('startBatch'), false);
assert.equal(sent.length, 1);
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.order.includes('resume'));
harness.resumeDeferred.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 Promise.all([pause, resume]);
assert.deepEqual(harness.order, ['save:true', 'pause', 'finish', 'resume', 'save:false', 'resume-manager', 'scan:resume:true']);
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, 0);
assert.deepEqual(harness.order, ['resume']);
harness.resumeDeferred.resolve();
await waitForCondition(() => harness.saves.length === 1);
assert.equal(harness.saves[0].paused, false);
harness.saves[0].deferred.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, ['resume', 'save:false', 'resume-manager', 'scan:resume:true', '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.order.includes('resume'));
harness.publishStatus();
assert.equal(harness.sent.length, 0);
harness.resumeDeferred.resolve();
await waitForCondition(() => harness.saves.length === 2);
harness.saves[1].deferred.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('every folder monitor start obeys current persisted pause and active activation reconciles exactly once', async () => {
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
const harness = createAutomationLifecycleHarness(mainSource);
const settings = { folderPath: 'C:\\watch', enabled: true, paused: false, reconcileIntervalMinutes: '1' };
const pausedResult = await harness.handlers.get('folder-monitor:start')(null, settings);
assert.deepEqual({ ...pausedResult }, { error: 'Automatik ist pausiert' });
assert.deepEqual(harness.order, ['configure']);
assert.equal(harness.configuredSettings[0].reconcileIntervalMinutes, 5);
harness.order.length = 0;
harness.setFolderMonitorState({ paused: false, pausedAt: null, reconcileIntervalMinutes: '1' });
const activeResult = await harness.handlers.get('folder-monitor:start')(null, settings);
assert.deepEqual({ ...activeResult }, { ok: true, includesExisting: false });
assert.deepEqual(harness.order, ['start', 'scan:startup:true']);
assert.equal(harness.startedSettings[0].reconcileIntervalMinutes, 5);
const status = harness.handlers.get('automation:get-status')();
assert.equal(status.reconcileIntervalMinutes, 5);
assert.equal(status.startedAt, 100);
assert.equal(status.nextReconcileAt, 200);
});
test('resume keeps pause authoritative until monitor success and restores the previous pause after rejection', async () => {
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
const harness = createAutomationLifecycleHarness(mainSource);
let settled = false;
const resume = harness.handlers.get('automation:resume')();
const outcome = resume.then(value => ({ value }), error => ({ error })).finally(() => { settled = true; });
await flushMicrotasks();
const pendingState = harness.state().globalSettings.folderMonitor;
const savesBeforeResolution = harness.saves.length;
const orderBeforeResolution = [...harness.order];
if (harness.saves[0]) harness.saves[0].deferred.resolve();
await waitForCondition(() => harness.order.includes('resume'));
harness.resumeDeferred.reject(new Error('token=resume-secret'));
for (let attempt = 0; attempt < 50 && !settled; attempt++) {
for (const save of harness.saves) save.deferred.resolve();
await Promise.resolve();
}
const result = await outcome;
assert.equal(savesBeforeResolution, 0);
assert.deepEqual(orderBeforeResolution, ['resume']);
assert.equal(pendingState.paused, true);
assert.equal(pendingState.pausedAt, 1);
assert.equal(result.error, undefined);
assert.equal(result.value.error, 'Automatik konnte nicht fortgesetzt werden');
assert.equal(result.value.paused, true);
assert.equal(result.value.pausedAt, 1);
assert.deepEqual(harness.saves.map(save => save.paused), [true]);
assert.deepEqual(harness.order, ['resume', 'finish', 'stop', 'configure', 'save:true']);
assert.equal(harness.state().globalSettings.folderMonitor.paused, true);
assert.equal(harness.state().globalSettings.folderMonitor.pausedAt, 1);
assert.equal(JSON.stringify(result.value).includes('resume-secret'), false);
});
test('resume reopens the manager before scanning and relatches it when the scan fails', async () => {
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
const harness = createAutomationLifecycleHarness(mainSource);
harness.setScanError(new Error('scan failed'));
const resume = harness.handlers.get('automation:resume')();
harness.resumeDeferred.resolve();
await waitForCondition(() => harness.saves.length === 1);
harness.saves[0].deferred.resolve();
await waitForCondition(() => harness.saves.length === 2);
harness.saves[1].deferred.resolve();
const result = await resume;
assert.deepEqual(harness.order, [
'resume',
'save:false',
'resume-manager',
'scan:resume:true',
'finish',
'stop',
'configure',
'save:true'
]);
assert.equal(result.error, 'Automatik konnte nicht fortgesetzt werden');
assert.equal(result.paused, true);
});
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 () => {');
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]*?await startFolderMonitor\(fm,\s*\{\s*deferStartupReconcile:\s*true\s*\}\)/u);
assert.doesNotMatch(startup, /fm\.paused\s*!==\s*true/u);
assert.doesNotMatch(startup, /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);
});