feat: add secure batch completion reports

Generate immutable post-cleanup summaries only after queue, history, and recovery finalization. Add bilingual accessible report UI, host and cleanup metrics, report-bound JSON and sanitized error CSV exports, renderer reload recovery, duplicate-filename-safe accounting, and public-source verification coverage.
This commit is contained in:
Sucukdeluxe
2026-08-17 00:18:20 +02:00
parent c8170b8556
commit 329d22e5b7
18 changed files with 1282 additions and 36 deletions
+58
View File
@@ -0,0 +1,58 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const mainSource = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
const preloadSource = fs.readFileSync(path.join(__dirname, '..', 'preload.js'), 'utf8');
test('publishes the authoritative batch report only after finalization and source cleanup', () => {
const handler = mainSource.slice(mainSource.indexOf("uploadManager.on('batch-done'"), mainSource.indexOf('// Shutdown after finish'));
const finalization = handler.indexOf('uploadFinalizationBarrier.finalize');
const cleanup = handler.indexOf('sourceCleanup.finishBatch');
const report = handler.indexOf('publishBatchCompletionReport');
assert.ok(finalization >= 0);
assert.ok(cleanup > finalization);
assert.ok(report > cleanup);
});
test('keeps initial and live admission skips in the final batch summary', () => {
assert.match(mainSource, /const uploadBatchAdmissionSkips = new WeakMap\(\)/);
assert.match(mainSource, /uploadBatchAdmissionSkips\.set\(_thisManager, batchAdmissionSkippedJobs\)/);
assert.match(mainSource, /uploadBatchAdmissionSkips\.get\(batchManager\).*push\(\.\.\.skippedJobs\)/s);
assert.match(mainSource, /stats\.mergeSkippedIntoSummary\(summary, batchAdmissionSkippedJobs\)/);
assert.match(mainSource, /fileName: j\.fileName \|\| path\.basename\(j\.file \|\| ''\)/);
assert.match(mainSource, /fileKey: buildBatchFileKey\(j\.file\)/);
assert.match(mainSource, /size: Number\(j\.bytesTotal\) \|\| 0/);
});
test('finalizes cleanup and reports skipped-only and rejected-start batches', () => {
const skippedOnly = mainSource.slice(mainSource.indexOf('if (tasks.length === 0)'), mainSource.indexOf('uploadManager = new UploadManager'));
const rejectedStart = mainSource.slice(mainSource.indexOf('}).catch(async (err) =>'), mainSource.indexOf('logMemorySnapshot(\'batch-start\')'));
assert.match(skippedOnly, /sourceCleanup\.finishBatch/);
assert.match(skippedOnly, /publishBatchCompletionReport/);
assert.match(rejectedStart, /sourceCleanup\.finishBatch/);
assert.match(rejectedStart, /publishBatchCompletionReport/);
});
test('preload exposes report recovery and report-bound exports', () => {
assert.match(preloadSource, /onUploadBatchReport/);
assert.match(preloadSource, /getLastBatchCompletionReport/);
assert.match(preloadSource, /exportBatchCompletionReport/);
assert.match(preloadSource, /removeAllListeners\('upload-batch-report'\)/);
assert.match(mainSource, /shellText\('Der Batch-Bericht ist nicht mehr verfügbar', 'The batch report is no longer available'\)/);
assert.match(mainSource, /shellText\('Ungültiges Exportformat', 'Invalid export format'\)/);
});
test('does not cache or publish a fully aborted batch report', () => {
const publisher = mainSource.slice(
mainSource.indexOf('function publishBatchCompletionReport'),
mainSource.indexOf('function shouldLogHosterToFile')
);
assert.match(publisher, /if \(isAllAborted\(summary\)\) return null/);
assert.ok(publisher.indexOf('isAllAborted(summary)') < publisher.indexOf('batchCompletionReports.set'));
assert.ok(publisher.indexOf('isAllAborted(summary)') < publisher.indexOf("safeSend('upload-batch-report'"));
});
+130
View File
@@ -0,0 +1,130 @@
const test = require('node:test');
const assert = require('node:assert/strict');
test('builds immutable file, job, transfer, cleanup, host and error totals', () => {
const { buildBatchCompletionReport } = require('../lib/batch-completion-report');
const report = buildBatchCompletionReport({
reportId: 'report-1',
startedAt: '2026-08-16T10:00:00.000Z',
completedAt: '2026-08-16T10:00:10.000Z',
cleanupOutcomes: ['deleted', 'blocked', 'source-changed', 'source-missing', 'unsafe-source-type', 'failed', 'setting-disabled'],
summary: {
id: 'batch-1',
files: [
{
name: 'complete.mkv',
size: 100,
results: [
{ jobId: 'done-a', hoster: 'doodstream.com', status: 'done', attempt: 1, maxAttempts: 3 },
{ jobId: 'done-b', hoster: 'voe.sx', status: 'done', attempt: 1, maxAttempts: 2 }
]
},
{
name: 'partial.mkv',
size: 200,
results: [
{ jobId: 'done-c', hoster: 'doodstream.com', status: 'done', attempt: 2, maxAttempts: 3 },
{ jobId: 'error-a', hoster: 'voe.sx', status: 'error', error: 'network timeout', attempt: 2, maxAttempts: 2 }
]
},
{
name: 'failed.mkv',
size: 300,
results: [
{ jobId: 'skip-a', hoster: 'doodstream.com', status: 'skipped', error: 'No account', attempt: 0, maxAttempts: 0 },
{ jobId: 'abort-a', hoster: 'voe.sx', status: 'aborted', error: 'Aborted', attempt: 0, maxAttempts: 2 },
{ jobId: 'error-b', hoster: 'byse.sx', status: 'error', error: 'account full', attempt: 1, maxAttempts: 1, remoteCommitUncertain: true }
]
}
]
}
});
assert.deepEqual(report.files, { total: 3, fullySucceeded: 1, partiallySucceeded: 1, failed: 1 });
assert.deepEqual(report.jobs, { total: 7, succeeded: 3, failed: 2, skipped: 1, aborted: 1 });
assert.deepEqual(report.cleanup, { requested: 6, deleted: 1, blocked: 4, failed: 1 });
assert.deepEqual(report.transfer, { successfulBytes: 400, averageBytesPerSecond: 40 });
assert.deepEqual(report.hosters['doodstream.com'], { total: 3, succeeded: 2, failed: 0, skipped: 1, aborted: 0, successfulBytes: 300 });
assert.equal(report.errors.length, 2);
assert.deepEqual(report.errors.map(error => error.category), ['network', 'account-error']);
assert.equal(report.errors[1].remoteCommitUncertain, true);
assert.equal(report.batchId, 'batch-1');
assert.equal(report.durationSec, 10);
assert.equal(Object.isFrozen(report), true);
assert.equal(Object.isFrozen(report.errors), true);
assert.equal(Object.isFrozen(report.errors[0]), true);
});
test('counts duplicate basenames as separate summary files without exposing local paths', () => {
const { buildBatchCompletionReport } = require('../lib/batch-completion-report');
const report = buildBatchCompletionReport({
summary: {
files: [
{ name: 'C:\\private\\one\\same.mkv', size: 10, results: [{ jobId: 'one', hoster: 'voe.sx', status: 'done' }] },
{ name: '/private/two/same.mkv', size: 10, results: [{ jobId: 'two', hoster: 'voe.sx', status: 'error', error: 'failed at C:\\private\\two\\same.mkv' }] }
]
}
});
assert.equal(report.files.total, 2);
assert.equal(report.files.fullySucceeded, 1);
assert.equal(report.files.failed, 1);
assert.equal(report.errors[0].fileName, 'same.mkv');
assert.doesNotMatch(JSON.stringify(report), /private[\\/](?:one|two)/i);
});
test('redacts configured secrets, opaque tokens, URLs and local paths from errors', () => {
const { buildBatchCompletionReport } = require('../lib/batch-completion-report');
const secret = 'private-api-value';
const opaqueToken = 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4';
const report = buildBatchCompletionReport({
secrets: [secret],
summary: {
files: [{
name: 'secret.mkv',
size: 1,
results: [{
jobId: 'error-secret',
hoster: 'doodstream.com',
status: 'error',
error: `token=${secret} path=D:\\private\\secret.mkv /home/private/customer/file.mkv https://private.example.test/upload/${opaqueToken}`
}]
}]
}
});
const serialized = JSON.stringify(report);
assert.doesNotMatch(serialized, new RegExp(secret));
assert.doesNotMatch(serialized, /D:\\\\private/);
assert.doesNotMatch(serialized, /\/home\/private/);
assert.doesNotMatch(serialized, /private\.example\.test/);
assert.doesNotMatch(serialized, new RegExp(opaqueToken));
assert.match(report.errors[0].message, /<redacted>/);
assert.match(report.errors[0].message, /<redacted-path>/);
});
test('builds a formula-safe English error CSV and handles zero duration', () => {
const { buildBatchCompletionReport, buildBatchErrorCsv } = require('../lib/batch-completion-report');
const report = buildBatchCompletionReport({
startedAt: '2026-08-16T10:00:00.000Z',
completedAt: '2026-08-16T10:00:00.000Z',
summary: {
files: [{
name: '=danger.csv',
size: 8,
results: [{ jobId: '+job', hoster: '@host', status: 'error', error: '-CMD()', attempt: 1, maxAttempts: 1 }]
}]
}
});
const csv = buildBatchErrorCsv(report);
assert.equal(report.transfer.averageBytesPerSecond, 0);
assert.match(csv, /^Job ID,File name,Host,Status,Category,Attempt,Max attempts,Remote commit uncertain,Message\n/);
assert.match(csv, /'\+job/);
assert.match(csv, /'=danger\.csv/);
assert.match(csv, /'@host/);
assert.match(csv, /'-CMD\(\)/);
const prefixed = buildBatchErrorCsv({ errors: [{ message: '\t=HYPERLINK("https://example.test")' }] });
assert.match(prefixed, /'\t=HYPERLINK/);
assert.equal(csv.endsWith('\n'), true);
});
+21
View File
@@ -422,6 +422,27 @@ test('mergeSkippedIntoSummary adds skipped jobs to totals and history files', ()
});
});
test('mergeSkippedIntoSummary keeps duplicate basenames separated by file key', () => {
const summary = {
total: 2,
succeeded: 2,
failed: 0,
skipped: 0,
files: [
{ name: 'same.mkv', fileKey: 'file-one', size: 10, results: [{ jobId: 'done-one', hoster: 'voe.sx', status: 'done' }] },
{ name: 'same.mkv', fileKey: 'file-two', size: 20, results: [{ jobId: 'done-two', hoster: 'voe.sx', status: 'done' }] }
]
};
const merged = mergeSkippedIntoSummary(summary, [
{ jobId: 'skip-one', fileName: 'same.mkv', fileKey: 'file-one', hoster: 'byse.sx', reason: 'Kein Account' },
{ jobId: 'skip-two', fileName: 'same.mkv', fileKey: 'file-two', hoster: 'doodstream.com', reason: 'Kein Account' }
]);
assert.strictEqual(merged.files.length, 2);
assert.deepStrictEqual(merged.files[0].results.map(result => result.jobId), ['done-one', 'skip-one']);
assert.deepStrictEqual(merged.files[1].results.map(result => result.jobId), ['done-two', 'skip-two']);
});
test('isRetryableCategory: only transient + network + unknown retry-worthy', () => {
assert.strictEqual(isRetryableCategory('hoster-transient'), true);
assert.strictEqual(isRetryableCategory('network'), true);
+150 -2
View File
@@ -118,6 +118,48 @@ let releaseBlockedWrite = null;
let blockedHistoryWriteMarker = '';
let blockedHistoryWriteStarted = false;
let releaseBlockedHistoryWrite = null;
const successfulBatchCompletionReport = {
reportId: 'ui-report-success',
batchId: 'ui-batch-success',
startedAt: '2026-08-16T10:00:00.000Z',
completedAt: '2026-08-16T10:00:12.000Z',
durationSec: 12,
files: { total: 2, fullySucceeded: 2, partiallySucceeded: 0, failed: 0 },
jobs: { total: 4, succeeded: 4, failed: 0, skipped: 0, aborted: 0 },
cleanup: { requested: 2, deleted: 2, blocked: 0, failed: 0 },
transfer: { successfulBytes: 3145728, averageBytesPerSecond: 262144 },
hosters: {
'doodstream.com': { total: 2, succeeded: 2, failed: 0, skipped: 0, aborted: 0, successfulBytes: 1572864 },
'voe.sx': { total: 2, succeeded: 2, failed: 0, skipped: 0, aborted: 0, successfulBytes: 1572864 }
},
errors: []
};
const mixedBatchCompletionReport = {
reportId: 'ui-report-mixed',
batchId: 'ui-batch-mixed',
startedAt: '2026-08-16T11:00:00.000Z',
completedAt: '2026-08-16T11:01:40.000Z',
durationSec: 100,
files: { total: 4, fullySucceeded: 1, partiallySucceeded: 1, failed: 2 },
jobs: { total: 10, succeeded: 3, failed: 5, skipped: 1, aborted: 1 },
cleanup: { requested: 6, deleted: 2, blocked: 3, failed: 1 },
transfer: { successfulBytes: 5242880, averageBytesPerSecond: 52428.8 },
hosters: {
'doodstream.com': { total: 4, succeeded: 2, failed: 2, skipped: 0, aborted: 0, successfulBytes: 4194304 },
'voe.sx': { total: 3, succeeded: 1, failed: 1, skipped: 1, aborted: 0, successfulBytes: 1048576 },
'byse.sx': { total: 3, succeeded: 0, failed: 2, skipped: 0, aborted: 1, successfulBytes: 0 }
},
errors: [
{ jobId: 'mixed-1', fileName: 'alpha.mkv', hoster: 'doodstream.com', status: 'error', category: 'network', attempt: 2, maxAttempts: 3, remoteCommitUncertain: false, message: 'Connection timed out' },
{ jobId: 'mixed-2', fileName: 'beta.mkv', hoster: 'voe.sx', status: 'error', category: 'account-error', attempt: 1, maxAttempts: 1, remoteCommitUncertain: false, message: 'Account rejected' },
{ jobId: 'mixed-3', fileName: 'gamma.mkv', hoster: 'byse.sx', status: 'error', category: 'file-rejected', attempt: 1, maxAttempts: 2, remoteCommitUncertain: false, message: 'File rejected' },
{ jobId: 'mixed-4', fileName: 'delta.mkv', hoster: 'doodstream.com', status: 'error', category: 'hoster-transient', attempt: 3, maxAttempts: 3, remoteCommitUncertain: true, message: 'Remote completion is uncertain' },
{ jobId: 'mixed-5', fileName: 'epsilon.mkv', hoster: 'byse.sx', status: 'error', category: 'unknown', attempt: 1, maxAttempts: 1, remoteCommitUncertain: false, message: 'Unknown response' },
{ jobId: 'mixed-6', fileName: 'zeta.mkv', hoster: 'voe.sx', status: 'done', category: 'unknown', attempt: 1, maxAttempts: 2, remoteCommitUncertain: true, message: 'Confirmation missing' }
]
};
let batchCompletionReportReads = 0;
const batchCompletionExportCalls = [];
ConfigStore.prototype._atomicWrite = function (data) {
activeConfigStore = this;
if (blockedWriteMarker && !blockedWriteStarted && String(data).includes(blockedWriteMarker)) {
@@ -294,6 +336,11 @@ setTimeout(async () => {
await saveSettings({ feedbackText: 'Saved' });
return new URL(location.href).searchParams.get('language');
})()\`);
ipcMain.removeHandler('get-last-batch-completion-report');
registerIpcHandler('get-last-batch-completion-report', () => {
batchCompletionReportReads++;
return successfulBatchCompletionReport;
});
const languageReloadFinished = new Promise(resolve => wc.once('did-finish-load', resolve));
wc.reload();
await languageReloadFinished;
@@ -301,6 +348,22 @@ setTimeout(async () => {
if (typeof config !== 'object' || config.globalSettings?.language !== 'en') return '';
return [document.documentElement.lang, new URL(location.href).searchParams.get('language'), [...document.querySelectorAll('.tab')].map(tab => tab.textContent.trim()).join(',')].join('|');
})()\`));
const startupBatchReportState = await waitUntil(() => wc.executeJavaScript(\`(() => {
const modal = document.getElementById('batchCompletionModal');
if (!modal || modal.style.display === 'none') return null;
return {
title: document.getElementById('batchCompletionTitle')?.textContent.trim(),
outcome: modal.dataset.outcome,
files: ['Total', 'FullySucceeded', 'PartiallySucceeded', 'Failed'].map(key => document.getElementById('batchCompletionFiles' + key)?.textContent.trim()).join('|'),
jobs: ['Total', 'Succeeded', 'Failed', 'Skipped', 'Aborted'].map(key => document.getElementById('batchCompletionJobs' + key)?.textContent.trim()).join('|'),
hosters: [...document.querySelectorAll('#batchCompletionHostersBody tr')].map(row => row.dataset.hoster).join('|'),
errorsHidden: document.getElementById('batchCompletionErrorsSection')?.hidden,
focused: document.activeElement?.id,
isolated: [...document.body.children].filter(element => element !== modal).every(element => element.inert)
};
})()\`));
check('Startup fetch shows the latest successful batch report exactly once', batchCompletionReportReads === 1 && startupBatchReportState?.title === 'Batch complete' && startupBatchReportState?.outcome === 'success' && startupBatchReportState?.files === '2|2|0|0' && startupBatchReportState?.jobs === '4|4|0|0|0');
check('Successful batch report renders hosts, hides empty errors and isolates the background', startupBatchReportState?.hosters === 'doodstream.com|voe.sx' && startupBatchReportState?.errorsHidden === true && startupBatchReportState?.focused === 'batchCompletionHeaderCloseBtn' && startupBatchReportState?.isolated === true);
const germanLanguageQuery = await wc.executeJavaScript(\`(async () => {
const input = document.getElementById('languageInput');
input.value = 'de';
@@ -310,6 +373,89 @@ setTimeout(async () => {
})()\`);
check('Saved language remains the startup language after a renderer reload', englishLanguageQuery === 'en' && reloadedLanguageState === 'en|en|Upload,Accounts,Settings,History' && germanLanguageQuery.query === 'de' && germanLanguageQuery.active === 'de');
const germanBatchReportState = await wc.executeJavaScript(\`(() => ({
title: document.getElementById('batchCompletionTitle')?.textContent.trim(),
filesHeading: document.getElementById('batchCompletionFilesTitle')?.textContent.trim(),
exportJson: document.getElementById('batchCompletionExportJsonBtn')?.textContent.trim(),
exportCsv: document.getElementById('batchCompletionExportCsvBtn')?.textContent.trim()
}))()\`);
check('Open batch report switches completely from English to German without restart', germanBatchReportState.title === 'Batch abgeschlossen' && germanBatchReportState.filesHeading === 'Dateien' && germanBatchReportState.exportJson === 'JSON exportieren' && germanBatchReportState.exportCsv === 'Fehler-CSV exportieren');
await wc.executeJavaScript('document.getElementById("batchCompletionCloseBtn")?.click()');
win.webContents.send('upload-batch-report', successfulBatchCompletionReport);
await new Promise(resolve => setTimeout(resolve, 80));
const duplicateBatchReportHidden = await wc.executeJavaScript('document.getElementById("batchCompletionModal")?.style.display === "none"');
check('The same batch report event is ignored after its reportId was already shown', duplicateBatchReportHidden === true);
await wc.executeJavaScript('document.getElementById("addFilesBtn")?.focus()');
win.webContents.send('upload-batch-report', mixedBatchCompletionReport);
const mixedBatchReportState = await waitUntil(() => wc.executeJavaScript(\`(() => {
const modal = document.getElementById('batchCompletionModal');
if (!modal || modal.style.display === 'none' || modal.dataset.reportId !== 'ui-report-mixed') return null;
return {
outcome: modal.dataset.outcome,
files: ['Total', 'FullySucceeded', 'PartiallySucceeded', 'Failed'].map(key => document.getElementById('batchCompletionFiles' + key)?.textContent.trim()).join('|'),
jobs: ['Total', 'Succeeded', 'Failed', 'Skipped', 'Aborted'].map(key => document.getElementById('batchCompletionJobs' + key)?.textContent.trim()).join('|'),
cleanup: ['Requested', 'Deleted', 'Blocked', 'Failed'].map(key => document.getElementById('batchCompletionCleanup' + key)?.textContent.trim()).join('|'),
errorCount: document.querySelectorAll('#batchCompletionErrorsList > li').length,
more: document.getElementById('batchCompletionErrorsMore')?.textContent.trim(),
uncertain: document.querySelectorAll('#batchCompletionErrorsList .batch-completion-error-uncertain').length,
hosters: [...document.querySelectorAll('#batchCompletionHostersBody tr')].map(row => row.dataset.hoster).join('|')
};
})()\`));
check('Mixed batch report renders all file, job, cleanup and host metrics', mixedBatchReportState?.outcome === 'mixed' && mixedBatchReportState?.files === '4|1|1|2' && mixedBatchReportState?.jobs === '10|3|5|1|1' && mixedBatchReportState?.cleanup === '6|2|3|1' && mixedBatchReportState?.hosters === 'doodstream.com|voe.sx|byse.sx');
check('Mixed batch report limits visible error examples to five and marks uncertain completion', mixedBatchReportState?.errorCount === 5 && mixedBatchReportState?.more === '1 weiterer Fehler' && mixedBatchReportState?.uncertain === 1);
const standardBatchReportFit = await wc.executeJavaScript(\`(() => {
const card = document.querySelector('#batchCompletionModal .batch-completion-card')?.getBoundingClientRect();
const body = document.querySelector('#batchCompletionModal .batch-completion-body');
return Boolean(card && body && card.left >= 0 && card.right <= innerWidth && card.top >= 0 && card.bottom <= innerHeight && body.scrollWidth <= body.clientWidth + 1);
})()\`);
check('Batch report fits the standard window without horizontal overflow', standardBatchReportFit === true);
await setWindowBounds({ ...originalBounds, width: 800, height: 550 });
const minimumBatchReportFit = await wc.executeJavaScript(\`(() => {
const card = document.querySelector('#batchCompletionModal .batch-completion-card')?.getBoundingClientRect();
const body = document.querySelector('#batchCompletionModal .batch-completion-body');
const footer = document.querySelector('#batchCompletionModal .modal-footer')?.getBoundingClientRect();
return Boolean(card && body && footer && card.left >= 0 && card.right <= innerWidth && card.top >= 0 && card.bottom <= innerHeight && body.scrollWidth <= body.clientWidth + 1 && footer.bottom <= card.bottom + 1);
})()\`);
check('Batch report remains readable and contained at the minimum window size', minimumBatchReportFit === true);
await setWindowBounds(originalBounds);
ipcMain.removeHandler('export-batch-completion-report');
registerIpcHandler('export-batch-completion-report', (_event, reportId, format) => {
batchCompletionExportCalls.push({ reportId, format });
return { ok: true, reportId, format, path: 'C:/ui/report.' + format };
});
await wc.executeJavaScript('document.getElementById("batchCompletionExportJsonBtn")?.click()');
await waitUntil(() => batchCompletionExportCalls.length === 1);
await wc.executeJavaScript('document.getElementById("batchCompletionExportCsvBtn")?.click()');
await waitUntil(() => batchCompletionExportCalls.length === 2);
check('Batch report exports bind JSON and error CSV to the exact visible reportId', batchCompletionExportCalls.length === 2 && batchCompletionExportCalls[0].reportId === 'ui-report-mixed' && batchCompletionExportCalls[0].format === 'json' && batchCompletionExportCalls[1].reportId === 'ui-report-mixed' && batchCompletionExportCalls[1].format === 'csv');
restoreInitialIpcHandler('export-batch-completion-report');
restoreInitialIpcHandler('get-last-batch-completion-report');
const batchReportFocusTrap = await wc.executeJavaScript(\`(() => {
const first = document.getElementById('batchCompletionHeaderCloseBtn');
const last = document.getElementById('batchCompletionCloseBtn');
first.focus();
first.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true, cancelable: true }));
const backward = document.activeElement?.id;
last.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
return { backward, forward: document.activeElement?.id };
})()\`);
check('Batch report traps forward and backward keyboard focus', batchReportFocusTrap.backward === 'batchCompletionCloseBtn' && batchReportFocusTrap.forward === 'batchCompletionHeaderCloseBtn');
await wc.executeJavaScript('document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true }))');
const escapedBatchReportState = await wc.executeJavaScript(\`(() => {
const modal = document.getElementById('batchCompletionModal');
return {
hidden: modal?.style.display === 'none' && modal?.getAttribute('aria-hidden') === 'true',
focus: document.activeElement?.id,
isolated: [...document.body.children].some(element => element !== modal && element.inert)
};
})()\`);
check('Escape closes the batch report, restores focus and removes background isolation', escapedBatchReportState.hidden && escapedBatchReportState.focus === 'addFilesBtn' && escapedBatchReportState.isolated === false);
await wc.executeJavaScript('queueJobs = []; selectedFiles = []; selectedJobIds.clear(); rebuildJobIndex(); setUploadSidebarFilter("all"); updateUploadView(); renderQueueTable(); updateStatusBar();');
console.log('\\n=== Upload View ===');
@@ -2764,6 +2910,7 @@ setTimeout(async () => {
await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")');
console.log('\\n=== History View ===');
await wc.executeJavaScript('document.getElementById("batchCompletionModal")?.style.display !== "none" && document.getElementById("batchCompletionCloseBtn")?.click()');
let historyFixture = [{
timestamp: '2026-08-10T10:00:00.000Z',
@@ -3359,7 +3506,7 @@ setTimeout(async () => {
[...document.querySelectorAll('[role="dialog"]')].every(dialog => dialog.getAttribute('aria-modal') === 'true' && dialog.tabIndex === -1),
[...document.querySelectorAll('[role="dialog"]')].every(dialog => dialog.parentElement?.getAttribute('aria-hidden') === 'true')
].join('|'))()\`);
check('Every renderer dialog has complete hidden modal semantics', modalSemantics === '8|true|true');
check('Every renderer dialog has complete hidden modal semantics', modalSemantics === '9|true|true');
const rapidViewStability = await wc.executeJavaScript(\`(async () => {
const sequence = ['upload', 'accounts', 'settings', 'history', 'settings', 'accounts', 'upload', 'history', 'upload', 'accounts', 'history', 'settings'];
@@ -3752,6 +3899,7 @@ setTimeout(async () => {
check('Renderer initialization failures notify main with serializable details', typeof initializationFailureSignal?.message === 'string' && initializationFailureSignal.message.includes('Injected renderer initialization failure') && typeof initializationFailureSignal.stack === 'string');
check('Renderer initialization failure recovery restores the real interface', initializationRecovery === true);
await wc.executeJavaScript('document.getElementById("batchCompletionModal")?.style.display !== "none" && document.getElementById("batchCompletionCloseBtn")?.click()');
const updateOverlayState = await wc.executeJavaScript('_knownUpdateInfo = { available: true, remoteVersion: "9.9.9" }; _syncHeaderUpdateState(); document.getElementById("headerUpdateBtn").focus(); showUpdateBanner({ remoteVersion: "9.9.9", releaseNotes: { de: "\\\\n\\\\n\\\\n## Neu in dieser Version\\\\n\\\\n\\\\n### Menüs und Navigation\\\\n\\\\n- Direkter Sprachwechsel hinzugefügt.\\\\n- Einstellungsdarstellung verbessert.\\\\n\\\\n\\\\n", en: "## New in this version\\\\n\\\\n### Menus and navigation\\\\n\\\\n- Added live language switching.\\\\n- Improved settings layout." } }); (() => { const overlay = document.getElementById("updateBanner"); const dialog = overlay?.querySelector(".update-dialog"); const button = document.getElementById("headerUpdateBtn"); return [overlay?.classList.contains("update-overlay"), overlay?.style.display, dialog?.getAttribute("role"), dialog?.getAttribute("aria-modal"), button?.hidden, getComputedStyle(button).display].join("|"); })()');
check('Available update opens an accessible update dialog', updateOverlayState === 'true|flex|dialog|true|false|flex');
@@ -3785,7 +3933,7 @@ setTimeout(async () => {
const updateHeaderHint = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [button?.textContent?.trim(), button?.getAttribute("aria-label"), button?.dataset.tooltip].join("|"); })()');
check('Available update gives the header action a matching hint', updateHeaderHint === 'Update verfügbar|Update v9.9.9 verfügbar. Klicken zum Installieren.|Update v9.9.9 verfügbar. Klicken zum Installieren.');
const updateDialogDismissed = await wc.executeJavaScript('document.getElementById("dismissUpdateBtn")?.click(); (() => { const overlay = document.getElementById("updateBanner"); return [overlay?.style.display, overlay?.getAttribute("aria-hidden"), document.activeElement?.id, document.querySelector(".app-header")?.inert, document.querySelector(".view.active")?.inert].join("|"); })()');
const updateDialogDismissed = await wc.executeJavaScript('document.getElementById("batchCompletionModal")?.style.display !== "none" && document.getElementById("batchCompletionCloseBtn")?.click(); document.getElementById("dismissUpdateBtn")?.click(); (() => { const overlay = document.getElementById("updateBanner"); return [overlay?.style.display, overlay?.getAttribute("aria-hidden"), document.activeElement?.id, document.querySelector(".app-header")?.inert, document.querySelector(".view.active")?.inert].join("|"); })()');
check('Update dialog closes and restores focus and background', updateDialogDismissed === 'none|true|headerUpdateBtn|false|false');
const busyUpdateState = await wc.executeJavaScript(\`(() => {
+3 -2
View File
@@ -264,8 +264,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' }
{ file: '/test/video1.mp4', fileKey: 'video-one', hoster: 'doodstream.com', apiKey: 'key1' },
{ file: '/test/video2.mp4', fileKey: 'video-two', hoster: 'doodstream.com', apiKey: 'key1' }
]);
assert.ok(summary);
@@ -273,6 +273,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.fileKey), ['video-one', 'video-two']);
assert.ok(summary.files.flatMap(file => file.results).every(result => typeof result.jobId === 'string' && result.jobId.length > 0));
});
+9
View File
@@ -17,6 +17,15 @@ test('catastrophic batch starts retain exact terminal outcomes for every job', (
]);
});
test('catastrophic batch summaries preserve safe file keys for later skipped-job merging', () => {
const { buildFailedUploadSummary } = require('../lib/upload-recovery');
const summary = buildFailedUploadSummary([
{ jobId: 'job-a', file: 'C:\\one\\same.mkv', fileKey: 'safe-file-key', hoster: 'doodstream.com' }
], 'Upload konnte nicht gestartet werden');
assert.equal(summary.files[0].fileKey, 'safe-file-key');
});
test('terminal recovery snapshots retain exact job outcomes and canonical links', () => {
const { buildTerminalJobSnapshots } = require('../lib/upload-recovery');
const snapshots = buildTerminalJobSnapshots({