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:
Sucukdeluxe
2026-08-26 15:47:31 +02:00
parent ca2d1f8fda
commit afd3b70cf3
12 changed files with 812 additions and 157 deletions
+33 -1
View File
@@ -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
View File
@@ -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',
+7
View File
@@ -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'],
+101 -16
View File
@@ -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
View File
@@ -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,
+38 -1
View File
@@ -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');