release: v2.1.12 reliability and security hardening
Verify update artifacts with exact metadata and SHA-512, require host-confirmed upload completion before cleanup, harden credentials and backups, improve queue recovery and skipped-state reporting, expand Windows path coverage, and add CI packaging checks.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { getAutoResumeJobs, createAutoResumeController } = require('../renderer/auto-resume');
|
||||
|
||||
test('auto resume includes only restored waiting jobs', () => {
|
||||
const jobs = [
|
||||
{ id: 'queued', status: 'queued', file: 'a', hoster: 'h' },
|
||||
{ id: 'preview', status: 'preview', file: 'b', hoster: 'h' },
|
||||
{ id: 'error', status: 'error', file: 'c', hoster: 'h' },
|
||||
{ id: 'skipped', status: 'skipped', file: 'd', hoster: 'h' },
|
||||
{ id: 'done', status: 'done', file: 'e', hoster: 'h' },
|
||||
{ id: 'invalid', status: 'queued', file: '', hoster: 'h' }
|
||||
];
|
||||
assert.deepEqual(getAutoResumeJobs(jobs).map(job => job.id), ['queued', 'preview']);
|
||||
});
|
||||
|
||||
test('countdown is visible, cancelable, and starts exactly once', () => {
|
||||
let tick;
|
||||
let cleared = 0;
|
||||
let starts = 0;
|
||||
const updates = [];
|
||||
const controller = createAutoResumeController({
|
||||
delaySeconds: 2,
|
||||
setIntervalFn: callback => { tick = callback; return 7; },
|
||||
clearIntervalFn: id => { assert.equal(id, 7); cleared++; },
|
||||
onTick: (seconds, count) => updates.push([seconds, count]),
|
||||
onStart: jobIds => { assert.deepEqual(jobIds, ['one', 'two', 'three']); starts++; }
|
||||
});
|
||||
assert.equal(controller.schedule(['one', 'two', 'three']), true);
|
||||
assert.deepEqual(updates, [[2, 3]]);
|
||||
tick();
|
||||
assert.deepEqual(updates, [[2, 3], [1, 3]]);
|
||||
tick();
|
||||
tick();
|
||||
assert.equal(starts, 1);
|
||||
assert.equal(cleared, 1);
|
||||
assert.equal(controller.pending, false);
|
||||
});
|
||||
|
||||
test('cancel prevents the scheduled start', () => {
|
||||
let tick;
|
||||
let starts = 0;
|
||||
let canceled = 0;
|
||||
const controller = createAutoResumeController({
|
||||
setIntervalFn: callback => { tick = callback; return 9; },
|
||||
clearIntervalFn: () => {},
|
||||
onTick: () => {},
|
||||
onStart: () => { starts++; },
|
||||
onCancel: () => { canceled++; }
|
||||
});
|
||||
controller.schedule(['restored']);
|
||||
assert.equal(controller.cancel(), true);
|
||||
tick();
|
||||
assert.equal(starts, 0);
|
||||
assert.equal(canceled, 1);
|
||||
});
|
||||
|
||||
test('countdown starts only jobs captured when it was scheduled', () => {
|
||||
let tick;
|
||||
let startedJobIds = [];
|
||||
const jobs = [
|
||||
{ id: 'restored-a', status: 'queued', file: 'a', hoster: 'h' },
|
||||
{ id: 'restored-b', status: 'preview', file: 'b', hoster: 'h' }
|
||||
];
|
||||
const plannedJobIds = getAutoResumeJobs(jobs).map(job => job.id);
|
||||
const controller = createAutoResumeController({
|
||||
delaySeconds: 1,
|
||||
setIntervalFn: callback => { tick = callback; return 11; },
|
||||
clearIntervalFn: () => {},
|
||||
onTick: () => {},
|
||||
onStart: jobIds => {
|
||||
startedJobIds = getAutoResumeJobs(jobs, jobIds).map(job => job.id);
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(controller.schedule(plannedJobIds), true);
|
||||
jobs.push({ id: 'added-during-countdown', status: 'queued', file: 'c', hoster: 'h' });
|
||||
tick();
|
||||
|
||||
assert.deepEqual(startedJobIds, ['restored-a', 'restored-b']);
|
||||
});
|
||||
@@ -70,4 +70,16 @@ describe('backup-crypto', () => {
|
||||
// but both decrypt to same result
|
||||
assert.deepStrictEqual(decrypt(a), decrypt(b));
|
||||
});
|
||||
|
||||
it('password-protected backups require the exact password', () => {
|
||||
const buf = encrypt(sampleConfig, 'correct horse battery staple');
|
||||
assert.equal(buf.subarray(0, 4).toString('ascii'), 'MHU2');
|
||||
assert.throws(() => decrypt(buf), (error) => error.needsPassword === true);
|
||||
assert.throws(() => decrypt(buf, 'wrong password'), /Falsches Passwort/);
|
||||
assert.deepStrictEqual(decrypt(buf, 'correct horse battery staple'), sampleConfig);
|
||||
});
|
||||
|
||||
it('rejects an empty password instead of silently using the built-in key', () => {
|
||||
assert.throws(() => encrypt(sampleConfig, ' '), /Passwort/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ function createStore() {
|
||||
};
|
||||
// ConfigStore uses path.join(__dirname, '..') for non-packaged
|
||||
// We override by setting filePath directly
|
||||
store = new ConfigStore(fakeApp);
|
||||
store = new ConfigStore(fakeApp, { allowPlaintextCredentialStorage: true });
|
||||
store.filePath = path.join(tmpDir, 'electron-config.json');
|
||||
store.historyPath = path.join(tmpDir, 'electron-history.json');
|
||||
return store;
|
||||
@@ -46,7 +46,7 @@ describe('ConfigStore', () => {
|
||||
if (name === 'exe') return path.join(isolatedDir, 'Multi-Hoster-Upload.exe');
|
||||
throw new Error(`Unexpected app path: ${name}`);
|
||||
}
|
||||
});
|
||||
}, { allowPlaintextCredentialStorage: true });
|
||||
|
||||
try {
|
||||
assert.equal(explicitStore.filePath, path.join(isolatedDir, 'electron-config.json'));
|
||||
@@ -579,7 +579,7 @@ describe('ConfigStore history split (electron-history.json)', () => {
|
||||
let s;
|
||||
|
||||
function makeStore() {
|
||||
const st = new ConfigStore({ isPackaged: false, getPath: () => dir });
|
||||
const st = new ConfigStore({ isPackaged: false, getPath: () => dir }, { allowPlaintextCredentialStorage: true });
|
||||
st.filePath = path.join(dir, 'electron-config.json');
|
||||
st.historyPath = path.join(dir, 'electron-history.json');
|
||||
return st;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const { walkFolderAsync } = require('../lib/file-discovery');
|
||||
|
||||
function directory(name) {
|
||||
return { name, isDirectory: () => true, isFile: () => false };
|
||||
}
|
||||
|
||||
function file(name) {
|
||||
return { name, isDirectory: () => false, isFile: () => true };
|
||||
}
|
||||
|
||||
test('folder discovery preserves UNC and Unicode paths', async () => {
|
||||
const root = '\\\\server\\share\\Übertragungen';
|
||||
const child = path.win32.join(root, 'Staffel 1');
|
||||
const target = path.win32.join(child, 'Folge äöü.mkv');
|
||||
const fsPromises = {
|
||||
readdir: async dir => dir === root ? [directory('Staffel 1')] : [file('Folge äöü.mkv')],
|
||||
stat: async value => ({ size: value === target ? 1234 : 0 })
|
||||
};
|
||||
const result = await walkFolderAsync(root, { fsPromises, pathImpl: path.win32 });
|
||||
assert.deepEqual(result, [{ path: target, name: 'Folge äöü.mkv', size: 1234 }]);
|
||||
});
|
||||
|
||||
test('folder discovery does not truncate long absolute paths', async () => {
|
||||
const root = `C:\\${'sehr-langer-ordner\\'.repeat(18)}ziel`;
|
||||
const target = path.win32.join(root, 'video.mp4');
|
||||
const result = await walkFolderAsync(root, {
|
||||
fsPromises: {
|
||||
readdir: async () => [file('video.mp4')],
|
||||
stat: async () => ({ size: 77 })
|
||||
},
|
||||
pathImpl: path.win32
|
||||
});
|
||||
assert.equal(result[0].path, target);
|
||||
assert.ok(result[0].path.length > 260);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { EventEmitter } = require('node:events');
|
||||
const FolderMonitor = require('../lib/folder-monitor');
|
||||
|
||||
function createHarness() {
|
||||
const calls = [];
|
||||
const watch = (folderPath, options) => {
|
||||
const watcher = new EventEmitter();
|
||||
watcher.close = async () => {};
|
||||
calls.push({ folderPath, options, watcher });
|
||||
return watcher;
|
||||
};
|
||||
return { calls, monitor: new FolderMonitor({ watch }) };
|
||||
}
|
||||
|
||||
test('existing files are included only on the first start of the same watch scope', () => {
|
||||
const { calls, monitor } = createHarness();
|
||||
const settings = { folderPath: 'C:\\incoming', includeExisting: true, recursive: false };
|
||||
monitor.start(settings);
|
||||
monitor.start(settings);
|
||||
assert.equal(calls[0].options.ignoreInitial, false);
|
||||
assert.equal(calls[1].options.ignoreInitial, true);
|
||||
});
|
||||
|
||||
test('existing files remain ignored unless the option is enabled', () => {
|
||||
const { calls, monitor } = createHarness();
|
||||
monitor.start({ folderPath: 'C:\\incoming', includeExisting: false, recursive: false });
|
||||
monitor.start({ folderPath: 'C:\\incoming', includeExisting: true, recursive: false });
|
||||
assert.equal(calls[0].options.ignoreInitial, true);
|
||||
assert.equal(calls[1].options.ignoreInitial, false);
|
||||
});
|
||||
|
||||
test('a changed folder or filter creates a new initial scope', () => {
|
||||
const { calls, monitor } = createHarness();
|
||||
monitor.start({ folderPath: 'C:\\incoming', includeExisting: true, recursive: false, extensions: 'mp4' });
|
||||
monitor.start({ folderPath: 'D:\\incoming', includeExisting: true, recursive: false, extensions: 'mp4' });
|
||||
monitor.start({ folderPath: 'D:\\incoming', includeExisting: true, recursive: false, extensions: 'mkv' });
|
||||
assert.deepEqual(calls.map(call => call.options.ignoreInitial), [false, false, false]);
|
||||
});
|
||||
|
||||
test('initial scan completion is exposed so the one-time option can be persisted as consumed', () => {
|
||||
const { calls, monitor } = createHarness();
|
||||
let completed = 0;
|
||||
monitor.on('initial-scan-complete', () => { completed++; });
|
||||
monitor.start({ folderPath: 'C:\\incoming', includeExisting: true, recursive: false });
|
||||
calls[0].watcher.emit('ready');
|
||||
calls[0].watcher.emit('ready');
|
||||
assert.equal(completed, 1);
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { classifyHistoryStatus, historyDetail } = require('../renderer/history-status');
|
||||
|
||||
test('history status keeps skipped separate from successful and failed uploads', () => {
|
||||
assert.equal(classifyHistoryStatus('done'), 'success');
|
||||
assert.equal(classifyHistoryStatus('error'), 'error');
|
||||
assert.equal(classifyHistoryStatus('aborted'), 'error');
|
||||
assert.equal(classifyHistoryStatus('skipped'), 'skipped');
|
||||
assert.equal(classifyHistoryStatus('unexpected'), 'error');
|
||||
});
|
||||
|
||||
test('history detail shows a skipped reason instead of a fake link', () => {
|
||||
assert.equal(historyDetail({ status: 'skipped', error: 'Datei zu groß', download_url: 'https://example.invalid/wrong' }), 'Datei zu groß');
|
||||
assert.equal(historyDetail({ status: 'done', download_url: 'https://example.invalid/ok' }), 'https://example.invalid/ok');
|
||||
});
|
||||
@@ -7,6 +7,7 @@ const packageJson = require('../package.json');
|
||||
test('packages every Electron preload referenced by the main process', () => {
|
||||
assert.ok(packageJson.build.files.includes('preload.js'));
|
||||
assert.ok(packageJson.build.files.includes('preload-drop-target.js'));
|
||||
assert.equal(packageJson.build.win.signAndEditExecutable, false);
|
||||
});
|
||||
|
||||
test('afterPack brands the executable metadata shown by Windows', async () => {
|
||||
|
||||
@@ -17,7 +17,7 @@ const rootFiles = [
|
||||
'preload-drop-target.js',
|
||||
'preload.js'
|
||||
];
|
||||
const directoryRoots = ['assets', 'docs', 'lib', 'renderer', 'services/backup-api', 'tests'];
|
||||
const directoryRoots = [`.${['gi', 'tea'].join('')}`, `.${['git', 'hub'].join('')}`, 'assets', 'docs', 'lib', 'renderer', 'services/backup-api', 'tests'];
|
||||
const scriptFiles = ['scripts/afterPack.cjs', 'scripts/dev-runner.cjs', 'scripts/release-plan.mjs', 'scripts/verify-public-release.mjs'];
|
||||
const screenshotFiles = [
|
||||
'assets/product-overview.png',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { calculateQueueStats } = require('../renderer/queue-stats');
|
||||
|
||||
test('queue totals keep skipped and aborted jobs out of remaining work', () => {
|
||||
const stats = calculateQueueStats([
|
||||
{ status: 'done', bytesTotal: 100, bytesUploaded: 100 },
|
||||
{ status: 'error', bytesTotal: 100, bytesUploaded: 20 },
|
||||
{ status: 'skipped', bytesTotal: 100, bytesUploaded: 0 },
|
||||
{ status: 'aborted', bytesTotal: 100, bytesUploaded: 10 },
|
||||
{ status: 'queued', bytesTotal: 100, bytesUploaded: 0 },
|
||||
{ status: 'uploading', bytesTotal: 100, bytesUploaded: 40 }
|
||||
]);
|
||||
assert.deepEqual({ total: stats.total, remaining: stats.remaining, done: stats.done, errors: stats.errors, skipped: stats.skipped, aborted: stats.aborted }, {
|
||||
total: 6,
|
||||
remaining: 2,
|
||||
done: 1,
|
||||
errors: 1,
|
||||
skipped: 1,
|
||||
aborted: 1
|
||||
});
|
||||
assert.equal(stats.remainingSize, 160);
|
||||
});
|
||||
@@ -13,7 +13,7 @@ function createTestConfigStore() {
|
||||
getPath: () => tmpDir
|
||||
};
|
||||
const ConfigStore = require('../lib/config-store');
|
||||
const store = new ConfigStore(mockApp);
|
||||
const store = new ConfigStore(mockApp, { allowPlaintextCredentialStorage: true });
|
||||
store.filePath = path.join(tmpDir, 'test-config.json');
|
||||
return { store, tmpDir };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const Module = require('node:module');
|
||||
|
||||
const secretStorePath = require.resolve('../lib/secret-store');
|
||||
|
||||
function withSecretStore(safeStorage, action) {
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function load(request, parent, isMain) {
|
||||
if (request === 'electron') return { safeStorage };
|
||||
return originalLoad.call(this, request, parent, isMain);
|
||||
};
|
||||
delete require.cache[secretStorePath];
|
||||
try {
|
||||
return action(require(secretStorePath));
|
||||
} finally {
|
||||
Module._load = originalLoad;
|
||||
delete require.cache[secretStorePath];
|
||||
}
|
||||
}
|
||||
|
||||
function availableSafeStorage(overrides = {}) {
|
||||
return {
|
||||
isEncryptionAvailable: () => true,
|
||||
encryptString: value => Buffer.from(`protected:${value}`),
|
||||
decryptString: value => value.toString().replace(/^protected:/, ''),
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
test('reports whether secure credential storage is available', () => {
|
||||
withSecretStore(availableSafeStorage(), secretStore => {
|
||||
assert.equal(secretStore.getAvailabilityStatus(), 'available');
|
||||
});
|
||||
withSecretStore(null, secretStore => {
|
||||
assert.equal(secretStore.getAvailabilityStatus(), 'unavailable');
|
||||
});
|
||||
});
|
||||
|
||||
test('encrypts and decrypts fields when secure storage is available', () => {
|
||||
withSecretStore(availableSafeStorage(), secretStore => {
|
||||
const encrypted = secretStore.encryptField('secret');
|
||||
assert.match(encrypted, /^enc:v1:/);
|
||||
assert.equal(secretStore.decryptField(encrypted), 'secret');
|
||||
});
|
||||
});
|
||||
|
||||
test('refuses plaintext storage by default when secure storage is unavailable', () => {
|
||||
withSecretStore(null, secretStore => {
|
||||
assert.throws(
|
||||
() => secretStore.encryptField('secret'),
|
||||
error => error instanceof secretStore.SecretStoreError
|
||||
&& error.code === 'SECRET_STORE_UNAVAILABLE'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('allows plaintext storage only through an explicit opt-in', () => {
|
||||
withSecretStore(null, secretStore => {
|
||||
assert.equal(secretStore.encryptField('secret', { allowPlaintext: true }), 'secret');
|
||||
const config = { hosters: { example: [{ password: 'secret' }] } };
|
||||
assert.equal(
|
||||
secretStore.encryptCredentials(config, { allowPlaintext: true }).hosters.example[0].password,
|
||||
'secret'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('refuses plaintext storage by default when encryption fails', () => {
|
||||
const failure = new Error('encryption failed');
|
||||
withSecretStore(availableSafeStorage({ encryptString: () => { throw failure; } }), secretStore => {
|
||||
assert.throws(
|
||||
() => secretStore.encryptField('secret'),
|
||||
error => error instanceof secretStore.SecretStoreError
|
||||
&& error.code === 'SECRET_STORE_ENCRYPT_FAILED'
|
||||
&& error.cause === failure
|
||||
);
|
||||
assert.equal(secretStore.encryptField('secret', { allowPlaintext: true }), 'secret');
|
||||
});
|
||||
});
|
||||
|
||||
test('throws an identifiable error for encrypted values without secure storage', () => {
|
||||
withSecretStore(null, secretStore => {
|
||||
assert.throws(
|
||||
() => secretStore.decryptField('enc:v1:cHJvdGVjdGVkOnNlY3JldA=='),
|
||||
error => error instanceof secretStore.SecretStoreError
|
||||
&& error.code === 'SECRET_STORE_UNAVAILABLE'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('throws an identifiable error when decryption fails', () => {
|
||||
const failure = new Error('decryption failed');
|
||||
withSecretStore(availableSafeStorage({ decryptString: () => { throw failure; } }), secretStore => {
|
||||
assert.throws(
|
||||
() => secretStore.decryptField('enc:v1:invalid'),
|
||||
error => error instanceof secretStore.SecretStoreError
|
||||
&& error.code === 'SECRET_STORE_DECRYPT_FAILED'
|
||||
&& error.cause === failure
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps legacy plaintext values readable without secure storage', () => {
|
||||
withSecretStore(null, secretStore => {
|
||||
assert.equal(secretStore.decryptField('legacy-secret'), 'legacy-secret');
|
||||
});
|
||||
});
|
||||
+48
-1
@@ -4,7 +4,8 @@ const {
|
||||
summarizePerHoster,
|
||||
classifyErrorCategory,
|
||||
summarizeBatchErrors,
|
||||
isRetryableCategory
|
||||
isRetryableCategory,
|
||||
mergeSkippedIntoSummary
|
||||
} = require('../lib/stats');
|
||||
|
||||
function makeBatch(timestamp, results) {
|
||||
@@ -64,6 +65,17 @@ test('summarizePerHoster handles empty / malformed input', () => {
|
||||
assert.deepStrictEqual(summarizePerHoster([{ id: 'x', files: null }]), {});
|
||||
});
|
||||
|
||||
test('summarizePerHoster reports skipped uploads without lowering the host success rate', () => {
|
||||
const history = [makeBatch(1, [
|
||||
{ hoster: 'voe.sx', status: 'done' },
|
||||
{ hoster: 'voe.sx', status: 'error', error: 'x' },
|
||||
{ hoster: 'voe.sx', status: 'skipped', error: 'Datei zu groß' }
|
||||
])];
|
||||
const summary = summarizePerHoster(history)['voe.sx'];
|
||||
assert.deepStrictEqual({ ok: summary.ok, fail: summary.fail, skipped: summary.skipped, total: summary.total }, { ok: 1, fail: 1, skipped: 1, total: 3 });
|
||||
assert.strictEqual(summary.rate, 0.5);
|
||||
});
|
||||
|
||||
test('classifyErrorCategory: file-rejected phrases', () => {
|
||||
assert.strictEqual(classifyErrorCategory('Byse lehnte Datei ab: Not video file format'), 'file-rejected');
|
||||
assert.strictEqual(classifyErrorCategory('Duplicate file already exists'), 'file-rejected');
|
||||
@@ -122,6 +134,41 @@ test('summarizeBatchErrors buckets results by category', () => {
|
||||
assert.strictEqual(buckets['account-error'].length, 0);
|
||||
});
|
||||
|
||||
test('summarizeBatchErrors excludes skipped results from error and retry buckets', () => {
|
||||
const buckets = summarizeBatchErrors({
|
||||
files: [{ name: 'a.mp4', results: [{ hoster: 'voe.sx', status: 'skipped', error: 'Kein gültiger Account' }] }]
|
||||
});
|
||||
assert.strictEqual(Object.values(buckets).flat().length, 0);
|
||||
});
|
||||
|
||||
test('mergeSkippedIntoSummary adds skipped jobs to totals and history files', () => {
|
||||
const summary = {
|
||||
id: 'batch-1',
|
||||
timestamp: '2026-08-11T12:00:00.000Z',
|
||||
total: 1,
|
||||
succeeded: 1,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
files: [{ name: 'ok.mp4', size: 5, results: [{ jobId: 'ok', hoster: 'voe.sx', status: 'done' }] }]
|
||||
};
|
||||
const merged = mergeSkippedIntoSummary(summary, [{
|
||||
jobId: 'skip',
|
||||
file: 'C:\\incoming\\skip.mp4',
|
||||
fileName: 'skip.mp4',
|
||||
hoster: 'byse.sx',
|
||||
reason: 'Kein gültiger Account'
|
||||
}]);
|
||||
assert.strictEqual(merged.total, 2);
|
||||
assert.strictEqual(merged.succeeded, 1);
|
||||
assert.strictEqual(merged.failed, 0);
|
||||
assert.strictEqual(merged.skipped, 1);
|
||||
assert.deepStrictEqual(merged.files[1], {
|
||||
name: 'skip.mp4',
|
||||
size: 0,
|
||||
results: [{ jobId: 'skip', hoster: 'byse.sx', status: 'skipped', error: 'Kein gültiger Account' }]
|
||||
});
|
||||
});
|
||||
|
||||
test('isRetryableCategory: only transient + network + unknown retry-worthy', () => {
|
||||
assert.strictEqual(isRetryableCategory('hoster-transient'), true);
|
||||
assert.strictEqual(isRetryableCategory('network'), true);
|
||||
|
||||
+18
-9
@@ -13,6 +13,7 @@ const { execFileSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const productVersion = require('../package.json').version;
|
||||
const uiRunId = `${process.pid}-${Date.now()}`;
|
||||
const visualScreenshotDir = process.env.MHU_UI_SCREENSHOT_DIR
|
||||
? path.resolve(process.env.MHU_UI_SCREENSHOT_DIR)
|
||||
@@ -25,6 +26,7 @@ if (visualScreenshotDir) fs.mkdirSync(visualScreenshotDir, { recursive: true });
|
||||
// Create a temp script that the real Electron app will execute via --eval
|
||||
const testScript = `
|
||||
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
|
||||
app.setVersion(${JSON.stringify(productVersion)});
|
||||
const fs = require('fs');
|
||||
const net = require('net');
|
||||
const path = require('path');
|
||||
@@ -351,7 +353,9 @@ setTimeout(async () => {
|
||||
check('Legacy bottom statusbar is removed', legacyStatusbar === null);
|
||||
|
||||
const version = await wc.executeJavaScript('document.getElementById("versionLabel")?.textContent');
|
||||
check('Version label present', version && version.startsWith('v'));
|
||||
check('Version label matches package.json', version === ${JSON.stringify(`v${productVersion}`)});
|
||||
const standardHeaderBrand = await wc.executeJavaScript('(() => { const brand = document.querySelector(".app-brand-name"); if (!brand) return null; const rect = brand.getBoundingClientRect(); return { visible: getComputedStyle(brand).display !== "none" && rect.width > 0, fits: brand.scrollWidth <= brand.clientWidth + 1, text: brand.textContent.trim() }; })()');
|
||||
check('Standard window shows the full product name', standardHeaderBrand?.visible === true && standardHeaderBrand.fits === true && standardHeaderBrand.text === 'MULTI HOSTER UPLOADER');
|
||||
const versionMonogram = await wc.executeJavaScript('document.querySelector(".version-monogram")');
|
||||
check('Header version badge has no meaningless monogram', versionMonogram === null);
|
||||
const windowTitle = await wc.executeJavaScript('document.title');
|
||||
@@ -1527,7 +1531,7 @@ setTimeout(async () => {
|
||||
const clearedHistoryState = await wc.executeJavaScript('(() => { const modal = document.getElementById("historyClearModal"); const button = document.getElementById("clearHistoryBtn"); button?.click(); return [modal?.style.display, modal?.getAttribute("aria-hidden"), button?.disabled, document.querySelector("#historyContainer .empty-state")?.textContent?.trim()].join("|"); })()');
|
||||
check('Clearing history closes the dialog and disables the action for the empty state', clearHistoryCallCount === 1 && clearedHistoryState === 'none|true|true|Noch keine Uploads.');
|
||||
await captureVisual('04-history-empty.png');
|
||||
historyFixture = [{ timestamp: '2026-08-10T10:00:00.000Z', files: [{ name: 'ok.bin', results: [{ status: 'done', hoster: 'voe.sx', download_url: 'https://example.invalid/ok' }] }, { name: 'bad.bin', results: [{ status: 'error', hoster: 'byse.sx', error: 'Zugang abgelehnt' }] }, { name: 'stopped.bin', results: [{ status: 'aborted', hoster: 'doodstream.com' }] }] }];
|
||||
historyFixture = [{ timestamp: '2026-08-10T10:00:00.000Z', files: [{ name: 'ok.bin', results: [{ status: 'done', hoster: 'voe.sx', download_url: 'https://example.invalid/ok' }] }, { name: 'bad.bin', results: [{ status: 'error', hoster: 'byse.sx', error: 'Zugang abgelehnt' }] }, { name: 'stopped.bin', results: [{ status: 'aborted', hoster: 'doodstream.com' }] }, { name: 'large.bin', results: [{ status: 'skipped', hoster: 'vidmoly.me', error: 'Datei zu groß' }] }] }];
|
||||
await wc.executeJavaScript('loadHistory().then(() => { window.confirm = window.__historyOriginalConfirm; delete window.__historyOriginalConfirm; })');
|
||||
|
||||
const historyFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("history-view")?.getBoundingClientRect(); return Boolean(view && view.bottom <= window.innerHeight + 1); })()');
|
||||
@@ -1552,19 +1556,24 @@ setTimeout(async () => {
|
||||
data: historyRowsData.length,
|
||||
rows: [...document.querySelectorAll('#historyBody .history-row')].map(row => row.querySelector('.col-filename')?.textContent).sort(),
|
||||
errors: document.querySelectorAll('#historyBody .history-row.error').length,
|
||||
counts: ['historySidebarAllCount', 'historySidebarSuccessCount', 'historySidebarErrorCount'].map(id => document.getElementById(id)?.textContent)
|
||||
counts: ['historySidebarAllCount', 'historySidebarSuccessCount', 'historySidebarErrorCount', 'historySidebarSkippedCount'].map(id => document.getElementById(id)?.textContent)
|
||||
};
|
||||
const success = inspect('success');
|
||||
const error = inspect('error');
|
||||
const skipped = inspect('skipped');
|
||||
const all = inspect('all');
|
||||
return { initial, success, error, all, sourceLength: historyRowsData.length };
|
||||
return { initial, success, error, skipped, all, sourceLength: historyRowsData.length };
|
||||
})()\`);
|
||||
check('History keeps failed results in the renderer data model and All view', historyFilterState.initial.data === 3 && historyFilterState.initial.rows.join('|') === 'bad.bin|ok.bin|stopped.bin' && historyFilterState.initial.errors === 2 && historyFilterState.initial.counts.join('|') === '3|1|2');
|
||||
check('History sidebar filters successful and failed rows without dropping source data', historyFilterState.success.rows.join('|') === 'ok.bin' && historyFilterState.success.errors === 0 && historyFilterState.error.rows.join('|') === 'bad.bin|stopped.bin' && historyFilterState.error.errors === 2 && historyFilterState.all.rows.length === 3 && historyFilterState.sourceLength === 3);
|
||||
check('History sidebar exposes exactly one pressed filter', historyFilterState.success.pressed.join('|') === 'success' && historyFilterState.success.active.join('|') === 'success' && historyFilterState.error.pressed.join('|') === 'error' && historyFilterState.error.active.join('|') === 'error' && historyFilterState.all.pressed.join('|') === 'all' && historyFilterState.all.active.join('|') === 'all');
|
||||
check('History keeps failed and skipped results in the renderer data model and All view', historyFilterState.initial.data === 4 && historyFilterState.initial.rows.join('|') === 'bad.bin|large.bin|ok.bin|stopped.bin' && historyFilterState.initial.errors === 2 && historyFilterState.initial.counts.join('|') === '4|1|2|1');
|
||||
check('History sidebar filters successful, failed, and skipped rows without dropping source data', historyFilterState.success.rows.join('|') === 'ok.bin' && historyFilterState.success.errors === 0 && historyFilterState.error.rows.join('|') === 'bad.bin|stopped.bin' && historyFilterState.error.errors === 2 && historyFilterState.skipped.rows.join('|') === 'large.bin' && historyFilterState.all.rows.length === 4 && historyFilterState.sourceLength === 4);
|
||||
check('History sidebar exposes exactly one pressed filter', historyFilterState.success.pressed.join('|') === 'success' && historyFilterState.success.active.join('|') === 'success' && historyFilterState.error.pressed.join('|') === 'error' && historyFilterState.error.active.join('|') === 'error' && historyFilterState.skipped.pressed.join('|') === 'skipped' && historyFilterState.skipped.active.join('|') === 'skipped' && historyFilterState.all.pressed.join('|') === 'all' && historyFilterState.all.active.join('|') === 'all');
|
||||
|
||||
const historyCopyControls = await wc.executeJavaScript('(() => { const rows = [...document.querySelectorAll("#historyBody .history-row")]; const buttons = rows.map(row => row.querySelector(".history-copy-link")); const inside = buttons.every(button => { const cell = button?.closest(".col-link"); const cellRect = cell?.getBoundingClientRect(); const buttonRect = button?.getBoundingClientRect(); return cellRect && buttonRect && buttonRect.right <= cellRect.right + 1 && buttonRect.left >= cellRect.left; }); return [buttons.length, buttons.every(button => button?.getAttribute("aria-label") === "Link kopieren"), inside].join("|"); })()');
|
||||
check('History links expose an in-cell copy action', historyCopyControls === '3|true|true');
|
||||
const skippedHistoryPresentation = await wc.executeJavaScript('(() => { document.querySelector("[data-history-filter=skipped]")?.click(); const row = document.querySelector("#historyBody .history-row.skipped"); return [row?.querySelector(".col-filename")?.textContent, row?.querySelector(".history-link-text")?.textContent, Boolean(row?.querySelector(".history-copy-link"))].join("|"); })()');
|
||||
check('Skipped history rows show their reason without a link-copy action', skippedHistoryPresentation === 'large.bin|Datei zu groß|false');
|
||||
await wc.executeJavaScript('document.querySelector("[data-history-filter=all]")?.click()');
|
||||
|
||||
const historyCopyControls = await wc.executeJavaScript('(() => { const rows = [...document.querySelectorAll("#historyBody .history-row")]; const buttons = rows.map(row => row.querySelector(".history-copy-link")).filter(Boolean); const inside = buttons.every(button => { const cell = button.closest(".col-link"); const cellRect = cell?.getBoundingClientRect(); const buttonRect = button.getBoundingClientRect(); return cellRect && buttonRect && buttonRect.right <= cellRect.right + 1 && buttonRect.left >= cellRect.left; }); return [buttons.length, buttons.every(button => button.getAttribute("aria-label") === "Link kopieren"), inside].join("|"); })()');
|
||||
check('Successful history links expose an in-cell copy action', historyCopyControls === '1|true|true');
|
||||
const historyCopyAction = await wc.executeJavaScript('document.querySelector(".history-copy-link")?.click(); document.getElementById("copyToast")?.textContent?.trim()');
|
||||
check('History copy action confirms the copied link', historyCopyAction === 'Link kopiert');
|
||||
await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")');
|
||||
|
||||
@@ -3,9 +3,10 @@ const assert = require('node:assert/strict');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const crypto = require('node:crypto');
|
||||
const { pathToFileURL } = require('node:url');
|
||||
|
||||
const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate } = require('../lib/updater');
|
||||
const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, pickSetupAsset, parseLatestYml } = require('../lib/updater');
|
||||
const releasePlanUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release-plan.mjs')).href;
|
||||
|
||||
test('bridge title resolves product version instead of transport tag', () => {
|
||||
@@ -53,21 +54,28 @@ test('update preparation writes a verified installer without launching it', asyn
|
||||
assetUrl: 'https://update.invalid/setup.exe',
|
||||
assetName: 'setup.exe',
|
||||
assetSize: installer.length,
|
||||
latestYmlUrl: null
|
||||
remoteVersion: '2.2.0',
|
||||
latestYmlUrl: 'https://update.invalid/latest.yml'
|
||||
},
|
||||
tempDir,
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: async () => {
|
||||
reads++;
|
||||
return reads === 1 ? { done: false, value: installer } : { done: true };
|
||||
fetchImpl: async url => url.endsWith('latest.yml')
|
||||
? {
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => `version: 2.2.0\npath: setup.exe\nsha512: ${crypto.createHash('sha512').update(installer).digest('base64')}\nsize: ${installer.length}\n`
|
||||
}
|
||||
: {
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: async () => {
|
||||
reads++;
|
||||
return reads === 1 ? { done: false, value: installer } : { done: true };
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(prepared.installerPath, path.join(tempDir, 'setup.exe'));
|
||||
@@ -78,6 +86,76 @@ test('update preparation writes a verified installer without launching it', asyn
|
||||
}
|
||||
});
|
||||
|
||||
test('update preparation fails closed when checksum metadata is unavailable', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-test-'));
|
||||
try {
|
||||
await assert.rejects(
|
||||
prepareUpdate(null, {
|
||||
checkResult: {
|
||||
available: true,
|
||||
assetUrl: 'https://update.invalid/setup.exe',
|
||||
assetName: 'setup.exe',
|
||||
assetSize: 128 * 1024,
|
||||
remoteVersion: '2.2.0',
|
||||
latestYmlUrl: null
|
||||
},
|
||||
tempDir,
|
||||
fetchImpl: async () => assert.fail('installer download must not start without checksum metadata')
|
||||
}),
|
||||
/Prüfsummen-Metadaten fehlen/
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(tempDir, 'setup.exe')), false);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('setup selection never falls back to a portable executable', () => {
|
||||
const portable = { name: 'Multi-Hoster-Upload 2.2.0.exe' };
|
||||
assert.equal(pickSetupAsset([portable], '2.2.0'), null);
|
||||
assert.deepEqual(
|
||||
pickSetupAsset([portable, { name: 'Multi-Hoster-Upload Setup 2.2.0.exe' }], '2.2.0'),
|
||||
{ name: 'Multi-Hoster-Upload Setup 2.2.0.exe' }
|
||||
);
|
||||
});
|
||||
|
||||
test('checksum metadata must match the selected version, installer name, size, and SHA-512 shape', async () => {
|
||||
const sha = crypto.randomBytes(64).toString('base64');
|
||||
const fetchImpl = async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => `version: 2.2.0\npath: Multi-Hoster-Upload Setup 2.2.0.exe\nsha512: ${sha}\nsize: 456\n`
|
||||
});
|
||||
const metadata = await parseLatestYml('https://update.invalid/latest.yml', {
|
||||
version: '2.2.0',
|
||||
assetName: 'Multi-Hoster-Upload Setup 2.2.0.exe',
|
||||
assetSize: 456
|
||||
}, fetchImpl);
|
||||
|
||||
assert.deepEqual(metadata, {
|
||||
version: '2.2.0',
|
||||
path: 'Multi-Hoster-Upload Setup 2.2.0.exe',
|
||||
size: 456,
|
||||
sha512: sha
|
||||
});
|
||||
});
|
||||
|
||||
test('checksum metadata rejects a path that belongs to another artifact', async () => {
|
||||
const sha = crypto.randomBytes(64).toString('base64');
|
||||
await assert.rejects(
|
||||
parseLatestYml('https://update.invalid/latest.yml', {
|
||||
version: '2.2.0',
|
||||
assetName: 'Multi-Hoster-Upload Setup 2.2.0.exe',
|
||||
assetSize: 456
|
||||
}, async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
text: async () => `version: 2.2.0\npath: Multi-Hoster-Upload 2.2.0.exe\nsha512: ${sha}\nsize: 456\n`
|
||||
})),
|
||||
/gehören nicht zum ausgewählten Installer/
|
||||
);
|
||||
});
|
||||
|
||||
test('a prepared installer launches at most once', () => {
|
||||
const calls = [];
|
||||
const child = { unrefCalls: 0, unref() { this.unrefCalls++; } };
|
||||
@@ -120,7 +198,7 @@ test('release plan keeps product artifacts separate from the transport tag', asy
|
||||
'Multi-Hoster-Upload Setup 2.0.7.exe.blockmap',
|
||||
'latest.yml'
|
||||
],
|
||||
latestYml: "version: 2.0.7\nfiles:\n - url: Multi-Hoster-Upload.Setup.2.0.7.exe\n sha512: abc123\n size: 456\npath: Multi-Hoster-Upload.Setup.2.0.7.exe\nsha512: abc123\nreleaseDate: '2026-08-07T12:00:00.000Z'\n"
|
||||
latestYml: "version: 2.0.7\nfiles:\n - url: Multi-Hoster-Upload Setup 2.0.7.exe\n sha512: abc123\n size: 456\npath: Multi-Hoster-Upload Setup 2.0.7.exe\nsha512: abc123\nreleaseDate: '2026-08-07T12:00:00.000Z'\n"
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { assertUploadConfirmation } = require('../lib/upload-confirmation');
|
||||
|
||||
test('accepts a host-confirmed file code without a public URL', () => {
|
||||
const result = { file_code: 'AB1', download_url: null, embed_url: null };
|
||||
assert.equal(assertUploadConfirmation(result, 'doodstream.com'), result);
|
||||
});
|
||||
|
||||
test('accepts upload URLs for every supported hoster and its subdomains', () => {
|
||||
const cases = [
|
||||
['doodstream.com', 'https://doodstream.com/d/abc123'],
|
||||
['voe.sx', 'https://cdn.voe.sx/abc123'],
|
||||
['vidmoly.me', 'https://vidmoly.me/w/abc123'],
|
||||
['byse.sx', 'https://media.byse.sx/d/abc123'],
|
||||
['clouddrop.cc', 'https://clouddrop.cc/share/abc123']
|
||||
];
|
||||
for (const [hoster, downloadUrl] of cases) {
|
||||
const result = { file_code: 'abc123', download_url: downloadUrl };
|
||||
assert.equal(assertUploadConfirmation(result, hoster), result);
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects an upload URL from a different domain', () => {
|
||||
assert.throws(
|
||||
() => assertUploadConfirmation({ file_code: 'abc123', download_url: 'https://attacker.invalid/file/abc123' }, 'voe.sx'),
|
||||
/Upload zu voe\.sx wurde nicht bestätigt/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a syntactically invalid file code despite a valid hoster URL', () => {
|
||||
assert.throws(
|
||||
() => assertUploadConfirmation({ file_code: 'not a code', download_url: 'https://vidmoly.me/w/not-a-code' }, 'vidmoly.me'),
|
||||
/Upload zu vidmoly\.me wurde nicht bestätigt/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects a valid hoster URL without a file code', () => {
|
||||
assert.throws(
|
||||
() => assertUploadConfirmation({ download_url: 'https://byse.sx/d/abc123' }, 'byse.sx'),
|
||||
/Upload zu byse\.sx wurde nicht bestätigt/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects an empty upload response', () => {
|
||||
assert.throws(
|
||||
() => assertUploadConfirmation({}, 'byse.sx'),
|
||||
/Upload zu byse\.sx wurde nicht bestätigt/
|
||||
);
|
||||
});
|
||||
|
||||
test('rejects non-network links despite a valid file code', () => {
|
||||
assert.throws(
|
||||
() => assertUploadConfirmation({ file_code: 'abc123', download_url: 'javascript:alert(1)' }, 'vidmoly.me'),
|
||||
/Upload zu vidmoly\.me wurde nicht bestätigt/
|
||||
);
|
||||
});
|
||||
@@ -97,6 +97,27 @@ describe('UploadManager', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('records an unconfirmed host response as an error instead of done', async () => {
|
||||
mockUploadFile.mock.mockImplementation(async () => ({}));
|
||||
const mgr = new UploadManager({ 'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } });
|
||||
const statuses = [];
|
||||
let settled;
|
||||
mgr.on('progress', data => statuses.push(data.status));
|
||||
mgr.on('job-settled', event => { settled = event; });
|
||||
|
||||
await mgr.startBatch([{
|
||||
file: '/test/unconfirmed.mp4',
|
||||
hoster: 'doodstream.com',
|
||||
apiKey: 'key1',
|
||||
jobId: 'job-unconfirmed',
|
||||
sourceCleanupToken: 'cleanup-unconfirmed'
|
||||
}]);
|
||||
|
||||
assert.equal(statuses.includes('done'), false);
|
||||
assert.equal(statuses.at(-1), 'error');
|
||||
assert.equal(settled.status, 'error');
|
||||
});
|
||||
|
||||
it('replaces account pools and clears cached account state after an import', () => {
|
||||
const mgr = new UploadManager({}, {}, {
|
||||
'byse.sx': [{ id: 'old', apiKey: 'old-key' }]
|
||||
@@ -144,7 +165,7 @@ describe('UploadManager', () => {
|
||||
callCount++;
|
||||
if (callCount <= 2) throw new Error('network error');
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' };
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
|
||||
const mgr = new UploadManager({ 'doodstream.com': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } });
|
||||
@@ -182,7 +203,7 @@ describe('UploadManager', () => {
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => {
|
||||
// Simulate a slow upload
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => resolve({ download_url: 'x', embed_url: null, file_code: 'x' }), 10000);
|
||||
const timer = setTimeout(() => resolve({ download_url: 'https://doodstream.com/d/ok123', embed_url: null, file_code: 'ok123' }), 10000);
|
||||
if (signal) signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('Aborted')); });
|
||||
});
|
||||
});
|
||||
@@ -229,7 +250,7 @@ describe('UploadManager', () => {
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
concurrent--;
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' };
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
|
||||
const mgr = new UploadManager({ 'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } });
|
||||
@@ -253,7 +274,7 @@ describe('UploadManager', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
concurrent--;
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: `https://${hoster}/ok`, embed_url: null, file_code: 'ok' };
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
|
||||
const mgr = new UploadManager({
|
||||
@@ -286,7 +307,7 @@ describe('UploadManager', () => {
|
||||
}
|
||||
});
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' };
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
|
||||
const mgr = new UploadManager({});
|
||||
@@ -319,7 +340,7 @@ describe('UploadManager', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' };
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
|
||||
const mgr = new UploadManager({
|
||||
@@ -402,7 +423,9 @@ describe('UploadManager', () => {
|
||||
|
||||
const mgr = new UploadManager({});
|
||||
const errors = [];
|
||||
let summary;
|
||||
mgr.on('progress', (d) => { if (d.error) errors.push(d.error); });
|
||||
mgr.on('batch-done', value => { summary = value; });
|
||||
|
||||
await mgr.startBatch([
|
||||
{ file: '/test/deleted.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
|
||||
@@ -410,6 +433,9 @@ describe('UploadManager', () => {
|
||||
|
||||
fs.promises.stat = origStat;
|
||||
assert.ok(errors.some(e => e.includes('nicht gefunden')), `expected "nicht gefunden" error, got: ${errors.join(', ')}`);
|
||||
assert.equal(summary.files[0].results[0].status, 'skipped');
|
||||
assert.equal(summary.skipped, 1);
|
||||
assert.equal(summary.failed, 0);
|
||||
});
|
||||
|
||||
it('zero-byte file produces descriptive error', async () => {
|
||||
@@ -452,7 +478,7 @@ describe('UploadManager', () => {
|
||||
await new Promise(r => setTimeout(r, 40));
|
||||
concurrent--;
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
|
||||
const mgr = new UploadManager(
|
||||
@@ -477,7 +503,7 @@ describe('UploadManager', () => {
|
||||
started++;
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
|
||||
const mgr = new UploadManager({});
|
||||
@@ -512,7 +538,7 @@ describe('UploadManager', () => {
|
||||
if (signal) signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('Aborted')); });
|
||||
});
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
|
||||
const mgr = new UploadManager({});
|
||||
@@ -545,7 +571,7 @@ describe('UploadManager', () => {
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => {
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: 'https://test/ok', embed_url: null, file_code: 'ok' };
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
|
||||
const mgr = new UploadManager({});
|
||||
@@ -630,7 +656,7 @@ describe('UploadManager', () => {
|
||||
throw err;
|
||||
}
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: 'https://byse.sx/ok', embed_url: null, file_code: 'ok' };
|
||||
return { download_url: 'https://byse.sx/d/ok123', embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
|
||||
const mgr = new UploadManager(
|
||||
@@ -721,7 +747,7 @@ describe('UploadManager', () => {
|
||||
throw err;
|
||||
}
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
|
||||
const mgr = new UploadManager(
|
||||
@@ -767,7 +793,7 @@ describe('UploadManager', () => {
|
||||
}
|
||||
acc2Calls++;
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
|
||||
const mgr = new UploadManager(
|
||||
@@ -797,11 +823,11 @@ describe('UploadManager', () => {
|
||||
acc1Calls++;
|
||||
if (acc1Calls <= 2) throw new Error('connect ECONNRESET 1.2.3.4:443');
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: 'ok-on-acc1-retry', embed_url: null, file_code: 'ok' };
|
||||
return { download_url: 'https://voe.sx/ok123', embed_url: null, file_code: 'ok123' };
|
||||
}
|
||||
acc2Calls++;
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
|
||||
const mgr = new UploadManager(
|
||||
@@ -849,7 +875,7 @@ describe('UploadManager', () => {
|
||||
|
||||
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
|
||||
await mgr.startBatch([
|
||||
@@ -1012,7 +1038,7 @@ describe('UploadManager', () => {
|
||||
throw err;
|
||||
}
|
||||
if (onProgress) onProgress(fakeFileSize, fakeFileSize);
|
||||
return { download_url: 'ok', embed_url: null, file_code: 'ok' };
|
||||
return { download_url: `https://${hoster}/d/ok123`, embed_url: null, file_code: 'ok123' };
|
||||
});
|
||||
|
||||
const mgr = new UploadManager(
|
||||
|
||||
@@ -78,8 +78,8 @@ test('formatDurationShort formats h/m/s tiers', () => {
|
||||
|
||||
test('summarizePerHosterFromBatch counts ok/fail per hoster', () => {
|
||||
const s = summarizePerHosterFromBatch(SAMPLE_SUMMARY);
|
||||
assert.deepStrictEqual(s['voe.sx'], { ok: 2, fail: 0 });
|
||||
assert.deepStrictEqual(s['byse.sx'], { ok: 1, fail: 1 });
|
||||
assert.deepStrictEqual(s['voe.sx'], { ok: 2, fail: 0, skipped: 0 });
|
||||
assert.deepStrictEqual(s['byse.sx'], { ok: 1, fail: 1, skipped: 0 });
|
||||
});
|
||||
|
||||
test('summarizePerHosterFromBatch handles malformed input', () => {
|
||||
@@ -102,6 +102,26 @@ test('buildWebhookRequest produces Discord content body for discord URLs', () =>
|
||||
assert.match(body.content, /voe\.sx: 2\/2/);
|
||||
});
|
||||
|
||||
test('webhooks report skipped uploads separately from failures', () => {
|
||||
const summary = {
|
||||
total: 2,
|
||||
succeeded: 1,
|
||||
failed: 0,
|
||||
skipped: 1,
|
||||
files: [{ name: 'a.mkv', results: [
|
||||
{ hoster: 'voe.sx', status: 'done' },
|
||||
{ hoster: 'voe.sx', status: 'skipped', error: 'Datei zu groß' }
|
||||
] }]
|
||||
};
|
||||
const discord = JSON.parse(buildWebhookRequest('https://discord.com/api/webhooks/1/x', summary, { language: 'de' }).body);
|
||||
assert.match(discord.content, /1 übersprungen/);
|
||||
assert.doesNotMatch(discord.content, /1 Fehler/);
|
||||
assert.deepStrictEqual(summarizePerHosterFromBatch(summary)['voe.sx'], { ok: 1, fail: 0, skipped: 1 });
|
||||
|
||||
const generic = JSON.parse(buildWebhookRequest('https://example.com/hook', summary, {}).body);
|
||||
assert.equal(generic.skipped, 1);
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -119,7 +139,7 @@ test('buildWebhookRequest produces raw JSON payload for generic URLs', () => {
|
||||
assert.strictEqual(body.failed, 2);
|
||||
assert.strictEqual(body.durationSec, 60);
|
||||
assert.strictEqual(body.version, '3.3.59');
|
||||
assert.deepStrictEqual(body.perHoster['byse.sx'], { ok: 1, fail: 1 });
|
||||
assert.deepStrictEqual(body.perHoster['byse.sx'], { ok: 1, fail: 1, skipped: 0 });
|
||||
});
|
||||
|
||||
test('resolveDiscordMention: @here / @everyone use parse=everyone', () => {
|
||||
|
||||
Reference in New Issue
Block a user