Discover base, daily, session, and fallback upload logs across configured, Desktop, and application-data locations without blocking the Electron main process. Treat in-session completion keys as authoritative automation evidence so removing finished rows cannot cause periodic reconciliation to enqueue and upload the same file again. Add real handler and done-remove-reconcile regressions covering custom extensions, mixed-case log names, fallback paths, stale evidence, and asynchronous log reads.
1138 lines
49 KiB
JavaScript
1138 lines
49 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('read-own-upload-log discovers base daily session and both fallback directories without synchronous reads', async () => {
|
|
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
|
const blockStart = mainSource.indexOf("ipcMain.handle('read-own-upload-log'");
|
|
const blockEnd = mainSource.indexOf("\nipcMain.handle('import-upload-log'", blockStart);
|
|
assert.notEqual(blockStart, -1);
|
|
assert.notEqual(blockEnd, -1);
|
|
const handlers = new Map();
|
|
const configured = 'C:\\configured';
|
|
const desktop = 'C:\\desktop';
|
|
const userData = 'C:\\user-data';
|
|
const entriesByDirectory = new Map([
|
|
[configured, ['custom.txt', 'custom-2026-08-27.txt', '27-08-2026-mdu-session-05-40-111111.txt', 'upload-audit.log']],
|
|
[desktop, ['FILEUPLOADER-2026-08-26.LOG', '26-08-2026-MDU-SESSION-05-40-222222.LOG', 'account-rotation.log']],
|
|
[userData, ['fileuploader-2026-08-25.log', '25-08-2026-mdu-session-05-40-333333.log', 'upload-debug.log']]
|
|
]);
|
|
const fileNames = new Map();
|
|
for (const [directory, names] of entriesByDirectory) {
|
|
for (const name of names) {
|
|
if (name.includes('audit') || name.includes('rotation') || name.includes('debug')) continue;
|
|
fileNames.set(path.win32.join(directory, name), `${name}.mkv`);
|
|
}
|
|
}
|
|
const fakeFs = {
|
|
readdirSync: directory => entriesByDirectory.get(directory) || [],
|
|
existsSync: filePath => fileNames.has(filePath),
|
|
readFileSync: () => { throw new Error('synchronous read forbidden'); },
|
|
promises: {
|
|
readFile: async filePath => require('../lib/upload-log').formatUploadLogLine(
|
|
new Date(2026, 7, 27, 5, 40, 0),
|
|
'voe.sx',
|
|
'https://voe.sx/e/test',
|
|
fileNames.get(filePath)
|
|
)
|
|
}
|
|
};
|
|
vm.runInNewContext(mainSource.slice(blockStart, blockEnd), {
|
|
_resolveUploadLogTarget: () => ({ path: path.win32.join(configured, '27-08-2026-mdu-session-05-40-111111.txt') }),
|
|
app: { getPath: name => name === 'desktop' ? desktop : userData },
|
|
fs: fakeFs,
|
|
getBaseLogFilePath: () => path.win32.join(configured, 'custom.txt'),
|
|
getSafeDesktopDir: () => desktop,
|
|
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
|
|
isManagedUploadLogFileName: require('../lib/log-mode').isManagedUploadLogFileName,
|
|
parseUploadLogLine: require('../lib/upload-log').parseUploadLogLine,
|
|
path: path.win32
|
|
});
|
|
|
|
const entries = await handlers.get('read-own-upload-log')();
|
|
assert.deepEqual([...entries.map(entry => entry.fileName)].sort(), [...fileNames.values()].sort());
|
|
});
|
|
|
|
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);
|
|
});
|