fix: harden automation reconciliation and pause lifecycle
Keep full folder scans recoverable under duplicate protection and classify every discovered file once before automatic admission. Separate manual previews from automatic capacity limits, serialize renderer intake, normalize automation counters and intervals, and make Main authoritative for runtime timestamps. Enforce persistent pause across startup, import, close recovery, resume failures, and reconciliation while preserving read-only paused scans and exactly one activation reconciliation. Restore failed asynchronous rotation chunks, complete automation localization and unlimited queue accessibility, and tighten hidden integration cleanup coverage.
This commit is contained in:
@@ -37,7 +37,7 @@ test('automation settings normalize invalid limits intervals and pause timestamp
|
||||
pausedAt: 1700
|
||||
}), {
|
||||
queueLimitJobs: 42,
|
||||
reconcileIntervalMinutes: 15,
|
||||
reconcileIntervalMinutes: 5,
|
||||
paused: false,
|
||||
pausedAt: null
|
||||
});
|
||||
@@ -47,6 +47,7 @@ test('automation settings normalize invalid limits intervals and pause timestamp
|
||||
paused: false,
|
||||
pausedAt: null
|
||||
});
|
||||
assert.equal(normalizeAutomationSettings({ reconcileIntervalMinutes: 15 }).reconcileIntervalMinutes, 15);
|
||||
});
|
||||
|
||||
test('automation settings allow only numeric and trimmed string zero to disable the queue limit', () => {
|
||||
@@ -238,6 +239,37 @@ test('telemetry deltas increment counters and update event details immutably', (
|
||||
assert.equal(telemetry.lastError, 'old');
|
||||
});
|
||||
|
||||
test('telemetry and deltas normalize every counter to a finite nonnegative integer', () => {
|
||||
const now = new Date(2026, 7, 26, 13, 14, 15).getTime();
|
||||
const telemetry = rollDailyTelemetry({
|
||||
dateKey: '2026-08-26',
|
||||
detected: Number.POSITIVE_INFINITY,
|
||||
queued: Number.NaN,
|
||||
skipped: -4,
|
||||
deferred: 3.9
|
||||
}, now);
|
||||
assert.deepEqual({
|
||||
detected: telemetry.detected,
|
||||
queued: telemetry.queued,
|
||||
skipped: telemetry.skipped,
|
||||
deferred: telemetry.deferred
|
||||
}, { detected: 0, queued: 0, skipped: 0, deferred: 3 });
|
||||
|
||||
const changed = applyTelemetryDelta(telemetry, {
|
||||
detected: Number.POSITIVE_INFINITY,
|
||||
queued: Number.NaN,
|
||||
skipped: -2,
|
||||
deferred: 2.8
|
||||
}, now);
|
||||
assert.deepEqual({
|
||||
detected: changed.detected,
|
||||
queued: changed.queued,
|
||||
skipped: changed.skipped,
|
||||
deferred: changed.deferred
|
||||
}, { detected: 0, queued: 0, skipped: 0, deferred: 5 });
|
||||
assert.equal(Object.values(changed).filter(value => typeof value === 'number').every(value => Number.isFinite(value)), true);
|
||||
});
|
||||
|
||||
test('pause has higher display priority than disconnect error and queue limit', () => {
|
||||
assert.equal(deriveAutomationState({ paused: true, enabled: true, folderPath: 'C:\\watch', reachable: false, error: 'x', queueLimited: true }), 'paused');
|
||||
});
|
||||
|
||||
+149
-20
@@ -22,9 +22,11 @@ function createWatcherHarness() {
|
||||
function createManualTimers() {
|
||||
const intervals = new Set();
|
||||
const timeouts = new Set();
|
||||
const intervalDelays = [];
|
||||
return {
|
||||
setIntervalFn(callback) {
|
||||
setIntervalFn(callback, delay) {
|
||||
intervals.add(callback);
|
||||
intervalDelays.push(delay);
|
||||
return callback;
|
||||
},
|
||||
clearIntervalFn(callback) {
|
||||
@@ -45,7 +47,8 @@ function createManualTimers() {
|
||||
timeouts.delete(callback);
|
||||
await callback();
|
||||
}
|
||||
}
|
||||
},
|
||||
intervalDelays
|
||||
};
|
||||
}
|
||||
|
||||
@@ -156,7 +159,7 @@ test('initial scan completion is exposed so the one-time option can be persisted
|
||||
assert.equal(completed, 1);
|
||||
});
|
||||
|
||||
test('dry scan returns matching descriptors without emitting new files', async () => {
|
||||
test('dry scan returns every descriptor with a disjoint filter classification without emitting files', async () => {
|
||||
const { monitor, events } = createScanHarness({
|
||||
files: [
|
||||
{ path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1 },
|
||||
@@ -165,10 +168,118 @@ test('dry scan returns matching descriptors without emitting new files', async (
|
||||
});
|
||||
monitor.start({ folderPath: 'C:\\incoming', extensions: 'mkv', filterMode: 'include', recursive: true, reconcileIntervalMinutes: 5 });
|
||||
const result = await monitor.scan({ emitFiles: false, trigger: 'test' });
|
||||
assert.deepEqual(result.files, [{ path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1 }]);
|
||||
assert.deepEqual(result.files, [
|
||||
{ path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1, filterMatched: true, filterReason: 'matched' },
|
||||
{ path: 'C:\\incoming\\b.txt', name: 'b.txt', size: 10, mtimeMs: 2, filterMatched: false, filterReason: 'extension' }
|
||||
]);
|
||||
assert.equal(result.files.length, 2);
|
||||
assert.equal(result.files.filter(file => file.filterMatched).length, 1);
|
||||
assert.equal(events.newFiles.length, 0);
|
||||
});
|
||||
|
||||
test('productive full scans emit every classified descriptor without consuming watcher duplicate reservations', async () => {
|
||||
const { monitor, events } = createScanHarness({
|
||||
files: [
|
||||
{ path: 'C:\\incoming\\a.mkv', name: 'a.mkv', size: 10, mtimeMs: 1 },
|
||||
{ path: 'C:\\incoming\\b.txt', name: 'b.txt', size: 10, mtimeMs: 2 }
|
||||
]
|
||||
});
|
||||
monitor.start({ folderPath: 'C:\\incoming', extensions: 'mkv', filterMode: 'include', recursive: true, skipDuplicates: true, reconcileIntervalMinutes: 5 });
|
||||
|
||||
await monitor.scan({ emitFiles: true, trigger: 'startup' });
|
||||
await monitor.scan({ emitFiles: true, trigger: 'interval' });
|
||||
|
||||
assert.deepEqual(events.newFiles.map(files => files.map(file => ({ name: file.name, filterMatched: file.filterMatched }))), [
|
||||
[{ name: 'a.mkv', filterMatched: true }, { name: 'b.txt', filterMatched: false }],
|
||||
[{ name: 'a.mkv', filterMatched: true }, { name: 'b.txt', filterMatched: false }]
|
||||
]);
|
||||
assert.equal(monitor.status().seenCount, 0);
|
||||
});
|
||||
|
||||
test('reconciliation intervals accept only finite numeric positive-list values', () => {
|
||||
const cases = [
|
||||
[1, 60000],
|
||||
[5, 300000],
|
||||
[15, 900000],
|
||||
[30, 1800000],
|
||||
[60, 3600000],
|
||||
[-1, 300000],
|
||||
[Number.POSITIVE_INFINITY, 300000],
|
||||
['1', 300000],
|
||||
['15', 300000],
|
||||
[2, 300000],
|
||||
[undefined, 300000]
|
||||
];
|
||||
|
||||
for (const [reconcileIntervalMinutes, expectedDelay] of cases) {
|
||||
const timers = createManualTimers();
|
||||
const monitor = new FolderMonitor({ watch: createSilentWatch(), ...timers });
|
||||
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes });
|
||||
assert.equal(timers.intervalDelays[0], expectedDelay, String(reconcileIntervalMinutes));
|
||||
monitor.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test('status owns monitoring start and next reconciliation timestamps across pause resume and intervals', async () => {
|
||||
let now = 1000;
|
||||
const timers = createManualTimers();
|
||||
const monitor = new FolderMonitor({
|
||||
watch: createSilentWatch(),
|
||||
access: async () => {},
|
||||
walkFolder: async () => [],
|
||||
stat: async () => ({ mtimeMs: 1 }),
|
||||
now: () => now,
|
||||
...timers
|
||||
});
|
||||
|
||||
monitor.start({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
|
||||
assert.equal(monitor.status().startedAt, 1000);
|
||||
assert.equal(monitor.status().nextReconcileAt, 301000);
|
||||
now = 2000;
|
||||
await monitor.scan({ emitFiles: true, trigger: 'startup' });
|
||||
assert.equal(monitor.status().startedAt, 1000);
|
||||
assert.equal(monitor.status().nextReconcileAt, 301000);
|
||||
await monitor.pause();
|
||||
assert.equal(monitor.status().startedAt, null);
|
||||
assert.equal(monitor.status().nextReconcileAt, null);
|
||||
now = 4000;
|
||||
await monitor.resume({ folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 });
|
||||
assert.equal(monitor.status().startedAt, 4000);
|
||||
assert.equal(monitor.status().nextReconcileAt, 304000);
|
||||
now = 304000;
|
||||
await timers.runInterval();
|
||||
assert.equal(monitor.status().startedAt, 4000);
|
||||
assert.equal(monitor.status().lastScanAt, 304000);
|
||||
assert.equal(monitor.status().nextReconcileAt, 604000);
|
||||
});
|
||||
|
||||
test('paused configuration keeps watcher and interval closed while allowing a read-only scan', async () => {
|
||||
const timers = createManualTimers();
|
||||
let watcherStarts = 0;
|
||||
const monitor = new FolderMonitor({
|
||||
watch: () => {
|
||||
watcherStarts++;
|
||||
return createSilentWatch()();
|
||||
},
|
||||
access: async () => {},
|
||||
walkFolder: async () => [{ path: 'C:\\watch\\a.mkv', name: 'a.mkv', size: 1 }],
|
||||
stat: async () => ({ mtimeMs: 1 }),
|
||||
...timers
|
||||
});
|
||||
|
||||
const result = monitor.configure({ folderPath: 'C:\\watch', extensions: 'mkv', reconcileIntervalMinutes: 5 });
|
||||
const scan = await monitor.scan({ emitFiles: false, trigger: 'test' });
|
||||
const productive = await monitor.scan({ emitFiles: true, trigger: 'manual' });
|
||||
|
||||
assert.deepEqual(result, { includesExisting: false, paused: true });
|
||||
assert.equal(watcherStarts, 0);
|
||||
assert.equal(timers.intervalDelays.length, 0);
|
||||
assert.equal(monitor.status().paused, true);
|
||||
assert.equal(monitor.status().folderPath, 'C:\\watch');
|
||||
assert.deepEqual(scan.files.map(file => file.name), ['a.mkv']);
|
||||
assert.equal(productive.cancelled, true);
|
||||
});
|
||||
|
||||
test('overlapping reconcile requests serialize and collapse to one follow-up scan', async () => {
|
||||
const { monitor, releaseFirstScan, scanCalls } = createDeferredScanHarness();
|
||||
const first = monitor.scan({ emitFiles: true, trigger: 'interval' });
|
||||
@@ -211,6 +322,20 @@ test('pause stops watcher and reconciliation until explicit resume', async () =>
|
||||
assert.equal(monitor.status().paused, false);
|
||||
});
|
||||
|
||||
test('resume can activate watcher and interval before a separately gated reconciliation', async () => {
|
||||
const { monitor, scanCalls } = createReachabilityHarness(true);
|
||||
const settings = { folderPath: 'C:\\watch', reconcileIntervalMinutes: 5 };
|
||||
monitor.start(settings);
|
||||
await monitor.pause();
|
||||
|
||||
const result = await monitor.resume(settings, { reconcile: false });
|
||||
|
||||
assert.equal(scanCalls(), 0);
|
||||
assert.equal(result.reconciled, false);
|
||||
assert.equal(monitor.status().running, true);
|
||||
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 });
|
||||
@@ -331,7 +456,7 @@ test('late watcher add callbacks are ignored after pause', async () => {
|
||||
assert.deepEqual(newFiles, []);
|
||||
});
|
||||
|
||||
test('resume preserves session duplicate history', async () => {
|
||||
test('resume full scan redelivers candidates while watcher duplicate history remains reserved', async () => {
|
||||
const timers = createManualTimers();
|
||||
const watchers = [];
|
||||
const newFiles = [];
|
||||
@@ -356,7 +481,10 @@ test('resume preserves session duplicate history', async () => {
|
||||
await monitor.resume(settings);
|
||||
watchers[1].emit('add', 'C:\\watch\\same.mkv');
|
||||
await timers.runTimeouts();
|
||||
assert.deepEqual(newFiles, [['C:\\watch\\same.mkv']]);
|
||||
assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [
|
||||
['C:\\watch\\same.mkv'],
|
||||
['C:\\watch\\same.mkv']
|
||||
]);
|
||||
assert.equal(monitor.status().seenCount, 1);
|
||||
});
|
||||
|
||||
@@ -385,11 +513,11 @@ test('watcher add paused before batch timeout is emitted exactly once by resume
|
||||
await monitor.pause();
|
||||
assert.deepEqual(newFiles, []);
|
||||
await monitor.resume(settings);
|
||||
assert.deepEqual(newFiles, [[filePath]]);
|
||||
assert.equal(monitor.status().seenCount, 1);
|
||||
assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [[filePath]]);
|
||||
assert.equal(monitor.status().seenCount, 0);
|
||||
});
|
||||
|
||||
test('synchronous pause during successful batch emission does not duplicate on resume', async () => {
|
||||
test('synchronous pause during watcher delivery keeps its reservation while resume scan redelivers the candidate', async () => {
|
||||
const timers = createManualTimers();
|
||||
const watchers = [];
|
||||
const newFiles = [];
|
||||
@@ -418,7 +546,7 @@ test('synchronous pause during successful batch emission does not duplicate on r
|
||||
await pausePromise;
|
||||
assert.deepEqual(newFiles, [[filePath]]);
|
||||
await monitor.resume(settings);
|
||||
assert.deepEqual(newFiles, [[filePath]]);
|
||||
assert.deepEqual(newFiles.map(files => files.map(file => typeof file === 'string' ? file : file.path)), [[filePath], [filePath]]);
|
||||
assert.equal(monitor.status().seenCount, 1);
|
||||
});
|
||||
|
||||
@@ -452,8 +580,9 @@ test('pause rollback never deletes historical seen state from a dedupe-off batch
|
||||
watchers[1].emit('add', filePath);
|
||||
assert.deepEqual(newFiles, [[filePath]]);
|
||||
await monitor.pause();
|
||||
discoverExisting = true;
|
||||
await monitor.resume(dedupeOn);
|
||||
watchers[2].emit('add', filePath);
|
||||
await timers.runTimeouts();
|
||||
assert.deepEqual(newFiles, [[filePath]]);
|
||||
assert.equal(monitor.status().seenCount, 1);
|
||||
});
|
||||
@@ -551,7 +680,7 @@ test('interval callback contains unexpected scan rejection', async () => {
|
||||
assert.equal(statuses.at(-1).error.includes('interval-secret'), false);
|
||||
});
|
||||
|
||||
test('real temporary folder converges through startup interval capacity recovery and one reconnect', async () => {
|
||||
test('real temporary folder recovers a file-atomic deferral with default duplicate protection', async () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-automation-folder-'));
|
||||
const detached = `${root}-detached`;
|
||||
const timers = createManualTimers();
|
||||
@@ -569,12 +698,12 @@ test('real temporary folder converges through startup interval capacity recovery
|
||||
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
|
||||
}));
|
||||
monitor.on('new-files', (files) => {
|
||||
const descriptors = files.map(file => typeof file === 'string' ? {
|
||||
path: file,
|
||||
name: path.basename(file),
|
||||
mtimeMs: fs.statSync(file).mtimeMs
|
||||
} : file).filter(file => file.filterMatched !== false);
|
||||
const processed = classifyProcessedCandidates({ candidates: descriptors, queuePaths: [...queuedPaths] });
|
||||
const unprocessed = new Set(processed.unprocessedPaths);
|
||||
const candidates = descriptors
|
||||
@@ -595,12 +724,12 @@ test('real temporary folder converges through startup interval capacity recovery
|
||||
recursive: true,
|
||||
extensions: 'mkv',
|
||||
filterMode: 'include',
|
||||
skipDuplicates: false,
|
||||
skipDuplicates: true,
|
||||
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.deepEqual(first.files.filter(file => file.filterMatched).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',
|
||||
|
||||
@@ -107,6 +107,13 @@ test('translates every automation control center label in both directions', () =
|
||||
['Automatik konnte nicht pausiert werden.', 'Automation could not be paused.'],
|
||||
['Automatik konnte nicht fortgesetzt werden.', 'Automation could not be resumed.'],
|
||||
['Ordnerüberwachung konnte nicht pausiert werden', 'Folder monitoring could not be paused'],
|
||||
['Automatik ist pausiert', 'Automation is paused'],
|
||||
['Telemetrie konnte nicht gespeichert werden.', 'Telemetry could not be saved.'],
|
||||
['Automatik konnte nicht fortgesetzt werden', 'Automation could not be resumed'],
|
||||
['Upload konnte nicht gestartet werden.', 'Upload could not be started.'],
|
||||
['Upload wurde nicht bestätigt.', 'Upload was not confirmed.'],
|
||||
['Upload-Wiederherstellung konnte nicht vorbereitet werden', 'Upload recovery could not be prepared'],
|
||||
['Upload-Start wurde verworfen', 'Upload start was discarded'],
|
||||
['Ordnerüberwachung fehlgeschlagen', 'Folder monitoring failed'],
|
||||
['Ordner nicht erreichbar', 'Folder unavailable'],
|
||||
['Ordnerscan fehlgeschlagen', 'Folder scan failed'],
|
||||
|
||||
@@ -45,6 +45,8 @@ function createAutomationLifecycleHarness(mainSource) {
|
||||
const saves = [];
|
||||
const pauseDeferred = createDeferred();
|
||||
const resumeDeferred = createDeferred();
|
||||
const configuredSettings = [];
|
||||
const startedSettings = [];
|
||||
let publishStatus = () => {};
|
||||
let state = {
|
||||
globalSettings: {
|
||||
@@ -60,9 +62,22 @@ function createAutomationLifecycleHarness(mainSource) {
|
||||
};
|
||||
const folderMonitor = new (require('node:events').EventEmitter)();
|
||||
folderMonitor.running = false;
|
||||
folderMonitor.status = () => ({ running: folderMonitor.running, reachable: true });
|
||||
folderMonitor.status = () => ({ running: folderMonitor.running, reachable: true, startedAt: 100, nextReconcileAt: 200 });
|
||||
folderMonitor.stop = () => { folderMonitor.running = false; order.push('stop'); };
|
||||
folderMonitor.start = () => { folderMonitor.running = true; order.push('start'); publishStatus(); return {}; };
|
||||
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();
|
||||
@@ -80,7 +95,10 @@ function createAutomationLifecycleHarness(mainSource) {
|
||||
return { reachable: true };
|
||||
});
|
||||
};
|
||||
folderMonitor.scan = async () => ({ reachable: true });
|
||||
folderMonitor.scan = async options => {
|
||||
order.push(`scan:${options.trigger}:${options.emitFiles}`);
|
||||
return { reachable: true, trigger: options.trigger };
|
||||
};
|
||||
const configStore = {
|
||||
load: () => structuredClone(state),
|
||||
save: config => {
|
||||
@@ -101,6 +119,7 @@ function createAutomationLifecycleHarness(mainSource) {
|
||||
dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
|
||||
folderMonitor,
|
||||
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
|
||||
normalizeAutomationSettings: require('../lib/automation-control').normalizeAutomationSettings,
|
||||
path,
|
||||
safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; },
|
||||
uploadManager
|
||||
@@ -109,11 +128,17 @@ function createAutomationLifecycleHarness(mainSource) {
|
||||
publishStatus = () => context.publishAutomationStatus();
|
||||
return {
|
||||
handlers,
|
||||
configuredSettings,
|
||||
context,
|
||||
order,
|
||||
pauseDeferred,
|
||||
resumeDeferred,
|
||||
saves,
|
||||
sent,
|
||||
setFolderMonitorState(value) {
|
||||
state.globalSettings.folderMonitor = { ...state.globalSettings.folderMonitor, ...value };
|
||||
},
|
||||
startedSettings,
|
||||
state: () => structuredClone(state),
|
||||
publishStatus
|
||||
};
|
||||
@@ -422,6 +447,7 @@ test('automation pause save commits before lifecycle effects and save failure is
|
||||
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 }; };
|
||||
@@ -458,6 +484,7 @@ test('automation pause save commits before lifecycle effects and save failure is
|
||||
dialog: { showOpenDialog: async () => ({ canceled: true, filePaths: [] }) },
|
||||
folderMonitor,
|
||||
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
|
||||
normalizeAutomationSettings: require('../lib/automation-control').normalizeAutomationSettings,
|
||||
path,
|
||||
safeSend: (channel, snapshot) => { sent.push([channel, snapshot]); return true; },
|
||||
uploadManager
|
||||
@@ -486,7 +513,7 @@ test('automation pause save commits before lifecycle effects and save failure is
|
||||
state.globalSettings.folderMonitor.pausedAt = 1;
|
||||
folderMonitor.running = false;
|
||||
await handlers.get('automation:resume')();
|
||||
assert.deepEqual(order, ['save:false', 'resume']);
|
||||
assert.deepEqual(order, ['resume', 'save:false', 'scan:resume:true']);
|
||||
assert.equal(order.includes('startBatch'), false);
|
||||
assert.equal(sent.length, 1);
|
||||
assert.equal(sent[0][1].paused, false);
|
||||
@@ -504,15 +531,15 @@ test('automation lifecycle serializes pause then resume so the newer intent wins
|
||||
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 flushMicrotasks();
|
||||
harness.resumeDeferred.resolve();
|
||||
await Promise.all([pause, resume]);
|
||||
|
||||
assert.deepEqual(harness.order, ['save:true', 'pause', 'finish', 'save:false', 'resume']);
|
||||
assert.deepEqual(harness.order, ['save:true', 'pause', 'finish', 'resume', 'save:false', '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);
|
||||
@@ -525,11 +552,12 @@ test('automation lifecycle serializes resume then pause so the newer intent wins
|
||||
const resume = harness.handlers.get('automation:resume')();
|
||||
const pause = harness.handlers.get('automation:pause-after-active')();
|
||||
|
||||
assert.equal(harness.saves.length, 1);
|
||||
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 flushMicrotasks();
|
||||
harness.resumeDeferred.resolve();
|
||||
await waitForCondition(() => harness.saves.length === 2);
|
||||
assert.equal(harness.saves.length, 2);
|
||||
assert.equal(harness.saves[1].paused, true);
|
||||
@@ -538,7 +566,7 @@ test('automation lifecycle serializes resume then pause so the newer intent wins
|
||||
harness.pauseDeferred.resolve();
|
||||
await Promise.all([resume, pause]);
|
||||
|
||||
assert.deepEqual(harness.order, ['save:false', 'resume', 'save:true', 'pause', 'finish']);
|
||||
assert.deepEqual(harness.order, ['resume', 'save:false', '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);
|
||||
@@ -553,14 +581,14 @@ test('automation status suppression remains active until the serialized operatio
|
||||
harness.saves[0].deferred.resolve();
|
||||
await waitForCondition(() => harness.order.includes('pause'));
|
||||
harness.pauseDeferred.resolve();
|
||||
await waitForCondition(() => harness.saves.length === 2);
|
||||
await waitForCondition(() => harness.order.includes('resume'));
|
||||
harness.publishStatus();
|
||||
|
||||
assert.equal(harness.sent.length, 0);
|
||||
|
||||
harness.saves[1].deferred.resolve();
|
||||
await waitForCondition(() => harness.order.includes('resume'));
|
||||
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);
|
||||
@@ -585,6 +613,62 @@ test('automation pause rejection still finishes active uploads and returns a san
|
||||
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', '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('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');
|
||||
@@ -728,7 +812,8 @@ test('startup keeps a missing configured folder disconnected without disabling a
|
||||
assert.notEqual(startupEnd, -1);
|
||||
const startup = mainSource.slice(startupStart, startupEnd);
|
||||
|
||||
assert.match(startup, /fm\s*&&\s*fm\.enabled\s*&&\s*fm\.folderPath\s*&&\s*fm\.paused\s*!==\s*true[\s\S]*?startFolderMonitor\(fm\)/u);
|
||||
assert.match(startup, /startFolderMonitor\(fm\);[\s\S]*?!fs\.existsSync\(fm\.folderPath\)[\s\S]*?folderMonitor\.scan\(\{\s*emitFiles:\s*true,\s*trigger:\s*'startup'\s*\}\)/u);
|
||||
assert.match(startup, /fm\s*&&\s*fm\.enabled\s*&&\s*fm\.folderPath[\s\S]*?await startFolderMonitor\(fm\)/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);
|
||||
});
|
||||
|
||||
+183
-18
@@ -81,6 +81,9 @@ let automationProbe = {
|
||||
saveSettingsError: '',
|
||||
testScanError: '',
|
||||
deferTestScan: false,
|
||||
deferInspect: false,
|
||||
activeInspections: 0,
|
||||
maxConcurrentInspections: 0,
|
||||
dryScan: { files: [], reachable: true, trigger: 'test' },
|
||||
readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 },
|
||||
mutationCalls: [],
|
||||
@@ -184,6 +187,9 @@ contextBridge.exposeInMainWorld('api', {
|
||||
saveSettingsError: String(value.saveSettingsError || ''),
|
||||
testScanError: String(value.testScanError || ''),
|
||||
deferTestScan: value.deferTestScan === true,
|
||||
deferInspect: value.deferInspect === true,
|
||||
activeInspections: 0,
|
||||
maxConcurrentInspections: 0,
|
||||
dryScan: value.dryScan || { files: [], reachable: true, trigger: 'test' },
|
||||
readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 },
|
||||
mutationCalls: [],
|
||||
@@ -194,13 +200,17 @@ contextBridge.exposeInMainWorld('api', {
|
||||
getAutomationProbeState() {
|
||||
return {
|
||||
readCalls: { ...automationProbe.readCalls },
|
||||
activeInspections: automationProbe.activeInspections,
|
||||
maxConcurrentInspections: automationProbe.maxConcurrentInspections,
|
||||
mutationCalls: automationProbe.mutationCalls.map(value => [...value]),
|
||||
logs: [...automationProbe.logs],
|
||||
savedSettings: automationProbe.savedSettings.map(value => JSON.parse(JSON.stringify(value)))
|
||||
};
|
||||
},
|
||||
inspectImportFiles(entries, existingPaths) {
|
||||
async inspectImportFiles(entries, existingPaths) {
|
||||
automationProbe.readCalls.inspect++;
|
||||
automationProbe.activeInspections++;
|
||||
automationProbe.maxConcurrentInspections = Math.max(automationProbe.maxConcurrentInspections, automationProbe.activeInspections);
|
||||
const candidates = Array.isArray(entries) ? entries : [];
|
||||
const normalize = value => String(value || '').replace(/\\\\/g, '/').toLowerCase();
|
||||
const seen = new Set((Array.isArray(existingPaths) ? existingPaths : []).map(normalize));
|
||||
@@ -216,7 +226,9 @@ contextBridge.exposeInMainWorld('api', {
|
||||
}
|
||||
const unavailable = unique.filter(entry => entry?.unavailable).map(entry => ({ ...entry, reason: 'unreadable' }));
|
||||
const accepted = unique.filter(entry => !entry?.unavailable).map(entry => ({ ...entry }));
|
||||
return Promise.resolve({
|
||||
if (automationProbe.deferInspect) await new Promise(resolve => setTimeout(resolve, 5));
|
||||
automationProbe.activeInspections--;
|
||||
return {
|
||||
candidateCount: candidates.length,
|
||||
duplicateCount: duplicates.length,
|
||||
unavailableCount: unavailable.length,
|
||||
@@ -224,7 +236,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
accepted,
|
||||
duplicates,
|
||||
unavailable
|
||||
});
|
||||
};
|
||||
},
|
||||
getHistory() {
|
||||
automationProbe.readCalls.history++;
|
||||
@@ -805,13 +817,76 @@ contextBridge.exposeInMainWorld('api', {
|
||||
handleFolderMonitorFiles([{ ...parallelFile, path: 'C:\\\\watch\\\\PARALLEL.mkv' }])
|
||||
]);
|
||||
const parallelAdmission = {
|
||||
admittedFiles: parallelResults.flatMap(result => result.admittedFiles.map(file => file.name)),
|
||||
admittedFiles: [...new Set(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
|
||||
};
|
||||
configureAtomicState(18);
|
||||
config.globalSettings.folderMonitor.queueLimitJobs = 20;
|
||||
config.globalSettings.folderMonitor.hosters = ['doodstream.com'];
|
||||
config.globalSettings.folderMonitor.autoStart = false;
|
||||
hosterSettings = {};
|
||||
const distinctFiles = Array.from({ length: 20 }, (_, index) => ({
|
||||
path: 'C:\\\\distinct\\\\distinct-' + String(index).padStart(3, '0') + '.mkv',
|
||||
name: 'distinct-' + String(index).padStart(3, '0') + '.mkv',
|
||||
size: 1,
|
||||
mtimeMs: index
|
||||
}));
|
||||
window.api.configureAutomationProbe({ paused: false, deferInspect: true });
|
||||
await Promise.all(distinctFiles.map(file => handleFolderMonitorFiles([file])));
|
||||
const distinctProbe = await window.api.getAutomationProbeState();
|
||||
const distinctTelemetry = config.globalSettings.folderMonitor.telemetry;
|
||||
const distinctParallel = {
|
||||
inspectCalls: distinctProbe.readCalls.inspect,
|
||||
maxConcurrentInspections: distinctProbe.maxConcurrentInspections,
|
||||
capacityJobs: window.AutomationControl.countAutomaticQueueJobs(queueJobs),
|
||||
distinctJobs: queueJobs.filter(job => job.file.startsWith('C:\\\\distinct\\\\')).length,
|
||||
detected: distinctTelemetry.detected,
|
||||
queued: distinctTelemetry.queued,
|
||||
deferred: distinctTelemetry.deferred,
|
||||
lastDetectedName: distinctTelemetry.lastDetectedName
|
||||
};
|
||||
configureAtomicState(14999);
|
||||
config.globalSettings.folderMonitor.hosters = ['doodstream.com'];
|
||||
config.globalSettings.folderMonitor.autoStart = false;
|
||||
hosterSettings = { 'doodstream.com': { maxSizeMb: 2 } };
|
||||
const reasonCandidates = [
|
||||
{ path: 'C:\\\\reasons\\\\admitted.mkv', name: 'admitted.mkv', size: 1, mtimeMs: 1, filterMatched: true },
|
||||
{ path: 'C:\\\\reasons\\\\deferred.mkv', name: 'deferred.mkv', size: 1, mtimeMs: 2, filterMatched: true },
|
||||
{ path: 'C:\\\\reasons\\\\filtered.txt', name: 'filtered.txt', size: 1, mtimeMs: 3, filterMatched: false },
|
||||
{ path: 'C:\\\\reasons\\\\processed.mkv', name: 'processed.mkv', size: 1, mtimeMs: 4, filterMatched: true },
|
||||
{ path: 'C:\\\\reasons\\\\inspection-duplicate.mkv', name: 'inspection-duplicate.mkv', size: 1, mtimeMs: 5, filterMatched: true },
|
||||
{ path: 'C:\\\\reasons\\\\unavailable.mkv', name: 'unavailable.mkv', size: 1, mtimeMs: 6, filterMatched: true, unavailable: true },
|
||||
{ path: 'C:\\\\reasons\\\\size-limited.mkv', name: 'size-limited.mkv', size: 3 * 1024 * 1024, mtimeMs: 7, filterMatched: true }
|
||||
];
|
||||
_pendingFiles = [reasonCandidates[4]];
|
||||
window.api.configureAutomationProbe({
|
||||
paused: false,
|
||||
history: [{ files: [{ path: reasonCandidates[3].path, name: reasonCandidates[3].name, results: [{ hoster: 'doodstream.com', status: 'done' }] }] }]
|
||||
});
|
||||
const reasonEvaluation = await evaluateAutomationCandidates(reasonCandidates, { dryRun: false, trigger: 'watcher' });
|
||||
const reasonResult = await applyAutomationEvaluation(reasonEvaluation);
|
||||
const reasonCounts = {};
|
||||
for (const entry of reasonEvaluation.classifications || []) reasonCounts[entry.reason] = (reasonCounts[entry.reason] || 0) + 1;
|
||||
const disjointClassification = {
|
||||
summary: reasonEvaluation.summary,
|
||||
reasonCounts,
|
||||
classificationCount: reasonEvaluation.classifications?.length || 0,
|
||||
telemetryDelta: reasonEvaluation.telemetryDelta,
|
||||
applied: {
|
||||
admitted: reasonResult.admittedFiles.map(file => file.name),
|
||||
deferred: reasonResult.deferredFiles.map(file => file.name)
|
||||
},
|
||||
telemetry: {
|
||||
detected: config.globalSettings.folderMonitor.telemetry.detected,
|
||||
queued: config.globalSettings.folderMonitor.telemetry.queued,
|
||||
skipped: config.globalSettings.folderMonitor.telemetry.skipped,
|
||||
deferred: config.globalSettings.folderMonitor.telemetry.deferred
|
||||
}
|
||||
};
|
||||
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 }
|
||||
@@ -833,6 +908,8 @@ contextBridge.exposeInMainWorld('api', {
|
||||
queued: config.globalSettings.folderMonitor.telemetry.queued,
|
||||
currentJobCount: window.AutomationControl.countAutomaticQueueJobs(queueJobs),
|
||||
unplannedJobs: queueJobs.filter(job => job.fileName === 'unplanned.mkv').length,
|
||||
manualJobHosters: queueJobs.filter(job => job.fileName === 'unplanned.mkv').map(job => job.hoster),
|
||||
automationJobHosters: queueJobs.filter(job => job.file === atomicCandidates[1].path).map(job => job.hoster).sort(),
|
||||
selectedHostersAfterApply,
|
||||
manualSelectionFilesAfterApply,
|
||||
plannedHostsBeforeRebuild,
|
||||
@@ -1802,7 +1879,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
startCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'start').length,
|
||||
injectCalls: pausedProbe.mutationCalls.filter(call => call[0] === 'inject').length
|
||||
};
|
||||
return { dry, manualTest, historyEvidence, pendingDedup, parallelAdmission, manualHostTransactional, atomic, status, zeroAdmission, stress, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused };
|
||||
return { dry, manualTest, historyEvidence, pendingDedup, parallelAdmission, distinctParallel, disjointClassification, manualHostTransactional, atomic, status, zeroAdmission, stress, persistedQueueExactness, stale, replannedEligibility, mainPauseResponses, cleanupRollback, crossPathCleanupRollback, partialAddOutcomes, collisionResolver, collisionAdmission, pauseBetweenApplyAndStart, startAcceptance, fulfilledFeedback, injectionOutcomes, paused };
|
||||
})()`;
|
||||
const automationControlCenterScript = `(async () => {
|
||||
const waitFor = async predicate => {
|
||||
@@ -1820,6 +1897,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
folderPath: 'C:\\\\watch',
|
||||
lastScanAt: fixedNow - 60000,
|
||||
startedAt: fixedNow - 3600000,
|
||||
nextReconcileAt: fixedNow + 123456,
|
||||
error: ''
|
||||
};
|
||||
setUiLanguage('de');
|
||||
@@ -1882,8 +1960,17 @@ contextBridge.exposeInMainWorld('api', {
|
||||
queueLimitMin: queueLimitInput?.min || null,
|
||||
intervalDefault: intervalInput?.value || null,
|
||||
intervalOptions: [...(intervalInput?.options || [])].map(option => option.value),
|
||||
snapshotFrozen: Object.isFrozen(createAutomationStatusSnapshot()) && Object.isFrozen(createAutomationStatusSnapshot().telemetry)
|
||||
snapshotFrozen: Object.isFrozen(createAutomationStatusSnapshot()) && Object.isFrozen(createAutomationStatusSnapshot().telemetry),
|
||||
startedAt: createAutomationStatusSnapshot().startedAt,
|
||||
nextReconcileAt: createAutomationStatusSnapshot().nextReconcileAt
|
||||
};
|
||||
applyAutomationRuntimeStatus({ ...runtimeStatus, startedAt: null, nextReconcileAt: null });
|
||||
const missingMainTimes = createAutomationStatusSnapshot();
|
||||
const noRendererTimeEstimate = {
|
||||
startedAt: missingMainTimes.startedAt,
|
||||
nextReconcileAt: missingMainTimes.nextReconcileAt
|
||||
};
|
||||
applyAutomationRuntimeStatus(runtimeStatus);
|
||||
if (queueLimitInput) {
|
||||
queueLimitInput.value = '0';
|
||||
queueLimitInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
@@ -1908,6 +1995,24 @@ contextBridge.exposeInMainWorld('api', {
|
||||
renderAutomationStatusSnapshot(baseSnapshot);
|
||||
}
|
||||
const originalSnapshotFactory = createAutomationStatusSnapshot;
|
||||
const finiteQueueSnapshot = originalSnapshotFactory();
|
||||
renderAutomationStatusSnapshot(Object.freeze({ ...finiteQueueSnapshot, queueLimitJobs: 0, availableSlots: null }));
|
||||
const unlimitedQueueAria = {
|
||||
now: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuenow'),
|
||||
max: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuemax'),
|
||||
text: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuetext')
|
||||
};
|
||||
renderAutomationStatusSnapshot(finiteQueueSnapshot);
|
||||
const finiteQueueAria = {
|
||||
now: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuenow'),
|
||||
max: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuemax'),
|
||||
text: document.getElementById('automationQueueMeterTrack')?.getAttribute('aria-valuetext')
|
||||
};
|
||||
setUiLanguage('en');
|
||||
renderAutomationStatusSnapshot(Object.freeze({ ...finiteQueueSnapshot, state: 'error', error: 'Ordnerscan fehlgeschlagen' }));
|
||||
const localizedStatusError = document.getElementById('automationLastError')?.textContent.trim() || '';
|
||||
setUiLanguage('de');
|
||||
renderAutomationStatusSnapshot(finiteQueueSnapshot);
|
||||
let snapshotCalls = 0;
|
||||
const pausedSnapshot = Object.freeze({
|
||||
...originalSnapshotFactory(),
|
||||
@@ -2089,7 +2194,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
enabledAfterCancel,
|
||||
lateResultStayedClosed: document.getElementById('automationTestOverlay')?.style.display === 'none'
|
||||
};
|
||||
return { initial, states, pausedControls, pauseResumeActions, loading, completed, english, closed, errorState, cancelLoading };
|
||||
return { initial, noRendererTimeEstimate, states, unlimitedQueueAria, finiteQueueAria, localizedStatusError, pausedControls, pauseResumeActions, loading, completed, english, closed, errorState, cancelLoading };
|
||||
})()`;
|
||||
const automationControlCenterLayoutScript = `(() => {
|
||||
const card = document.getElementById('automationStatusCard');
|
||||
@@ -2545,6 +2650,49 @@ app.whenReady().then(async () => {
|
||||
matchingPaths: ['c:/watch/parallel.mkv'],
|
||||
queuedTelemetry: 1
|
||||
});
|
||||
assert.deepEqual(result.automationPipeline.distinctParallel, {
|
||||
inspectCalls: 3,
|
||||
maxConcurrentInspections: 1,
|
||||
capacityJobs: 20,
|
||||
distinctJobs: 2,
|
||||
detected: 20,
|
||||
queued: 2,
|
||||
deferred: 18,
|
||||
lastDetectedName: 'distinct-019.mkv'
|
||||
});
|
||||
assert.deepEqual(result.automationPipeline.disjointClassification, {
|
||||
summary: {
|
||||
found: 7,
|
||||
filterMatched: 6,
|
||||
alreadyProcessed: 2,
|
||||
unavailable: 1,
|
||||
sizeLimitedJobs: 1,
|
||||
acceptedFiles: 2,
|
||||
selectedTargets: 1,
|
||||
resultingJobs: 2,
|
||||
availableSlots: 1,
|
||||
deferredFiles: 1
|
||||
},
|
||||
reasonCounts: {
|
||||
admitted: 1,
|
||||
deferred: 1,
|
||||
'filter-rejected': 1,
|
||||
processed: 1,
|
||||
'inspection-duplicate': 1,
|
||||
unavailable: 1,
|
||||
'size-limited': 1
|
||||
},
|
||||
classificationCount: 7,
|
||||
telemetryDelta: {
|
||||
detected: 7,
|
||||
queued: 1,
|
||||
skipped: 5,
|
||||
deferred: 1,
|
||||
lastDetectedName: 'size-limited.mkv'
|
||||
},
|
||||
applied: { admitted: ['admitted.mkv'], deferred: ['deferred.mkv'] },
|
||||
telemetry: { detected: 7, queued: 1, skipped: 5, deferred: 1 }
|
||||
});
|
||||
assert.deepEqual(result.automationPipeline.manualHostTransactional, {
|
||||
readFailure: {
|
||||
result: { ok: false, error: 'Automatische Aufnahme konnte nicht abgeschlossen werden.' },
|
||||
@@ -2569,8 +2717,10 @@ app.whenReady().then(async () => {
|
||||
admittedFiles: ['b.mkv'],
|
||||
deferred: 1,
|
||||
queued: 1,
|
||||
currentJobCount: 15000,
|
||||
unplannedJobs: 0,
|
||||
currentJobCount: 15001,
|
||||
unplannedJobs: 1,
|
||||
manualJobHosters: ['clouddrop.cc'],
|
||||
automationJobHosters: ['byse.sx', 'vidmoly.me'],
|
||||
selectedHostersAfterApply: ['clouddrop.cc'],
|
||||
manualSelectionFilesAfterApply: ['unplanned.mkv'],
|
||||
plannedHostsBeforeRebuild: ['byse.sx', 'vidmoly.me'],
|
||||
@@ -2578,7 +2728,7 @@ app.whenReady().then(async () => {
|
||||
});
|
||||
assert.deepEqual(result.automationPipeline.status, {
|
||||
state: 'queue-limited',
|
||||
currentJobCount: 15000,
|
||||
currentJobCount: 15001,
|
||||
availableSlots: 0,
|
||||
queueLimited: true,
|
||||
frozen: true
|
||||
@@ -2842,15 +2992,15 @@ app.whenReady().then(async () => {
|
||||
assert.deepEqual(result.automationPipeline.fulfilledFeedback, {
|
||||
watcherWarning: {
|
||||
result: { ok: false, warning: 'Telemetrie konnte nicht gespeichert werden.', error: null },
|
||||
feedback: ['Telemetrie konnte nicht gespeichert werden.']
|
||||
feedback: ['Telemetry could not be saved.']
|
||||
},
|
||||
watcherError: {
|
||||
result: { ok: false, warning: null, error: 'Jobs konnten nicht hinzugefügt werden.' },
|
||||
feedback: ['Jobs konnten nicht hinzugefügt werden.']
|
||||
feedback: ['Jobs could not be added.']
|
||||
},
|
||||
modalWarning: {
|
||||
result: { ok: false, warning: 'Telemetrie konnte nicht gespeichert werden.', error: null },
|
||||
feedback: ['Telemetrie konnte nicht gespeichert werden.'],
|
||||
feedback: ['Telemetry could not be saved.'],
|
||||
pending: 0,
|
||||
markers: 0,
|
||||
modalOpen: false,
|
||||
@@ -2919,6 +3069,9 @@ app.whenReady().then(async () => {
|
||||
assert.equal(result.automationControlCenter.initial.intervalDefault, '5');
|
||||
assert.deepEqual(result.automationControlCenter.initial.intervalOptions, ['1', '5', '15', '30', '60']);
|
||||
assert.equal(result.automationControlCenter.initial.snapshotFrozen, true);
|
||||
assert.equal(result.automationControlCenter.initial.startedAt, 1787709000000);
|
||||
assert.equal(result.automationControlCenter.initial.nextReconcileAt, 1787712723456);
|
||||
assert.deepEqual(result.automationControlCenter.noRendererTimeEstimate, { startedAt: null, nextReconcileAt: null });
|
||||
assert.deepEqual(result.automationControlCenter.states, [
|
||||
{ state: 'inactive', expectedLabel: 'Inaktiv', text: 'Inaktiv', classApplied: true },
|
||||
{ state: 'active', expectedLabel: 'Aktiv', text: 'Aktiv', classApplied: true },
|
||||
@@ -2927,6 +3080,9 @@ app.whenReady().then(async () => {
|
||||
{ state: 'disconnected', expectedLabel: 'Ordner getrennt', text: 'Ordner getrennt', classApplied: true },
|
||||
{ state: 'error', expectedLabel: 'Fehler', text: 'Fehler', classApplied: true }
|
||||
]);
|
||||
assert.deepEqual(result.automationControlCenter.unlimitedQueueAria, { now: null, max: null, text: '8.420 / Unbegrenzt' });
|
||||
assert.deepEqual(result.automationControlCenter.finiteQueueAria, { now: '8420', max: '15000', text: '8.420 / 15.000' });
|
||||
assert.equal(result.automationControlCenter.localizedStatusError, 'Folder scan failed');
|
||||
assert.deepEqual(result.automationControlCenter.pausedControls, {
|
||||
snapshotCalls: 1,
|
||||
pauseButtonDisabled: false,
|
||||
@@ -3393,6 +3549,7 @@ test('persisted automation pause survives runtime restart and resumes one reconc
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
contextBridge.exposeInMainWorld('restartProbe', {
|
||||
status: () => ipcRenderer.invoke('automation:get-status'),
|
||||
testScan: () => ipcRenderer.invoke('folder-monitor:test-scan'),
|
||||
startMonitor: settings => ipcRenderer.invoke('folder-monitor:start', settings),
|
||||
reconcile: () => ipcRenderer.invoke('folder-monitor:reconcile'),
|
||||
start: job => ipcRenderer.invoke('start-upload', { files: [], hosters: [], jobs: [job] }),
|
||||
@@ -3421,6 +3578,7 @@ contextBridge.exposeInMainWorld('restartProbe', {
|
||||
status: 'preview'
|
||||
};
|
||||
const initial = await window.restartProbe.status();
|
||||
const testScan = await window.restartProbe.testScan();
|
||||
const monitorStart = await window.restartProbe.startMonitor({ folderPath: 'C:\\\\blocked' });
|
||||
const reconcile = await captureFailure(() => window.restartProbe.reconcile());
|
||||
const start = await window.restartProbe.start(preview);
|
||||
@@ -3443,6 +3601,7 @@ contextBridge.exposeInMainWorld('restartProbe', {
|
||||
const final = await window.restartProbe.counters();
|
||||
window.__automationRestartResult = {
|
||||
initial,
|
||||
testScan,
|
||||
monitorStart,
|
||||
reconcile,
|
||||
start,
|
||||
@@ -3467,6 +3626,7 @@ 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 { normalizeAutomationSettings } = require(${JSON.stringify(path.join(projectRoot, 'lib', 'automation-control.js'))});
|
||||
const outputPath = process.env.MHU_AUTOMATION_OUTPUT;
|
||||
const rendererPath = process.env.MHU_AUTOMATION_RENDERER;
|
||||
const preloadPath = process.env.MHU_AUTOMATION_PRELOAD;
|
||||
@@ -3650,18 +3810,23 @@ ${startupAutomation}
|
||||
});
|
||||
assert.equal(outcome.result.error, undefined);
|
||||
assert.equal(outcome.result.initial.paused, true);
|
||||
assert.equal(outcome.result.testScan.reachable, true);
|
||||
assert.equal(outcome.result.testScan.trigger, 'test');
|
||||
assert.equal(outcome.result.testScan.files.length, 1);
|
||||
assert.equal(outcome.result.testScan.files[0].name, 'manual-preview.mkv');
|
||||
assert.deepEqual(outcome.result.monitorStart, { error: 'Automatik ist pausiert' });
|
||||
assert.equal(outcome.result.reconcile.ok, false);
|
||||
assert.equal(outcome.result.reconcile.ok, true);
|
||||
assert.deepEqual(outcome.result.reconcile.value, { error: 'Automatik ist pausiert' });
|
||||
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.beforeResume.walkCalls, 1);
|
||||
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.walkCalls, 2);
|
||||
assert.equal(outcome.result.afterResume.newFileEvents, 1);
|
||||
assert.equal(outcome.result.afterResume.addJobsCalls, 0);
|
||||
assert.equal(outcome.result.afterResume.startBatchCalls, 0);
|
||||
@@ -3673,7 +3838,7 @@ ${startupAutomation}
|
||||
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.walkCalls, 2);
|
||||
assert.equal(outcome.result.final.configPaused, true);
|
||||
assert.equal(outcome.result.final.monitor.paused, true);
|
||||
assert.equal(outcome.result.final.monitor.running, false);
|
||||
@@ -3685,6 +3850,7 @@ ${startupAutomation}
|
||||
test('real app resume keeps the ConfigStore-restored manual preview byte-identical without starting work', { skip: process.platform !== 'win32' }, () => {
|
||||
const projectRoot = path.join(__dirname, '..');
|
||||
const probeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-real-resume-e2e-'));
|
||||
try {
|
||||
const appRoot = path.join(probeRoot, 'app');
|
||||
const userDataPath = path.join(probeRoot, 'user-data');
|
||||
const outputPath = path.join(probeRoot, 'result.json');
|
||||
@@ -3820,7 +3986,6 @@ async function waitFor(read, timeoutMs = 20000) {
|
||||
});
|
||||
`;
|
||||
fs.writeFileSync(probePath, probeSource, 'utf8');
|
||||
try {
|
||||
const electronPath = path.join(projectRoot, 'node_modules', 'electron', 'dist', 'electron.exe');
|
||||
const probeEnvironment = {
|
||||
...process.env,
|
||||
|
||||
@@ -7,6 +7,7 @@ const path = require('path');
|
||||
const {
|
||||
createInternalLogPathResolver,
|
||||
createInternalLogWriter,
|
||||
createBufferedInternalLogFlusher,
|
||||
createUploadAuditWriter,
|
||||
getLogOpenDirectory
|
||||
} = require('../lib/upload-audit');
|
||||
@@ -128,10 +129,46 @@ test('synchronous rotation flush falls back completely and retains buffered line
|
||||
test('quit flush delegates the rotation buffer to the synchronous internal writer', () => {
|
||||
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
|
||||
|
||||
assert.match(mainSource, /_rotLogWriter\.flushSync\(_rotLogBuffer, 'rot-log'\);/);
|
||||
assert.match(mainSource, /_rotLogFlusher\.flushSync\('rot-log'\);/);
|
||||
assert.doesNotMatch(mainSource, /appendFileSync\(getRotLogPath\(\), _rotLogBuffer\.join\(''\)/);
|
||||
});
|
||||
|
||||
test('asynchronous rotation flush restores a failed chunk ahead of newer lines without a retry loop', async () => {
|
||||
const buffer = ['first\n', 'second\n'];
|
||||
const scheduled = [];
|
||||
let resolveAppend;
|
||||
const appendCalls = [];
|
||||
const syncCalls = [];
|
||||
const writer = {
|
||||
append(value) {
|
||||
appendCalls.push(value);
|
||||
return new Promise(resolve => { resolveAppend = resolve; });
|
||||
},
|
||||
flushSync(lines) {
|
||||
syncCalls.push([...lines]);
|
||||
lines.length = 0;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
const flusher = createBufferedInternalLogFlusher({
|
||||
buffer,
|
||||
writer,
|
||||
schedule: callback => scheduled.push(callback)
|
||||
});
|
||||
|
||||
const failed = flusher.flush('rot-log');
|
||||
assert.deepEqual(buffer, []);
|
||||
buffer.push('third\n');
|
||||
resolveAppend(false);
|
||||
assert.equal(await failed, false);
|
||||
assert.deepEqual(buffer, ['first\n', 'second\n', 'third\n']);
|
||||
assert.deepEqual(appendCalls, ['first\nsecond\n']);
|
||||
assert.deepEqual(scheduled, []);
|
||||
assert.equal(flusher.flushSync('rot-log'), true);
|
||||
assert.deepEqual(syncCalls, [['first\n', 'second\n', 'third\n']]);
|
||||
assert.deepEqual(buffer, []);
|
||||
});
|
||||
|
||||
test('upload audit writer leaves the configured fileuploader log contract unchanged', async (t) => {
|
||||
const directory = createTempDirectory(t, 'mhu-upload-log-contract-');
|
||||
const userDataPath = path.join(directory, 'user-data');
|
||||
|
||||
Reference in New Issue
Block a user