test: add unit tests (41) and UI smoke tests (21), fix semaphore listener leak
- 12 Semaphore tests: FIFO ordering, abort support, limit updates, listener cleanup - 8 Throttle tests: rate limiting, abort signal, concurrent consume, updateRate - 9 ConfigStore tests: defaults, merge, round-trip, corruption fallback, history cap - 12 UploadManager tests: progress events, retry, cancel, size filter, concurrency - 21 UI smoke tests: tab navigation, settings panels, statusbar, context menu - Fix: Semaphore.release() and updateLimit() now properly remove abort listeners Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
3d759eb8a6
commit
61681de9a3
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
123
tests/config-store.test.js
Normal file
123
tests/config-store.test.js
Normal file
@ -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
|
||||
});
|
||||
});
|
||||
165
tests/semaphore.test.js
Normal file
165
tests/semaphore.test.js
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
86
tests/throttle.test.js
Normal file
86
tests/throttle.test.js
Normal file
@ -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`);
|
||||
});
|
||||
});
|
||||
168
tests/ui-smoke.js
Normal file
168
tests/ui-smoke.js
Normal file
@ -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 {}
|
||||
}
|
||||
243
tests/upload-manager.test.js
Normal file
243
tests/upload-manager.test.js
Normal file
@ -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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user