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:
@@ -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'"));
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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(\`(() => {
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user