Multi-Hoster-Upload/tests/config-store.test.js
Administrator 29d1944328 perf(config): cache parsed config + lean serialize + drop load() from log flush (long-run lag)
A 13-agent hunt pinned the "wird mit der Zeit laggy" symptom (CPU/RAM normal, UI
sluggish after many uploads) to the main process re-doing config I/O that scales
with the ever-growing history, stalling the synchronous main event loop so the
renderer's IPC round-trips feel laggy. The renderer render path was already
optimized (virtualized queue, capped panels) — confirmed clean.

This commit lands the two contained fixes (T1 + T3); the queue-persist rewrite (T2)
follows separately.

config-store (T1):
- load() now has an in-memory cache keyed on the file mtime+size. The processed
  config (merged + credential-decrypted) is re-read/re-parsed/re-DPAPI-decrypted
  ONLY when the file actually changes; our writes invalidate it, external edits
  change mtime/size so the cache misses. Eliminates a full disk read + JSON.parse of
  the whole growing history + per-credential decrypt on the vast majority of the ~38
  load() call sites. load() always returns a structuredClone so callers can mutate
  freely without corrupting the cache.
- _serializeForDisk clones ONLY the hosters subtree (the only thing encryptCredentials
  touches) instead of JSON.parse(JSON.stringify(whole config)) — no more deep-cloning
  an 8 MB history on every write.
- _atomicWrite refreshes the .bak with a raw fs.copyFileSync instead of
  read + JSON.parse + write (it was re-parsing the full config a 2nd time per write).

main.js (T3):
- The log-flush paths resolved the log file via configStore.load() ~8x/second during
  uploads (re-reading + cloning the whole config just to read logMode/logFilePath).
  Cache those two strings in module scope, invalidate on the settings-save handlers.

Verified: 26 config-store tests (incl. new cache-correctness: independent clones,
external-change invalidation, save invalidation) + full 394-test suite green, lint 0
errors. Benchmark (8000-batch / 4.6 MB history): log flush no longer calls load() at
all; the remaining per-write history serialize is what T2 removes from the hot path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 00:49:55 +02:00

296 lines
13 KiB
JavaScript

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.equal(config.globalSettings.logFilePath, '');
assert.equal(config.globalSettings.resumeQueueOnLaunch, true);
assert.equal(config.globalSettings.parallelUploadCount, 0);
assert.equal(config.globalSettings.scaleParallelUploads, false);
assert.equal(config.globalSettings.pendingQueue, null);
assert.deepEqual(config.history, []);
});
it('save then load round-trips', async () => {
await store.save({ hosters: { 'doodstream.com': [{ id: 'test-1', enabled: true, authType: 'api', apiKey: 'test-key-123' }] } });
const config = store.load();
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'test-key-123');
});
it('default logMode is "single"', () => {
const config = store.load();
assert.equal(config.globalSettings.logMode, 'single');
});
it('persists pendingQueue incl. savedAt (number) and ts-bearing jobs across save/load', async () => {
// The queue-persistence fix stamps pendingQueue.savedAt and restores it on launch.
// This proves the persistence layer round-trips the new fields untouched (the
// ts-gate is worthless if savedAt does not survive serialization).
const pendingQueue = {
savedAt: 1750000000123,
selectedUploadHosters: ['voe.sx', 'byse.sx'],
selectedFiles: [{ path: 'C:/dl/a.mkv', name: 'a.mkv', size: 4242 }],
queueJobs: [
{ id: 'j1', file: 'C:/dl/a.mkv', fileName: 'a.mkv', hoster: 'voe.sx', status: 'preview', bytesTotal: 4242, maxAttempts: 0 },
{ id: 'j2', file: 'C:/dl/a.mkv', fileName: 'a.mkv', hoster: 'byse.sx', status: 'error', error: 'boom', maxAttempts: 3 }
]
};
const current = store.load();
await store.save({ globalSettings: { ...current.globalSettings, pendingQueue } });
const loaded = store.load();
const pq = loaded.globalSettings.pendingQueue;
assert.equal(pq.savedAt, 1750000000123, 'savedAt epoch survives JSON round-trip');
assert.equal(typeof pq.savedAt, 'number');
assert.equal(pq.queueJobs.length, 2);
assert.equal(pq.queueJobs[0].fileName, 'a.mkv');
assert.equal(pq.queueJobs[0].hoster, 'voe.sx');
assert.equal(pq.queueJobs[1].status, 'error');
assert.deepEqual(pq.selectedUploadHosters, ['voe.sx', 'byse.sx']);
assert.equal(pq.selectedFiles[0].path, 'C:/dl/a.mkv');
});
it('pendingQueue can be cleared back to null (clearPersistedQueueStateSoon path)', async () => {
const current = store.load();
await store.save({ globalSettings: { ...current.globalSettings, pendingQueue: { savedAt: 1, queueJobs: [] } } });
assert.ok(store.load().globalSettings.pendingQueue);
const c2 = store.load();
await store.save({ globalSettings: { ...c2.globalSettings, pendingQueue: null } });
assert.equal(store.load().globalSettings.pendingQueue, null);
});
it('regression: legacy sessionLog:true on disk normalizes to logMode "daily" (NOT "session")', async () => {
// Write a config with the legacy boolean only (what an existing user has).
await store.save({ globalSettings: { sessionLog: true } });
const config = store.load();
// The misnamed legacy field MUST map to daily — mapping to "session" would
// silently change every per-day user's behaviour on upgrade.
assert.equal(config.globalSettings.logMode, 'daily');
});
it('logMode round-trips for all three values', async () => {
for (const mode of ['single', 'daily', 'session']) {
await store.save({ globalSettings: { logMode: mode } });
const config = store.load();
assert.equal(config.globalSettings.logMode, mode, `mode ${mode}`);
}
});
it('load merges with defaults for missing hosters', () => {
// Write partial config in old single-object format (triggers migration)
fs.writeFileSync(store.filePath, JSON.stringify({
hosters: { 'doodstream.com': { apiKey: 'abc' } }
}), 'utf-8');
const config = store.load();
// Old format is migrated to array
assert.ok(Array.isArray(config.hosters['doodstream.com']));
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'abc');
// Other hosters should still have defaults (empty arrays)
assert.ok(Array.isArray(config.hosters['voe.sx']));
assert.equal(config.hosters['voe.sx'].length, 0);
});
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
assert.equal(config.hosterSettings['voe.sx'].logToFile, true); // default on
});
it('logToFile defaults to true for every hoster', () => {
const config = store.load();
for (const name of ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']) {
assert.equal(config.hosterSettings[name].logToFile, true, `${name} should default logToFile=true`);
}
});
it('sizeMemoEnabled defaults to true for every hoster and persists when disabled', async () => {
const fresh = store.load();
for (const name of ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']) {
assert.equal(fresh.hosterSettings[name].sizeMemoEnabled, true, `${name} should default sizeMemoEnabled=true`);
}
await store.save({ hosterSettings: { 'byse.sx': { sizeMemoEnabled: false } } });
const config = store.load();
assert.equal(config.hosterSettings['byse.sx'].sizeMemoEnabled, false, 'explicit false preserved');
assert.equal(config.hosterSettings['voe.sx'].sizeMemoEnabled, true, 'other hoster still defaults on');
});
it('logToFile=false persists and survives reload', async () => {
await store.save({ hosterSettings: { 'voe.sx': { logToFile: false } } });
const config = store.load();
assert.equal(config.hosterSettings['voe.sx'].logToFile, false, 'explicit false preserved');
assert.equal(config.hosterSettings['byse.sx'].logToFile, true, 'other hoster still defaults on');
});
it('save only updates provided sections', async () => {
// Save hoster settings first
await store.save({ hosterSettings: { 'doodstream.com': { retries: 10, maxSpeedKbs: 0, parallelCount: 2, restartBelowKbs: 0, timeIntervalSec: 0, maxSizeMb: 0 } } });
// Save hosters credentials separately (array format)
await store.save({ hosters: { 'doodstream.com': [{ id: 'test-1', enabled: true, authType: 'api', apiKey: 'key123' }] } });
const config = store.load();
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'key123');
assert.equal(config.hosterSettings['doodstream.com'].retries, 10); // preserved
});
it('appendHistory keeps complete history without truncation', async () => {
for (let i = 0; i < 105; i++) {
await store.appendHistory({ id: `batch-${i}`, timestamp: new Date().toISOString(), files: [] });
}
const history = store.loadHistory();
assert.equal(history.length, 105);
assert.equal(history[0].id, 'batch-0');
assert.equal(history[104].id, 'batch-104');
});
it('clearHistory empties the array', async () => {
await store.appendHistory({ id: 'test', files: [] });
assert.equal(store.loadHistory().length, 1);
await store.clearHistory();
assert.equal(store.loadHistory().length, 0);
});
it('rotationCursors default to an empty object', () => {
const config = store.load();
assert.deepEqual(config.rotationCursors, {});
});
it('saveRotationCursors round-trips and survives reload', async () => {
await store.saveRotationCursors({ 'byse.sx': 7, 'voe.sx': 2 });
const config = store.load();
assert.equal(config.rotationCursors['byse.sx'], 7);
assert.equal(config.rotationCursors['voe.sx'], 2);
});
it('an unrelated save() does not clobber persisted rotationCursors', async () => {
await store.saveRotationCursors({ 'byse.sx': 3 });
await store.save({ globalSettings: { alwaysOnTop: true } });
const config = store.load();
assert.equal(config.rotationCursors['byse.sx'], 3, 'cursor preserved across a settings save');
assert.equal(config.globalSettings.alwaysOnTop, true);
});
it('saveRotationCursors does not disturb credentials', async () => {
await store.save({ hosters: { 'byse.sx': [{ id: 'k1', enabled: true, authType: 'api', apiKey: 'secret-key' }] } });
await store.saveRotationCursors({ 'byse.sx': 1 });
const config = store.load();
assert.equal(config.hosters['byse.sx'][0].apiKey, 'secret-key');
assert.equal(config.rotationCursors['byse.sx'], 1);
});
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
assert.equal(config.globalSettings.resumeQueueOnLaunch, true);
assert.equal(config.globalSettings.parallelUploadCount, 0);
assert.equal(config.globalSettings.scaleParallelUploads, false);
assert.equal(config.globalSettings.logFilePath, '');
});
it('concurrent saves preserve both sections', async () => {
const save1 = store.save({ hosters: { 'doodstream.com': [{ id: 'c1', enabled: true, authType: 'api', apiKey: 'concurrent-key' }] } });
const save2 = store.save({ globalSettings: { alwaysOnTop: true } });
await Promise.all([save1, save2]);
const config = store.load();
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'concurrent-key');
assert.equal(config.globalSettings.alwaysOnTop, true);
});
it('load() returns independent clones — mutating one result must not leak into the cache', () => {
store.load(); // warm the cache
const a = store.load();
a.globalSettings.alwaysOnTop = true;
a.hosters['voe.sx'].push({ id: 'mutant' });
a.history.push({ id: 'ghost' });
const b = store.load();
assert.equal(b.globalSettings.alwaysOnTop, false, 'mutating a prior load() result must not corrupt the cache');
assert.equal(b.hosters['voe.sx'].length, 0);
assert.equal(b.history.length, 0);
});
it('load() reflects an external file change (mtime/size cache invalidation)', () => {
store.load(); // warm cache on the no-file defaults
fs.writeFileSync(store.filePath, JSON.stringify({ globalSettings: { alwaysOnTop: true } }), 'utf-8');
assert.equal(store.load().globalSettings.alwaysOnTop, true, 'an external write must invalidate the cache');
fs.writeFileSync(store.filePath, JSON.stringify({ globalSettings: { alwaysOnTop: false } }), 'utf-8');
assert.equal(store.load().globalSettings.alwaysOnTop, false, 'a second external write must be seen too');
});
it('save() invalidates the cache so the next load() sees the new value', async () => {
assert.equal(store.load().globalSettings.alwaysOnTop, false);
await store.save({ globalSettings: { alwaysOnTop: true } });
assert.equal(store.load().globalSettings.alwaysOnTop, true, 'load() after save() must reflect the write');
});
it('backup recovery when main file is corrupted', () => {
// Write valid config first
fs.writeFileSync(store.filePath, JSON.stringify({
hosters: { 'doodstream.com': [{ id: 'bak-1', authType: 'api', apiKey: 'from-backup' }] },
hosterSettings: {}, globalSettings: {}, history: []
}), 'utf-8');
// Copy to backup
fs.copyFileSync(store.filePath, store.filePath + '.bak');
// Corrupt main file
fs.writeFileSync(store.filePath, 'CORRUPTED!!!', 'utf-8');
const config = store.load();
assert.equal(config.hosters['doodstream.com'][0].apiKey, 'from-backup');
});
});