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:
@@ -139,10 +139,22 @@ Folder monitoring watches for new files while the application is running. Config
|
|||||||
2. Use an include or exclude extension list such as `mp4,mkv`.
|
2. Use an include or exclude extension list such as `mp4,mkv`.
|
||||||
3. Set a write-completion delay so partially copied files are not queued too early.
|
3. Set a write-completion delay so partially copied files are not queued too early.
|
||||||
4. Choose whether duplicate file events are ignored during the current monitoring session.
|
4. Choose whether duplicate file events are ignored during the current monitoring session.
|
||||||
5. Select the destination hosts and decide whether matching jobs start automatically.
|
5. Set the maximum automatic queue size. The default is `15,000` jobs; `0` means unlimited.
|
||||||
6. Enable monitoring and save the settings.
|
6. Select a reconciliation interval of 1, 5, 15, 30, or 60 minutes. The default is 5 minutes.
|
||||||
|
7. Select the destination hosts and decide whether matching jobs start automatically.
|
||||||
|
8. Enable monitoring and save the settings.
|
||||||
|
|
||||||
The watcher ignores files that already exist when monitoring starts. New matching files are collected after they have remained stable for the configured delay. If no hosts are preselected, the application asks for destinations instead of starting a batch automatically.
|
The status card summarizes the complete automation state. **Inactive** means monitoring is disabled or has no usable path. **Active** means the watcher and reconciliation are running. **Paused** means the persistent manual pause is in effect. **Queue limit reached** means matching files are being deferred until capacity becomes available. **Folder disconnected** means the configured folder is currently missing or unreadable and will be checked again. **Error** reports another monitoring failure. The card also shows reachability, current queue use, today's counters, the latest detected file, reconciliation times, and the latest error when one exists.
|
||||||
|
|
||||||
|
**Test folder monitoring** performs a read-only full scan with the current folder, filter, subfolder, destination, size-limit, processed-file, and queue-limit rules. It reports aggregate counts without changing the queue, selected files, telemetry, history, logs, source files, settings, or the one-time existing-file option.
|
||||||
|
|
||||||
|
The automatic queue limit counts preview, queued, server-selection, uploading, and retrying jobs. Manual imports remain available and also consume queue capacity. Admission is file-atomic: all eligible destination jobs for one file are admitted together, or the complete file is deferred. A deferred file is reconsidered by a later reconciliation after capacity becomes available. Setting the limit to `0` disables this automatic backpressure.
|
||||||
|
|
||||||
|
New matching files are collected after they have remained stable for the configured delay. The optional one-time existing-file scan, live watcher events, interval scans, and reconnect scans use the same filtering and admission path. Reconciliation checks the complete folder at the selected interval. A missing folder keeps its configuration; after it becomes reachable again, exactly one reconnect scan runs before normal interval scans continue. If no hosts are preselected, the application asks for destinations instead of starting a batch automatically.
|
||||||
|
|
||||||
|
**Finish and pause** saves the pause before allowing active uploads to finish. While paused, no watcher, reconciliation, queued upload start, automatic start, or active-batch injection can proceed. Manual files can still be imported as preview jobs. The pause survives an application restart. **Resume** explicitly removes it, restarts monitoring, and performs one reconciliation without automatically starting existing preview jobs.
|
||||||
|
|
||||||
|
For automation decisions, **already processed** describes current evidence only: exact paths in the current queue, successful history results, and successful entries in the application's upload log. It is not a permanent file registry. A basename-only match is accepted only when it identifies one candidate unambiguously; same-named files from different folders remain eligible unless exact current evidence identifies them.
|
||||||
|
|
||||||
## History, retention, links, and export
|
## History, retention, links, and export
|
||||||
|
|
||||||
@@ -233,7 +245,9 @@ You can always install manually from the [GitHub releases page](https://github.c
|
|||||||
### Folder monitoring does not add a file
|
### Folder monitoring does not add a file
|
||||||
|
|
||||||
- Confirm that monitoring is enabled and the selected folder still exists.
|
- Confirm that monitoring is enabled and the selected folder still exists.
|
||||||
- Remember that existing files are ignored when the watcher starts.
|
- Check the status card for a persistent pause, a disconnected folder, an error, or a full automatic queue.
|
||||||
|
- Use **Test folder monitoring** to inspect the current read-only classification.
|
||||||
|
- Enable the one-time existing-file option when files already present at activation should be considered.
|
||||||
- Check the include or exclude extension list and the subfolder option.
|
- Check the include or exclude extension list and the subfolder option.
|
||||||
- Wait for the configured write-completion delay.
|
- Wait for the configured write-completion delay.
|
||||||
- Confirm that at least one destination host is selected if automatic start is enabled.
|
- Confirm that at least one destination host is selected if automatic start is enabled.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const fs = require('node:fs');
|
|||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const FolderMonitor = require('../lib/folder-monitor');
|
const FolderMonitor = require('../lib/folder-monitor');
|
||||||
|
const { classifyProcessedCandidates, planAtomicAdmissions } = require('../lib/automation-control');
|
||||||
|
|
||||||
function createWatcherHarness() {
|
function createWatcherHarness() {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
@@ -210,6 +211,20 @@ test('pause stops watcher and reconciliation until explicit resume', async () =>
|
|||||||
assert.equal(monitor.status().paused, false);
|
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', () => {
|
test('stop emits an immutable non-running status snapshot', () => {
|
||||||
const { monitor } = createWatcherHarness();
|
const { monitor } = createWatcherHarness();
|
||||||
const statuses = [];
|
const statuses = [];
|
||||||
@@ -536,26 +551,88 @@ test('interval callback contains unexpected scan rejection', async () => {
|
|||||||
assert.equal(statuses.at(-1).error.includes('interval-secret'), false);
|
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 root = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-folder-'));
|
||||||
const detached = `${root}-detached`;
|
const detached = `${root}-detached`;
|
||||||
|
const timers = createManualTimers();
|
||||||
|
const queuedPaths = new Set();
|
||||||
|
const admissions = [];
|
||||||
|
let currentJobCount = 14998;
|
||||||
try {
|
try {
|
||||||
fs.mkdirSync(path.join(root, 'nested'));
|
fs.mkdirSync(path.join(root, 'nested'));
|
||||||
fs.writeFileSync(path.join(root, 'a.mkv'), Buffer.from('a'));
|
const fourJobPath = path.join(root, 'four-jobs.mkv');
|
||||||
fs.writeFileSync(path.join(root, 'ignored.txt'), Buffer.from('b'));
|
const twoJobPath = path.join(root, 'nested', 'two-jobs.mkv');
|
||||||
fs.writeFileSync(path.join(root, 'nested', 'c.mkv'), Buffer.from('c'));
|
fs.writeFileSync(fourJobPath, Buffer.from('four'));
|
||||||
const monitor = new FolderMonitor();
|
fs.writeFileSync(path.join(root, 'ignored.txt'), Buffer.from('ignored'));
|
||||||
monitor.start({ folderPath: root, recursive: true, extensions: 'mkv', filterMode: 'include', reconcileIntervalMinutes: 5 });
|
fs.writeFileSync(twoJobPath, Buffer.from('two'));
|
||||||
const first = await monitor.scan({ emitFiles: false, trigger: 'test' });
|
fs.writeFileSync(path.join(root, 'nested', 'excluded.mp4'), Buffer.from('excluded'));
|
||||||
assert.deepEqual(first.files.map((file) => file.name).sort(), ['a.mkv', 'c.mkv']);
|
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.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);
|
fs.renameSync(root, detached);
|
||||||
const disconnected = await monitor.scan({ emitFiles: true, trigger: 'interval' });
|
await timers.runInterval();
|
||||||
assert.equal(disconnected.reachable, false);
|
assert.equal(monitor.status().reachable, false);
|
||||||
|
assert.equal(admissions.length, 2);
|
||||||
|
|
||||||
fs.renameSync(detached, root);
|
fs.renameSync(detached, root);
|
||||||
const reconnected = await monitor.scan({ emitFiles: true, trigger: 'interval' });
|
await timers.runInterval();
|
||||||
assert.equal(reconnected.reachable, true);
|
assert.equal(monitor.status().reachable, true);
|
||||||
assert.equal(reconnected.reconnected, 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();
|
await monitor.pause();
|
||||||
} finally {
|
} finally {
|
||||||
if (fs.existsSync(detached)) fs.renameSync(detached, root);
|
if (fs.existsSync(detached)) fs.renameSync(detached, root);
|
||||||
|
|||||||
+321
-31
@@ -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 projectRoot = path.join(__dirname, '..');
|
||||||
const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-rdp-compositor-'));
|
const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-rdp-compositor-'));
|
||||||
const probePath = path.join(probeRoot, 'probe.cjs');
|
const probePath = path.join(probeRoot, 'probe.cjs');
|
||||||
@@ -795,6 +795,23 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
rebuildJobIndex();
|
rebuildJobIndex();
|
||||||
window.api.configureAutomationProbe({ paused: false });
|
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 = [
|
const atomicCandidates = [
|
||||||
{ path: 'C:\\\\watch\\\\a.mkv', name: 'a.mkv', size: 1024 * 1024, mtimeMs: 1, filterMatched: true },
|
{ 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 }
|
{ 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,
|
queueLimited: statusSnapshot.queueLimited,
|
||||||
frozen: Object.isFrozen(statusSnapshot) && Object.isFrozen(statusSnapshot.telemetry)
|
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);
|
configureAtomicState(0);
|
||||||
selectedFiles = [];
|
selectedFiles = [];
|
||||||
@@ -1701,11 +1754,12 @@ contextBridge.exposeInMainWorld('api', {
|
|||||||
const paused = {
|
const paused = {
|
||||||
uploading,
|
uploading,
|
||||||
statuses: Object.fromEntries(queueJobs.map(job => [job.fileName, job.status])),
|
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,
|
automaticApplied: pausedAutomaticResult.admittedFiles.length,
|
||||||
startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length,
|
startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length,
|
||||||
injectCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'inject').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 automationControlCenterScript = `(async () => {
|
||||||
const waitFor = async predicate => {
|
const waitFor = async predicate => {
|
||||||
@@ -2429,6 +2483,12 @@ app.whenReady().then(async () => {
|
|||||||
pendingAccepted: 1,
|
pendingAccepted: 1,
|
||||||
markerPaths: ['c:/pending/overlap.mkv']
|
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, {
|
assert.deepEqual(result.automationPipeline.manualHostTransactional, {
|
||||||
readFailure: {
|
readFailure: {
|
||||||
result: { ok: false, error: 'Automatische Aufnahme konnte nicht abgeschlossen werden.' },
|
result: { ok: false, error: 'Automatische Aufnahme konnte nicht abgeschlossen werden.' },
|
||||||
@@ -2467,6 +2527,17 @@ app.whenReady().then(async () => {
|
|||||||
queueLimited: true,
|
queueLimited: true,
|
||||||
frozen: 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, {
|
assert.deepEqual(result.automationPipeline.persistedQueueExactness, {
|
||||||
restoredSelectedFiles: [],
|
restoredSelectedFiles: [],
|
||||||
automationMarkers: 2,
|
automationMarkers: 2,
|
||||||
@@ -2764,6 +2835,7 @@ app.whenReady().then(async () => {
|
|||||||
'paused-preview.mkv': 'preview',
|
'paused-preview.mkv': 'preview',
|
||||||
'allowed-preview.mkv': 'preview'
|
'allowed-preview.mkv': 'preview'
|
||||||
},
|
},
|
||||||
|
manualPreviewJobs: 1,
|
||||||
automaticApplied: 0,
|
automaticApplied: 0,
|
||||||
startCalls: 0,
|
startCalls: 0,
|
||||||
injectCalls: 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 projectRoot = path.join(__dirname, '..');
|
||||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||||
const startUploadStart = mainSource.indexOf("ipcMain.handle('start-upload'");
|
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 addJobsStart = mainSource.indexOf("ipcMain.handle('add-jobs-to-batch'");
|
||||||
const addJobsEnd = mainSource.indexOf("\nipcMain.handle('finish-after-active'", addJobsStart);
|
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(startUploadStart, -1);
|
||||||
assert.notEqual(startUploadEnd, -1);
|
assert.notEqual(startUploadEnd, -1);
|
||||||
assert.notEqual(addJobsStart, -1);
|
assert.notEqual(addJobsStart, -1);
|
||||||
assert.notEqual(addJobsEnd, -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 probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-pause-ipc-'));
|
||||||
const probePath = path.join(probeRoot, 'probe.cjs');
|
const probePath = path.join(probeRoot, 'probe.cjs');
|
||||||
const preloadPath = path.join(probeRoot, 'preload.cjs');
|
const preloadPath = path.join(probeRoot, 'preload.cjs');
|
||||||
const rendererPath = path.join(probeRoot, 'renderer.html');
|
const rendererPath = path.join(probeRoot, 'renderer.html');
|
||||||
const outputPath = path.join(probeRoot, 'result.json');
|
const outputPath = path.join(probeRoot, 'result.json');
|
||||||
const userDataPath = path.join(probeRoot, 'user-data');
|
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, `
|
fs.writeFileSync(preloadPath, `
|
||||||
const { contextBridge, ipcRenderer } = require('electron');
|
const { contextBridge, ipcRenderer } = require('electron');
|
||||||
contextBridge.exposeInMainWorld('automationProbe', {
|
contextBridge.exposeInMainWorld('restartProbe', {
|
||||||
start: () => ipcRenderer.invoke('start-upload', { files: [], hosters: [], jobs: [] }),
|
status: () => ipcRenderer.invoke('automation:get-status'),
|
||||||
extend: () => ipcRenderer.invoke('add-jobs-to-batch', { jobs: [], sourceCleanupGroups: [] })
|
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');
|
`, 'utf8');
|
||||||
fs.writeFileSync(rendererPath, `<!doctype html><html><body><script>
|
fs.writeFileSync(rendererPath, `<!doctype html><html><body><script>
|
||||||
(async () => {
|
(async () => {
|
||||||
const start = await window.automationProbe.start();
|
const captureFailure = async operation => {
|
||||||
const extend = await window.automationProbe.extend();
|
try {
|
||||||
window.__automationPauseResult = { start, extend };
|
return { ok: true, value: await operation() };
|
||||||
})().catch(error => { window.__automationPauseResult = { error: error.message || String(error) }; });
|
} 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');
|
</script></body></html>`, 'utf8');
|
||||||
const productionHandlers = `${mainSource.slice(startUploadStart, startUploadEnd)}\n${mainSource.slice(addJobsStart, addJobsEnd)}`;
|
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 probeSource = `
|
||||||
const { app, BrowserWindow, ipcMain } = require('electron');
|
const { app, BrowserWindow, ipcMain } = require('electron');
|
||||||
|
const { EventEmitter } = require('node:events');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
const ConfigStore = require(${JSON.stringify(path.join(projectRoot, 'lib', 'config-store.js'))});
|
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 outputPath = process.env.MHU_AUTOMATION_OUTPUT;
|
||||||
const rendererPath = process.env.MHU_AUTOMATION_RENDERER;
|
const rendererPath = process.env.MHU_AUTOMATION_RENDERER;
|
||||||
const preloadPath = process.env.MHU_AUTOMATION_PRELOAD;
|
const preloadPath = process.env.MHU_AUTOMATION_PRELOAD;
|
||||||
|
const watchPath = process.env.MHU_AUTOMATION_WATCH;
|
||||||
app.setPath('userData', process.env.MHU_AUTOMATION_USER_DATA);
|
app.setPath('userData', process.env.MHU_AUTOMATION_USER_DATA);
|
||||||
const configStore = new ConfigStore(app);
|
let configStore;
|
||||||
let closeFlushRequested = false;
|
let closeFlushRequested = false;
|
||||||
const settingsImportGate = { canStartUpload: () => true };
|
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}
|
${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 () => {
|
app.whenReady().then(async () => {
|
||||||
const current = configStore.load();
|
const initialStore = new ConfigStore(app);
|
||||||
await configStore.save({
|
const current = initialStore.load();
|
||||||
|
await initialStore.save({
|
||||||
globalSettings: {
|
globalSettings: {
|
||||||
...current.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({
|
const window = new BrowserWindow({
|
||||||
show: false,
|
show: false,
|
||||||
webPreferences: { contextIsolation: true, nodeIntegration: false, preload: preloadPath }
|
webPreferences: { contextIsolation: true, nodeIntegration: false, preload: preloadPath }
|
||||||
@@ -3276,11 +3533,11 @@ app.whenReady().then(async () => {
|
|||||||
await window.loadFile(rendererPath);
|
await window.loadFile(rendererPath);
|
||||||
let result = null;
|
let result = null;
|
||||||
for (let attempt = 0; attempt < 200; attempt++) {
|
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;
|
if (result) break;
|
||||||
await new Promise(resolve => setTimeout(resolve, 10));
|
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();
|
window.destroy();
|
||||||
app.exit(0);
|
app.exit(0);
|
||||||
}).catch(error => {
|
}).catch(error => {
|
||||||
@@ -3291,15 +3548,18 @@ app.whenReady().then(async () => {
|
|||||||
fs.writeFileSync(probePath, probeSource, 'utf8');
|
fs.writeFileSync(probePath, probeSource, 'utf8');
|
||||||
try {
|
try {
|
||||||
const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe');
|
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}`], {
|
const execution = spawnSync(electronPath, [probePath, `--user-data-dir=${userDataPath}`], {
|
||||||
cwd: projectRoot,
|
cwd: projectRoot,
|
||||||
env: {
|
env: probeEnvironment,
|
||||||
...process.env,
|
|
||||||
MHU_AUTOMATION_OUTPUT: outputPath,
|
|
||||||
MHU_AUTOMATION_RENDERER: rendererPath,
|
|
||||||
MHU_AUTOMATION_PRELOAD: preloadPath,
|
|
||||||
MHU_AUTOMATION_USER_DATA: userDataPath
|
|
||||||
},
|
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
@@ -3307,13 +3567,43 @@ app.whenReady().then(async () => {
|
|||||||
assert.equal(execution.status, 0, `${execution.stdout}\n${execution.stderr}`);
|
assert.equal(execution.status, 0, `${execution.stdout}\n${execution.stderr}`);
|
||||||
const outcome = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
|
const outcome = JSON.parse(fs.readFileSync(outputPath, 'utf8'));
|
||||||
assert.equal(outcome.error, undefined);
|
assert.equal(outcome.error, undefined);
|
||||||
assert.deepEqual(outcome, {
|
assert.equal(outcome.hidden, true);
|
||||||
hidden: true,
|
assert.deepEqual(outcome.pausedStartup, {
|
||||||
result: {
|
configPaused: true,
|
||||||
start: { error: 'Automatik ist pausiert' },
|
pausedAt: 1787712000000,
|
||||||
extend: { error: 'Automatik ist pausiert' }
|
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 {
|
} finally {
|
||||||
fs.rmSync(probeRoot, { recursive: true, force: true });
|
fs.rmSync(probeRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -618,7 +618,13 @@ describe('UploadManager', () => {
|
|||||||
'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 }
|
||||||
});
|
});
|
||||||
const settled = new Map();
|
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([
|
const batch = mgr.startBatch([
|
||||||
{ jobId: 'active', file: '/test/active.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
|
{ jobId: 'active', file: '/test/active.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
|
||||||
{ jobId: 'queued', file: '/test/queued.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');
|
assert.equal(typeof releaseActive, 'function');
|
||||||
|
|
||||||
|
mgr.finishAfterActive();
|
||||||
mgr.finishAfterActive();
|
mgr.finishAfterActive();
|
||||||
releaseActive();
|
releaseActive();
|
||||||
await batch;
|
await batch;
|
||||||
@@ -636,6 +643,9 @@ describe('UploadManager', () => {
|
|||||||
assert.deepEqual(started, ['/test/active.mp4']);
|
assert.deepEqual(started, ['/test/active.mp4']);
|
||||||
assert.equal(settled.get('active'), 'done');
|
assert.equal(settled.get('active'), 'done');
|
||||||
assert.equal(settled.get('queued'), 'aborted');
|
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', () => {
|
it('_combineSignals propagates abort from either source', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user