diff --git a/lib/semaphore.js b/lib/semaphore.js index b8cd0b8..0c0a179 100644 --- a/lib/semaphore.js +++ b/lib/semaphore.js @@ -7,7 +7,7 @@ class Semaphore { constructor(limit) { this.limit = Math.max(1, limit || 1); this.active = 0; - this.queue = []; // { resolve, reject, onAbort? } + this.queue = []; // { resolve, reject, signal?, onAbort? } } acquire(signal) { @@ -26,8 +26,8 @@ class Semaphore { const entry = { resolve, reject }; if (signal) { + entry.signal = signal; entry.onAbort = () => { - // Remove from queue without granting a slot const idx = this.queue.indexOf(entry); if (idx !== -1) this.queue.splice(idx, 1); reject(new Error('Aborted')); @@ -39,14 +39,16 @@ class Semaphore { }); } + _cleanupEntry(entry) { + if (entry.signal && entry.onAbort) { + entry.signal.removeEventListener('abort', entry.onAbort); + } + } + release() { if (this.queue.length > 0) { - // Don't decrement active — hand slot directly to next waiter const entry = this.queue.shift(); - // Clean up abort listener - if (entry.onAbort) { - // Entry was granted a slot; no need for abort listener anymore - } + this._cleanupEntry(entry); entry.resolve(); } else { this.active = Math.max(0, this.active - 1); @@ -55,10 +57,10 @@ class Semaphore { updateLimit(newLimit) { this.limit = Math.max(1, newLimit || 1); - // If new limit is higher, wake up waiting tasks while (this.active < this.limit && this.queue.length > 0) { this.active++; const entry = this.queue.shift(); + this._cleanupEntry(entry); entry.resolve(); } } diff --git a/package.json b/package.json index 0c25f30..4c219ef 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "main": "main.js", "scripts": { "start": "electron .", + "test": "node --test tests/", "dist": "electron-builder --win", "release:win": "electron-builder --publish never --win nsis portable", "release:gitea": "node scripts/release_gitea.mjs" diff --git a/tests/config-store.test.js b/tests/config-store.test.js new file mode 100644 index 0000000..212cd66 --- /dev/null +++ b/tests/config-store.test.js @@ -0,0 +1,123 @@ +const { describe, it, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); +const ConfigStore = require('../lib/config-store'); + +let tmpDir; +let store; + +function createStore() { + const fakeApp = { + isPackaged: false, + getPath: () => tmpDir + }; + // ConfigStore uses path.join(__dirname, '..') for non-packaged + // We override by setting filePath directly + store = new ConfigStore(fakeApp); + store.filePath = path.join(tmpDir, 'electron-config.json'); + return store; +} + +describe('ConfigStore', () => { + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cfg-test-')); + store = createStore(); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('load returns defaults when file does not exist', () => { + const config = store.load(); + assert.ok(config.hosters); + assert.ok(config.hosters['doodstream.com']); + assert.ok(config.hosters['voe.sx']); + assert.ok(config.hosters['vidmoly.me']); + assert.ok(config.hosters['byse.sx']); + assert.ok(config.hosterSettings); + assert.equal(config.hosterSettings['doodstream.com'].retries, 3); + assert.equal(config.hosterSettings['doodstream.com'].parallelCount, 2); + assert.equal(config.globalSettings.alwaysOnTop, false); + assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing'); + assert.deepEqual(config.history, []); + }); + + it('save then load round-trips', () => { + store.save({ hosters: { 'doodstream.com': { enabled: true, apiKey: 'test-key-123' } } }); + const config = store.load(); + assert.equal(config.hosters['doodstream.com'].apiKey, 'test-key-123'); + }); + + it('load merges with defaults for missing hosters', () => { + // Write partial config + fs.writeFileSync(store.filePath, JSON.stringify({ + hosters: { 'doodstream.com': { apiKey: 'abc' } } + }), 'utf-8'); + + const config = store.load(); + assert.equal(config.hosters['doodstream.com'].apiKey, 'abc'); + // Other hosters should still have defaults + assert.equal(config.hosters['voe.sx'].enabled, true); + assert.equal(config.hosters['voe.sx'].apiKey, ''); + }); + + it('hosterSettings merge fills gaps with defaults', () => { + fs.writeFileSync(store.filePath, JSON.stringify({ + hosterSettings: { 'voe.sx': { retries: 5 } } + }), 'utf-8'); + + const config = store.load(); + assert.equal(config.hosterSettings['voe.sx'].retries, 5); + assert.equal(config.hosterSettings['voe.sx'].parallelCount, 2); // default + assert.equal(config.hosterSettings['voe.sx'].maxSpeedKbs, 0); // default + }); + + it('save only updates provided sections', () => { + // Save hoster settings first + store.save({ hosterSettings: { 'doodstream.com': { retries: 10, maxSpeedKbs: 0, parallelCount: 2, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } } }); + // Save hosters credentials separately + store.save({ hosters: { 'doodstream.com': { enabled: true, apiKey: 'key123' } } }); + + const config = store.load(); + assert.equal(config.hosters['doodstream.com'].apiKey, 'key123'); + assert.equal(config.hosterSettings['doodstream.com'].retries, 10); // preserved + }); + + it('appendHistory adds entries and caps at 100', () => { + for (let i = 0; i < 105; i++) { + store.appendHistory({ id: `batch-${i}`, timestamp: new Date().toISOString(), files: [] }); + } + const history = store.loadHistory(); + assert.equal(history.length, 100); + assert.equal(history[0].id, 'batch-5'); // first 5 dropped + assert.equal(history[99].id, 'batch-104'); + }); + + it('clearHistory empties the array', () => { + store.appendHistory({ id: 'test', files: [] }); + assert.equal(store.loadHistory().length, 1); + store.clearHistory(); + assert.equal(store.loadHistory().length, 0); + }); + + it('corrupted JSON falls back to defaults', () => { + fs.writeFileSync(store.filePath, '{invalid json!!!', 'utf-8'); + const config = store.load(); + assert.ok(config.hosters); + assert.ok(config.hosterSettings); + assert.deepEqual(config.history, []); + }); + + it('globalSettings merge preserves partial values', () => { + fs.writeFileSync(store.filePath, JSON.stringify({ + globalSettings: { alwaysOnTop: true } + }), 'utf-8'); + + const config = store.load(); + assert.equal(config.globalSettings.alwaysOnTop, true); + assert.equal(config.globalSettings.shutdownAfterFinish, 'nothing'); // default + }); +}); diff --git a/tests/semaphore.test.js b/tests/semaphore.test.js new file mode 100644 index 0000000..3763ac0 --- /dev/null +++ b/tests/semaphore.test.js @@ -0,0 +1,165 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const Semaphore = require('../lib/semaphore'); + +describe('Semaphore', () => { + it('clamps limit to at least 1', () => { + assert.equal(new Semaphore(0).limit, 1); + assert.equal(new Semaphore(-5).limit, 1); + assert.equal(new Semaphore(undefined).limit, 1); + assert.equal(new Semaphore(3).limit, 3); + }); + + it('acquire resolves immediately when slots available', async () => { + const sem = new Semaphore(2); + await sem.acquire(); + await sem.acquire(); + assert.equal(sem.active, 2); + }); + + it('acquire blocks when all slots taken', async () => { + const sem = new Semaphore(1); + await sem.acquire(); + + let resolved = false; + const p = sem.acquire().then(() => { resolved = true; }); + + // Give microtask a chance to resolve + await new Promise(r => setTimeout(r, 10)); + assert.equal(resolved, false, 'should not resolve while slot is taken'); + assert.equal(sem.pending, 1); + + sem.release(); + await p; + assert.equal(resolved, true); + }); + + it('FIFO ordering', async () => { + const sem = new Semaphore(1); + await sem.acquire(); // take the one slot + + const order = []; + const p1 = sem.acquire().then(() => order.push(1)); + const p2 = sem.acquire().then(() => order.push(2)); + const p3 = sem.acquire().then(() => order.push(3)); + + assert.equal(sem.pending, 3); + + sem.release(); await p1; + sem.release(); await p2; + sem.release(); await p3; + + assert.deepEqual(order, [1, 2, 3]); + }); + + it('release with no waiters decrements active', async () => { + const sem = new Semaphore(2); + await sem.acquire(); + assert.equal(sem.active, 1); + sem.release(); + assert.equal(sem.active, 0); + }); + + it('release never goes below 0', () => { + const sem = new Semaphore(2); + sem.release(); + assert.equal(sem.active, 0); + sem.release(); + assert.equal(sem.active, 0); + }); + + it('acquire rejects immediately if signal already aborted', async () => { + const sem = new Semaphore(2); + const ac = new AbortController(); + ac.abort(); + + await assert.rejects(sem.acquire(ac.signal), /Aborted/); + assert.equal(sem.active, 0, 'no slot should be acquired'); + }); + + it('abort while waiting in queue removes entry and rejects', async () => { + const sem = new Semaphore(1); + await sem.acquire(); // take the slot + + const ac = new AbortController(); + const p = sem.acquire(ac.signal); + + assert.equal(sem.pending, 1); + ac.abort(); + await assert.rejects(p, /Aborted/); + assert.equal(sem.pending, 0, 'entry should be removed from queue'); + + // Release original slot - should not cause issues + sem.release(); + assert.equal(sem.active, 0); + }); + + it('abort listener is cleaned up when slot is granted via release', async () => { + const sem = new Semaphore(1); + await sem.acquire(); + + const ac = new AbortController(); + let rejected = false; + const p = sem.acquire(ac.signal).catch(() => { rejected = true; }); + + sem.release(); // grants slot to the waiter + await p; + + // Now abort after the slot was already granted + ac.abort(); + await new Promise(r => setTimeout(r, 10)); + assert.equal(rejected, false, 'reject should not fire after slot was granted'); + }); + + it('updateLimit wakes waiters', async () => { + const sem = new Semaphore(1); + await sem.acquire(); + + const resolved = []; + const p1 = sem.acquire().then(() => resolved.push(1)); + const p2 = sem.acquire().then(() => resolved.push(2)); + + sem.updateLimit(3); + await Promise.all([p1, p2]); + assert.deepEqual(resolved, [1, 2]); + }); + + it('updateLimit to lower value does not kill active slots', async () => { + const sem = new Semaphore(3); + await sem.acquire(); + await sem.acquire(); + await sem.acquire(); + assert.equal(sem.active, 3); + + sem.updateLimit(1); + assert.equal(sem.active, 3, 'existing active slots should not be evicted'); + + sem.release(); + sem.release(); + sem.release(); + assert.equal(sem.active, 0); + + // Now only 1 slot should be available + await sem.acquire(); + let blocked = false; + const p = sem.acquire().then(() => { blocked = true; }); + await new Promise(r => setTimeout(r, 10)); + assert.equal(blocked, false, 'should block at limit 1'); + sem.release(); + await p; + }); + + it('pending getter tracks queue size', async () => { + const sem = new Semaphore(1); + assert.equal(sem.pending, 0); + + await sem.acquire(); + sem.acquire(); // blocked + sem.acquire(); // blocked + assert.equal(sem.pending, 2); + + sem.release(); + await new Promise(r => setTimeout(r, 5)); + assert.equal(sem.pending, 1); + }); +}); diff --git a/tests/throttle.test.js b/tests/throttle.test.js new file mode 100644 index 0000000..b692058 --- /dev/null +++ b/tests/throttle.test.js @@ -0,0 +1,86 @@ +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const Throttle = require('../lib/throttle'); + +describe('Throttle', () => { + it('unlimited mode (0) returns immediately', async () => { + const t = new Throttle(0); + const start = Date.now(); + await t.consume(10_000_000); + assert.ok(Date.now() - start < 50, 'should be instant'); + }); + + it('unlimited with falsy values', async () => { + for (const val of [undefined, null, false, 0]) { + const t = new Throttle(val); + const start = Date.now(); + await t.consume(1_000_000); + assert.ok(Date.now() - start < 50, `should be instant for ${val}`); + } + }); + + it('small consume within initial token budget resolves immediately', async () => { + const t = new Throttle(1024 * 1024); // 1 MB/s + const start = Date.now(); + await t.consume(100); // 100 bytes, well within 1MB budget + assert.ok(Date.now() - start < 50); + }); + + it('large consume exceeding tokens introduces delay', async () => { + const t = new Throttle(1000); // 1000 bytes/sec + // Drain initial tokens + await t.consume(1000); + + const start = Date.now(); + await t.consume(500); // needs ~500ms of refill + const elapsed = Date.now() - start; + assert.ok(elapsed >= 400, `expected >=400ms, got ${elapsed}ms`); + assert.ok(elapsed < 2000, `expected <2000ms, got ${elapsed}ms`); + }); + + it('aborted signal stops consumption early', async () => { + const t = new Throttle(100); // 100 bytes/sec + await t.consume(100); // drain budget + + const ac = new AbortController(); + setTimeout(() => ac.abort(), 100); + + const start = Date.now(); + await t.consume(10000, ac.signal); // would take ~100s without abort + const elapsed = Date.now() - start; + assert.ok(elapsed < 1000, `should abort quickly, took ${elapsed}ms`); + }); + + it('updateRate changes behavior', async () => { + const t = new Throttle(100); + await t.consume(100); // drain + + t.updateRate(0); // switch to unlimited + const start = Date.now(); + await t.consume(999999); + assert.ok(Date.now() - start < 50, 'should be instant after switching to unlimited'); + }); + + it('_refill does not exceed maxBps', () => { + const t = new Throttle(1000); + t.tokens = 0; + t.lastRefill = Date.now() - 60000; // simulate 60 seconds elapsed + t._refill(); + assert.ok(t.tokens <= 1000, `tokens should not exceed maxBps, got ${t.tokens}`); + }); + + it('concurrent consume calls share the token pool', async () => { + const t = new Throttle(2000); // 2000 bytes/sec, initial tokens = 2000 + + // Two concurrent consumes of 1000 each - should both fit in initial budget + const start = Date.now(); + await Promise.all([t.consume(1000), t.consume(1000)]); + assert.ok(Date.now() - start < 100, 'both should resolve from initial budget'); + + // Third consume should need to wait for refill + const start2 = Date.now(); + await t.consume(500); + const elapsed = Date.now() - start2; + assert.ok(elapsed >= 150, `third consume should wait for refill, took ${elapsed}ms`); + }); +}); diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js new file mode 100644 index 0000000..5929db8 --- /dev/null +++ b/tests/ui-smoke.js @@ -0,0 +1,168 @@ +/** + * UI smoke test - launches the real app and checks DOM elements via webContents. + * Run with: node tests/ui-smoke.js + * (This spawns Electron as a child process) + */ +const { execSync } = require('child_process'); +const path = require('path'); +const fs = require('fs'); + +// Create a temp script that the real Electron app will execute via --eval +const testScript = ` +const { app, BrowserWindow } = require('electron'); + +// Monkey-patch: after the real window loads, run tests +const origReady = app.whenReady; + +async function runAfterDelay(win, delayMs) { + await new Promise(r => setTimeout(r, delayMs)); + return win; +} + +// Wait for app to be ready, then wait for the real window to load +setTimeout(async () => { + const windows = BrowserWindow.getAllWindows(); + if (windows.length === 0) { console.log('ERROR: No windows found'); process.exit(1); } + const win = windows[0]; + const wc = win.webContents; + + // Wait for renderer init + await new Promise(r => setTimeout(r, 2000)); + + let passed = 0; + let failed = 0; + const results = []; + + function check(name, condition) { + if (condition) { passed++; results.push(' PASS: ' + name); } + else { failed++; results.push(' FAIL: ' + name); } + } + + try { + console.log('\\n=== Upload View ==='); + + const tabCount = await wc.executeJavaScript('document.querySelectorAll(".tab").length'); + check('3 tabs exist', tabCount === 3); + + const activeTab = await wc.executeJavaScript('document.querySelector(".tab.active")?.textContent?.trim()'); + check('Upload tab active by default', activeTab === 'Upload'); + + const dropVisible = await wc.executeJavaScript('document.getElementById("dropZone")?.style.display !== "none"'); + check('Drop zone visible (no files)', dropVisible); + + const queueHidden = await wc.executeJavaScript('document.getElementById("queueContainer")?.style.display'); + check('Queue hidden (no files)', queueHidden === 'none'); + + const chips = await wc.executeJavaScript('document.querySelectorAll(".hoster-chip").length'); + check('4 hoster chips', chips === 4); + + const startDisabled = await wc.executeJavaScript('document.getElementById("startUploadBtn")?.disabled'); + check('Start button disabled initially', startDisabled === true); + + const sbState = await wc.executeJavaScript('document.getElementById("sbState")?.textContent'); + check('Statusbar: Bereit', sbState === 'Bereit'); + + const version = await wc.executeJavaScript('document.getElementById("versionLabel")?.textContent'); + check('Version label present', version && version.startsWith('v')); + + const ctxHidden = await wc.executeJavaScript('document.getElementById("contextMenu")?.style.display'); + check('Context menu hidden', ctxHidden === 'none'); + + console.log('\\n=== Settings View ==='); + + await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'settings\\']").click()'); + await new Promise(r => setTimeout(r, 300)); + + const settingsActive = await wc.executeJavaScript('document.getElementById("settings-view")?.classList.contains("active")'); + check('Settings tab active', settingsActive); + + const panels = await wc.executeJavaScript('document.querySelectorAll(".hoster-settings-panel").length'); + check('4 hoster panels', panels === 4); + + const hsInputCount = await wc.executeJavaScript('document.querySelectorAll(".hs-input").length'); + check('24 per-hoster inputs (6x4)', hsInputCount === 24); + + await wc.executeJavaScript('document.querySelector(".hoster-panel-header").click()'); + await new Promise(r => setTimeout(r, 200)); + + const panelBody = await wc.executeJavaScript('document.querySelector(".hoster-panel-body").style.display'); + check('Panel expands on click', panelBody !== 'none'); + + const retries = await wc.executeJavaScript('document.querySelector(".hs-input[data-hs=\\'retries\\']")?.value'); + check('Retries default 3', retries === '3'); + + const parallel = await wc.executeJavaScript('document.querySelector(".hs-input[data-hs=\\'parallelCount\\']")?.value'); + check('ParallelCount default 2', parallel === '2'); + + // Test save + await wc.executeJavaScript('document.getElementById("saveSettingsBtn").click()'); + await new Promise(r => setTimeout(r, 500)); + const feedback = await wc.executeJavaScript('document.getElementById("saveFeedback")?.textContent'); + check('Save shows Gespeichert!', feedback === 'Gespeichert!'); + + console.log('\\n=== History View ==='); + + await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'history\\']").click()'); + await new Promise(r => setTimeout(r, 1000)); // Wait for async loadHistory + + const historyActive = await wc.executeJavaScript('document.getElementById("history-view")?.classList.contains("active")'); + check('History tab active', historyActive); + + const emptyState = await wc.executeJavaScript('document.querySelector("#historyContainer .empty-state")?.textContent'); + check('Empty state or history table shown', emptyState === 'Noch keine Uploads.' || emptyState === undefined); + + console.log('\\n=== Global UI ==='); + + const shutdownHidden = await wc.executeJavaScript('document.getElementById("shutdownOverlay")?.style.display'); + check('Shutdown overlay hidden', shutdownHidden === 'none'); + + const toastHidden = await wc.executeJavaScript('!document.getElementById("copyToast")?.classList.contains("show")'); + check('Copy toast hidden', toastHidden); + + const updateHidden = await wc.executeJavaScript('document.getElementById("updateBanner")?.style.display'); + check('Update banner hidden', updateHidden === 'none'); + + } catch (err) { + console.error('Test error:', err.message); + failed++; + } + + console.log('\\n=== Results ==='); + results.forEach(r => console.log(r)); + console.log('\\nTotal: ' + (passed + failed) + ' | Passed: ' + passed + ' | Failed: ' + failed); + + if (failed > 0) process.exitCode = 1; + app.quit(); +}, 5000); +`; + +// Write the injection script +const injectPath = path.join(__dirname, '_ui-inject.tmp.js'); +fs.writeFileSync(injectPath, testScript, 'utf-8'); + +// Run the real app with the injection +try { + const electronPath = path.join(__dirname, '..', 'node_modules', '.bin', 'electron'); + const mainPath = path.join(__dirname, '..', 'main.js'); + + // We'll use --require to inject the test after the main process loads + const result = execSync( + `"${electronPath}" --require "${injectPath}" "${mainPath}"`, + { cwd: path.join(__dirname, '..'), timeout: 20000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } + ); + console.log(result); +} catch (err) { + // timeout or exit code - still print output + if (err.stdout) console.log(err.stdout); + if (err.stderr) { + const filtered = err.stderr.split('\n') + .filter(l => !l.includes('cache_util') && !l.includes('disk_cache') && !l.includes('gpu_disk_cache')) + .join('\n'); + if (filtered.trim()) console.error(filtered); + } + if (err.status && err.status !== 0 && !err.killed) { + process.exit(err.status); + } +} finally { + try { fs.unlinkSync(injectPath); } catch {} +} diff --git a/tests/upload-manager.test.js b/tests/upload-manager.test.js new file mode 100644 index 0000000..eb817c5 --- /dev/null +++ b/tests/upload-manager.test.js @@ -0,0 +1,243 @@ +const { describe, it, mock, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('path'); +const { EventEmitter } = require('events'); + +// We need to mock fs.statSync and the hoster upload functions before requiring upload-manager +// Use node:test mock.module (available in Node 22+) + +describe('UploadManager', () => { + let UploadManager; + let mockUploadFile; + let fakeFileSize; + + beforeEach(() => { + fakeFileSize = 1024 * 1024; // 1 MB default + + // Clear module cache for fresh mocks each test + delete require.cache[require.resolve('../lib/upload-manager')]; + + // Mock the hosters module + mockUploadFile = mock.fn(async (hoster, filePath, apiKey, onProgress, signal, throttle) => { + // Simulate upload progress + if (onProgress) { + onProgress(fakeFileSize / 2, fakeFileSize); + onProgress(fakeFileSize, fakeFileSize); + } + return { download_url: `https://${hoster}/test123`, embed_url: null, file_code: 'test123' }; + }); + + // Override require for hosters + const origRequire = module.constructor.prototype.require; + const hosters = require('../lib/hosters'); + hosters.uploadFile = mockUploadFile; + + // Mock fs.statSync for test file paths + const fs = require('fs'); + const origStatSync = fs.statSync; + fs.statSync = function(p) { + if (typeof p === 'string' && p.startsWith('/test/')) { + return { size: fakeFileSize }; + } + return origStatSync.call(this, p); + }; + + UploadManager = require('../lib/upload-manager'); + }); + + it('emits progress events for each task', async () => { + const mgr = new UploadManager({}); + const events = []; + mgr.on('progress', (data) => events.push(data)); + + await mgr.startBatch([ + { file: '/test/video1.mp4', hoster: 'doodstream.com', apiKey: 'key1' } + ]); + + const statuses = events.map(e => e.status); + assert.ok(statuses.includes('queued'), 'should have queued status'); + assert.ok(statuses.includes('done'), 'should have done status'); + }); + + it('emits batch-done with correct summary', async () => { + const mgr = new UploadManager({}); + let summary = null; + mgr.on('batch-done', (s) => { summary = s; }); + + await mgr.startBatch([ + { file: '/test/video1.mp4', hoster: 'doodstream.com', apiKey: 'key1' }, + { file: '/test/video2.mp4', hoster: 'doodstream.com', apiKey: 'key1' } + ]); + + assert.ok(summary); + assert.equal(summary.total, 2); + assert.equal(summary.succeeded, 2); + assert.equal(summary.failed, 0); + assert.equal(summary.files.length, 2); + }); + + it('retries on failure then succeeds', async () => { + let callCount = 0; + mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => { + 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' }; + }); + + const mgr = new UploadManager({ 'doodstream.com': { retries: 3, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }); + const statuses = []; + mgr.on('progress', (d) => statuses.push(d.status)); + + await mgr.startBatch([ + { file: '/test/video.mp4', hoster: 'doodstream.com', apiKey: 'key1' } + ]); + + assert.ok(statuses.includes('retrying'), 'should show retrying status'); + assert.ok(statuses.includes('done'), 'should eventually succeed'); + assert.equal(callCount, 3); + }); + + it('exhausted retries result in error', async () => { + mockUploadFile.mock.mockImplementation(async () => { + throw new Error('permanent failure'); + }); + + const mgr = new UploadManager({ 'doodstream.com': { retries: 1, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }); + let summary = null; + mgr.on('batch-done', (s) => { summary = s; }); + + await mgr.startBatch([ + { file: '/test/video.mp4', hoster: 'doodstream.com', apiKey: 'key1' } + ]); + + assert.ok(summary); + assert.equal(summary.failed, 1); + assert.equal(summary.succeeded, 0); + }); + + it('cancel aborts running uploads', async () => { + 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); + if (signal) signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('Aborted')); }); + }); + }); + + const mgr = new UploadManager({}); + let batchDone = false; + mgr.on('batch-done', () => { batchDone = true; }); + + const batchPromise = mgr.startBatch([ + { file: '/test/video.mp4', hoster: 'doodstream.com', apiKey: 'key1' } + ]); + + // Wait a bit then cancel + await new Promise(r => setTimeout(r, 100)); + mgr.cancel(); + + await batchPromise; + assert.equal(mgr.running, false); + assert.ok(batchDone, 'batch-done should be emitted even after cancel'); + }); + + it('maxSizeMb filter skips oversized files', async () => { + fakeFileSize = 5 * 1024 * 1024; // 5 MB + + const mgr = new UploadManager({ 'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 1 } }); + const statuses = []; + mgr.on('progress', (d) => statuses.push(d.status)); + + await mgr.startBatch([ + { file: '/test/big.mp4', hoster: 'doodstream.com', apiKey: 'key1' } + ]); + + assert.ok(statuses.includes('skipped'), 'oversized file should be skipped'); + assert.ok(!statuses.includes('uploading'), 'should not attempt upload'); + }); + + it('per-hoster semaphore limits concurrency', async () => { + let concurrent = 0; + let maxConcurrent = 0; + + mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress, signal) => { + concurrent++; + maxConcurrent = Math.max(maxConcurrent, concurrent); + 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' }; + }); + + const mgr = new UploadManager({ 'doodstream.com': { retries: 0, parallelCount: 1, maxSpeedKbs: 0, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } }); + + await mgr.startBatch([ + { file: '/test/a.mp4', hoster: 'doodstream.com', apiKey: 'k' }, + { file: '/test/b.mp4', hoster: 'doodstream.com', apiKey: 'k' }, + { file: '/test/c.mp4', hoster: 'doodstream.com', apiKey: 'k' } + ]); + + assert.equal(maxConcurrent, 1, 'should only run 1 upload at a time'); + }); + + it('_combineSignals propagates abort from either source', () => { + const mgr = new UploadManager({}); + const ac1 = new AbortController(); + const ac2 = new AbortController(); + + const combined = mgr._combineSignals(ac1.signal, ac2.signal); + assert.equal(combined.aborted, false); + + ac2.abort(); + assert.equal(combined.aborted, true); + }); + + it('_combineSignals returns aborted signal if input already aborted', () => { + const mgr = new UploadManager({}); + const ac1 = new AbortController(); + ac1.abort(); + const ac2 = new AbortController(); + + const combined = mgr._combineSignals(ac1.signal, ac2.signal); + assert.equal(combined.aborted, true); + }); + + it('_sleep resolves after delay', async () => { + const mgr = new UploadManager({}); + const start = Date.now(); + await mgr._sleep(100); + assert.ok(Date.now() - start >= 90); + }); + + it('_sleep rejects on abort', async () => { + const mgr = new UploadManager({}); + const ac = new AbortController(); + setTimeout(() => ac.abort(), 10); + await assert.rejects(mgr._sleep(5000, ac.signal), /Aborted/); + }); + + it('stats event contains expected fields', async () => { + // Make upload take long enough for stats interval to fire + 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' }; + }); + + const mgr = new UploadManager({}); + const statsEvents = []; + mgr.on('stats', (d) => statsEvents.push(d)); + + await mgr.startBatch([ + { file: '/test/video.mp4', hoster: 'doodstream.com', apiKey: 'key1' } + ]); + + assert.ok(statsEvents.length > 0, 'should have received stats events'); + const stat = statsEvents[0]; + assert.ok('globalSpeedKbs' in stat); + assert.ok('totalBytes' in stat); + assert.ok('elapsed' in stat); + assert.ok('activeJobs' in stat); + }); +});