CI / verify (push) Canceled after 0s
Persist successful watched-file uploads in a dedicated fsync-backed ledger before exposing completion to the renderer. Match entries by normalized full path, hoster, size, and modification time so restart reconciliation skips unchanged completed files while changed files and explicit manual retries remain available. Remove restored queue ghosts from the ledger even when history and user upload logging are unavailable. Preserve per-hoster partial completion, capture missing file metadata asynchronously, fail closed on corrupted or unwritable evidence, and keep local persistence failures outside automatic upload retries. Stream managed upload logs with bounded lines, bytes, files, directories, and result counts. Include numbered rotations, reject unconfirmed rows, share concurrent scans through a generation-safe cache, invalidate after successful appends, avoid synchronous configuration and directory reads, and close streams on every path.
1216 lines
54 KiB
JavaScript
1216 lines
54 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('let _uploadLogEvidenceCache');
|
|
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', 'custom.1.txt', '27-08-2026-mdu-session-05-40-111111.txt', 'upload-audit.log']],
|
|
[desktop, ['FILEUPLOADER-2026-08-26.LOG', 'FILEUPLOADER-2026-08-26.2.LOG', '26-08-2026-MDU-SESSION-05-40-222222.LOG', 'account-rotation.log']],
|
|
[userData, ['fileuploader-2026-08-25.log', 'fileuploader.3.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`);
|
|
}
|
|
}
|
|
let streamReads = 0;
|
|
let failedPath = '';
|
|
let scanLabel = '';
|
|
let holdNextRead = false;
|
|
let heldRead = null;
|
|
const fakeFs = {
|
|
readdirSync: () => { throw new Error('synchronous enumeration forbidden'); },
|
|
existsSync: () => { throw new Error('synchronous existence check forbidden'); },
|
|
readFileSync: () => { throw new Error('synchronous read forbidden'); },
|
|
promises: {
|
|
readdir: async () => { throw new Error('materialized directory read forbidden'); },
|
|
opendir: async directory => ({
|
|
async *[Symbol.asyncIterator]() {
|
|
for (const name of entriesByDirectory.get(directory) || []) yield { name };
|
|
}
|
|
}),
|
|
readFile: async () => { throw new Error('full-file read forbidden'); }
|
|
}
|
|
};
|
|
const context = {
|
|
_activeLogPath: path.win32.join(configured, '27-08-2026-mdu-session-05-40-111111.txt'),
|
|
_resolveUploadLogTarget: () => { throw new Error('write-target resolution forbidden'); },
|
|
app: { getPath: name => name === 'desktop' ? desktop : userData },
|
|
fs: fakeFs,
|
|
getBaseLogFilePath: () => path.win32.join(configured, 'custom.txt'),
|
|
getSafeDesktopDir: () => { throw new Error('synchronous desktop probe forbidden'); },
|
|
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
|
|
isManagedUploadLogFileName: require('../lib/log-mode').isManagedUploadLogFileName,
|
|
iterateUploadLogEntries: async function* (filePath) {
|
|
streamReads++;
|
|
const label = scanLabel;
|
|
if (filePath === failedPath) {
|
|
const error = new Error('managed log denied');
|
|
error.code = 'EACCES';
|
|
throw error;
|
|
}
|
|
if (holdNextRead) {
|
|
holdNextRead = false;
|
|
heldRead = createDeferred();
|
|
await heldRead.promise;
|
|
}
|
|
yield require('../lib/upload-log').parseUploadLogLine(require('../lib/upload-log').formatUploadLogLine(
|
|
new Date(2026, 7, 27, 5, 40, 0),
|
|
'voe.sx',
|
|
'https://voe.sx/e/test',
|
|
`${fileNames.get(filePath)}${label}`
|
|
));
|
|
},
|
|
path: path.win32
|
|
};
|
|
vm.runInNewContext(mainSource.slice(blockStart, blockEnd), context);
|
|
|
|
const handler = handlers.get('read-own-upload-log');
|
|
const [first, concurrent] = await Promise.all([handler(), handler()]);
|
|
const cached = await handler();
|
|
const expected = [...fileNames.values()].sort();
|
|
assert.deepEqual([...first.map(entry => entry.fileName)].sort(), expected);
|
|
assert.deepEqual([...concurrent.map(entry => entry.fileName)].sort(), expected);
|
|
assert.deepEqual([...cached.map(entry => entry.fileName)].sort(), expected);
|
|
assert.equal(streamReads, fileNames.size);
|
|
vm.runInNewContext('_invalidateUploadLogEvidenceCache()', context);
|
|
failedPath = [...fileNames.keys()][0];
|
|
await assert.rejects(handler(), /managed log denied/);
|
|
failedPath = '';
|
|
vm.runInNewContext('_invalidateUploadLogEvidenceCache()', context);
|
|
holdNextRead = true;
|
|
scanLabel = '.old';
|
|
const staleScan = handler();
|
|
while (!heldRead) await new Promise(resolve => setImmediate(resolve));
|
|
vm.runInNewContext('_invalidateUploadLogEvidenceCache()', context);
|
|
scanLabel = '.new';
|
|
const freshScan = handler();
|
|
heldRead.resolve();
|
|
const [staleEntries, freshEntries] = await Promise.all([staleScan, freshScan]);
|
|
const cachedFreshEntries = await handler();
|
|
assert.equal(staleEntries.some(entry => entry.fileName.endsWith('.old')), true);
|
|
assert.equal(freshEntries.every(entry => entry.fileName.endsWith('.new')), true);
|
|
assert.equal(cachedFreshEntries.every(entry => entry.fileName.endsWith('.new')), true);
|
|
});
|
|
|
|
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');
|
|
const rendererSource = fs.readFileSync(path.join(projectRoot, 'renderer', 'app.js'), 'utf8');
|
|
|
|
assert.match(mainSource, /ipcMain\.handle\('automation:get-status'/u);
|
|
assert.match(mainSource, /ipcMain\.handle\('automation:get-completions'/u);
|
|
assert.match(mainSource, /ipcMain\.handle\('automation:record-completions'/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, /getAutomationCompletions:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('automation:get-completions'\)/u);
|
|
assert.match(preloadSource, /recordAutomationCompletions:\s*\(entries\)\s*=>\s*ipcRenderer\.invoke\('automation:record-completions',\s*entries\)/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);
|
|
assert.match(mainSource, /async function registerAutomationCompletionJobs/u);
|
|
assert.match(mainSource, /await fs\.promises\.stat\(job\.file\)/u);
|
|
assert.match(mainSource, /await registerAutomationCompletionJobs\(_thisManager,\s*jobs\)/u);
|
|
assert.match(mainSource, /await registerAutomationCompletionJobs\(batchManager,\s*jobs\)/u);
|
|
assert.match(mainSource, /_automationCompletionProgress\.set\(automationCompletionKey\(entry\),\s*data\)/u);
|
|
assert.match(mainSource, /_automationCompletionWriter\.add\(entry\)/u);
|
|
assert.match(mainSource, /await _thisManager\._automationCompletionWriter\?\.flush\(\)/u);
|
|
assert.match(mainSource, /requestUploadFinalization\(summary,\s*!automationCompletionsPersisted\)/u);
|
|
assert.match(rendererSource, /data\.preserveQueue\s*===\s*true\s*\|\|\s*queueJobs\.some/u);
|
|
assert.match(rendererSource, /await window\.api\.recordAutomationCompletions\(completionRows\)/u);
|
|
const serializerStart = rendererSource.indexOf('function serializeUploadJob');
|
|
const serializerEnd = rendererSource.indexOf('\n}', serializerStart);
|
|
const serializer = rendererSource.slice(serializerStart, serializerEnd);
|
|
assert.match(serializer, /automationAdmission:\s*job\.automationAdmission\s*===\s*true/u);
|
|
assert.match(serializer, /automationMtimeMs:\s*job\.automationMtimeMs/u);
|
|
assert.match(serializer, /automationSize:\s*job\.automationSize/u);
|
|
assert.match(serializer, /sourceMtimeMs:\s*job\.sourceMtimeMs/u);
|
|
assert.match(serializer, /sourceSize:\s*job\.sourceSize/u);
|
|
const syncStart = rendererSource.indexOf('function syncSelectedFilesFromQueue');
|
|
const syncEnd = rendererSource.indexOf('\n}', syncStart);
|
|
const syncSelected = rendererSource.slice(syncStart, syncEnd);
|
|
assert.match(syncSelected, /mtimeMs:\s*job\.sourceMtimeMs\s*\?\?\s*job\.automationMtimeMs/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);
|
|
});
|