test: verify automation control center

Cover real folder reconciliation from startup through queue-capacity recovery and reconnect.

Stress the 15,000-job policy, parallel event deduplication, Infinity fallback, restart-persistent pause, the delayed batch gate, and repeated finish behavior without expanding the rendered queue.

Document status, read-only scans, atomic admission, reconciliation, reconnect, pause and current-data processing semantics in English.
This commit is contained in:
Sucukdeluxe
2026-08-26 14:01:10 +02:00
parent 325a20d3fb
commit 362d1dd2cb
4 changed files with 440 additions and 49 deletions
+90 -13
View File
@@ -5,6 +5,7 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const FolderMonitor = require('../lib/folder-monitor');
const { classifyProcessedCandidates, planAtomicAdmissions } = require('../lib/automation-control');
function createWatcherHarness() {
const calls = [];
@@ -210,6 +211,20 @@ test('pause stops watcher and reconciliation until explicit resume', async () =>
assert.equal(monitor.status().paused, false);
});
test('repeated pause emits one status change and keeps reconciliation stopped', async () => {
const { monitor, statusEvents, runInterval, scanCalls } = createReachabilityHarness(true);
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
const statusCountBeforePause = statusEvents.length;
await monitor.pause();
await monitor.pause();
await runInterval();
assert.equal(statusEvents.length, statusCountBeforePause + 1);
assert.equal(statusEvents.at(-1).paused, true);
assert.equal(scanCalls(), 0);
});
test('stop emits an immutable non-running status snapshot', () => {
const { monitor } = createWatcherHarness();
const statuses = [];
@@ -536,26 +551,88 @@ test('interval callback contains unexpected scan rejection', async () => {
assert.equal(statuses.at(-1).error.includes('interval-secret'), false);
});
test('real temporary folder scan survives disconnect and reconnect', async () => {
test('real temporary folder converges through startup interval capacity recovery and one reconnect', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-folder-'));
const detached = `${root}-detached`;
const timers = createManualTimers();
const queuedPaths = new Set();
const admissions = [];
let currentJobCount = 14998;
try {
fs.mkdirSync(path.join(root, 'nested'));
fs.writeFileSync(path.join(root, 'a.mkv'), Buffer.from('a'));
fs.writeFileSync(path.join(root, 'ignored.txt'), Buffer.from('b'));
fs.writeFileSync(path.join(root, 'nested', 'c.mkv'), Buffer.from('c'));
const monitor = new FolderMonitor();
monitor.start({ folderPath: root, recursive: true, extensions: 'mkv', filterMode: 'include', reconcileIntervalMinutes: 5 });
const first = await monitor.scan({ emitFiles: false, trigger: 'test' });
assert.deepEqual(first.files.map((file) => file.name).sort(), ['a.mkv', 'c.mkv']);
const fourJobPath = path.join(root, 'four-jobs.mkv');
const twoJobPath = path.join(root, 'nested', 'two-jobs.mkv');
fs.writeFileSync(fourJobPath, Buffer.from('four'));
fs.writeFileSync(path.join(root, 'ignored.txt'), Buffer.from('ignored'));
fs.writeFileSync(twoJobPath, Buffer.from('two'));
fs.writeFileSync(path.join(root, 'nested', 'excluded.mp4'), Buffer.from('excluded'));
fs.utimesSync(fourJobPath, new Date('2020-01-01T00:00:00.000Z'), new Date('2020-01-01T00:00:00.000Z'));
fs.utimesSync(twoJobPath, new Date('2020-01-02T00:00:00.000Z'), new Date('2020-01-02T00:00:00.000Z'));
const monitor = new FolderMonitor({ watch: createSilentWatch(), ...timers });
monitor.on('new-files', (paths) => {
const descriptors = paths.map(filePath => ({
path: filePath,
name: path.basename(filePath),
mtimeMs: fs.statSync(filePath).mtimeMs
}));
const processed = classifyProcessedCandidates({ candidates: descriptors, queuePaths: [...queuedPaths] });
const unprocessed = new Set(processed.unprocessedPaths);
const candidates = descriptors
.filter(file => unprocessed.has(file.path))
.map(file => ({ ...file, eligibleJobCount: file.name === 'four-jobs.mkv' ? 4 : 2 }));
const plan = planAtomicAdmissions({ candidates, currentJobCount, queueLimitJobs: 15000 });
for (const filePath of plan.admittedPaths) queuedPaths.add(filePath);
currentJobCount += plan.plannedJobs;
admissions.push({
trigger: monitor.status().lastScanTrigger,
admittedPaths: plan.admittedPaths,
deferredPaths: plan.deferredPaths,
currentJobCount
});
});
monitor.start({
folderPath: root,
recursive: true,
extensions: 'mkv',
filterMode: 'include',
skipDuplicates: false,
reconcileIntervalMinutes: 5
});
const first = await monitor.scan({ emitFiles: true, trigger: 'startup' });
assert.deepEqual(first.files.map(file => file.name).sort(), ['four-jobs.mkv', 'two-jobs.mkv']);
assert.equal(first.files.every((file) => Number.isFinite(file.mtimeMs)), true);
assert.deepEqual(admissions[0], {
trigger: 'startup',
admittedPaths: [twoJobPath],
deferredPaths: [fourJobPath],
currentJobCount: 15000
});
currentJobCount = 14996;
await timers.runInterval();
assert.deepEqual(admissions[1], {
trigger: 'interval',
admittedPaths: [fourJobPath],
deferredPaths: [],
currentJobCount: 15000
});
fs.renameSync(root, detached);
const disconnected = await monitor.scan({ emitFiles: true, trigger: 'interval' });
assert.equal(disconnected.reachable, false);
await timers.runInterval();
assert.equal(monitor.status().reachable, false);
assert.equal(admissions.length, 2);
fs.renameSync(detached, root);
const reconnected = await monitor.scan({ emitFiles: true, trigger: 'interval' });
assert.equal(reconnected.reachable, true);
assert.equal(reconnected.reconnected, true);
await timers.runInterval();
assert.equal(monitor.status().reachable, true);
assert.equal(admissions.at(-1).trigger, 'reconnect');
assert.deepEqual(admissions.at(-1).admittedPaths, []);
await timers.runInterval();
assert.equal(admissions.filter(entry => entry.trigger === 'reconnect').length, 1);
assert.equal(admissions.at(-1).trigger, 'interval');
assert.deepEqual([...queuedPaths].sort(), [fourJobPath, twoJobPath].sort());
await monitor.pause();
} finally {
if (fs.existsSync(detached)) fs.renameSync(detached, root);
+321 -31
View File
@@ -53,7 +53,7 @@ test('production startup never forces software compositing', () => {
}
});
test('Windows compositor paints the full hidden surface with an RDP session environment', { skip: process.platform !== 'win32' }, () => {
test('Windows compositor paints the full hidden surface with an RDP session environment', { skip: process.platform !== 'win32' }, (t) => {
const projectRoot = path.join(__dirname, '..');
const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-rdp-compositor-'));
const probePath = path.join(probeRoot, 'probe.cjs');
@@ -795,6 +795,23 @@ contextBridge.exposeInMainWorld('api', {
rebuildJobIndex();
window.api.configureAutomationProbe({ paused: false });
};
configureAtomicState(0);
config.globalSettings.folderMonitor.hosters = ['doodstream.com'];
hosterSettings = {};
const parallelFile = { path: 'C:\\\\watch\\\\parallel.mkv', name: 'parallel.mkv', size: 1, mtimeMs: 1 };
const parallelResults = await Promise.all([
handleFolderMonitorFiles([parallelFile, { ...parallelFile, path: 'c:\\\\WATCH\\\\parallel.mkv' }]),
handleFolderMonitorFiles([{ ...parallelFile }]),
handleFolderMonitorFiles([{ ...parallelFile, path: 'C:\\\\watch\\\\PARALLEL.mkv' }])
]);
const parallelAdmission = {
admittedFiles: parallelResults.flatMap(result => result.admittedFiles.map(file => file.name)),
matchingJobs: queueJobs.filter(job => normalizeAutomationPath(job.file) === normalizeAutomationPath(parallelFile.path)).length,
matchingPaths: [...new Set(queueJobs
.filter(job => normalizeAutomationPath(job.file) === normalizeAutomationPath(parallelFile.path))
.map(job => normalizeAutomationPath(job.file)))],
queuedTelemetry: config.globalSettings.folderMonitor.telemetry.queued
};
const atomicCandidates = [
{ path: 'C:\\\\watch\\\\a.mkv', name: 'a.mkv', size: 1024 * 1024, mtimeMs: 1, filterMatched: true },
{ path: 'C:\\\\watch\\\\b.mkv', name: 'b.mkv', size: 3 * 1024 * 1024, mtimeMs: 2, filterMatched: true }
@@ -829,6 +846,42 @@ contextBridge.exposeInMainWorld('api', {
queueLimited: statusSnapshot.queueLimited,
frozen: Object.isFrozen(statusSnapshot) && Object.isFrozen(statusSnapshot.telemetry)
};
const stressStartedAt = performance.now();
const stressQueue = Array.from({ length: 14996 }, (_, index) => ({
id: 'stress-queue-' + index,
file: 'C:\\\\stress\\\\queued-' + index + '.mkv',
status: 'queued'
}));
const stressCandidateCount = 15000;
const stressCandidates = Array.from({ length: stressCandidateCount }, (_, index) => ({
path: 'C:\\\\stress\\\\candidate-' + String(index).padStart(5, '0') + '.mkv',
mtimeMs: index,
eligibleJobCount: 4
}));
const stressCurrentJobCount = window.AutomationControl.countAutomaticQueueJobs(stressQueue);
const stressPlan = window.AutomationControl.planAtomicAdmissions({
candidates: stressCandidates,
currentJobCount: stressCurrentJobCount,
queueLimitJobs: Number.POSITIVE_INFINITY
});
const stressDomRowsBefore = document.querySelectorAll('#queueBody .queue-row').length;
queueJobs = stressQueue;
config.globalSettings.folderMonitor.queueLimitJobs = Number.POSITIVE_INFINITY;
rebuildJobIndex();
const stressSnapshot = createAutomationStatusSnapshot();
const stressDomRowsAfter = document.querySelectorAll('#queueBody .queue-row').length;
const stress = {
candidateCount: stressCandidateCount,
currentJobCount: stressPlan.currentJobCount,
plannedJobs: stressPlan.plannedJobs,
deferredPaths: stressPlan.deferredPaths.length,
queueLimitJobs: stressSnapshot.queueLimitJobs,
snapshotCurrentJobCount: stressSnapshot.currentJobCount,
snapshotAvailableSlots: stressSnapshot.availableSlots,
domRowsBefore: stressDomRowsBefore,
domRowsAfter: stressDomRowsAfter,
durationMs: performance.now() - stressStartedAt
};
configureAtomicState(0);
selectedFiles = [];
@@ -1701,11 +1754,12 @@ contextBridge.exposeInMainWorld('api', {
const paused = {
uploading,
statuses: Object.fromEntries(queueJobs.map(job => [job.fileName, job.status])),
manualPreviewJobs: queueJobs.filter(job => job.fileName === 'allowed-preview.mkv' && job.status === 'preview').length,
automaticApplied: pausedAutomaticResult.admittedFiles.length,
startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length,
injectCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'inject').length
};
return { dry, manualTest, historyEvidence, pendingDedup, manualHostTransactional, atomic, status, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused };
return { dry, manualTest, historyEvidence, pendingDedup, parallelAdmission, manualHostTransactional, atomic, status, stress, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused };
})()`;
const automationControlCenterScript = `(async () => {
const waitFor = async predicate => {
@@ -2429,6 +2483,12 @@ app.whenReady().then(async () => {
pendingAccepted: 1,
markerPaths: ['c:/pending/overlap.mkv']
});
assert.deepEqual(result.automationPipeline.parallelAdmission, {
admittedFiles: ['parallel.mkv'],
matchingJobs: 1,
matchingPaths: ['c:/watch/parallel.mkv'],
queuedTelemetry: 1
});
assert.deepEqual(result.automationPipeline.manualHostTransactional, {
readFailure: {
result: { ok: false, error: 'Automatische Aufnahme konnte nicht abgeschlossen werden.' },
@@ -2467,6 +2527,17 @@ app.whenReady().then(async () => {
queueLimited: true,
frozen: true
});
assert.equal(result.automationPipeline.stress.candidateCount, 15000);
assert.equal(result.automationPipeline.stress.currentJobCount, 14996);
assert.equal(result.automationPipeline.stress.plannedJobs, 4);
assert.equal(result.automationPipeline.stress.deferredPaths, 14999);
assert.equal(result.automationPipeline.stress.queueLimitJobs, 15000);
assert.equal(result.automationPipeline.stress.snapshotCurrentJobCount, 14996);
assert.equal(result.automationPipeline.stress.snapshotAvailableSlots, 4);
assert.equal(result.automationPipeline.stress.domRowsAfter, result.automationPipeline.stress.domRowsBefore);
assert.ok(result.automationPipeline.stress.domRowsAfter < 15000);
assert.ok(result.automationPipeline.stress.durationMs < 1000, `15,000-job stress took ${result.automationPipeline.stress.durationMs}ms`);
t.diagnostic(`automation-stress-duration-ms=${result.automationPipeline.stress.durationMs.toFixed(3)}`);
assert.deepEqual(result.automationPipeline.persistedQueueExactness, {
restoredSelectedFiles: [],
automationMarkers: 2,
@@ -2764,6 +2835,7 @@ app.whenReady().then(async () => {
'paused-preview.mkv': 'preview',
'allowed-preview.mkv': 'preview'
},
manualPreviewJobs: 1,
automaticApplied: 0,
startCalls: 0,
injectCalls: 0
@@ -3216,59 +3288,244 @@ app.whenReady().then(async () => {
}
});
test('persisted automation pause rejects batch starts through hidden real IPC', { skip: process.platform !== 'win32' }, () => {
test('persisted automation pause survives runtime restart and resumes one reconciliation without starting previews', { skip: process.platform !== 'win32' }, () => {
const projectRoot = path.join(__dirname, '..');
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
const startUploadStart = mainSource.indexOf("ipcMain.handle('start-upload'");
const startUploadEnd = mainSource.indexOf('\n// Logged at batch boundaries', startUploadStart);
const startUploadEnd = mainSource.indexOf("\nipcMain.handle('cancel-upload'", startUploadStart);
const addJobsStart = mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'");
const addJobsEnd = mainSource.indexOf("\nipcMain.handle('finish-after-active'", addJobsStart);
const automationStart = mainSource.indexOf('const automationLifecycleQueue = []');
const automationEnd = mainSource.indexOf("\nipcMain.handle('folder-monitor:select-folder'", automationStart);
const startupStart = mainSource.indexOf(' try {\n const launchConfig = configStore.load();');
const startupEnd = mainSource.indexOf('\n // Auto-start remote server', startupStart);
assert.notEqual(startUploadStart, -1);
assert.notEqual(startUploadEnd, -1);
assert.notEqual(addJobsStart, -1);
assert.notEqual(addJobsEnd, -1);
assert.notEqual(automationStart, -1);
assert.notEqual(automationEnd, -1);
assert.notEqual(startupStart, -1);
assert.notEqual(startupEnd, -1);
const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-pause-ipc-'));
const probePath = path.join(probeRoot, 'probe.cjs');
const preloadPath = path.join(probeRoot, 'preload.cjs');
const rendererPath = path.join(probeRoot, 'renderer.html');
const outputPath = path.join(probeRoot, 'result.json');
const userDataPath = path.join(probeRoot, 'user-data');
const watchPath = path.join(probeRoot, 'watch');
fs.mkdirSync(watchPath);
fs.writeFileSync(path.join(watchPath, 'manual-preview.mkv'), Buffer.from('preview'));
fs.writeFileSync(preloadPath, `
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('automationProbe', {
start: () => ipcRenderer.invoke('start-upload', { files: [], hosters: [], jobs: [] }),
extend: () => ipcRenderer.invoke('add-jobs-to-batch', { jobs: [], sourceCleanupGroups: [] })
contextBridge.exposeInMainWorld('restartProbe', {
status: () => ipcRenderer.invoke('automation:get-status'),
startMonitor: settings => ipcRenderer.invoke('folder-monitor:start', settings),
reconcile: () => ipcRenderer.invoke('folder-monitor:reconcile'),
start: job => ipcRenderer.invoke('start-upload', { files: [], hosters: [], jobs: [job] }),
extend: job => ipcRenderer.invoke('add-jobs-to-batch', { jobs: [job], sourceCleanupGroups: [] }),
resume: () => ipcRenderer.invoke('automation:resume'),
pause: () => ipcRenderer.invoke('automation:pause-after-active'),
counters: () => ipcRenderer.invoke('probe:counters'),
waitCleanup: () => ipcRenderer.invoke('probe:wait-cleanup'),
releaseCleanup: () => ipcRenderer.invoke('probe:release-cleanup')
});
`, 'utf8');
fs.writeFileSync(rendererPath, `<!doctype html><html><body><script>
(async () => {
const start = await window.automationProbe.start();
const extend = await window.automationProbe.extend();
window.__automationPauseResult = { start, extend };
})().catch(error => { window.__automationPauseResult = { error: error.message || String(error) }; });
const captureFailure = async operation => {
try {
return { ok: true, value: await operation() };
} catch (error) {
return { ok: false, error: error.message || String(error) };
}
};
const preview = {
id: 'manual-preview',
file: 'C:\\\\manual\\\\preview.mkv',
fileName: 'preview.mkv',
hoster: 'doodstream.com',
status: 'preview'
};
const initial = await window.restartProbe.status();
const monitorStart = await window.restartProbe.startMonitor({ folderPath: 'C:\\\\blocked' });
const reconcile = await captureFailure(() => window.restartProbe.reconcile());
const start = await window.restartProbe.start(preview);
const extend = await window.restartProbe.extend(preview);
const beforeResume = await window.restartProbe.counters();
const resume = await window.restartProbe.resume();
const afterResume = await window.restartProbe.counters();
const lateJob = {
id: 'late-add',
file: 'C:\\\\manual\\\\late-add.mkv',
fileName: 'late-add.mkv',
hoster: 'doodstream.com',
status: 'preview'
};
const latePromise = window.restartProbe.extend(lateJob);
await window.restartProbe.waitCleanup();
const pause = await window.restartProbe.pause();
await window.restartProbe.releaseCleanup();
const late = await latePromise;
const final = await window.restartProbe.counters();
window.__automationRestartResult = {
initial,
monitorStart,
reconcile,
start,
extend,
previewStatus: preview.status,
beforeResume,
resume,
afterResume,
pause,
late,
final
};
})().catch(error => { window.__automationRestartResult = { error: error.stack || error.message || String(error) }; });
</script></body></html>`, 'utf8');
const productionHandlers = `${mainSource.slice(startUploadStart, startUploadEnd)}\n${mainSource.slice(addJobsStart, addJobsEnd)}`;
const automationHandlers = mainSource.slice(automationStart, automationEnd);
const startupAutomation = mainSource.slice(startupStart, startupEnd);
const probeSource = `
const { app, BrowserWindow, ipcMain } = require('electron');
const { EventEmitter } = require('node:events');
const fs = require('node:fs');
const path = require('node:path');
const ConfigStore = require(${JSON.stringify(path.join(projectRoot, 'lib', 'config-store.js'))});
const FolderMonitor = require(${JSON.stringify(path.join(projectRoot, 'lib', 'folder-monitor.js'))});
const outputPath = process.env.MHU_AUTOMATION_OUTPUT;
const rendererPath = process.env.MHU_AUTOMATION_RENDERER;
const preloadPath = process.env.MHU_AUTOMATION_PRELOAD;
const watchPath = process.env.MHU_AUTOMATION_WATCH;
app.setPath('userData', process.env.MHU_AUTOMATION_USER_DATA);
const configStore = new ConfigStore(app);
let configStore;
let closeFlushRequested = false;
const settingsImportGate = { canStartUpload: () => true };
let uploadManager = { running: false };
const sentEvents = [];
const intervalCallbacks = new Set();
const timeoutCallbacks = new Set();
let watcherStarts = 0;
let watcherCloseCalls = 0;
let walkCalls = 0;
let addJobsCalls = 0;
let startBatchCalls = 0;
let finishCalls = 0;
let cleanupRelease;
let cleanupStartedResolve;
const cleanupStarted = new Promise(resolve => { cleanupStartedResolve = resolve; });
const folderMonitor = new FolderMonitor({
watch: () => {
watcherStarts++;
const watcher = new EventEmitter();
watcher.close = async () => { watcherCloseCalls++; };
return watcher;
},
access: fs.promises.access,
walkFolder: async folderPath => {
walkCalls++;
const filePath = path.join(folderPath, 'manual-preview.mkv');
return [{ path: filePath, name: 'manual-preview.mkv', size: fs.statSync(filePath).size }];
},
stat: fs.promises.stat,
setIntervalFn: callback => {
intervalCallbacks.add(callback);
return callback;
},
clearIntervalFn: callback => intervalCallbacks.delete(callback),
setTimeoutFn: callback => {
timeoutCallbacks.add(callback);
return callback;
},
clearTimeoutFn: callback => timeoutCallbacks.delete(callback)
});
const safeSend = (channel, payload) => sentEvents.push({ channel, payload });
const debugLog = () => {};
const makeAccountPicker = () => ({});
const persistRotation = () => {};
const buildUploadTasksFromJobs = (_config, jobs) => jobs.map(job => ({
jobId: job.id,
file: job.file,
hoster: job.hoster
}));
const appendUploadPlanAudit = async () => {};
const summarizeBatchPlan = ({ jobs = [] } = {}) => ({
fileCount: jobs.length,
destinationCount: jobs.length,
plannedUploadCount: jobs.length
});
let uploadManager = {
running: true,
sourceFileCleanup: {
registerGroups: async () => {
cleanupStartedResolve();
await new Promise(resolve => { cleanupRelease = resolve; });
return {};
},
markSkipped: () => {}
},
addJobs: tasks => {
addJobsCalls++;
return { added: tasks.length, alreadyInBatchJobIds: [] };
},
startBatch: () => {
startBatchCalls++;
return Promise.resolve();
},
finishAfterActive: () => { finishCalls++; }
};
${productionHandlers}
${automationHandlers}
ipcMain.handle('probe:counters', () => ({
watcherStarts,
watcherCloseCalls,
intervalCount: intervalCallbacks.size,
timeoutCount: timeoutCallbacks.size,
walkCalls,
addJobsCalls,
startBatchCalls,
finishCalls,
newFileEvents: sentEvents.filter(event => event.channel === 'folder-monitor:new-files').length,
configPaused: configStore.load().globalSettings.folderMonitor.paused === true,
monitor: folderMonitor.status()
}));
ipcMain.handle('probe:wait-cleanup', async () => {
await cleanupStarted;
return true;
});
ipcMain.handle('probe:release-cleanup', () => {
if (cleanupRelease) cleanupRelease();
return true;
});
app.whenReady().then(async () => {
const current = configStore.load();
await configStore.save({
const initialStore = new ConfigStore(app);
const current = initialStore.load();
await initialStore.save({
globalSettings: {
...current.globalSettings,
folderMonitor: { ...current.globalSettings.folderMonitor, paused: true, pausedAt: Date.now() }
folderMonitor: {
...current.globalSettings.folderMonitor,
enabled: true,
folderPath: watchPath,
recursive: true,
extensions: 'mkv',
filterMode: 'include',
hosters: ['doodstream.com'],
paused: true,
pausedAt: 1787712000000
}
}
});
configStore = new ConfigStore(app);
const restartedSettings = configStore.load().globalSettings.folderMonitor;
${startupAutomation}
const pausedStartup = {
configPaused: restartedSettings.paused === true,
pausedAt: restartedSettings.pausedAt,
watcherStarts,
intervalCount: intervalCallbacks.size,
walkCalls
};
const window = new BrowserWindow({
show: false,
webPreferences: { contextIsolation: true, nodeIntegration: false, preload: preloadPath }
@@ -3276,11 +3533,11 @@ app.whenReady().then(async () => {
await window.loadFile(rendererPath);
let result = null;
for (let attempt = 0; attempt < 200; attempt++) {
result = await window.webContents.executeJavaScript('window.__automationPauseResult || null');
result = await window.webContents.executeJavaScript('window.__automationRestartResult || null');
if (result) break;
await new Promise(resolve => setTimeout(resolve, 10));
}
fs.writeFileSync(outputPath, JSON.stringify({ hidden: window.isVisible() === false, result }), 'utf8');
fs.writeFileSync(outputPath, JSON.stringify({ hidden: window.isVisible() === false, pausedStartup, result }), 'utf8');
window.destroy();
app.exit(0);
}).catch(error => {
@@ -3291,15 +3548,18 @@ app.whenReady().then(async () => {
fs.writeFileSync(probePath, probeSource, 'utf8');
try {
const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe');
const probeEnvironment = {
...process.env,
MHU_AUTOMATION_OUTPUT: outputPath,
MHU_AUTOMATION_RENDERER: rendererPath,
MHU_AUTOMATION_PRELOAD: preloadPath,
MHU_AUTOMATION_USER_DATA: userDataPath,
MHU_AUTOMATION_WATCH: watchPath
};
delete probeEnvironment.RUN_UI_SMOKE;
const execution = spawnSync(electronPath, [probePath, `--user-data-dir=${userDataPath}`], {
cwd: projectRoot,
env: {
...process.env,
MHU_AUTOMATION_OUTPUT: outputPath,
MHU_AUTOMATION_RENDERER: rendererPath,
MHU_AUTOMATION_PRELOAD: preloadPath,
MHU_AUTOMATION_USER_DATA: userDataPath
},
env: probeEnvironment,
encoding: 'utf8',
windowsHide: true,
timeout: 30000
@@ -3307,13 +3567,43 @@ app.whenReady().then(async () => {
assert.equal(execution.status, 0, `${execution.stdout}\n${execution.stderr}`);
const outcome = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
assert.equal(outcome.error, undefined);
assert.deepEqual(outcome, {
hidden: true,
result: {
start: { error: 'Automatik ist pausiert' },
extend: { error: 'Automatik ist pausiert' }
}
assert.equal(outcome.hidden, true);
assert.deepEqual(outcome.pausedStartup, {
configPaused: true,
pausedAt: 1787712000000,
watcherStarts: 0,
intervalCount: 0,
walkCalls: 0
});
assert.equal(outcome.result.error, undefined);
assert.equal(outcome.result.initial.paused, true);
assert.deepEqual(outcome.result.monitorStart, { error: 'Automatik ist pausiert' });
assert.equal(outcome.result.reconcile.ok, false);
assert.deepEqual(outcome.result.start, { error: 'Automatik ist pausiert' });
assert.deepEqual(outcome.result.extend, { error: 'Automatik ist pausiert' });
assert.equal(outcome.result.previewStatus, 'preview');
assert.equal(outcome.result.beforeResume.watcherStarts, 0);
assert.equal(outcome.result.beforeResume.intervalCount, 0);
assert.equal(outcome.result.beforeResume.walkCalls, 0);
assert.equal(outcome.result.resume.paused, false);
assert.equal(outcome.result.afterResume.watcherStarts, 1);
assert.equal(outcome.result.afterResume.intervalCount, 1);
assert.equal(outcome.result.afterResume.walkCalls, 1);
assert.equal(outcome.result.afterResume.newFileEvents, 1);
assert.equal(outcome.result.afterResume.addJobsCalls, 0);
assert.equal(outcome.result.afterResume.startBatchCalls, 0);
assert.equal(outcome.result.afterResume.monitor.lastScanTrigger, 'resume');
assert.deepEqual(outcome.result.late, { error: 'Automatik ist pausiert' });
assert.equal(outcome.result.pause.paused, true);
assert.equal(outcome.result.final.addJobsCalls, 0);
assert.equal(outcome.result.final.startBatchCalls, 0);
assert.equal(outcome.result.final.finishCalls, 1);
assert.equal(outcome.result.final.watcherCloseCalls, 1);
assert.equal(outcome.result.final.intervalCount, 0);
assert.equal(outcome.result.final.walkCalls, 1);
assert.equal(outcome.result.final.configPaused, true);
assert.equal(outcome.result.final.monitor.paused, true);
assert.equal(outcome.result.final.monitor.running, false);
} finally {
fs.rmSync(probeRoot, { recursive: true, force: true });
}
+11 -1
View File
@@ -618,7 +618,13 @@ describe('UploadManager', () => {
'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
});
const settled = new Map();
mgr.on('job-settled', event => settled.set(event.jobId, event.status));
const settledEvents = [];
const batchDoneEvents = [];
mgr.on('job-settled', event => {
settled.set(event.jobId, event.status);
settledEvents.push({ jobId: event.jobId, status: event.status });
});
mgr.on('batch-done', event => batchDoneEvents.push(event));
const batch = mgr.startBatch([
{ jobId: 'active', file: '/test/active.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
{ jobId: 'queued', file: '/test/queued.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
@@ -629,6 +635,7 @@ describe('UploadManager', () => {
}
assert.equal(typeof releaseActive, 'function');
mgr.finishAfterActive();
mgr.finishAfterActive();
releaseActive();
await batch;
@@ -636,6 +643,9 @@ describe('UploadManager', () => {
assert.deepEqual(started, ['/test/active.mp4']);
assert.equal(settled.get('active'), 'done');
assert.equal(settled.get('queued'), 'aborted');
assert.equal(settledEvents.filter(event => event.jobId === 'active').length, 1);
assert.equal(settledEvents.filter(event => event.jobId === 'queued').length, 1);
assert.equal(batchDoneEvents.length, 1);
});
it('_combineSignals propagates abort from either source', () => {