Persist successful watched-file uploads in a dedicated fsync-backed ledger before exposing completion to the renderer. Match entries by normalized full path, hoster, size, and modification time so restart reconciliation skips unchanged completed files while changed files and explicit manual retries remain available. Remove restored queue ghosts from the ledger even when history and user upload logging are unavailable. Preserve per-hoster partial completion, capture missing file metadata asynchronously, fail closed on corrupted or unwritable evidence, and keep local persistence failures outside automatic upload retries. Stream managed upload logs with bounded lines, bytes, files, directories, and result counts. Include numbered rotations, reject unconfirmed rows, share concurrent scans through a generation-safe cache, invalidate after successful appends, avoid synchronous configuration and directory reads, and close streams on every path.
This commit is contained in:
@@ -7,7 +7,12 @@ const {
|
||||
rollDailyTelemetry,
|
||||
applyTelemetryDelta,
|
||||
deriveAutomationState,
|
||||
classifyProcessedCandidates
|
||||
isPathWithinAutomationFolder,
|
||||
classifyProcessedCandidates,
|
||||
classifyAutomationCompletionLedger,
|
||||
createAutomationCompletionWriter,
|
||||
mergeAutomationCompletions,
|
||||
removeAutomationCompletions
|
||||
} = require('../lib/automation-control');
|
||||
|
||||
test('automation defaults use 15000 jobs and a five minute reconciliation interval', () => {
|
||||
@@ -284,6 +289,14 @@ test('automation state follows inactive disconnected error queue-limited and act
|
||||
assert.equal(deriveAutomationState(null), 'inactive');
|
||||
});
|
||||
|
||||
test('watched-folder membership respects Windows casing and recursive scope', () => {
|
||||
assert.equal(isPathWithinAutomationFolder('C:\\Watch\\episode.mkv', 'c:/watch', false), true);
|
||||
assert.equal(isPathWithinAutomationFolder('C:\\Watch\\Season 1\\episode.mkv', 'c:/watch', false), false);
|
||||
assert.equal(isPathWithinAutomationFolder('C:\\Watch\\Season 1\\episode.mkv', 'c:/watch', true), true);
|
||||
assert.equal(isPathWithinAutomationFolder('C:\\Watcher\\episode.mkv', 'c:/watch', true), false);
|
||||
assert.equal(isPathWithinAutomationFolder('', 'c:/watch', true), false);
|
||||
});
|
||||
|
||||
test('exact queue and history paths mark candidates processed case-insensitively', () => {
|
||||
const result = classifyProcessedCandidates({
|
||||
candidates: [
|
||||
@@ -345,3 +358,96 @@ test('processed classification tolerates malformed collections and does not muta
|
||||
unprocessedPaths: []
|
||||
});
|
||||
});
|
||||
|
||||
test('durable completion ledger excludes only unchanged completed hosters', () => {
|
||||
const candidate = {
|
||||
path: 'C:\\Watch\\Episode.mkv',
|
||||
size: 1048576,
|
||||
mtimeMs: 1787828400123.75,
|
||||
eligibleHosters: ['doodstream.com', 'voe.sx', 'byse.sx']
|
||||
};
|
||||
const completionRows = [
|
||||
{ path: 'c:/watch/episode.mkv', size: 1048576, mtimeMs: 1787828400123, hoster: 'DOODSTREAM.COM', completedAt: 10 },
|
||||
{ path: 'C:\\WATCH\\EPISODE.MKV', size: 1048576, mtimeMs: 1787828400123.9, hoster: 'voe.sx', completedAt: 20 }
|
||||
];
|
||||
|
||||
assert.deepEqual(classifyAutomationCompletionLedger({ candidates: [candidate], completionRows }), {
|
||||
processedPaths: [],
|
||||
completedByPath: [{ path: candidate.path, hosters: ['doodstream.com', 'voe.sx'] }],
|
||||
remainingByPath: [{ path: candidate.path, hosters: ['byse.sx'] }]
|
||||
});
|
||||
|
||||
const complete = classifyAutomationCompletionLedger({
|
||||
candidates: [candidate],
|
||||
completionRows: completionRows.concat({
|
||||
path: candidate.path,
|
||||
size: candidate.size,
|
||||
mtimeMs: candidate.mtimeMs,
|
||||
hoster: 'byse.sx',
|
||||
completedAt: 30
|
||||
})
|
||||
});
|
||||
assert.deepEqual(complete.processedPaths, [candidate.path]);
|
||||
assert.deepEqual(complete.remainingByPath, [{ path: candidate.path, hosters: [] }]);
|
||||
|
||||
const changed = classifyAutomationCompletionLedger({
|
||||
candidates: [{ ...candidate, mtimeMs: candidate.mtimeMs + 1 }],
|
||||
completionRows
|
||||
});
|
||||
assert.deepEqual(changed.completedByPath, [{ path: candidate.path, hosters: [] }]);
|
||||
assert.deepEqual(changed.remainingByPath, [{ path: candidate.path, hosters: candidate.eligibleHosters }]);
|
||||
});
|
||||
|
||||
test('completion ledger replaces only the same path and hoster without evicting unrelated entries', () => {
|
||||
const existing = [
|
||||
{ path: 'C:\\watch\\a.mkv', size: 1, mtimeMs: 1, hoster: 'voe.sx', completedAt: 10 },
|
||||
{ path: 'C:\\watch\\b.mkv', size: 2, mtimeMs: 2, hoster: 'voe.sx', completedAt: 20 }
|
||||
];
|
||||
const merged = mergeAutomationCompletions(existing, [
|
||||
{ path: 'c:/WATCH/a.mkv', size: 3, mtimeMs: 3, hoster: 'VOE.SX', completedAt: 30 },
|
||||
{ path: 'C:\\watch\\c.mkv', size: 4, mtimeMs: 4, hoster: 'byse.sx', completedAt: 40 }
|
||||
]);
|
||||
|
||||
assert.deepEqual(merged, [
|
||||
{ path: 'C:\\watch\\b.mkv', size: 2, mtimeMs: 2, hoster: 'voe.sx', completedAt: 20 },
|
||||
{ path: 'c:/WATCH/a.mkv', size: 3, mtimeMs: 3, hoster: 'voe.sx', completedAt: 30 },
|
||||
{ path: 'C:\\watch\\c.mkv', size: 4, mtimeMs: 4, hoster: 'byse.sx', completedAt: 40 }
|
||||
]);
|
||||
assert.deepEqual(removeAutomationCompletions(merged, [{ path: 'C:\\WATCH\\A.MKV', hoster: 'voe.sx' }]), [merged[0], merged[2]]);
|
||||
assert.deepEqual(removeAutomationCompletions(merged, [{ path: 'c:/watch/c.mkv' }]), [merged[0], merged[1]]);
|
||||
assert.throws(() => mergeAutomationCompletions(existing, [
|
||||
{ path: 'C:\\watch\\c.mkv', size: 4, mtimeMs: 4, hoster: 'byse.sx', completedAt: 40 }
|
||||
], 2), /zu viele Einträge/);
|
||||
});
|
||||
|
||||
test('completion writer coalesces successful jobs and retains a failed batch for retry', async () => {
|
||||
const scheduled = [];
|
||||
const writes = [];
|
||||
const persisted = [];
|
||||
const failures = [];
|
||||
let fail = true;
|
||||
const writer = createAutomationCompletionWriter({
|
||||
schedule: callback => scheduled.push(callback),
|
||||
onPersisted: entries => persisted.push(structuredClone(entries)),
|
||||
onError: (error, entries) => failures.push({ message: error.message, entries: structuredClone(entries) }),
|
||||
save: async entries => {
|
||||
if (fail) throw new Error('disk unavailable');
|
||||
writes.push(structuredClone(entries));
|
||||
}
|
||||
});
|
||||
const first = { path: 'C:\\watch\\episode.mkv', size: 1, mtimeMs: 2, hoster: 'voe.sx', completedAt: 3 };
|
||||
const newer = { ...first, completedAt: 4 };
|
||||
|
||||
writer.add(first);
|
||||
writer.add(newer);
|
||||
assert.equal(scheduled.length, 1);
|
||||
await assert.rejects(writer.flush(), /disk unavailable/);
|
||||
assert.deepEqual(persisted, []);
|
||||
assert.deepEqual(failures, [{ message: 'disk unavailable', entries: [newer] }]);
|
||||
|
||||
fail = false;
|
||||
await writer.flush();
|
||||
assert.deepEqual(writes, [[newer]]);
|
||||
assert.deepEqual(persisted, [[newer]]);
|
||||
assert.equal(writer.pendingCount(), 0);
|
||||
});
|
||||
|
||||
@@ -31,6 +31,7 @@ function createStore() {
|
||||
store = new ConfigStore(fakeApp);
|
||||
store.filePath = path.join(tmpDir, 'electron-config.json');
|
||||
store.historyPath = path.join(tmpDir, 'electron-history.json');
|
||||
store.automationCompletionPath = path.join(tmpDir, 'automation-completions.json');
|
||||
return store;
|
||||
}
|
||||
|
||||
@@ -41,6 +42,7 @@ function createStoreAt(filePath) {
|
||||
});
|
||||
configuredStore.filePath = filePath;
|
||||
configuredStore.historyPath = path.join(path.dirname(filePath), 'electron-history.json');
|
||||
configuredStore.automationCompletionPath = path.join(path.dirname(filePath), 'automation-completions.json');
|
||||
return configuredStore;
|
||||
}
|
||||
|
||||
@@ -125,6 +127,74 @@ describe('ConfigStore', () => {
|
||||
assert.equal(reloaded.globalSettings.folderMonitor.pausedAt, 1787712000000);
|
||||
});
|
||||
|
||||
it('automation completions survive queue clearing and can be removed explicitly', async () => {
|
||||
const completion = {
|
||||
path: 'C:\\watch\\episode.mkv',
|
||||
size: 1024,
|
||||
mtimeMs: 1787828400123,
|
||||
hoster: 'doodstream.com',
|
||||
completedAt: 1787828500000
|
||||
};
|
||||
|
||||
await store.saveAutomationCompletions([completion]);
|
||||
await store.savePendingQueue(null);
|
||||
await store.appendHistory({ id: 'old', timestamp: '2026-01-01T00:00:00.000Z', files: [] });
|
||||
await store.clearHistory();
|
||||
|
||||
const reloaded = createStoreAt(store.filePath);
|
||||
assert.deepEqual(await reloaded.loadAutomationCompletions(), [completion]);
|
||||
|
||||
await reloaded.clearAutomationCompletions([{ path: completion.path, hoster: completion.hoster }]);
|
||||
assert.deepEqual(await reloaded.loadAutomationCompletions(), []);
|
||||
assert.equal(JSON.parse(fs.readFileSync(reloaded.automationCompletionPath, 'utf8')).version, 1);
|
||||
});
|
||||
|
||||
it('automation completion writes remain serialized without evicting older unique paths', async () => {
|
||||
const first = store.saveAutomationCompletions([
|
||||
{ path: 'C:\\watch\\old.mkv', size: 1, mtimeMs: 1, hoster: 'voe.sx', completedAt: 1 }
|
||||
], { maxEntries: 2 });
|
||||
const second = store.saveAutomationCompletions([
|
||||
{ path: 'C:\\watch\\middle.mkv', size: 2, mtimeMs: 2, hoster: 'voe.sx', completedAt: 2 },
|
||||
{ path: 'C:\\watch\\new.mkv', size: 3, mtimeMs: 3, hoster: 'byse.sx', completedAt: 3 }
|
||||
], { maxEntries: 2 });
|
||||
|
||||
await Promise.all([first, second]);
|
||||
await store.drainAutomationCompletionWrites();
|
||||
|
||||
assert.deepEqual((await store.loadAutomationCompletions()).map(entry => entry.path), [
|
||||
'C:\\watch\\old.mkv',
|
||||
'C:\\watch\\middle.mkv',
|
||||
'C:\\watch\\new.mkv'
|
||||
]);
|
||||
});
|
||||
|
||||
it('corrupted automation completion evidence fails closed', async () => {
|
||||
fs.writeFileSync(store.automationCompletionPath, '{broken', 'utf8');
|
||||
await assert.rejects(store.loadAutomationCompletions());
|
||||
const reloaded = createStoreAt(store.filePath);
|
||||
fs.writeFileSync(reloaded.automationCompletionPath, JSON.stringify({ version: 1, entries: [{ path: 'C:\\watch\\invalid.mkv' }] }), 'utf8');
|
||||
await assert.rejects(reloaded.loadAutomationCompletions(), /ungültig/);
|
||||
});
|
||||
|
||||
it('automation completion drain waits for an active durable write', async () => {
|
||||
const originalWrite = store._writeAutomationCompletionFile.bind(store);
|
||||
let releaseWrite;
|
||||
store._writeAutomationCompletionFile = entries => new Promise((resolve, reject) => {
|
||||
releaseWrite = () => originalWrite(entries).then(resolve, reject);
|
||||
});
|
||||
const saving = store.saveAutomationCompletions([
|
||||
{ path: 'C:\\watch\\drain.mkv', size: 1, mtimeMs: 2, hoster: 'voe.sx', completedAt: 3 }
|
||||
]);
|
||||
while (!releaseWrite) await new Promise(resolve => setImmediate(resolve));
|
||||
let drained = false;
|
||||
const draining = store.drainAutomationCompletionWrites().then(() => { drained = true; });
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.equal(drained, false);
|
||||
releaseWrite();
|
||||
await Promise.all([saving, draining]);
|
||||
assert.equal(drained, true);
|
||||
});
|
||||
|
||||
it('drops the retired plaintext credential setting from legacy configurations', () => {
|
||||
fs.writeFileSync(store.filePath, JSON.stringify({
|
||||
hosters: {},
|
||||
|
||||
@@ -13,7 +13,7 @@ test('inspects duplicates, unavailable files, accepted files, and configured siz
|
||||
], {
|
||||
existingPaths: ['C:\\queue\\duplicate.mkv'],
|
||||
inspectPath: async filePath => {
|
||||
if (filePath.endsWith('accepted.mkv')) return { exists: true, readable: true, size: 2 * 1024 * 1024 };
|
||||
if (filePath.endsWith('accepted.mkv')) return { exists: true, readable: true, size: 2 * 1024 * 1024, mtimeMs: 1787828400123 };
|
||||
if (filePath.endsWith('empty.mkv')) return { exists: true, readable: true, size: 0 };
|
||||
return { exists: false };
|
||||
}
|
||||
@@ -42,6 +42,7 @@ test('inspects duplicates, unavailable files, accepted files, and configured siz
|
||||
jobCount: 1,
|
||||
sizeLimitedJobCount: 1
|
||||
});
|
||||
assert.equal(inspection.accepted[0].mtimeMs, 1787828400123);
|
||||
});
|
||||
|
||||
test('connects the import preflight through the main process, preload, renderer, and hoster dialog', () => {
|
||||
|
||||
@@ -92,8 +92,11 @@ test('managed upload-log discovery includes session logs and excludes unrelated
|
||||
assert.equal(typeof isManagedUploadLogFileName, 'function');
|
||||
const options = { baseName: 'fileuploader', ext: '.log' };
|
||||
assert.equal(isManagedUploadLogFileName('fileuploader.log', options), true);
|
||||
assert.equal(isManagedUploadLogFileName('fileuploader.1.log', options), true);
|
||||
assert.equal(isManagedUploadLogFileName('fileuploader-2026-08-27.log', options), true);
|
||||
assert.equal(isManagedUploadLogFileName('fileuploader-2026-08-27.2.log', options), true);
|
||||
assert.equal(isManagedUploadLogFileName('fileuploader-session-2026-08-27_05-40-59-1234.log', options), true);
|
||||
assert.equal(isManagedUploadLogFileName('27-08-2026-mdu-session-05-40-111111.3.log', options), true);
|
||||
assert.equal(isManagedUploadLogFileName('27-08-2026-mdu-session-05-40-599797.log', options), true);
|
||||
assert.equal(isManagedUploadLogFileName('FILEUPLOADER-2026-08-27.LOG', options), true);
|
||||
assert.equal(isManagedUploadLogFileName('27-08-2026-MDU-SESSION-05-40-599797.LOG', options), true);
|
||||
|
||||
@@ -224,7 +224,7 @@ test('packages every Electron preload referenced by the main process', () => {
|
||||
|
||||
test('read-own-upload-log discovers base daily session and both fallback directories without synchronous reads', async () => {
|
||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||
const blockStart = mainSource.indexOf("ipcMain.handle('read-own-upload-log'");
|
||||
const blockStart = mainSource.indexOf('let _uploadLogEvidenceCache');
|
||||
const blockEnd = mainSource.indexOf("\nipcMain.handle('import-upload-log'", blockStart);
|
||||
assert.notEqual(blockStart, -1);
|
||||
assert.notEqual(blockEnd, -1);
|
||||
@@ -233,9 +233,9 @@ test('read-own-upload-log discovers base daily session and both fallback directo
|
||||
const desktop = 'C:\\desktop';
|
||||
const userData = 'C:\\user-data';
|
||||
const entriesByDirectory = new Map([
|
||||
[configured, ['custom.txt', 'custom-2026-08-27.txt', '27-08-2026-mdu-session-05-40-111111.txt', 'upload-audit.log']],
|
||||
[desktop, ['FILEUPLOADER-2026-08-26.LOG', '26-08-2026-MDU-SESSION-05-40-222222.LOG', 'account-rotation.log']],
|
||||
[userData, ['fileuploader-2026-08-25.log', '25-08-2026-mdu-session-05-40-333333.log', 'upload-debug.log']]
|
||||
[configured, ['custom.txt', 'custom-2026-08-27.txt', 'custom.1.txt', '27-08-2026-mdu-session-05-40-111111.txt', 'upload-audit.log']],
|
||||
[desktop, ['FILEUPLOADER-2026-08-26.LOG', 'FILEUPLOADER-2026-08-26.2.LOG', '26-08-2026-MDU-SESSION-05-40-222222.LOG', 'account-rotation.log']],
|
||||
[userData, ['fileuploader-2026-08-25.log', 'fileuploader.3.log', '25-08-2026-mdu-session-05-40-333333.log', 'upload-debug.log']]
|
||||
]);
|
||||
const fileNames = new Map();
|
||||
for (const [directory, names] of entriesByDirectory) {
|
||||
@@ -244,33 +244,84 @@ test('read-own-upload-log discovers base daily session and both fallback directo
|
||||
fileNames.set(path.win32.join(directory, name), `${name}.mkv`);
|
||||
}
|
||||
}
|
||||
let streamReads = 0;
|
||||
let failedPath = '';
|
||||
let scanLabel = '';
|
||||
let holdNextRead = false;
|
||||
let heldRead = null;
|
||||
const fakeFs = {
|
||||
readdirSync: directory => entriesByDirectory.get(directory) || [],
|
||||
existsSync: filePath => fileNames.has(filePath),
|
||||
readdirSync: () => { throw new Error('synchronous enumeration forbidden'); },
|
||||
existsSync: () => { throw new Error('synchronous existence check forbidden'); },
|
||||
readFileSync: () => { throw new Error('synchronous read forbidden'); },
|
||||
promises: {
|
||||
readFile: async filePath => require('../lib/upload-log').formatUploadLogLine(
|
||||
new Date(2026, 7, 27, 5, 40, 0),
|
||||
'voe.sx',
|
||||
'https://voe.sx/e/test',
|
||||
fileNames.get(filePath)
|
||||
)
|
||||
readdir: async () => { throw new Error('materialized directory read forbidden'); },
|
||||
opendir: async directory => ({
|
||||
async *[Symbol.asyncIterator]() {
|
||||
for (const name of entriesByDirectory.get(directory) || []) yield { name };
|
||||
}
|
||||
}),
|
||||
readFile: async () => { throw new Error('full-file read forbidden'); }
|
||||
}
|
||||
};
|
||||
vm.runInNewContext(mainSource.slice(blockStart, blockEnd), {
|
||||
_resolveUploadLogTarget: () => ({ path: path.win32.join(configured, '27-08-2026-mdu-session-05-40-111111.txt') }),
|
||||
const context = {
|
||||
_activeLogPath: path.win32.join(configured, '27-08-2026-mdu-session-05-40-111111.txt'),
|
||||
_resolveUploadLogTarget: () => { throw new Error('write-target resolution forbidden'); },
|
||||
app: { getPath: name => name === 'desktop' ? desktop : userData },
|
||||
fs: fakeFs,
|
||||
getBaseLogFilePath: () => path.win32.join(configured, 'custom.txt'),
|
||||
getSafeDesktopDir: () => desktop,
|
||||
getSafeDesktopDir: () => { throw new Error('synchronous desktop probe forbidden'); },
|
||||
ipcMain: { handle: (channel, handler) => handlers.set(channel, handler) },
|
||||
isManagedUploadLogFileName: require('../lib/log-mode').isManagedUploadLogFileName,
|
||||
parseUploadLogLine: require('../lib/upload-log').parseUploadLogLine,
|
||||
iterateUploadLogEntries: async function* (filePath) {
|
||||
streamReads++;
|
||||
const label = scanLabel;
|
||||
if (filePath === failedPath) {
|
||||
const error = new Error('managed log denied');
|
||||
error.code = 'EACCES';
|
||||
throw error;
|
||||
}
|
||||
if (holdNextRead) {
|
||||
holdNextRead = false;
|
||||
heldRead = createDeferred();
|
||||
await heldRead.promise;
|
||||
}
|
||||
yield require('../lib/upload-log').parseUploadLogLine(require('../lib/upload-log').formatUploadLogLine(
|
||||
new Date(2026, 7, 27, 5, 40, 0),
|
||||
'voe.sx',
|
||||
'https://voe.sx/e/test',
|
||||
`${fileNames.get(filePath)}${label}`
|
||||
));
|
||||
},
|
||||
path: path.win32
|
||||
});
|
||||
};
|
||||
vm.runInNewContext(mainSource.slice(blockStart, blockEnd), context);
|
||||
|
||||
const entries = await handlers.get('read-own-upload-log')();
|
||||
assert.deepEqual([...entries.map(entry => entry.fileName)].sort(), [...fileNames.values()].sort());
|
||||
const handler = handlers.get('read-own-upload-log');
|
||||
const [first, concurrent] = await Promise.all([handler(), handler()]);
|
||||
const cached = await handler();
|
||||
const expected = [...fileNames.values()].sort();
|
||||
assert.deepEqual([...first.map(entry => entry.fileName)].sort(), expected);
|
||||
assert.deepEqual([...concurrent.map(entry => entry.fileName)].sort(), expected);
|
||||
assert.deepEqual([...cached.map(entry => entry.fileName)].sort(), expected);
|
||||
assert.equal(streamReads, fileNames.size);
|
||||
vm.runInNewContext('_invalidateUploadLogEvidenceCache()', context);
|
||||
failedPath = [...fileNames.keys()][0];
|
||||
await assert.rejects(handler(), /managed log denied/);
|
||||
failedPath = '';
|
||||
vm.runInNewContext('_invalidateUploadLogEvidenceCache()', context);
|
||||
holdNextRead = true;
|
||||
scanLabel = '.old';
|
||||
const staleScan = handler();
|
||||
while (!heldRead) await new Promise(resolve => setImmediate(resolve));
|
||||
vm.runInNewContext('_invalidateUploadLogEvidenceCache()', context);
|
||||
scanLabel = '.new';
|
||||
const freshScan = handler();
|
||||
heldRead.resolve();
|
||||
const [staleEntries, freshEntries] = await Promise.all([staleScan, freshScan]);
|
||||
const cachedFreshEntries = await handler();
|
||||
assert.equal(staleEntries.some(entry => entry.fileName.endsWith('.old')), true);
|
||||
assert.equal(freshEntries.every(entry => entry.fileName.endsWith('.new')), true);
|
||||
assert.equal(cachedFreshEntries.every(entry => entry.fileName.endsWith('.new')), true);
|
||||
});
|
||||
|
||||
test('exposes managed online backup operations through narrow IPC boundaries', () => {
|
||||
@@ -633,20 +684,47 @@ test('preload exposes account cooldown snapshots and removes their listener duri
|
||||
test('exposes persistent automation controls and status through narrow IPC boundaries', () => {
|
||||
const preloadSource = fs.readFileSync(path.join(projectRoot, 'preload.js'), 'utf8');
|
||||
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
|
||||
const rendererSource = fs.readFileSync(path.join(projectRoot, 'renderer', 'app.js'), 'utf8');
|
||||
|
||||
assert.match(mainSource, /ipcMain\.handle\('automation:get-status'/u);
|
||||
assert.match(mainSource, /ipcMain\.handle\('automation:get-completions'/u);
|
||||
assert.match(mainSource, /ipcMain\.handle\('automation:record-completions'/u);
|
||||
assert.match(mainSource, /ipcMain\.handle\('automation:pause-after-active'/u);
|
||||
assert.match(mainSource, /ipcMain\.handle\('automation:resume'/u);
|
||||
assert.match(mainSource, /ipcMain\.handle\('folder-monitor:test-scan'/u);
|
||||
assert.match(mainSource, /ipcMain\.handle\('folder-monitor:reconcile'/u);
|
||||
assert.match(mainSource, /safeSend\('automation:status'/u);
|
||||
assert.match(preloadSource, /automationGetStatus:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('automation:get-status'\)/u);
|
||||
assert.match(preloadSource, /getAutomationCompletions:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('automation:get-completions'\)/u);
|
||||
assert.match(preloadSource, /recordAutomationCompletions:\s*\(entries\)\s*=>\s*ipcRenderer\.invoke\('automation:record-completions',\s*entries\)/u);
|
||||
assert.match(preloadSource, /automationPauseAfterActive:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('automation:pause-after-active'\)/u);
|
||||
assert.match(preloadSource, /automationResume:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('automation:resume'\)/u);
|
||||
assert.match(preloadSource, /folderMonitorTestScan:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('folder-monitor:test-scan'\)/u);
|
||||
assert.match(preloadSource, /folderMonitorReconcile:\s*\(\)\s*=>\s*ipcRenderer\.invoke\('folder-monitor:reconcile'\)/u);
|
||||
assert.match(preloadSource, /onAutomationStatus:\s*\(callback\)\s*=>\s*\{[\s\S]*?ipcRenderer\.on\('automation:status'/u);
|
||||
assert.match(preloadSource, /ipcRenderer\.removeAllListeners\('automation:status'\)/u);
|
||||
assert.match(mainSource, /async function registerAutomationCompletionJobs/u);
|
||||
assert.match(mainSource, /await fs\.promises\.stat\(job\.file\)/u);
|
||||
assert.match(mainSource, /await registerAutomationCompletionJobs\(_thisManager,\s*jobs\)/u);
|
||||
assert.match(mainSource, /await registerAutomationCompletionJobs\(batchManager,\s*jobs\)/u);
|
||||
assert.match(mainSource, /_automationCompletionProgress\.set\(automationCompletionKey\(entry\),\s*data\)/u);
|
||||
assert.match(mainSource, /_automationCompletionWriter\.add\(entry\)/u);
|
||||
assert.match(mainSource, /await _thisManager\._automationCompletionWriter\?\.flush\(\)/u);
|
||||
assert.match(mainSource, /requestUploadFinalization\(summary,\s*!automationCompletionsPersisted\)/u);
|
||||
assert.match(rendererSource, /data\.preserveQueue\s*===\s*true\s*\|\|\s*queueJobs\.some/u);
|
||||
assert.match(rendererSource, /await window\.api\.recordAutomationCompletions\(completionRows\)/u);
|
||||
const serializerStart = rendererSource.indexOf('function serializeUploadJob');
|
||||
const serializerEnd = rendererSource.indexOf('\n}', serializerStart);
|
||||
const serializer = rendererSource.slice(serializerStart, serializerEnd);
|
||||
assert.match(serializer, /automationAdmission:\s*job\.automationAdmission\s*===\s*true/u);
|
||||
assert.match(serializer, /automationMtimeMs:\s*job\.automationMtimeMs/u);
|
||||
assert.match(serializer, /automationSize:\s*job\.automationSize/u);
|
||||
assert.match(serializer, /sourceMtimeMs:\s*job\.sourceMtimeMs/u);
|
||||
assert.match(serializer, /sourceSize:\s*job\.sourceSize/u);
|
||||
const syncStart = rendererSource.indexOf('function syncSelectedFilesFromQueue');
|
||||
const syncEnd = rendererSource.indexOf('\n}', syncStart);
|
||||
const syncSelected = rendererSource.slice(syncStart, syncEnd);
|
||||
assert.match(syncSelected, /mtimeMs:\s*job\.sourceMtimeMs\s*\?\?\s*job\.automationMtimeMs/u);
|
||||
});
|
||||
|
||||
test('every batch start and extension IPC fails closed before account and cleanup side effects', () => {
|
||||
|
||||
@@ -69,6 +69,8 @@ let pendingAutomationTestScan = null;
|
||||
let automationProbe = {
|
||||
history: [],
|
||||
uploadLog: [],
|
||||
completionRows: [],
|
||||
completionError: '',
|
||||
paused: false,
|
||||
runtimeStatus: {},
|
||||
automationStatusSequence: [],
|
||||
@@ -85,7 +87,7 @@ let automationProbe = {
|
||||
activeInspections: 0,
|
||||
maxConcurrentInspections: 0,
|
||||
dryScan: { files: [], reachable: true, trigger: 'test' },
|
||||
readCalls: { history: 0, uploadLog: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 },
|
||||
readCalls: { history: 0, uploadLog: 0, completions: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 },
|
||||
mutationCalls: [],
|
||||
logs: [],
|
||||
savedSettings: []
|
||||
@@ -175,6 +177,8 @@ contextBridge.exposeInMainWorld('api', {
|
||||
automationProbe = {
|
||||
history: Array.isArray(value.history) ? value.history : [],
|
||||
uploadLog: Array.isArray(value.uploadLog) ? value.uploadLog : [],
|
||||
completionRows: Array.isArray(value.completionRows) ? value.completionRows : [],
|
||||
completionError: String(value.completionError || ''),
|
||||
paused: value.paused === true,
|
||||
runtimeStatus: value.runtimeStatus && typeof value.runtimeStatus === 'object' ? { ...value.runtimeStatus } : {},
|
||||
automationStatusSequence: Array.isArray(value.automationStatusSequence) ? value.automationStatusSequence.map(entry => ({ ...entry })) : [],
|
||||
@@ -191,7 +195,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
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 },
|
||||
readCalls: { history: 0, uploadLog: 0, completions: 0, inspect: 0, status: 0, testScan: 0, reconcile: 0 },
|
||||
mutationCalls: [],
|
||||
logs: [],
|
||||
savedSettings: []
|
||||
@@ -200,6 +204,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
setAutomationEvidence(value = {}) {
|
||||
if (Array.isArray(value.history)) automationProbe.history = value.history;
|
||||
if (Array.isArray(value.uploadLog)) automationProbe.uploadLog = value.uploadLog;
|
||||
if (Array.isArray(value.completionRows)) automationProbe.completionRows = value.completionRows;
|
||||
},
|
||||
getAutomationProbeState() {
|
||||
return {
|
||||
@@ -251,6 +256,15 @@ contextBridge.exposeInMainWorld('api', {
|
||||
automationProbe.readCalls.uploadLog++;
|
||||
return Promise.resolve(automationProbe.uploadLog);
|
||||
},
|
||||
getAutomationCompletions() {
|
||||
automationProbe.readCalls.completions++;
|
||||
if (automationProbe.completionError) return Promise.reject(new Error(automationProbe.completionError));
|
||||
return Promise.resolve(automationProbe.completionRows);
|
||||
},
|
||||
clearAutomationCompletions(removals) {
|
||||
automationProbe.mutationCalls.push(['clear-completions', JSON.parse(JSON.stringify(removals || []))]);
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
automationGetStatus() {
|
||||
automationProbe.readCalls.status++;
|
||||
if (automationProbe.automationStatusSequence.length > 0) return Promise.resolve(automationProbe.automationStatusSequence.shift());
|
||||
@@ -686,7 +700,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
resultingJobs: historyEvaluation.summary.resultingJobs
|
||||
};
|
||||
_completedUploadKeys.clear();
|
||||
const completedFile = { path: 'C:\\history\\completed-in-session.mkv', name: 'completed-in-session.mkv', size: 1 };
|
||||
const completedFile = { path: 'C:\\history\\completed-in-session.mkv', name: 'completed-in-session.mkv', size: 1, mtimeMs: 1787828400123 };
|
||||
config.globalSettings.removeFromQueueOnDone = true;
|
||||
config.globalSettings.folderMonitor = {
|
||||
enabled: true,
|
||||
@@ -703,6 +717,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
hoster: 'doodstream.com',
|
||||
status: 'queued',
|
||||
bytesTotal: 1,
|
||||
automationMtimeMs: completedFile.mtimeMs,
|
||||
automationAdmission: true
|
||||
};
|
||||
queueJobs = [completedJob];
|
||||
@@ -721,18 +736,127 @@ contextBridge.exposeInMainWorld('api', {
|
||||
result: { download_url: 'https://doodstream.com/d/completed-in-session' }
|
||||
});
|
||||
_doneRemovalCoalescer?.drainSync();
|
||||
window.api.setAutomationEvidence({
|
||||
completionRows: [{
|
||||
path: completedFile.path,
|
||||
size: completedFile.size,
|
||||
mtimeMs: completedFile.mtimeMs,
|
||||
hoster: completedJob.hoster,
|
||||
completedAt: 1787828500000
|
||||
}]
|
||||
});
|
||||
automationEvidenceSnapshotGeneration++;
|
||||
automationEvidenceSnapshotCache = null;
|
||||
const removedAfterDone = !queueJobs.some(job => job.id === completedJob.id);
|
||||
const completedKeyPresent = _completedUploadKeys.has(completedFile.path + '|doodstream.com');
|
||||
const completedResult = await handleFolderMonitorFiles([completedFile]);
|
||||
const completedProbe = await window.api.getAutomationProbeState();
|
||||
_completedUploadKeys.clear();
|
||||
queueJobs = [];
|
||||
rebuildJobIndex();
|
||||
window.api.configureAutomationProbe({
|
||||
paused: false,
|
||||
history: [],
|
||||
uploadLog: [],
|
||||
completionRows: [{
|
||||
path: completedFile.path,
|
||||
size: completedFile.size,
|
||||
mtimeMs: completedFile.mtimeMs,
|
||||
hoster: 'doodstream.com',
|
||||
completedAt: 1787828500000
|
||||
}]
|
||||
});
|
||||
automationEvidenceSnapshotGeneration++;
|
||||
automationEvidenceSnapshotCache = null;
|
||||
const durableEvaluation = await evaluateAutomationCandidates([completedFile], { dryRun: true, trigger: 'startup' });
|
||||
_completedUploadKeys.add(completedFile.path + '|doodstream.com');
|
||||
automationEvidenceSnapshotGeneration++;
|
||||
automationEvidenceSnapshotCache = null;
|
||||
const changedEvaluation = await evaluateAutomationCandidates([{ ...completedFile, mtimeMs: completedFile.mtimeMs + 1 }], { dryRun: true, trigger: 'startup' });
|
||||
const partialFile = { path: 'C:\\history\\partial-in-session.mkv', name: 'partial-in-session.mkv', size: 2, mtimeMs: 1787828400456 };
|
||||
config.globalSettings.folderMonitor.hosters = ['doodstream.com', 'voe.sx'];
|
||||
window.api.configureAutomationProbe({
|
||||
paused: false,
|
||||
history: [],
|
||||
uploadLog: [{ fileName: partialFile.name, hoster: 'doodstream.com' }],
|
||||
completionRows: [{
|
||||
path: partialFile.path,
|
||||
size: partialFile.size,
|
||||
mtimeMs: partialFile.mtimeMs,
|
||||
hoster: 'doodstream.com',
|
||||
completedAt: 1787828500001
|
||||
}]
|
||||
});
|
||||
automationEvidenceSnapshotGeneration++;
|
||||
automationEvidenceSnapshotCache = null;
|
||||
const partialEvaluation = await evaluateAutomationCandidates([partialFile], { dryRun: true, trigger: 'startup' });
|
||||
const restoredFile = { path: 'C:\\history\\restored-after-ledger.mkv', name: 'restored-after-ledger.mkv', size: 3, mtimeMs: 1787828400789 };
|
||||
const restoredJob = {
|
||||
id: 'restored-after-ledger',
|
||||
file: restoredFile.path,
|
||||
fileName: restoredFile.name,
|
||||
hoster: 'doodstream.com',
|
||||
status: 'preview',
|
||||
bytesTotal: restoredFile.size,
|
||||
sourceSize: restoredFile.size,
|
||||
sourceMtimeMs: restoredFile.mtimeMs,
|
||||
automationAdmission: true
|
||||
};
|
||||
queueJobs = [restoredJob];
|
||||
selectedFiles = [];
|
||||
rebuildJobIndex();
|
||||
_completedUploadKeys.clear();
|
||||
window.api.configureAutomationProbe({
|
||||
paused: false,
|
||||
history: [],
|
||||
uploadLog: [],
|
||||
completionRows: [{
|
||||
path: restoredFile.path,
|
||||
size: restoredFile.size,
|
||||
mtimeMs: restoredFile.mtimeMs,
|
||||
hoster: restoredJob.hoster,
|
||||
completedAt: 1787828500002
|
||||
}]
|
||||
});
|
||||
await _autoDeduplicateFromLog();
|
||||
queueJobs = [{
|
||||
id: 'blocked-restored-evidence',
|
||||
file: 'C:\\history\\blocked-restored-evidence.mkv',
|
||||
fileName: 'blocked-restored-evidence.mkv',
|
||||
hoster: 'doodstream.com',
|
||||
status: 'preview',
|
||||
bytesTotal: 4
|
||||
}];
|
||||
selectedFiles = [];
|
||||
rebuildJobIndex();
|
||||
config.globalSettings.autoStartRestoredQueue = true;
|
||||
_startupAutoResumeController = null;
|
||||
window.api.configureAutomationProbe({ paused: false, completionError: 'ledger unavailable' });
|
||||
const failedEvidenceResult = await _autoDeduplicateFromLog();
|
||||
scheduleRestoredQueueAutoStart();
|
||||
const failedEvidence = {
|
||||
result: failedEvidenceResult,
|
||||
available: typeof _startupQueueEvidenceAvailable === 'undefined' ? null : _startupQueueEvidenceAvailable,
|
||||
controllerCreated: _startupAutoResumeController !== null
|
||||
};
|
||||
cancelStartupQueueAutoStart();
|
||||
config.globalSettings.autoStartRestoredQueue = false;
|
||||
const completedEvidence = {
|
||||
removedAfterDone,
|
||||
completedKeyPresent,
|
||||
admittedFiles: completedResult.admittedFiles.length,
|
||||
matchingQueueJobs: queueJobs.filter(job => normalizeAutomationPath(job.file) === normalizeAutomationPath(completedFile.path)).length,
|
||||
startOrInjectCalls: completedProbe.mutationCalls.filter(call => call[0] === 'start' || call[0] === 'inject').length
|
||||
startOrInjectCalls: completedProbe.mutationCalls.filter(call => call[0] === 'start' || call[0] === 'inject').length,
|
||||
durableAlreadyProcessed: durableEvaluation.summary.alreadyProcessed,
|
||||
durableResultingJobs: durableEvaluation.summary.resultingJobs,
|
||||
changedAlreadyProcessed: changedEvaluation.summary.alreadyProcessed,
|
||||
changedResultingJobs: changedEvaluation.summary.resultingJobs,
|
||||
partialAlreadyProcessed: partialEvaluation.summary.alreadyProcessed,
|
||||
partialResultingJobs: partialEvaluation.summary.resultingJobs,
|
||||
partialHosters: partialEvaluation.candidates[0]?.eligibleHosters || [],
|
||||
restoredQueueRemoved: !queueJobs.some(job => job.id === restoredJob.id),
|
||||
restoredCompletionKey: _completedUploadKeys.has(restoredJob.file + '|' + restoredJob.hoster),
|
||||
failedEvidence
|
||||
};
|
||||
_completedUploadKeys.clear();
|
||||
config.globalSettings.removeFromQueueOnDone = false;
|
||||
@@ -2827,7 +2951,7 @@ app.whenReady().then(async () => {
|
||||
deferredFiles: 70
|
||||
},
|
||||
frozen: true,
|
||||
reads: { history: 1, uploadLog: 1, inspect: 1, status: 0, testScan: 0, reconcile: 0 }
|
||||
reads: { history: 1, uploadLog: 1, completions: 1, inspect: 1, status: 0, testScan: 0, reconcile: 0 }
|
||||
});
|
||||
assert.deepEqual(result.automationPipeline.manualTest, {
|
||||
fingerprintEqual: true,
|
||||
@@ -2843,7 +2967,7 @@ app.whenReady().then(async () => {
|
||||
availableSlots: 1200,
|
||||
deferredFiles: 0
|
||||
},
|
||||
reads: { history: 1, uploadLog: 1, inspect: 1, status: 0, testScan: 1, reconcile: 0 }
|
||||
reads: { history: 1, uploadLog: 1, completions: 1, inspect: 1, status: 0, testScan: 1, reconcile: 0 }
|
||||
});
|
||||
assert.deepEqual(result.automationPipeline.historyEvidence, {
|
||||
alreadyProcessed: 2,
|
||||
@@ -2855,7 +2979,17 @@ app.whenReady().then(async () => {
|
||||
completedKeyPresent: true,
|
||||
admittedFiles: 0,
|
||||
matchingQueueJobs: 0,
|
||||
startOrInjectCalls: 0
|
||||
startOrInjectCalls: 0,
|
||||
durableAlreadyProcessed: 1,
|
||||
durableResultingJobs: 0,
|
||||
changedAlreadyProcessed: 0,
|
||||
changedResultingJobs: 1,
|
||||
partialAlreadyProcessed: 0,
|
||||
partialResultingJobs: 1,
|
||||
partialHosters: ['voe.sx'],
|
||||
restoredQueueRemoved: true,
|
||||
restoredCompletionKey: true,
|
||||
failedEvidence: { result: false, available: false, controllerCreated: false }
|
||||
});
|
||||
assert.deepEqual(result.automationPipeline.pendingDedup, {
|
||||
evaluatedNames: ['new.mkv'],
|
||||
|
||||
@@ -105,6 +105,12 @@ test('classifyErrorCategory: aborted is its own bucket (not retryable)', () => {
|
||||
assert.strictEqual(isRetryableCategory('aborted'), false);
|
||||
});
|
||||
|
||||
test('automation completion persistence failures are never retried as uploads', () => {
|
||||
const category = classifyErrorCategory('Automatik-Abschlussnachweis konnte nicht gespeichert werden');
|
||||
assert.strictEqual(category, 'local-persistence');
|
||||
assert.strictEqual(isRetryableCategory(category), false);
|
||||
});
|
||||
|
||||
test('classifyErrorCategory: unknown for everything else', () => {
|
||||
assert.strictEqual(classifyErrorCategory(''), 'unknown');
|
||||
assert.strictEqual(classifyErrorCategory(null), 'unknown');
|
||||
@@ -176,4 +182,5 @@ test('isRetryableCategory: only transient + network + unknown retry-worthy', ()
|
||||
assert.strictEqual(isRetryableCategory('file-rejected'), false);
|
||||
assert.strictEqual(isRetryableCategory('account-error'), false);
|
||||
assert.strictEqual(isRetryableCategory('aborted'), false);
|
||||
assert.strictEqual(isRetryableCategory('local-persistence'), false);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ const assert = require('node:assert');
|
||||
const {
|
||||
formatUploadLogLine,
|
||||
parseUploadLogLine,
|
||||
iterateUploadLogEntries,
|
||||
readUploadLogEntries,
|
||||
summarizeBatchPlan,
|
||||
formatUploadPlanLogLine
|
||||
} = require('../lib/upload-log');
|
||||
@@ -102,6 +104,11 @@ test('parseUploadLogLine skips comments, blanks and malformed lines', () => {
|
||||
assert.equal(parseUploadLogLine(42), null);
|
||||
});
|
||||
|
||||
test('parser distinguishes confirmed uploads from filename-only rows', () => {
|
||||
assert.equal(parseUploadLogLine('2026-08-27 05:40:00|voe.sx|||episode.mkv|').confirmed, false);
|
||||
assert.equal(parseUploadLogLine('2026-08-27 05:40:00|voe.sx|https://voe.sx/e/code||episode.mkv|').confirmed, true);
|
||||
});
|
||||
|
||||
test('parseUploadLogLine: missing/garbage timestamp yields ts=undefined (legacy lines still match by name)', () => {
|
||||
const parsed = parseUploadLogLine('|voe.sx|link||a.mkv|');
|
||||
assert.equal(parsed.hoster, 'voe.sx');
|
||||
@@ -136,3 +143,78 @@ test('SEAM: a leading-space filename round-trips and the gate still drops its gh
|
||||
const { removed } = partitionRestoredJobsByLog([job], [parsed], savedAt);
|
||||
assert.equal(removed.length, 1, 'leading-space filename now matches end-to-end (was a mismatch before)');
|
||||
});
|
||||
|
||||
test('stream reader parses large logs incrementally and yields between bounded line batches', async () => {
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'upload-log-stream-'));
|
||||
const filePath = path.join(directory, 'fileuploader.log');
|
||||
const lines = Array.from({ length: 2505 }, (_, index) => formatUploadLogLine(
|
||||
new Date(2026, 7, 27, 5, 40, index % 60),
|
||||
index % 2 === 0 ? 'voe.sx' : 'doodstream.com',
|
||||
`https://example.invalid/${index}`,
|
||||
`episode-${index}.mkv`
|
||||
)).join('');
|
||||
fs.writeFileSync(filePath, lines, 'utf8');
|
||||
let yields = 0;
|
||||
try {
|
||||
const entries = await readUploadLogEntries(filePath, {
|
||||
yieldEvery: 500,
|
||||
yieldFn: async () => { yields++; }
|
||||
});
|
||||
assert.equal(entries.length, 2505);
|
||||
assert.equal(entries[0].fileName, 'episode-0.mkv');
|
||||
assert.equal(entries.at(-1).fileName, 'episode-2504.mkv');
|
||||
assert.equal(yields, 5);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('upload-log iterator is lazy and rejects oversized lines', async () => {
|
||||
let produced = 0;
|
||||
async function* source() {
|
||||
produced++;
|
||||
yield formatUploadLogLine(new Date(2026, 7, 27, 5, 40, 0), 'voe.sx', 'https://example.invalid/1', 'one.mkv').trimEnd();
|
||||
produced++;
|
||||
yield formatUploadLogLine(new Date(2026, 7, 27, 5, 40, 1), 'voe.sx', 'https://example.invalid/2', 'two.mkv').trimEnd();
|
||||
}
|
||||
const iterator = iterateUploadLogEntries('', { lines: source(), maxLineLength: 65536 });
|
||||
assert.deepEqual(await iterator.next(), {
|
||||
done: false,
|
||||
value: { hoster: 'voe.sx', fileName: 'one.mkv', ts: new Date(2026, 7, 27, 5, 40, 0).getTime(), confirmed: true }
|
||||
});
|
||||
assert.equal(produced, 1);
|
||||
await iterator.return();
|
||||
|
||||
const oversized = iterateUploadLogEntries('', {
|
||||
lines: (async function* () { yield 'x'.repeat(11); })(),
|
||||
maxLineLength: 10
|
||||
});
|
||||
await assert.rejects(async () => { for await (const entry of oversized) void entry; }, /Zeile ist zu lang/);
|
||||
|
||||
let destroyed = 0;
|
||||
const input = {
|
||||
async *[Symbol.asyncIterator]() { yield 'x'.repeat(11); },
|
||||
destroy: () => { destroyed++; }
|
||||
};
|
||||
const leaking = iterateUploadLogEntries('ignored.log', {
|
||||
fs: { createReadStream: () => input },
|
||||
maxLineLength: 10
|
||||
});
|
||||
await assert.rejects(async () => { for await (const entry of leaking) void entry; }, /Zeile ist zu lang/);
|
||||
assert.equal(destroyed, 1);
|
||||
|
||||
const oversizedStream = iterateUploadLogEntries('ignored.log', {
|
||||
fs: {
|
||||
createReadStream: () => ({
|
||||
async *[Symbol.asyncIterator]() { yield 'x'.repeat(11); },
|
||||
destroy() {}
|
||||
})
|
||||
},
|
||||
maxBytes: 10,
|
||||
maxLineLength: 100
|
||||
});
|
||||
await assert.rejects(async () => { for await (const entry of oversizedStream) void entry; }, /Leselimit/);
|
||||
});
|
||||
|
||||
@@ -151,8 +151,8 @@ describe('UploadManager', () => {
|
||||
mgr.on('batch-done', (s) => { summary = s; });
|
||||
|
||||
await mgr.startBatch([
|
||||
{ file: '/test/video1.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
|
||||
{ file: '/test/video2.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
{ jobId: 'summary-1', file: '/test/video1.mp4', hoster: 'doodstream.com', apiKey: 'key1' },
|
||||
{ jobId: 'summary-2', file: '/test/video2.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
]);
|
||||
|
||||
assert.ok(summary);
|
||||
@@ -160,6 +160,7 @@ describe('UploadManager', () => {
|
||||
assert.equal(summary.succeeded, 2);
|
||||
assert.equal(summary.failed, 0);
|
||||
assert.equal(summary.files.length, 2);
|
||||
assert.deepEqual(summary.files.map(file => file.results[0].jobId).sort(), ['summary-1', 'summary-2']);
|
||||
});
|
||||
|
||||
it('emits a final idle stats snapshot after a normal batch', async () => {
|
||||
|
||||
Reference in New Issue
Block a user