release: Multi-Hoster-Upload 2.1.7

This commit is contained in:
Sucukdeluxe
2026-08-11 16:10:14 +02:00
parent f6b20a8bc8
commit 622460d844
28 changed files with 2368 additions and 80 deletions
+13
View File
@@ -83,6 +83,11 @@ describe('ConfigStore', () => {
assert.deepEqual(config.history, []);
});
it('keeps permanent source deletion disabled by default', () => {
const config = store.load();
assert.equal(config.globalSettings.deleteSourceAfterSuccessfulUpload, false);
});
it('save then load round-trips', async () => {
await store.save({ hosters: { 'doodstream.com': [{ id: 'test-1', enabled: true, authType: 'api', apiKey: 'test-key-123' }] } });
const config = store.load();
@@ -664,6 +669,14 @@ describe('ConfigStore history split (electron-history.json)', () => {
assert.equal(JSON.parse(fs.readFileSync(s.filePath, 'utf-8')).globalSettings.historyRetention, 'all');
});
it('appendHistory rejects when the migrated history file is corrupted', async () => {
writeConfigWithHistory(12);
s._migrateHistory();
fs.writeFileSync(s.historyPath, '{broken-history', 'utf-8');
await assert.rejects(s.appendHistory({ id: 'unsafe-delete-barrier', files: [] }), /Verlaufsdatei ist beschädigt/);
});
it('pruneHistory trims history.json and persists the retention setting', async () => {
writeConfigWithHistory(12);
s._migrateHistory();
+174
View File
@@ -1,8 +1,16 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const espree = require('espree');
const { normalizeLanguage, translateText } = require('../renderer/i18n');
test('translates permanent source deletion controls to English', () => {
assert.equal(translateText('Quelldatei nach vollständigem Upload dauerhaft löschen', 'en'), 'Permanently delete source file after complete upload');
assert.equal(translateText('Dauerhaftes Löschen aktivieren', 'en'), 'Enable permanent deletion');
});
test('English is the fallback language and German remains selectable', () => {
assert.equal(normalizeLanguage(), 'en');
assert.equal(normalizeLanguage('fr'), 'en');
@@ -61,3 +69,169 @@ test('English copy uses complete actions and correct singular plurals', () => {
assert.equal(translateText('Aktiv auf Port 9100 — 1 Client verbunden', 'en'), 'Active on port 9100 — 1 client connected');
assert.equal(translateText('Aktiv auf Port 9100 — 2 Clients verbunden', 'en'), 'Active on port 9100 — 2 clients connected');
});
test('rare account, backup, update, and confirmation states translate in both directions', () => {
const cases = [
['Einstellungen konnten vor dem Update nicht gespeichert werden', 'Settings could not be saved before the update'],
['Das Update wurde nicht gestartet, weil die Einstellungen vor dem Beenden nicht gespeichert werden konnten', 'The update was not started because the settings could not be saved before quitting'],
['Login ok, Upload-Seite bereit', 'Login successful, upload page ready'],
['Login oder API Key fehlt', 'Login or API key is missing'],
['Account-Check lieferte kein gültiges JSON', 'Account check did not return valid JSON'],
['Account-Check fehlgeschlagen', 'Account check failed'],
['Upload-Server-Check lieferte kein gültiges JSON', 'Upload server check did not return valid JSON'],
['API Key gültig, Upload-Server verfügbar', 'API key is valid, upload server available'],
['API Key gültig, aktuell kein Server von API (Uploader nutzt Fallback)', 'API key is valid, but the API currently provides no server (the uploader uses a fallback)'],
['API Key gültig, Upload-Server aktuell nicht geliefert', 'API key is valid, but the API currently provides no upload server'],
['Username oder Passwort fehlt', 'Username or password is missing'],
['Upload-URL wurde nicht erkannt', 'Upload URL was not recognized'],
['API Key gültig, aktuell kein Server verfügbar', 'API key is valid, but no server is currently available'],
['API Key ungültig oder Server nicht erreichbar', 'API key is invalid or the server is unreachable'],
['Login ok, aber Upload-Seite liefert kein CSRF-Token', 'Login successful, but the upload page did not provide a CSRF token'],
['API Key fehlt', 'API key is missing'],
['API lieferte kein gültiges JSON', 'API did not return valid JSON'],
['API Key gültig', 'API key is valid'],
['Clouddrop Auth fehlgeschlagen', 'Clouddrop authentication failed'],
['Account-ID fehlt im Check-Payload', 'Account ID is missing from the check payload'],
['Kein Health-Check für diesen Hoster', 'No health check is available for this host'],
['Health-Check fehlgeschlagen', 'Health check failed'],
['Hoster fehlt', 'Host is missing'],
['Validierung fehlgeschlagen', 'Validation failed'],
['Ein Upload wird bereits ausgeführt oder abgeschlossen', 'An upload is already running or completed'],
['Kein gültiger Account für diesen Hoster', 'No valid account is available for this host'],
['Keine gültigen Zugangsdaten für die gewählten Hoster.', 'No valid credentials are available for the selected hosts.'],
['Unbekannter Fehler', 'Unknown error'],
['Kein Log-Pfad gefunden', 'No log path was found'],
['Ungültige URL (muss mit http(s):// beginnen)', 'Invalid URL (must start with http(s)://)'],
['Backup-Datei ist zu groß oder ungültig', 'The backup file is too large or invalid'],
['Online-Sicherungsschlüssel ist ungültig', 'The online backup key is invalid'],
['Ein Update wird bereits vorbereitet', 'An update is already being prepared'],
['Die Anwendung ist noch nicht bereit, das Update sicher zu installieren', 'The application is not yet ready to install the update safely'],
['Update abgebrochen', 'Update canceled'],
['Ausgewählte Einträge entfernen?', 'Remove selected entries?'],
['Export fehlgeschlagen', 'Export failed'],
['Backup exportiert', 'Backup exported'],
['Online-Backup importiert', 'Online backup imported'],
['Passwort', 'Password'],
['Backup importiert', 'Backup imported'],
['Import fehlgeschlagen', 'Import failed'],
['Upload-Start fehlgeschlagen', 'Failed to start upload'],
['erneut versuchbar', 'retryable'],
['manuell', 'manual'],
['Abgebrochen.', 'Canceled.'],
['API-Token neu erzeugen?', 'Generate a new API token?'],
['Der bisherige Token wird sofort ungültig. Verbundene Clients müssen den neuen Token verwenden.', 'The current token will become invalid immediately. Connected clients must use the new token.'],
['Neu erzeugen', 'Generate new'],
['Verbindungs-Code neu erzeugen?', 'Generate a new connection code?'],
['Der bisherige Diagnose-Code wird sofort ungültig.', 'The current diagnostics code will become invalid immediately.'],
['Aktivieren', 'Enable'],
['Bitte den OTP-Code eingeben.', 'Enter the OTP code.'],
['Login fehlgeschlagen', 'Login failed'],
['Aufbewahrung ändern?', 'Change retention?'],
['Update fehlgeschlagen', 'Update failed']
];
for (const [german, english] of cases) {
assert.equal(translateText(german, 'en'), english, german);
assert.equal(translateText(english, 'de'), german, english);
}
});
test('interpolated rare errors translate without leaking German copy', () => {
const cases = [
['Login ok, Upload-Form bereit (Dateifeld: file)', 'Login successful, upload form ready (file field: file)'],
['Klartext-Backup ist kein gültiges JSON: Unexpected token', 'Plain JSON backup is not valid JSON: Unexpected token'],
['Export fehlgeschlagen: Zugriff verweigert', 'Export failed: Zugriff verweigert'],
['Import fehlgeschlagen: Datei beschädigt', 'Import failed: Datei beschädigt'],
['Initialisierung fehlgeschlagen: Konfiguration fehlt', 'Initialization failed: Konfiguration fehlt']
];
for (const [german, english] of cases) assert.equal(translateText(german, 'en'), english, german);
});
test('main-process user-facing copy contains no mojibake', () => {
const source = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8');
assert.doesNotMatch(source, /Ã|Â|â€/u);
});
test('library and updater errors exposed in the UI translate to English', () => {
const cases = [
['Ungültiges Backup-Format', 'Invalid backup format'],
['Keine gültige .mhu Backup-Datei', 'Not a valid .mhu backup file'],
['Falsches Passwort oder beschädigte Datei', 'Incorrect password or damaged file'],
['Online-Sicherungsdienst ist nicht erreichbar', 'The online backup service is unavailable'],
['Online-Sicherung wurde nicht gefunden', 'Online backup was not found'],
['Online-Sicherung konnte nicht gelöscht werden', 'Online backup could not be deleted'],
['Kein Ordnerpfad angegeben', 'No folder path was provided'],
['Kein Update verfügbar', 'No update available'],
['Update-Asset unvollständig (URL oder Name fehlt)', 'The update asset is incomplete (URL or name is missing)'],
['Heruntergeladene Datei ist keine gültige EXE', 'The downloaded file is not a valid EXE'],
['SHA-512 Prüfung fehlgeschlagen', 'SHA-512 verification failed'],
['Datei nicht gefunden', 'File not found'],
['Netzwerkfehler', 'Network error']
];
for (const [german, english] of cases) {
assert.equal(translateText(german, 'en'), english, german);
assert.equal(translateText(english, 'de'), german, english);
}
});
test('interpolated host and updater errors translate their user-facing copy', () => {
const cases = [
['Clouddrop: Datei nicht lesbar: video.mkv', 'Clouddrop: File cannot be read: video.mkv'],
['Doodstream: konnte Upload-Server nicht ermitteln (Endpoint geändert?). Details', 'Doodstream: Could not determine the upload server (endpoint changed?). Details'],
['Doodstream Upload fehlgeschlagen: rejected', 'Doodstream upload failed: rejected'],
['Upload zu VOE wurde vom Server abgelehnt.', 'The upload to VOE was rejected by the server.'],
['Vidmoly Upload abgelehnt: rejected', 'Vidmoly upload rejected: rejected'],
['VOE Upload-Fehler: rejected', 'VOE upload error: rejected'],
['Download fehlgeschlagen: HTTP 503', 'Download failed: HTTP 503']
];
for (const [german, english] of cases) assert.equal(translateText(german, 'en'), english, german);
});
test('German runtime error copy in the application source has an English translation', () => {
const files = [
path.join(__dirname, '..', 'main.js'),
path.join(__dirname, '..', 'renderer', 'app.js'),
...fs.readdirSync(path.join(__dirname, '..', 'lib'))
.filter(file => file.endsWith('.js'))
.map(file => path.join(__dirname, '..', 'lib', file))
];
const germanCopy = /[äöüß]|\b(?:der|die|das|ein|eine|kein|keine|nicht|fehlgeschlagen|ungültig|beschädigt|wurde|werden|konnte|können|verfügbar|gefunden|erhalten|abgelehnt|Antwort|Datei|Einstellungen|Upload-Server|Prüfung|Passwort|Session|Verlauf|Sicherung|Fehler)\b/i;
const candidates = [];
const addCandidate = (file, node) => {
if (node?.type === 'Literal' && typeof node.value === 'string') candidates.push([file, node.loc.start.line, node.value]);
if (node?.type === 'TemplateLiteral') {
if (node.expressions.some(expression => expression.type === 'ConditionalExpression')) return;
let sample = '';
node.quasis.forEach((part, index) => {
sample += part.value.cooked;
if (index < node.expressions.length) sample += 'X';
});
candidates.push([file, node.loc.start.line, sample]);
}
};
const walk = (file, node) => {
if (!node || typeof node !== 'object') return;
if (node.type === 'NewExpression' && ['Error', 'AggregateError'].includes(node.callee?.name)) addCandidate(file, node.arguments?.[0]);
if (node.type === 'Property' && ['error', 'message', 'reason'].includes(node.key?.name || node.key?.value)) addCandidate(file, node.value);
for (const value of Object.values(node)) {
if (Array.isArray(value)) value.forEach(child => walk(file, child));
else if (value && typeof value === 'object' && value.type) walk(file, value);
}
};
for (const file of files) {
const source = fs.readFileSync(file, 'utf8');
const tree = espree.parse(source, { ecmaVersion: 'latest', sourceType: 'script', loc: true });
walk(path.relative(path.join(__dirname, '..'), file), tree);
}
const untranslated = candidates
.filter(([, , value]) => germanCopy.test(value) && translateText(value, 'en') === value)
.map(([file, line, value]) => `${file}:${line} ${value}`);
assert.deepEqual(untranslated, []);
});
+205
View File
@@ -0,0 +1,205 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const policy = require('../lib/source-cleanup-policy');
function queueFixture() {
return [
{
id: 'job-doodstream',
file: 'C:\\Uploads\\Movie.MKV',
hoster: 'doodstream.com',
status: 'done'
},
{
id: 'job-voe',
file: 'C:\\Uploads\\Movie.MKV',
hoster: 'voe.sx',
status: 'done'
},
{
id: 'job-vidmoly',
file: 'C:\\Uploads\\Movie.MKV',
hoster: 'vidmoly.me',
status: 'error'
},
{
id: 'job-byse',
file: 'C:\\Uploads\\Movie.MKV',
hoster: 'byse.sx',
status: 'preview'
}
];
}
test('prepareGroups creates one Windows manifest with a stable token and immutable hosters', () => {
const queueJobs = queueFixture();
let tokenCalls = 0;
const prepared = policy.prepareGroups(queueJobs, [queueJobs[2]], () => {
tokenCalls += 1;
return 'cleanup-1';
}, 'win32');
assert.equal(tokenCalls, 1);
assert.equal(prepared.groups.length, 1);
assert.equal(prepared.touchedJobs.length, 4);
assert.deepEqual(prepared.groups[0], {
token: 'cleanup-1',
file: 'C:\\Uploads\\Movie.MKV',
requiredHosters: ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx'],
completedHosters: ['doodstream.com', 'voe.sx'],
fingerprint: null,
jobs: [
{ jobId: 'job-doodstream', hoster: 'doodstream.com', status: 'done' },
{ jobId: 'job-voe', hoster: 'voe.sx', status: 'done' },
{ jobId: 'job-vidmoly', hoster: 'vidmoly.me', status: 'error' },
{ jobId: 'job-byse', hoster: 'byse.sx', status: 'preview' }
]
});
assert.deepEqual(queueJobs.map((job) => job.sourceCleanupToken), [
'cleanup-1',
'cleanup-1',
'cleanup-1',
'cleanup-1'
]);
for (const job of queueJobs) {
assert.deepEqual(job.sourceCleanupRequiredHosters, [
'doodstream.com',
'voe.sx',
'vidmoly.me',
'byse.sx'
]);
}
assert.doesNotThrow(() => JSON.stringify(prepared.groups));
});
test('prepareGroups reuses persisted metadata across a partial retry', () => {
const queueJobs = queueFixture();
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
queueJobs[0].sourceCleanupCompletedHosters = ['doodstream.com', 'voe.sx'];
queueJobs[1].sourceCleanupCompletedHosters = ['doodstream.com', 'voe.sx'];
queueJobs[0].status = 'preview';
queueJobs[1].status = 'preview';
const prepared = policy.prepareGroups(queueJobs, [queueJobs[2]], () => {
throw new Error('token must stay stable');
}, 'win32');
assert.equal(prepared.groups[0].token, 'cleanup-1');
assert.deepEqual(prepared.groups[0].requiredHosters, [
'doodstream.com',
'voe.sx',
'vidmoly.me',
'byse.sx'
]);
assert.deepEqual(prepared.groups[0].completedHosters, ['doodstream.com', 'voe.sx']);
assert.equal(prepared.groups[0].jobs.find((job) => job.hoster === 'vidmoly.me').status, 'error');
});
test('prepareGroups extends an active manifest when a new hoster is added', () => {
const queueJobs = queueFixture().slice(0, 2);
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
queueJobs.push({ id: 'job-new', file: 'C:\\Uploads\\Movie.MKV', hoster: 'byse.sx', status: 'preview' });
const prepared = policy.prepareGroups(queueJobs, [queueJobs[2]], () => 'cleanup-2', 'win32');
assert.equal(prepared.groups[0].token, 'cleanup-1');
assert.deepEqual(prepared.groups[0].requiredHosters, ['doodstream.com', 'voe.sx', 'byse.sx']);
});
test('keeps case-distinct Windows paths in separate groups', () => {
const queueJobs = [
{ id: 'upper', file: 'C:\\CaseSensitive\\Movie.mkv', hoster: 'voe.sx', status: 'preview' },
{ id: 'lower', file: 'C:\\CaseSensitive\\movie.mkv', hoster: 'byse.sx', status: 'preview' }
];
const prepared = policy.prepareGroups(queueJobs, queueJobs, (file) => `token-${file}`, 'win32');
assert.equal(prepared.groups.length, 2);
assert.deepEqual(prepared.groups.map((group) => group.requiredHosters), [['voe.sx'], ['byse.sx']]);
});
test('removing a failed job never relaxes the stored requirements', () => {
const queueJobs = queueFixture();
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
const failedJob = queueJobs[2];
const remainingJobs = queueJobs.filter((job) => job !== failedJob);
policy.removeRequirement(remainingJobs, failedJob, 'win32');
const prepared = policy.prepareGroups(remainingJobs, [remainingJobs[2]], () => {
throw new Error('token must stay stable');
}, 'win32');
assert.deepEqual(prepared.groups[0].requiredHosters, [
'doodstream.com',
'voe.sx',
'vidmoly.me',
'byse.sx'
]);
for (const job of remainingJobs) {
assert.deepEqual(job.sourceCleanupRequiredHosters, [
'doodstream.com',
'voe.sx',
'vidmoly.me',
'byse.sx'
]);
}
});
test('removeRequirement drops only an explicitly discarded unstarted hoster', () => {
const queueJobs = queueFixture();
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
const touchedJobs = policy.removeRequirement(queueJobs, queueJobs[3], 'win32');
assert.equal(touchedJobs.length, 4);
for (const job of queueJobs) {
assert.deepEqual(job.sourceCleanupRequiredHosters, [
'doodstream.com',
'voe.sx',
'vidmoly.me'
]);
}
});
test('markCompleted preserves successful hosters for later retries', () => {
const queueJobs = queueFixture();
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
policy.markCompleted(queueJobs, queueJobs[2], 'win32');
policy.markCompleted(queueJobs, queueJobs[2], 'win32');
for (const job of queueJobs) {
assert.deepEqual(job.sourceCleanupCompletedHosters, [
'doodstream.com',
'voe.sx',
'vidmoly.me'
]);
}
});
test('applyFingerprints attaches Main fingerprints by token and includes them in payloads', () => {
const queueJobs = queueFixture();
policy.prepareGroups(queueJobs, queueJobs, () => 'cleanup-1', 'win32');
const fingerprint = {
type: 'file',
size: 1048576,
mtimeMs: 1786467600000,
birthtimeMs: 1786467500000,
dev: 2114,
ino: 9001
};
const touchedJobs = policy.applyFingerprints(queueJobs, { 'cleanup-1': fingerprint });
const prepared = policy.prepareGroups(queueJobs, [queueJobs[3]], () => {
throw new Error('token must stay stable');
}, 'win32');
assert.equal(touchedJobs.length, 4);
assert.deepEqual(prepared.groups[0].fingerprint, fingerprint);
for (const job of queueJobs) {
assert.deepEqual(job.sourceCleanupFingerprint, fingerprint);
assert.notEqual(job.sourceCleanupFingerprint, fingerprint);
}
});
+85
View File
@@ -0,0 +1,85 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const SourceDeleteJournal = require('../lib/source-delete-journal');
test('restores a staged source after an interrupted deletion', async (t) => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mhu-delete-journal-'));
t.after(() => fs.promises.rm(directory, { recursive: true, force: true }));
const file = path.join(directory, 'source.bin');
const stagedFile = path.join(directory, '.source.bin.mhu-delete-test');
const journal = new SourceDeleteJournal(path.join(directory, 'journal.jsonl'));
await fs.promises.writeFile(file, 'source');
await journal.plan({ token: 'cleanup-1', file, stagedFile });
await fs.promises.rename(file, stagedFile);
const outcomes = await journal.recover();
assert.equal(await fs.promises.readFile(file, 'utf-8'), 'source');
assert.equal(outcomes[0].outcome, 'restored');
assert.deepEqual(await journal.recover(), []);
});
test('clears a completed deletion record when neither source nor stage remains', async (t) => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mhu-delete-journal-'));
t.after(() => fs.promises.rm(directory, { recursive: true, force: true }));
const journal = new SourceDeleteJournal(path.join(directory, 'journal.jsonl'));
await journal.plan({ token: 'cleanup-2', file: path.join(directory, 'source.bin'), stagedFile: path.join(directory, '.staged') });
const outcomes = await journal.recover();
assert.equal(outcomes[0].outcome, 'cleared');
assert.deepEqual(await journal.recover(), []);
});
test('continues recovery after one malformed active entry', async (t) => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mhu-delete-journal-'));
t.after(() => fs.promises.rm(directory, { recursive: true, force: true }));
const journal = new SourceDeleteJournal(path.join(directory, 'journal.jsonl'));
const file = path.join(directory, 'source.bin');
const stagedFile = path.join(directory, '.source.bin.mhu-delete-test');
await journal.plan({ token: 'broken', file: null, stagedFile: null });
await fs.promises.writeFile(stagedFile, 'source');
await journal.plan({ token: 'valid', file, stagedFile });
const outcomes = await journal.recover();
assert.equal(outcomes.find((entry) => entry.token === 'broken').outcome, 'recovery-failed');
assert.equal(outcomes.find((entry) => entry.token === 'valid').outcome, 'restored');
assert.equal(await fs.promises.readFile(file, 'utf-8'), 'source');
});
test('retries transient Windows rename locks during recovery', async (t) => {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mhu-delete-journal-'));
t.after(() => fs.promises.rm(directory, { recursive: true, force: true }));
const file = path.join(directory, 'source.bin');
const stagedFile = path.join(directory, '.source.bin.mhu-delete-test');
const journalPath = path.join(directory, 'journal.jsonl');
let renameCalls = 0;
const waits = [];
const injectedFs = {
promises: {
mkdir: (...args) => fs.promises.mkdir(...args),
open: (...args) => fs.promises.open(...args),
readFile: (...args) => fs.promises.readFile(...args),
lstat: (...args) => fs.promises.lstat(...args),
rename: async (...args) => {
renameCalls += 1;
if (renameCalls < 3) throw Object.assign(new Error('locked'), { code: 'EBUSY' });
return fs.promises.rename(...args);
}
}
};
const writer = new SourceDeleteJournal(journalPath);
await fs.promises.writeFile(stagedFile, 'source');
await writer.plan({ token: 'cleanup-retry', file, stagedFile });
const journal = new SourceDeleteJournal(journalPath, null, { fs: injectedFs, wait: async (delay) => waits.push(delay) });
const outcomes = await journal.recover();
assert.equal(outcomes[0].outcome, 'restored');
assert.equal(renameCalls, 3);
assert.deepEqual(waits, [100, 250]);
});
+469
View File
@@ -0,0 +1,469 @@
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { createSourceFileCleanup } = require('../lib/source-file-cleanup');
async function makeSource(t, name = 'source.bin') {
const directory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'mhu-source-cleanup-'));
const file = path.join(directory, name);
await fs.promises.writeFile(file, Buffer.from('original source data'));
t.after(() => fs.promises.rm(directory, { recursive: true, force: true }));
return { directory, file };
}
function group(file, overrides = {}) {
return {
token: 'cleanup-1',
file,
requiredHosters: ['voe.sx', 'byse.sx'],
completedHosters: [],
jobs: [
{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending' },
{ jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending' }
],
...overrides
};
}
function makeCleanup(overrides = {}) {
const audits = [];
const waits = [];
const cleanup = createSourceFileCleanup({
fs,
path,
platform: process.platform,
isEnabled: () => true,
audit: (event) => audits.push(event),
journal: { plan: async () => {}, clear: async () => {} },
wait: async (milliseconds) => waits.push(milliseconds),
...overrides
});
return { cleanup, audits, waits };
}
async function settleDone(cleanup, manifest) {
for (const job of manifest.jobs) {
await cleanup.settle({
token: manifest.token,
jobId: job.jobId,
file: manifest.file,
hoster: job.hoster,
status: 'done'
});
}
}
async function exists(file) {
try {
await fs.promises.lstat(file);
return true;
} catch (error) {
if (error.code === 'ENOENT') return false;
throw error;
}
}
test('fingerprints a regular file and keeps the registered manifest immutable', async (t) => {
const { file } = await makeSource(t);
const { cleanup, audits } = makeCleanup();
const manifest = group(file);
const fingerprints = await cleanup.registerGroups([manifest]);
assert.equal(fingerprints['cleanup-1'].type, 'file');
assert.equal(fingerprints['cleanup-1'].size, Buffer.byteLength('original source data'));
assert.equal(typeof fingerprints['cleanup-1'].mtimeMs, 'number');
assert.equal(typeof fingerprints['cleanup-1'].birthtimeMs, 'number');
assert.equal(typeof fingerprints['cleanup-1'].dev, 'number');
assert.equal(typeof fingerprints['cleanup-1'].ino, 'number');
manifest.requiredHosters.splice(1, 1);
manifest.completedHosters.push('byse.sx');
manifest.jobs[1].hoster = 'voe.sx';
await cleanup.settle({
token: 'cleanup-1',
jobId: 'job-voe',
file,
hoster: 'voe.sx',
status: 'done'
});
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(await exists(file), true);
assert.equal(audits.length, 1);
assert.equal(audits[0].outcome, 'blocked');
assert.deepEqual(audits[0].blockingStatuses, [{ hoster: 'byse.sx', status: 'pending' }]);
});
test('extends a registered group before finalization', async (t) => {
const { file } = await makeSource(t);
const { cleanup, audits } = makeCleanup();
await cleanup.registerGroups([group(file, {
requiredHosters: ['voe.sx'],
jobs: [{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'pending' }]
})]);
await cleanup.registerGroups([group(file)]);
await cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' });
assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
assert.equal(await exists(file), true);
assert.equal(audits[0].blockingStatuses[0].hoster, 'byse.sx');
});
test('deletes only when enabled, history persisted, queue persisted, and every required hoster is done', async (t) => {
const cases = [
{ name: 'setting disabled', enabled: false, historyPersisted: true, queuePersisted: true, outcome: 'setting-disabled' },
{ name: 'history missing', enabled: true, historyPersisted: false, queuePersisted: true, outcome: 'blocked' },
{ name: 'queue missing', enabled: true, historyPersisted: true, queuePersisted: false, outcome: 'blocked' }
];
for (const entry of cases) {
await t.test(entry.name, async (subtest) => {
const { file } = await makeSource(subtest, `${entry.name}.bin`);
const { cleanup, audits } = makeCleanup({ isEnabled: () => entry.enabled });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await cleanup.finishBatch({
historyPersisted: entry.historyPersisted,
queuePersisted: entry.queuePersisted
});
assert.equal(await exists(file), true);
assert.equal(audits.length, 1);
assert.equal(audits[0].outcome, entry.outcome);
});
}
const { file } = await makeSource(t, 'all-barriers.bin');
const { cleanup, audits } = makeCleanup();
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(await exists(file), false);
assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'deleted']);
});
test('collects terminal states and blocks error, aborted, skipped, and pending jobs', async (t) => {
const cases = [
{ status: 'error', apply: (cleanup, file) => cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'error' }) },
{ status: 'aborted', apply: (cleanup, file) => cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'aborted' }) },
{ status: 'skipped', apply: (cleanup) => cleanup.markSkipped('job-byse', 'missing-account') },
{ status: 'pending', apply: (cleanup, file) => cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'uploading' }) }
];
for (const entry of cases) {
await t.test(entry.status, async (subtest) => {
const { file } = await makeSource(subtest, `${entry.status}.bin`);
const { cleanup, audits } = makeCleanup();
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await cleanup.settle({ token: 'cleanup-1', jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' });
await entry.apply(cleanup, file);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(await exists(file), true);
assert.equal(audits.length, 1);
assert.equal(audits[0].outcome, 'blocked');
assert.deepEqual(audits[0].blockingStatuses, [{ hoster: 'byse.sx', status: entry.status }]);
});
}
});
test('combines previous successes with a successful retry without relaxing other requirements', async (t) => {
await t.test('last retry completes the immutable group', async (subtest) => {
const { file } = await makeSource(subtest, 'retry-completes.bin');
const { cleanup } = makeCleanup();
const manifest = group(file, { completedHosters: ['voe.sx'] });
await cleanup.registerGroups([manifest]);
await cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'done' });
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(await exists(file), false);
});
await t.test('an unselected failed requirement still blocks deletion', async (subtest) => {
const { file } = await makeSource(subtest, 'retry-partial.bin');
const { cleanup, audits } = makeCleanup();
const manifest = group(file, {
requiredHosters: ['voe.sx', 'byse.sx', 'vidmoly.me'],
completedHosters: ['voe.sx'],
jobs: [
{ jobId: 'job-voe', file, hoster: 'voe.sx', status: 'done' },
{ jobId: 'job-byse', file, hoster: 'byse.sx', status: 'pending' },
{ jobId: 'job-vidmoly', file, hoster: 'vidmoly.me', status: 'error' }
]
});
await cleanup.registerGroups([manifest]);
await cleanup.settle({ token: 'cleanup-1', jobId: 'job-byse', file, hoster: 'byse.sx', status: 'done' });
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(await exists(file), true);
assert.deepEqual(audits[0].blockingStatuses, [{ hoster: 'vidmoly.me', status: 'error' }]);
});
});
test('audits a changed or missing source without deleting a replacement', async (t) => {
await t.test('source changed', async (subtest) => {
const { file } = await makeSource(subtest, 'changed.bin');
const { cleanup, audits } = makeCleanup();
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await fs.promises.writeFile(file, Buffer.from('replacement source data with a different size'));
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(await exists(file), true);
assert.equal(audits.length, 1);
assert.equal(audits[0].outcome, 'source-changed');
});
await t.test('source missing', async (subtest) => {
const { file } = await makeSource(subtest, 'missing.bin');
const { cleanup, audits } = makeCleanup();
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await fs.promises.unlink(file);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(audits.length, 1);
assert.equal(audits[0].outcome, 'source-missing');
});
});
test('rejects non-regular sources and audits the unsafe type', async (t) => {
const { directory } = await makeSource(t);
const unsafePath = path.join(directory, 'folder-source');
await fs.promises.mkdir(unsafePath);
const { cleanup, audits } = makeCleanup();
const manifest = group(unsafePath);
const fingerprints = await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(fingerprints['cleanup-1'], null);
assert.equal((await fs.promises.lstat(unsafePath)).isDirectory(), true);
assert.equal(audits.length, 1);
assert.equal(audits[0].outcome, 'unsafe-source-type');
});
test('runs unlink exactly once across repeated and concurrent finalization', async (t) => {
const { file } = await makeSource(t);
let unlinkCalls = 0;
const injectedFs = {
promises: {
lstat: (...args) => fs.promises.lstat(...args),
rename: (...args) => fs.promises.rename(...args),
unlink: async (...args) => {
unlinkCalls += 1;
await fs.promises.unlink(...args);
}
}
};
const { cleanup, audits } = makeCleanup({ fs: injectedFs });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await Promise.all([
cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }),
cleanup.finishBatch({ historyPersisted: true, queuePersisted: true })
]);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(unlinkCalls, 1);
assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'deleted']);
});
test('retries EBUSY and EPERM with the bounded Windows delay schedule', async (t) => {
const { file } = await makeSource(t);
const failures = ['EBUSY', 'EPERM', 'EBUSY', 'EPERM', 'EBUSY'];
let unlinkCalls = 0;
const injectedFs = {
promises: {
lstat: (...args) => fs.promises.lstat(...args),
rename: (...args) => fs.promises.rename(...args),
unlink: async (...args) => {
const code = failures[unlinkCalls];
unlinkCalls += 1;
if (code) throw Object.assign(new Error(code), { code });
await fs.promises.unlink(...args);
}
}
};
const { cleanup, audits, waits } = makeCleanup({ fs: injectedFs });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(unlinkCalls, 6);
assert.deepEqual(waits, [100, 250, 500, 1000, 2000]);
assert.equal(await exists(file), false);
assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'deleted']);
assert.equal(audits[2].attempts, 6);
});
test('swallows cleanup failures, preserves the source, and audits one final failure', async (t) => {
await t.test('retryable failure exhausts all delays', async (subtest) => {
const { file } = await makeSource(subtest, 'locked.bin');
let unlinkCalls = 0;
const injectedFs = {
promises: {
lstat: (...args) => fs.promises.lstat(...args),
rename: (...args) => fs.promises.rename(...args),
unlink: async () => {
unlinkCalls += 1;
throw Object.assign(new Error('locked'), { code: 'EBUSY' });
}
}
};
const { cleanup, audits, waits } = makeCleanup({ fs: injectedFs });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await assert.doesNotReject(cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }));
assert.equal(unlinkCalls, 6);
assert.deepEqual(waits, [100, 250, 500, 1000, 2000]);
assert.equal(await exists(file), true);
assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'failed']);
assert.equal(audits[2].attempts, 6);
});
await t.test('non-retryable failure stops immediately', async (subtest) => {
const { file } = await makeSource(subtest, 'denied.bin');
let unlinkCalls = 0;
const injectedFs = {
promises: {
lstat: (...args) => fs.promises.lstat(...args),
rename: (...args) => fs.promises.rename(...args),
unlink: async () => {
unlinkCalls += 1;
throw Object.assign(new Error('denied'), { code: 'EACCES' });
}
}
};
const { cleanup, audits, waits } = makeCleanup({ fs: injectedFs });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await assert.doesNotReject(cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }));
assert.equal(unlinkCalls, 1);
assert.deepEqual(waits, []);
assert.equal(await exists(file), true);
assert.deepEqual(audits.map((event) => event.outcome), ['delete-approved', 'source-staged', 'failed']);
assert.equal(audits[2].attempts, 1);
});
});
test('preserves the original Windows path casing for filesystem operations', async (t) => {
const { file } = await makeSource(t, 'SourceCase.BIN');
const observed = [];
const injectedFs = {
promises: {
lstat: async (target) => {
observed.push(target);
return fs.promises.lstat(target);
},
rename: (...args) => fs.promises.rename(...args),
unlink: (...args) => fs.promises.unlink(...args)
}
};
const { cleanup } = makeCleanup({ fs: injectedFs, platform: 'win32' });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(observed[0], file);
assert.equal(await exists(file), false);
});
test('blocks both case-colliding Windows source groups', async (t) => {
const { directory, file } = await makeSource(t, 'Movie.mkv');
const secondFile = path.join(directory, 'movie.mkv');
await fs.promises.writeFile(secondFile, Buffer.from('other source data'));
const { cleanup } = makeCleanup({ platform: 'win32' });
const first = group(file, { token: 'cleanup-upper' });
const second = group(secondFile, { token: 'cleanup-lower' });
await cleanup.registerGroups([first, second]);
await settleDone(cleanup, first);
await settleDone(cleanup, second);
assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked', 'blocked']);
assert.equal(await exists(file), true);
assert.equal(await exists(secondFile), true);
});
test('blocks deletion when the audit approval cannot be persisted', async (t) => {
const { file } = await makeSource(t, 'audit-failure.bin');
const { cleanup } = makeCleanup({ audit: async () => false });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
assert.equal(await exists(file), true);
});
test('restores the staged source when the deletion commit audit fails', async (t) => {
const { file } = await makeSource(t, 'audit-commit-failure.bin');
let auditCalls = 0;
const { cleanup } = makeCleanup({ audit: async () => ++auditCalls !== 2 });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
assert.deepEqual(await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true }), ['blocked']);
assert.equal(await exists(file), true);
});
test('rechecks the staged file and restores a replacement without deleting it', async (t) => {
const { file } = await makeSource(t, 'stage-race.bin');
let unlinkCalls = 0;
let firstRename = true;
const injectedFs = {
promises: {
lstat: (...args) => fs.promises.lstat(...args),
rename: async (from, to) => {
await fs.promises.rename(from, to);
if (firstRename) {
firstRename = false;
await fs.promises.writeFile(to, Buffer.from('replacement data'));
}
},
unlink: async (...args) => {
unlinkCalls += 1;
return fs.promises.unlink(...args);
}
}
};
const { cleanup, audits } = makeCleanup({ fs: injectedFs });
const manifest = group(file);
await cleanup.registerGroups([manifest]);
await settleDone(cleanup, manifest);
await cleanup.finishBatch({ historyPersisted: true, queuePersisted: true });
assert.equal(unlinkCalls, 0);
assert.equal((await fs.promises.readFile(file, 'utf-8')), 'replacement data');
assert.equal(audits.at(-1).outcome, 'source-changed');
});
+17 -6
View File
@@ -180,9 +180,8 @@ setTimeout(async () => {
check('English upload telemetry is fully localized', englishTelemetryLabels === 'Total|Connections|Remaining|Running|Completed|Failed|Speed|ETA');
const englishLayoutFits = await wc.executeJavaScript('(() => { const states = [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const view = document.querySelector(".view.active"); return view && view.scrollWidth <= view.clientWidth + 1; }); document.querySelector(".tab[data-view=upload]")?.click(); return states.every(Boolean) && document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1; })()');
check('English labels fit every main view without horizontal overflow', englishLayoutFits === true);
await wc.executeJavaScript('document.querySelector(".tab[data-view=accounts]")?.click()');
const hiddenAccountSparkline = await wc.executeJavaScript('document.getElementById("uploadSpeedSparkline")?.classList.contains("is-hidden")');
check('Upload speed sparkline stays reserved but hidden outside Uploads', hiddenAccountSparkline === true);
const speedSparklineAcrossTabs = await wc.executeJavaScript('(() => [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const widget = document.getElementById("uploadSpeedSparkline"); const rect = widget?.getBoundingClientRect(); const style = widget && getComputedStyle(widget); return Boolean(widget && !widget.classList.contains("is-hidden") && style.visibility === "visible" && style.opacity === "1" && rect.width > 0 && rect.height > 0); }))()');
check('Upload speed sparkline stays visible across every main tab', speedSparklineAcrossTabs.length === 4 && speedSparklineAcrossTabs.every(Boolean));
const englishEmptyAccountHosterLabel = await wc.executeJavaScript('(() => { const container = document.getElementById("accountsSidebarHosters"); return [container?.getAttribute("data-empty-label"), getComputedStyle(container, "::after").content].join("|"); })()');
check('Empty account hoster sidebar renders its localized English label', englishEmptyAccountHosterLabel === 'No hosts yet|"No hosts yet"');
await wc.executeJavaScript('document.querySelector(".tab[data-view=upload]")?.click()');
@@ -868,7 +867,7 @@ setTimeout(async () => {
const automationInputAlignment = await wc.executeJavaScript('(() => { const first = document.getElementById("autoRetryRoundsInput")?.getBoundingClientRect(); const second = document.getElementById("autoRetryDelayMinInput")?.getBoundingClientRect(); const firstHintEl = document.getElementById("autoRetryRoundsInput")?.closest(".automation-retry-row")?.querySelector(".hint"); const secondHintEl = document.getElementById("autoRetryDelayMinInput")?.closest(".automation-retry-row")?.querySelector(".hint"); const firstHint = firstHintEl?.getBoundingClientRect(); const secondHint = secondHintEl?.getBoundingClientRect(); if (!first || !second || !firstHint || !secondHint || !firstHintEl || !secondHintEl) return "missing"; const firstTextLeft = firstHint.left + parseFloat(getComputedStyle(firstHintEl).paddingLeft); const secondTextLeft = secondHint.left + parseFloat(getComputedStyle(secondHintEl).paddingLeft); return [Math.round(Math.abs(first.left - second.left)), Math.round(first.width), Math.round(second.width), firstHint.top >= first.bottom + 6, secondHint.top >= second.bottom + 6, Math.round(Math.abs(firstTextLeft - first.left)) <= 1, Math.round(Math.abs(secondTextLeft - second.left)) <= 1].join("|"); })()');
check('Automation retry hints start directly below their aligned inputs', automationInputAlignment === '0|100|100|true|true|true|true');
await wc.executeJavaScript('document.querySelector("[data-settings-page=allgemein]")?.click()');
const updateActionAlignment = await wc.executeJavaScript('(() => { const row = document.querySelector(".program-update-row")?.getBoundingClientRect(); const button = document.getElementById("manualUpdateCheckBtn")?.getBoundingClientRect(); return row && button ? [Math.abs(row.right - button.right) <= 12, button.bottom <= row.bottom, button.left > row.left + row.width / 2].join("|") : "missing"; })()');
const updateActionAlignment = await wc.executeJavaScript('(() => { const row = document.querySelector(".program-update-row")?.getBoundingClientRect(); const button = document.getElementById("manualUpdateCheckBtn")?.getBoundingClientRect(); return row && button ? [Math.abs(row.right - button.right) <= 16, button.bottom <= row.bottom, button.left > row.left + row.width / 2].join("|") : "missing"; })()');
check('Program update action sits at the lower right of its card', updateActionAlignment === 'true|true|true');
const updateCardContract = await wc.executeJavaScript('(() => { const card = document.querySelector(".program-update-card"); const title = card?.querySelector(".program-update-title"); const description = card?.querySelector(".program-update-description"); const button = document.getElementById("manualUpdateCheckBtn"); if (!card || !title || !description || !button) return "missing"; const cardRect = card.getBoundingClientRect(); const titleRect = title.getBoundingClientRect(); const descriptionRect = description.getBoundingClientRect(); const buttonRect = button.getBoundingClientRect(); const center = rect => rect.top + rect.height / 2; return [title.textContent.trim(), description.textContent.trim(), titleRect.top < descriptionRect.top, Math.abs(center(buttonRect) - center(cardRect)) <= 2, buttonRect.right <= cardRect.right - 10, titleRect.left >= cardRect.left + 10].join("|"); })()');
check('Program update card uses a clear title, description and vertically centered action', updateCardContract === 'Nach neuer Version suchen|Verfügbare Updates werden zusammen mit dem Changelog angezeigt.|true|true|true|true');
@@ -883,7 +882,7 @@ setTimeout(async () => {
const settingsSearchIconAlignment = await wc.executeJavaScript('(() => { const icon = document.querySelector(".settings-search-icon"); const style = getComputedStyle(icon); return [style.display, style.alignItems, Boolean(icon?.querySelector("svg"))].join("|"); })()');
check('Settings search icon aligns to the input text line', settingsSearchIconAlignment === 'flex|center|true');
const settingsSearchControlGeometry = await wc.executeJavaScript('(() => { const control = document.querySelector(".settings-search-control"); const input = document.getElementById("settingsSearchInput"); const icon = document.querySelector(".settings-search-icon"); const svg = icon?.querySelector("svg"); if (!control || !input || !icon || !svg) return "missing"; const controlRect = control.getBoundingClientRect(); const inputRect = input.getBoundingClientRect(); const iconRect = icon.getBoundingClientRect(); const inputStyle = getComputedStyle(input); const iconStyle = getComputedStyle(icon); return [Math.round(controlRect.height), Math.round(inputRect.height), Math.round(Math.abs((inputRect.top + inputRect.height / 2) - (iconRect.top + iconRect.height / 2))), svg.getAttribute("viewBox"), inputStyle.lineHeight, inputStyle.paddingTop, inputStyle.paddingBottom, iconStyle.display, iconStyle.alignItems].join("|"); })()');
check('Settings search control keeps icon and text on one shared center line', settingsSearchControlGeometry === '40|40|0|0 0 24 24|18px|0px|0px|flex|center');
check('Settings search control keeps icon and text on one shared center line', settingsSearchControlGeometry === '44|44|0|0 0 24 24|18px|0px|0px|flex|center');
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'logs\\']")?.click()');
await new Promise(resolve => setTimeout(resolve, 100));
@@ -905,9 +904,21 @@ setTimeout(async () => {
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'uploads\\']")?.click()');
const uploadSettingsState = await wc.executeJavaScript('(() => { const activePage = document.querySelector(".settings-subpage.active"); return [activePage?.dataset.subpage, activePage?.querySelector("h3")?.textContent.trim(), document.querySelector("label[for=removeFromQueueOnDoneInput]")?.textContent.trim(), document.getElementById("removeFromQueueOnDoneInput")?.closest(".settings-option")?.querySelector(".settings-option-description")?.textContent.trim()].join("|"); })()');
check('Upload completion behavior is immediately findable', uploadSettingsState === 'uploads|Upload-Verhalten|Nach Abschluss aus der Liste entfernen|Erfolgreich hochgeladene Dateien verschwinden automatisch aus der Upload-Liste.');
const settingsTypography = await wc.executeJavaScript('(() => { const size = selector => parseFloat(getComputedStyle(document.querySelector(selector)).fontSize); return { heading: size(".settings-subpage.active .settings-page-header h3"), intro: size(".settings-subpage.active .settings-page-header p"), section: size(".settings-subpage.active .settings-section-label"), rowLabel: size(".settings-subpage.active .settings-row > label"), hint: size(".settings-subpage.active .hint"), optionLabel: size(".settings-subpage.active .settings-option-copy label"), optionDescription: size(".settings-subpage.active .settings-option-description"), navigation: size(".settings-nav-button"), search: size("#settingsSearchInput") }; })()');
check('Settings use the enlarged readable typography scale', settingsTypography.heading >= 22 && settingsTypography.intro >= 14 && settingsTypography.section >= 12 && settingsTypography.rowLabel >= 14 && settingsTypography.hint >= 12 && settingsTypography.optionLabel >= 14 && settingsTypography.optionDescription >= 12 && settingsTypography.navigation >= 13 && settingsTypography.search >= 13);
const settingsSelection = await wc.executeJavaScript('(() => ({ heading: getComputedStyle(document.querySelector(".settings-subpage.active .settings-page-header h3")).userSelect, hint: getComputedStyle(document.querySelector(".settings-subpage.active .hint")).userSelect, input: getComputedStyle(document.getElementById("globalMaxSpeedMbsInput")).userSelect }))()');
check('Settings interface copy cannot be selected while input values remain selectable', settingsSelection.heading === 'none' && settingsSelection.hint === 'none' && settingsSelection.input === 'text');
const enlargedSettingsFit = await wc.executeJavaScript('(() => { const results = [...document.querySelectorAll(".settings-nav-button")].map(button => { button.click(); const page = document.querySelector(".settings-subpage.active"); return Boolean(page && page.scrollWidth <= page.clientWidth + 1); }); document.querySelector("[data-settings-page=uploads]")?.click(); return results.every(Boolean); })()');
check('Enlarged settings pages stay horizontally contained', enlargedSettingsFit === true);
const sourceDeleteWarning = await wc.executeJavaScript('(() => { const option = document.querySelector(".source-delete-option"); const style = option && getComputedStyle(option); if (!style) return { visible: false }; const channels = style.borderTopColor.match(/[0-9.]+/g)?.map(Number) || []; return { visible: style.borderTopStyle === "solid" && parseFloat(style.borderTopWidth) >= 1, red: channels.length >= 3 && channels[0] > channels[1] * 1.25 && channels[0] > channels[2] * 1.15 }; })()');
check('Permanent source deletion is visibly framed as a danger setting', sourceDeleteWarning.visible && sourceDeleteWarning.red);
const sourceDeleteSafety = await wc.executeJavaScript('(async () => { const input = document.getElementById("deleteSourceAfterSuccessfulUploadInput"); const initial = input.checked; input.click(); await new Promise(resolve => setTimeout(resolve, 25)); const dialog = document.getElementById("appAlertModal"); const visible = dialog.style.display === "flex"; const danger = document.getElementById("appAlertConfirmBtn").classList.contains("btn-danger"); document.getElementById("appAlertCancelBtn").click(); await new Promise(resolve => setTimeout(resolve, 25)); return { initial, visible, danger, cancelled: input.checked === false }; })()');
check('Permanent source deletion defaults off and requires a danger confirmation', sourceDeleteSafety.initial === false && sourceDeleteSafety.visible && sourceDeleteSafety.danger && sourceDeleteSafety.cancelled);
const sourceDeletePersistence = await wc.executeJavaScript('(async () => { const input = document.getElementById("deleteSourceAfterSuccessfulUploadInput"); input.click(); await new Promise(resolve => setTimeout(resolve, 25)); document.getElementById("appAlertConfirmBtn").click(); await new Promise(resolve => setTimeout(resolve, 25)); const dirty = document.getElementById("saveSettingsBtn").disabled === false; await saveSettings({ feedbackText: "Gespeichert" }); const enabled = (await window.api.getGlobalSettings()).deleteSourceAfterSuccessfulUpload === true; input.checked = false; input.dispatchEvent(new Event("change", { bubbles: true })); await saveSettings({ feedbackText: "Gespeichert" }); const restored = (await window.api.getGlobalSettings()).deleteSourceAfterSuccessfulUpload === false; return { dirty, enabled, restored }; })()');
check('Confirmed source deletion persists and can be safely disabled again', sourceDeletePersistence.dirty && sourceDeletePersistence.enabled && sourceDeletePersistence.restored);
const settingsReadingWidth = await wc.executeJavaScript('(() => { const activePage = document.querySelector(".settings-subpage.active"); if (!activePage) return 0; return activePage.getBoundingClientRect().width; })()');
check('Active settings page keeps a readable content width', settingsReadingWidth > 0 && settingsReadingWidth <= 760);
check('Active settings page keeps a readable content width', settingsReadingWidth > 0 && settingsReadingWidth <= 820);
const settingsFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("settings-view")?.getBoundingClientRect(); return Boolean(view && view.bottom <= window.innerHeight + 1); })()');
check('Settings view fits inside the viewport', settingsFrameFit === true);
+30
View File
@@ -67,6 +67,36 @@ describe('UploadManager', () => {
assert.ok(events.length > 0, 'should emit at least one progress event');
});
it('emits job-settled after releasing job resources', async () => {
const mgr = new UploadManager({});
let settled;
mgr.on('job-settled', (event) => {
settled = {
...event,
activeJobs: mgr.activeJobs.size,
abortControllers: mgr.jobAbortControllers.size
};
});
await mgr.startBatch([{
file: '/test/settled.mp4',
hoster: 'doodstream.com',
apiKey: 'key1',
jobId: 'job-settled-1',
sourceCleanupToken: 'cleanup-1'
}]);
assert.deepEqual(settled, {
jobId: 'job-settled-1',
sourceCleanupToken: 'cleanup-1',
file: '/test/settled.mp4',
hoster: 'doodstream.com',
status: 'done',
activeJobs: 0,
abortControllers: 0
});
});
it('replaces account pools and clears cached account state after an import', () => {
const mgr = new UploadManager({}, {}, {
'byse.sx': [{ id: 'old', apiKey: 'old-key' }]
+12 -4
View File
@@ -55,7 +55,7 @@ test('buildWebhookRequest: many hosters does not exceed Discord limit', () => {
});
test('buildWebhookRequest: aborted meta changes the headline', () => {
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', { total: 5, succeeded: 0, failed: 5, files: [] }, { aborted: true });
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', { total: 5, succeeded: 0, failed: 5, files: [] }, { aborted: true, language: 'de' });
const body = JSON.parse(req.body);
assert.match(body.content, /Batch abgebrochen/);
});
@@ -94,14 +94,22 @@ test('buildWebhookRequest produces Discord content body for discord URLs', () =>
assert.strictEqual(req.headers['Content-Type'], 'application/json');
const body = JSON.parse(req.body);
assert.ok(typeof body.content === 'string');
assert.match(body.content, /Batch fertig/);
assert.match(body.content, /Batch completed/);
assert.match(body.content, /srv-1/);
assert.match(body.content, /8 ok/);
assert.match(body.content, /2 Fehler/);
assert.match(body.content, /8 succeeded/);
assert.match(body.content, /2 failed/);
assert.match(body.content, /1h 1m/);
assert.match(body.content, /voe\.sx: 2\/2/);
});
test('buildWebhookRequest localizes Discord content from the selected language', () => {
const req = buildWebhookRequest('https://discord.com/api/webhooks/1/x', SAMPLE_SUMMARY, { language: 'de' });
const body = JSON.parse(req.body);
assert.match(body.content, /Batch fertig/);
assert.match(body.content, /2 Fehler/);
assert.match(body.content, /10 gesamt/);
});
test('buildWebhookRequest produces raw JSON payload for generic URLs', () => {
const req = buildWebhookRequest('https://example.com/hook', SAMPLE_SUMMARY, { durationSec: 60, appVersion: '3.3.59', timestamp: '2026-06-09T00:00:00Z' });
const body = JSON.parse(req.body);