release: Multi-Hoster-Upload 2.1.7
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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, []);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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' }]
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user