fix: stabilize renderer state and lifecycle updates

Prevent stale history, account, diagnostics, and language updates from repainting newer state. Keep queue virtualization, panel sizing, telemetry, cancellation, and runtime timing consistent across rapid UI changes. Strengthen painted-frame and lifecycle regression coverage, and brand packaged Windows metadata with the product publisher.
This commit is contained in:
Sucukdeluxe
2026-08-13 07:07:42 +02:00
parent b7754f355b
commit 59cead80c6
13 changed files with 747 additions and 50 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ Multi-Hoster-Upload is a Windows desktop application for sending file batches to
Download the current Setup or Portable build from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest). Download the current Setup or Portable build from the [latest GitHub release](https://github.com/Sucukdeluxe/Multi-Hoster-Upload/releases/latest).
The latest public release is version 2.1.17. Use the release page for the executables and the full English changelog. The latest public release is version 2.1.18. Use the release page for the executables and the full English changelog.
## Features ## Features
+7 -3
View File
@@ -4,6 +4,10 @@ function configureStartupRenderer(app, env = process.env, platform = process.pla
return app; return app;
} }
function resolveStartupLanguage(config) {
return config && config.globalSettings && config.globalSettings.language === 'de' ? 'de' : 'en';
}
function createStartupWindow(BrowserWindow, options) { function createStartupWindow(BrowserWindow, options) {
const window = new BrowserWindow({ ...options, show: false }); const window = new BrowserWindow({ ...options, show: false });
window.once('ready-to-show', () => { window.once('ready-to-show', () => {
@@ -12,10 +16,10 @@ function createStartupWindow(BrowserWindow, options) {
return { return {
window, window,
load(target, onLoadError) { load(target, onLoadError, options) {
return window.loadFile(target).catch(onLoadError); return window.loadFile(target, options).catch(onLoadError);
} }
}; };
} }
module.exports = { configureStartupRenderer, createStartupWindow }; module.exports = { configureStartupRenderer, createStartupWindow, resolveStartupLanguage };
+9 -6
View File
@@ -406,8 +406,9 @@ class UploadManager extends EventEmitter {
await Promise.allSettled(batch); await Promise.allSettled(batch);
} }
this._stopStatsTimer();
this.running = false; this.running = false;
this._stopStatsTimer();
this._emitStats();
const files = Array.from(results.values()); const files = Array.from(results.values());
const total = tasks.length; const total = tasks.length;
@@ -1291,7 +1292,10 @@ class UploadManager extends EventEmitter {
_startStatsTimer() { _startStatsTimer() {
if (this.statsInterval) clearInterval(this.statsInterval); if (this.statsInterval) clearInterval(this.statsInterval);
this.statsInterval = setInterval(() => { this.statsInterval = setInterval(() => this._emitStats(), 1000);
}
_emitStats() {
try { try {
let globalSpeedKbs = 0; let globalSpeedKbs = 0;
let activeCount = 0; let activeCount = 0;
@@ -1312,8 +1316,7 @@ class UploadManager extends EventEmitter {
activeJobs: activeCount, activeJobs: activeCount,
pendingJobs: Object.values(this.semaphores).reduce((sum, semaphore) => sum + semaphore.pending, 0) pendingJobs: Object.values(this.semaphores).reduce((sum, semaphore) => sum + semaphore.pending, 0)
}); });
} catch { /* never let a stats tick crash the timer + caller */ } } catch {}
}, 1000);
} }
_stopStatsTimer() { _stopStatsTimer() {
@@ -1454,12 +1457,12 @@ class UploadManager extends EventEmitter {
cancel() { cancel() {
if (!this.running) return; if (!this.running) return;
this.abortController.abort(); this.abortController.abort();
this.stopAfterActive = false; this.stopAfterActive = true;
this.running = false;
for (const controller of this.jobAbortControllers.values()) { for (const controller of this.jobAbortControllers.values()) {
if (!controller.signal.aborted) controller.abort(); if (!controller.signal.aborted) controller.abort();
} }
this._stopStatsTimer(); this._stopStatsTimer();
this._emitStats();
} }
} }
+6 -3
View File
@@ -1,7 +1,7 @@
process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '8'; process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '8';
const { monitorEventLoopDelay, PerformanceObserver } = require('perf_hooks'); const { monitorEventLoopDelay, PerformanceObserver } = require('perf_hooks');
const { app, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Tray, Menu, nativeImage } = require('electron'); const { app, BrowserWindow, ipcMain, dialog, clipboard, nativeTheme, Tray, Menu, nativeImage } = require('electron');
const { configureStartupRenderer, createStartupWindow } = require('./lib/startup-renderer'); const { configureStartupRenderer, createStartupWindow, resolveStartupLanguage } = require('./lib/startup-renderer');
configureStartupRenderer(app); configureStartupRenderer(app);
nativeTheme.themeSource = 'dark'; nativeTheme.themeSource = 'dark';
const path = require('path'); const path = require('path');
@@ -1441,7 +1441,8 @@ function createWindow() {
mainWindow.webContents.setBackgroundThrottling(false); mainWindow.webContents.setBackgroundThrottling(false);
mainWindow.webContents.on('did-start-loading', () => { mainWindow.webContents.on('did-start-navigation', (_event, _url, isInPlace, isMainFrame) => {
if (isInPlace || !isMainFrame) return;
closeHandshakeReady = false; closeHandshakeReady = false;
restoreClosePreparation(closePreparationAttempt); restoreClosePreparation(closePreparationAttempt);
}); });
@@ -1490,10 +1491,12 @@ function createWindow() {
debugLog(`CHILD PROCESS GONE: type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`); debugLog(`CHILD PROCESS GONE: type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`);
}); });
let startupLanguage = 'en';
try { startupLanguage = resolveStartupLanguage(configStore.load()); } catch {}
startupWindow.load(path.join(__dirname, 'renderer', 'index.html'), (err) => { startupWindow.load(path.join(__dirname, 'renderer', 'index.html'), (err) => {
_writeCrashLog('LOAD FILE FAILED', err); _writeCrashLog('LOAD FILE FAILED', err);
debugLog(`LOAD FILE FAILED: ${err && err.stack ? err.stack : err}`); debugLog(`LOAD FILE FAILED: ${err && err.stack ? err.stack : err}`);
}); }, { query: { language: startupLanguage } });
} }
function createTray() { function createTray() {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.1.17", "version": "2.1.18",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.1.17", "version": "2.1.18",
"dependencies": { "dependencies": {
"chokidar": "^3.6.0", "chokidar": "^3.6.0",
"undici": "^7.29.0", "undici": "^7.29.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "multi-hoster-uploader", "name": "multi-hoster-uploader",
"version": "2.1.17", "version": "2.1.18",
"description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously",
"main": "main.js", "main": "main.js",
"scripts": { "scripts": {
+162 -29
View File
@@ -1,6 +1,6 @@
const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']; const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc'];
const uiLocalizer = window.I18n.createDomLocalizer(document); const uiLocalizer = window.I18n.createDomLocalizer(document);
uiLocalizer.start('en'); uiLocalizer.start(new URLSearchParams(window.location.search).get('language'));
function setUiLanguage(value) { function setUiLanguage(value) {
const language = uiLocalizer.setLanguage(window.I18n.normalizeLanguage(value)); const language = uiLocalizer.setLanguage(window.I18n.normalizeLanguage(value));
@@ -24,6 +24,9 @@ function formatRemoteClientStatus(port, count) {
function refreshLocalizedRuntimeUi() { function refreshLocalizedRuntimeUi() {
renderQueueTable(); renderQueueTable();
renderAccounts(); renderAccounts();
sessionFilesData.forEach(row => {
if (Number.isFinite(row.dateTs)) row.date = formatDateTime(row.dateTs).text;
});
renderRecentUploadsPanel(); renderRecentUploadsPanel();
historyRowsData.forEach(row => { historyRowsData.forEach(row => {
if (row.rawTimestamp !== undefined) { if (row.rawTimestamp !== undefined) {
@@ -115,6 +118,8 @@ function _maybeLogRendererPerf(activeJobs) {
_resetRendererPerf(); _resetRendererPerf();
} }
let accountStatuses = {}; // { accountId: { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } } let accountStatuses = {}; // { accountId: { status: 'ok'|'warn'|'error'|'checking'|'unchecked', message: '' } }
let accountStatusGenerationSequence = 0;
const accountStatusGenerations = new Map();
let editingAccountId = null; // null = adding, string = editing account by ID let editingAccountId = null; // null = adding, string = editing account by ID
let autoHealthCheckEnabled = true; let autoHealthCheckEnabled = true;
const queuePersistThrottle = (window.ThrottleTimer && window.ThrottleTimer.makeThrottleTimer) const queuePersistThrottle = (window.ThrottleTimer && window.ThrottleTimer.makeThrottleTimer)
@@ -571,6 +576,7 @@ async function init() {
// --- Tab switching --- // --- Tab switching ---
let _historyDirty = false; let _historyDirty = false;
let _historyEverLoaded = false; let _historyEverLoaded = false;
let clampRecentPanelHeight = () => {};
function _isHistoryTabActive() { function _isHistoryTabActive() {
const tab = document.querySelector('.tab.active'); const tab = document.querySelector('.tab.active');
return !!(tab && tab.dataset.view === 'history'); return !!(tab && tab.dataset.view === 'history');
@@ -601,6 +607,7 @@ function _isHistoryTabActive() {
tab.tabIndex = 0; tab.tabIndex = 0;
const nextView = viewsById[`${tab.dataset.view}-view`]; const nextView = viewsById[`${tab.dataset.view}-view`];
if (nextView) nextView.classList.add('active'); if (nextView) nextView.classList.add('active');
if (tab.dataset.view === 'upload') requestAnimationFrame(clampRecentPanelHeight);
activeTab = tab; activeTab = tab;
syncTabIndicator(tab); syncTabIndicator(tab);
const activeSidebarButton = nextView?.querySelector('.view-sidebar-navigation > .view-sidebar-item.active, .settings-navigation > .settings-nav-button.active'); const activeSidebarButton = nextView?.querySelector('.view-sidebar-navigation > .view-sidebar-item.active, .settings-navigation > .settings-nav-button.active');
@@ -993,14 +1000,37 @@ function maskCredential(value, keep = 4) {
function ensureAccountStatusEntries() { function ensureAccountStatusEntries() {
const nextStatuses = {}; const nextStatuses = {};
const activeIds = new Set();
for (const { account } of getAllAccountsFlat()) { for (const { account } of getAllAccountsFlat()) {
if (account.id) { if (account.id) {
activeIds.add(account.id);
nextStatuses[account.id] = accountStatuses[account.id] || { status: 'unchecked', message: '' }; nextStatuses[account.id] = accountStatuses[account.id] || { status: 'unchecked', message: '' };
} }
} }
for (const accountId of accountStatusGenerations.keys()) {
if (!activeIds.has(accountId)) accountStatusGenerations.delete(accountId);
}
accountStatuses = nextStatuses; accountStatuses = nextStatuses;
} }
function _nextAccountStatusGeneration(accountId) {
const generation = ++accountStatusGenerationSequence;
accountStatusGenerations.set(accountId, generation);
return generation;
}
function _isCurrentAccountStatusGeneration(accountId, generation) {
return accountStatusGenerations.get(accountId) === generation;
}
function _finishAccountStatusGeneration(accountId, generation) {
if (_isCurrentAccountStatusGeneration(accountId, generation)) accountStatusGenerations.delete(accountId);
}
function _invalidateAccountStatusGeneration(accountId) {
accountStatusGenerations.delete(accountId);
}
// Returns flat array of all accounts: [{ name, account, index }] // Returns flat array of all accounts: [{ name, account, index }]
function getAllAccountsFlat() { function getAllAccountsFlat() {
const result = []; const result = [];
@@ -1969,7 +1999,8 @@ function renderQueueTable() {
if (totalRows < 200) { if (totalRows < 200) {
// Try in-place update if row count matches (fast path) // Try in-place update if row count matches (fast path)
const existingRows = tbody.querySelectorAll('.queue-row'); const existingRows = tbody.querySelectorAll('.queue-row');
if (existingRows.length === totalRows && totalRows > 0) { const hasVirtualSpacers = Boolean(tbody.querySelector('.virtual-spacer'));
if (!hasVirtualSpacers && existingRows.length === totalRows && totalRows > 0) {
// In-place update no DOM destruction // In-place update no DOM destruction
for (let i = 0; i < totalRows; i++) { for (let i = 0; i < totalRows; i++) {
const tr = existingRows[i]; const tr = existingRows[i];
@@ -2464,6 +2495,8 @@ function applyImportedConfig(importedConfig, message) {
} }
}; };
hosterSettings = config.hosterSettings || {}; hosterSettings = config.hosterSettings || {};
accountStatusGenerations.clear();
accountStatuses = {};
ensureAccountStatusEntries(); ensureAccountStatusEntries();
syncSelectedUploadHosters(); syncSelectedUploadHosters();
alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop); alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop);
@@ -2709,7 +2742,7 @@ document.addEventListener('keydown', (e) => {
cancelHosterModal(); cancelHosterModal();
if (accountModal && accountModal.style.display !== 'none') closeAccountModal(); if (accountModal && accountModal.style.display !== 'none') closeAccountModal();
} }
if (e.target.closest('input, textarea, select')) return; if (e.target instanceof window.Element && e.target.closest('input, textarea, select')) return;
const activeView = document.querySelector('.view.active'); const activeView = document.querySelector('.view.active');
// Ctrl+A // Ctrl+A
if ((e.ctrlKey || e.metaKey) && e.key === 'a') { if ((e.ctrlKey || e.metaKey) && e.key === 'a') {
@@ -3412,9 +3445,10 @@ function _handleStatsImpl(data) {
if (el) el.textContent = formatDuration(Math.round((Date.now() - statsStartTime) / 1000)); if (el) el.textContent = formatDuration(Math.round((Date.now() - statsStartTime) / 1000));
}, 1000); }, 1000);
} }
} else if (data.state === 'idle' && statsRunTimer) { } else if (data.state === 'idle') {
clearInterval(statsRunTimer); if (statsRunTimer) clearInterval(statsRunTimer);
statsRunTimer = null; statsRunTimer = null;
statsStartTime = 0;
} }
} }
@@ -3934,7 +3968,15 @@ function _setRollingUploadMetric(id, value) {
const nextText = numericValue.toLocaleString(getUiLocale()); const nextText = numericValue.toLocaleString(getUiLocale());
const previousValue = Number(element.dataset.numericValue) || 0; const previousValue = Number(element.dataset.numericValue) || 0;
if (previousValue === numericValue) { if (previousValue === numericValue) {
const previousText = element.getAttribute('aria-label');
element.setAttribute('aria-label', nextText); element.setAttribute('aria-label', nextText);
if (previousText !== null && previousText !== nextText) {
element.querySelectorAll(':scope > span').forEach(span => span.getAnimations().forEach(animation => animation.cancel()));
const settled = document.createElement('span');
settled.textContent = nextText;
element.replaceChildren(settled);
element.dataset.direction = 'none';
}
return; return;
} }
@@ -4056,7 +4098,7 @@ function updateStatusBar() {
_setRollingUploadMetric('uploadTelemetryRemaining', stats.remaining); _setRollingUploadMetric('uploadTelemetryRemaining', stats.remaining);
_setRollingUploadMetric('uploadTelemetryRunning', stats.inProgress); _setRollingUploadMetric('uploadTelemetryRunning', stats.inProgress);
_setRollingUploadMetric('uploadTelemetryCompleted', _sessionDoneCount); _setRollingUploadMetric('uploadTelemetryCompleted', _sessionDoneCount);
_setRollingUploadMetric('uploadTelemetryFailed', _sessionErrorCount); _setRollingUploadMetric('uploadTelemetryFailed', Math.max(_sessionErrorCount, stats.errors));
updateUploadSpeedDisplays(); updateUploadSpeedDisplays();
_setUploadTelemetryText('uploadTelemetryEta', etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'); _setUploadTelemetryText('uploadTelemetryEta', etaSeconds > 0 ? formatTime(etaSeconds) : '--:--');
updateUploadSidebarSummary(stats); updateUploadSidebarSummary(stats);
@@ -4141,12 +4183,17 @@ function showAppChoice({ message, title, confirmText, alternateText, cancelText
return showAppDialog({ message, title, confirmText, alternateText, cancelText, showCancel: true }); return showAppDialog({ message, title, confirmText, alternateText, cancelText, showCancel: true });
} }
async function executeHealthCheck(hosters, _mode) { async function executeHealthCheck(hosters, _mode, generations) {
renderHealthCheckResults([]); renderHealthCheckResults([]);
const result = await window.api.runHealthCheck({ hosters }); const result = await window.api.runHealthCheck({ hosters });
const rows = result && Array.isArray(result.results) ? result.results : []; const rows = result && Array.isArray(result.results) ? result.results : [];
rows.forEach((row) => { const currentRows = rows.filter((row) => {
if (!row) return; if (!row) return false;
const key = row.accountId || row.hoster;
const generation = generations?.get(key);
return generation === undefined || _isCurrentAccountStatusGeneration(key, generation);
});
currentRows.forEach((row) => {
const key = row.accountId || row.hoster; const key = row.accountId || row.hoster;
if (key) { if (key) {
accountStatuses[key] = { accountStatuses[key] = {
@@ -4156,10 +4203,10 @@ async function executeHealthCheck(hosters, _mode) {
}; };
} }
}); });
renderHealthCheckResults(rows); renderHealthCheckResults(currentRows);
renderAccounts(); renderAccounts();
renderHosterModal(); renderHosterModal();
return rows; return currentRows;
} }
async function runHealthCheck(mode = 'manual', requestedHosters = null) { async function runHealthCheck(mode = 'manual', requestedHosters = null) {
@@ -4180,18 +4227,21 @@ async function runHealthCheck(mode = 'manual', requestedHosters = null) {
return []; return [];
} }
healthCheckRunning = true; healthCheckRunning = true;
const generations = new Map();
// Mark all accounts as checking // Mark all accounts as checking
for (const h of hosters) { for (const h of hosters) {
const key = typeof h === 'string' ? h : (h.accountId || h.hoster); const key = typeof h === 'string' ? h : (h.accountId || h.hoster);
generations.set(key, _nextAccountStatusGeneration(key));
accountStatuses[key] = { status: 'checking', message: '', checkedAt: null }; accountStatuses[key] = { status: 'checking', message: '', checkedAt: null };
} }
renderAccounts(); renderAccounts();
try { try {
return await executeHealthCheck(hosters, mode); return await executeHealthCheck(hosters, mode, generations);
} catch (err) { } catch (err) {
renderHealthCheckResults([{ hoster: 'System', status: 'error', message: err.message }]); renderHealthCheckResults([{ hoster: 'System', status: 'error', message: err.message }]);
return []; return [];
} finally { } finally {
for (const [key, generation] of generations) _finishAccountStatusGeneration(key, generation);
healthCheckRunning = false; healthCheckRunning = false;
renderAccounts(); renderAccounts();
} }
@@ -4808,6 +4858,7 @@ function renderSettings() {
const issuedEl = document.getElementById('diagCodeIssued'); const issuedEl = document.getElementById('diagCodeIssued');
const badgeEl = document.getElementById('diagStatusBadge'); const badgeEl = document.getElementById('diagStatusBadge');
if (!enabledEl) return; if (!enabledEl) return;
const diagnosticsEditedFields = new Set();
const fmtIssued = (ts) => { const fmtIssued = (ts) => {
if (!ts) return ''; if (!ts) return '';
@@ -4828,7 +4879,7 @@ function renderSettings() {
const b = document.createElement('button'); const b = document.createElement('button');
b.className = 'btn btn-xs btn-secondary'; b.className = 'btn btn-xs btn-secondary';
b.textContent = h; b.textContent = h;
b.addEventListener('click', () => { publicHostEl.value = h; markSettingsDirty(); }); b.addEventListener('click', () => { diagnosticsEditedFields.add(publicHostEl.id); publicHostEl.value = h; markSettingsDirty(); });
suggestChips.appendChild(b); suggestChips.appendChild(b);
} }
} else { } else {
@@ -4838,18 +4889,18 @@ function renderSettings() {
let lastSuggested = []; let lastSuggested = [];
const applySettings = (s) => { const applySettings = (s) => {
if (!s) return; if (!s) return;
enabledEl.checked = !!s.enabled; if (!diagnosticsEditedFields.has(enabledEl.id)) enabledEl.checked = !!s.enabled;
portEl.value = s.port || 9110; if (!diagnosticsEditedFields.has(portEl.id)) portEl.value = s.port || 9110;
modeEl.value = s.bindMode === 'network' ? 'network' : 'local'; if (!diagnosticsEditedFields.has(modeEl.id)) modeEl.value = s.bindMode === 'network' ? 'network' : 'local';
publicHostEl.value = s.publicHost || ''; if (!diagnosticsEditedFields.has(publicHostEl.id)) publicHostEl.value = s.publicHost || '';
allowlistEl.value = Array.isArray(s.allowlist) ? s.allowlist.join('\n') : ''; if (!diagnosticsEditedFields.has(allowlistEl.id)) allowlistEl.value = Array.isArray(s.allowlist) ? s.allowlist.join('\n') : '';
lastSuggested = Array.isArray(s.suggestedHosts) ? s.suggestedHosts : []; lastSuggested = Array.isArray(s.suggestedHosts) ? s.suggestedHosts : [];
codeEl.value = s.code || ''; codeEl.value = s.code || '';
issuedEl.textContent = fmtIssued(s.codeIssuedAt); issuedEl.textContent = fmtIssued(s.codeIssuedAt);
renderModeUi(lastSuggested); renderModeUi(lastSuggested);
if (badgeEl) { if (badgeEl) {
badgeEl.textContent = s.enabled ? 'Aktiv' : 'Inaktiv'; badgeEl.textContent = enabledEl.checked ? 'Aktiv' : 'Inaktiv';
badgeEl.className = 'panel-status' + (s.enabled ? ' active' : ''); badgeEl.className = 'panel-status' + (enabledEl.checked ? ' active' : '');
} }
}; };
const refreshStatus = () => { const refreshStatus = () => {
@@ -4876,6 +4927,11 @@ function renderSettings() {
return true; return true;
}; };
[enabledEl, portEl, modeEl, publicHostEl, allowlistEl].forEach(element => {
const markEdited = () => diagnosticsEditedFields.add(element.id);
element.addEventListener('input', markEdited);
element.addEventListener('change', markEdited);
});
window.api.diagnosticsGetSettings().then(applySettings).catch(() => {}); window.api.diagnosticsGetSettings().then(applySettings).catch(() => {});
refreshStatus(); refreshStatus();
@@ -5153,6 +5209,9 @@ async function performSaveSettings(options = {}) {
config.hosterSettings = newHosterSettings; config.hosterSettings = newHosterSettings;
config.globalSettings = globalSettings; config.globalSettings = globalSettings;
hosterSettings = newHosterSettings; hosterSettings = newHosterSettings;
const startupUrl = new URL(window.location.href);
startupUrl.searchParams.set('language', globalSettings.language);
window.history.replaceState(null, '', startupUrl.href);
clearTimeout(settingsSaveTimer); clearTimeout(settingsSaveTimer);
settingsSaveTimer = null; settingsSaveTimer = null;
@@ -5545,8 +5604,8 @@ function _buildAccountHosterGroupHtml(name, accounts) {
accounts.forEach((account, idx) => { cardsHtml += _buildAccountCardHtml(name, account, idx); }); accounts.forEach((account, idx) => { cardsHtml += _buildAccountCardHtml(name, account, idx); });
const lifeStat = _hosterLifetimeStat(name); const lifeStat = _hosterLifetimeStat(name);
const lifeMeta = lifeStat && lifeStat.total > 0 const lifeMeta = lifeStat && lifeStat.total > 0
? `<span class="account-hoster-group-meta" title="Erfolgsrate aus den letzten ${lifeStat.total} Uploads dieses Hosters">${Math.round(lifeStat.rate * 100)}% ok (${lifeStat.total})</span>` ? `<span class="account-hoster-group-meta account-hoster-lifetime-meta" data-hoster-lifetime="${escapeAttr(name)}" title="${escapeAttr(localizeUiText(`Erfolgsrate aus den letzten ${lifeStat.total} Uploads dieses Hosters`))}">${Math.round(lifeStat.rate * 100)}% ok (${lifeStat.total})</span>`
: ''; : `<span class="account-hoster-group-meta account-hoster-lifetime-meta" data-hoster-lifetime="${escapeAttr(name)}" hidden></span>`;
return `<div class="account-hoster-group" data-hoster-group="${name}"> return `<div class="account-hoster-group" data-hoster-group="${name}">
<div class="account-hoster-group-header" data-hoster-toggle="${name}" role="button" tabindex="0" aria-expanded="${isOpen}"> <div class="account-hoster-group-header" data-hoster-toggle="${name}" role="button" tabindex="0" aria-expanded="${isOpen}">
<span class="panel-arrow">&#9654;</span> <span class="panel-arrow">&#9654;</span>
@@ -5573,6 +5632,22 @@ function _hosterLifetimeStat(name) {
} }
function _invalidateHosterLifetimeCache() { _hosterLifetimeCache = null; } function _invalidateHosterLifetimeCache() { _hosterLifetimeCache = null; }
function _refreshAccountHosterLifetimeStats() {
document.querySelectorAll('[data-hoster-lifetime]').forEach(meta => {
const name = meta.dataset.hosterLifetime;
const lifeStat = _hosterLifetimeStat(name);
if (!lifeStat || lifeStat.total <= 0) {
meta.hidden = true;
meta.textContent = '';
meta.removeAttribute('title');
return;
}
meta.hidden = false;
meta.textContent = `${Math.round(lifeStat.rate * 100)}% ok (${lifeStat.total})`;
meta.title = localizeUiText(`Erfolgsrate aus den letzten ${lifeStat.total} Uploads dieses Hosters`);
});
}
function _allAccountGroupsOpen() { function _allAccountGroupsOpen() {
const bodies = document.querySelectorAll('#accountsList .account-hoster-group-body'); const bodies = document.querySelectorAll('#accountsList .account-hoster-group-body');
if (!bodies.length) return false; if (!bodies.length) return false;
@@ -5777,20 +5852,25 @@ async function checkSingleAccount(accountId) {
if (!accountId || healthCheckRunning) return; if (!accountId || healthCheckRunning) return;
const found = findAccountById(accountId); const found = findAccountById(accountId);
if (!found) return; if (!found) return;
const generation = _nextAccountStatusGeneration(accountId);
healthCheckRunning = true; healthCheckRunning = true;
accountStatuses[accountId] = { status: 'checking', message: '' }; accountStatuses[accountId] = { status: 'checking', message: '' };
updateAccountCard(accountId); updateAccountCard(accountId);
let nextStatus = null;
try { try {
const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId }] }); const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId }] });
const rows = result && Array.isArray(result.results) ? result.results : []; const rows = result && Array.isArray(result.results) ? result.results : [];
const row = rows.find(r => r.accountId === accountId); const row = rows.find(r => r.accountId === accountId);
if (row) accountStatuses[accountId] = { status: row.status || 'error', message: row.message || '' }; if (row) nextStatus = { status: row.status || 'error', message: row.message || '' };
} catch (err) { } catch (err) {
accountStatuses[accountId] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' }; nextStatus = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' };
} finally { } finally {
healthCheckRunning = false; healthCheckRunning = false;
} }
if (!_isCurrentAccountStatusGeneration(accountId, generation) || !findAccountById(accountId)) return;
if (nextStatus) accountStatuses[accountId] = nextStatus;
updateAccountCard(accountId); updateAccountCard(accountId);
_finishAccountStatusGeneration(accountId, generation);
} }
async function submitAccountOtp(accountId) { async function submitAccountOtp(accountId) {
@@ -5809,6 +5889,7 @@ async function submitAccountOtp(accountId) {
return; return;
} }
const submitButton = card?.querySelector('[data-account-otp-submit]'); const submitButton = card?.querySelector('[data-account-otp-submit]');
const generation = _nextAccountStatusGeneration(accountId);
healthCheckRunning = true; healthCheckRunning = true;
accountStatuses[accountId] = { status: 'checking', message: 'OTP wird geprüft…' }; accountStatuses[accountId] = { status: 'checking', message: 'OTP wird geprüft…' };
if (otpInput) otpInput.disabled = true; if (otpInput) otpInput.disabled = true;
@@ -5816,21 +5897,25 @@ async function submitAccountOtp(accountId) {
submitButton.disabled = true; submitButton.disabled = true;
submitButton.textContent = 'Prüfe…'; submitButton.textContent = 'Prüfe…';
} }
let nextStatus;
try { try {
const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId, otp }] }); const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId, otp }] });
const row = result && Array.isArray(result.results) const row = result && Array.isArray(result.results)
? result.results.find(item => item.accountId === accountId) ? result.results.find(item => item.accountId === accountId)
: null; : null;
accountStatuses[accountId] = row nextStatus = row
? { status: row.status || 'error', message: row.message || '' } ? { status: row.status || 'error', message: row.message || '' }
: { status: 'error', message: 'Keine Antwort vom Hoster erhalten' }; : { status: 'error', message: 'Keine Antwort vom Hoster erhalten' };
} catch (err) { } catch (err) {
accountStatuses[accountId] = { status: 'error', message: err.message || 'OTP-Prüfung fehlgeschlagen' }; nextStatus = { status: 'error', message: err.message || 'OTP-Prüfung fehlgeschlagen' };
} finally { } finally {
healthCheckRunning = false; healthCheckRunning = false;
}
if (!_isCurrentAccountStatusGeneration(accountId, generation) || !findAccountById(accountId)) return;
accountStatuses[accountId] = nextStatus;
updateAccountCard(accountId); updateAccountCard(accountId);
renderHosterModal(); renderHosterModal();
} _finishAccountStatusGeneration(accountId, generation);
} }
// Per-hoster overrides for the login form. VOE only accepts emails — the // Per-hoster overrides for the login form. VOE only accepts emails — the
@@ -5972,6 +6057,7 @@ async function deleteAccount(accountId) {
if (Array.isArray(accounts)) { if (Array.isArray(accounts)) {
config.hosters[found.name] = accounts.filter(a => a.id !== accountId); config.hosters[found.name] = accounts.filter(a => a.id !== accountId);
} }
_invalidateAccountStatusGeneration(accountId);
delete accountStatuses[accountId]; delete accountStatuses[accountId];
// saveConfig is async — close the modal immediately so the UI feels // saveConfig is async — close the modal immediately so the UI feels
// responsive instead of waiting for the atomic write + safeStorage encrypt. // responsive instead of waiting for the atomic write + safeStorage encrypt.
@@ -6190,6 +6276,7 @@ async function _persistAccount(ctx, creds) {
function _applyCommittedAccount(persisted, validation) { function _applyCommittedAccount(persisted, validation) {
const { accountId, candidateHosters, isEdit } = persisted; const { accountId, candidateHosters, isEdit } = persisted;
config.hosters = candidateHosters; config.hosters = candidateHosters;
_invalidateAccountStatusGeneration(accountId);
accountStatuses[accountId] = { status: validation.status, message: validation.message || '' }; accountStatuses[accountId] = { status: validation.status, message: validation.message || '' };
ensureAccountStatusEntries(); ensureAccountStatusEntries();
syncSelectedUploadHosters(); syncSelectedUploadHosters();
@@ -6327,13 +6414,16 @@ async function confirmHistoryClear() {
} }
} }
let _historyLoadGeneration = 0;
async function loadHistory() { async function loadHistory() {
const generation = ++_historyLoadGeneration;
const container = document.getElementById('historyContainer'); const container = document.getElementById('historyContainer');
if (container) container.innerHTML = `<p class="empty-state history-loading-state">${localizeUiText('Wird geladen…')}</p>`; if (container) container.innerHTML = `<p class="empty-state history-loading-state">${localizeUiText('Wird geladen…')}</p>`;
let history; let history;
try { try {
history = await window.api.getHistory(); history = await window.api.getHistory();
} catch (error) { } catch (error) {
if (generation !== _historyLoadGeneration) return;
historyRowsData = []; historyRowsData = [];
historySidebarCounts = { total: 0, success: 0, error: 0, skipped: 0 }; historySidebarCounts = { total: 0, success: 0, error: 0, skipped: 0 };
updateHistorySidebarSummary(); updateHistorySidebarSummary();
@@ -6342,10 +6432,12 @@ async function loadHistory() {
container?.querySelector('[data-retry-history]')?.addEventListener('click', loadHistory); container?.querySelector('[data-retry-history]')?.addEventListener('click', loadHistory);
return; return;
} }
if (generation !== _historyLoadGeneration) return;
window._historyForStats = history || []; window._historyForStats = history || [];
_historyEverLoaded = true; _historyEverLoaded = true;
_historyDirty = false; _historyDirty = false;
_invalidateHosterLifetimeCache(); _invalidateHosterLifetimeCache();
_refreshAccountHosterLifetimeStats();
const retSel = document.getElementById('historyRetentionSelect'); const retSel = document.getElementById('historyRetentionSelect');
if (retSel) { if (retSel) {
retSel.value = (config.globalSettings && config.globalSettings.historyRetention) || 'all'; retSel.value = (config.globalSettings && config.globalSettings.historyRetention) || 'all';
@@ -6498,6 +6590,7 @@ function _renderRecentVirtualRows() {
function renderRecentUploadsPanel(_appendOnly = false) { function renderRecentUploadsPanel(_appendOnly = false) {
const tbody = document.getElementById('recentFilesBody'); const tbody = document.getElementById('recentFilesBody');
if (!tbody) return; if (!tbody) return;
const pendingAppends = _recentPendingAppends;
_recentPendingAppends = 0; _recentPendingAppends = 0;
const wrap = tbody.closest('.recent-files-table-wrap'); const wrap = tbody.closest('.recent-files-table-wrap');
@@ -6511,7 +6604,7 @@ function renderRecentUploadsPanel(_appendOnly = false) {
_recentLastRange = { start: -1, end: -1 }; _recentLastRange = { start: -1, end: -1 };
const sig = `${recentSortState.key}|${recentSortState.direction}`; const sig = `${recentSortState.key}|${recentSortState.direction}`;
if (wrap) { if (wrap) {
const added = _recentWorking.length - prevLen; const added = Math.max(_recentWorking.length - prevLen, pendingAppends);
if (sig === 'date|desc' && wrap.scrollTop <= 48) wrap.scrollTop = 0; if (sig === 'date|desc' && wrap.scrollTop <= 48) wrap.scrollTop = 0;
else if (sig === 'date|desc' && added > 0) wrap.scrollTop += added * VIRTUAL_ROW_HEIGHT; else if (sig === 'date|desc' && added > 0) wrap.scrollTop += added * VIRTUAL_ROW_HEIGHT;
} }
@@ -7779,6 +7872,44 @@ function showCopyToast(msg, durationMs) {
if (resizer && panel) { if (resizer && panel) {
let startY = 0; let startY = 0;
let startH = 0; let startH = 0;
let resizeFrame = 0;
const maxPanelHeight = () => {
if (!document.getElementById('upload-view')?.classList.contains('active')) return null;
const shell = panel.closest('.queue-shell');
const queue = document.getElementById('queueContainer');
if (!shell || !queue) return window.innerHeight * 0.7;
const shellRect = shell.getBoundingClientRect();
const queueRect = queue.getBoundingClientRect();
if (shellRect.width <= 0 || shellRect.height <= 0 || queueRect.width <= 0) return null;
let betweenHeight = 0;
let afterQueue = false;
for (const element of shell.children) {
if (element === queue) {
afterQueue = true;
continue;
}
if (element === panel) break;
if (afterQueue) betweenHeight += element.getBoundingClientRect().height;
}
const shellBottom = Math.min(shellRect.bottom, window.innerHeight);
const availableHeight = Math.max(0, shellBottom - queueRect.top - betweenHeight);
return Math.max(60, Math.min(window.innerHeight * 0.7, availableHeight - 120));
};
clampRecentPanelHeight = () => {
const requestedHeight = Number.parseFloat(panel.style.flexBasis);
if (!Number.isFinite(requestedHeight)) return;
const maximumHeight = maxPanelHeight();
if (!Number.isFinite(maximumHeight)) return;
const nextHeight = Math.max(60, Math.min(requestedHeight, maximumHeight));
if (Math.abs(nextHeight - requestedHeight) > 0.5) panel.style.flex = `0 0 ${nextHeight}px`;
};
window.addEventListener('resize', () => {
window.cancelAnimationFrame(resizeFrame);
resizeFrame = window.requestAnimationFrame(clampRecentPanelHeight);
});
resizer.addEventListener('mousedown', (e) => { resizer.addEventListener('mousedown', (e) => {
e.preventDefault(); e.preventDefault();
@@ -7790,7 +7921,9 @@ function showCopyToast(msg, durationMs) {
const onMove = (e2) => { const onMove = (e2) => {
const delta = startY - e2.clientY; const delta = startY - e2.clientY;
const newH = Math.max(60, Math.min(window.innerHeight * 0.7, startH + delta)); const maximumHeight = maxPanelHeight();
if (!Number.isFinite(maximumHeight)) return;
const newH = Math.max(60, Math.min(maximumHeight, startH + delta));
panel.style.flex = `0 0 ${newH}px`; panel.style.flex = `0 0 ${newH}px`;
}; };
+2
View File
@@ -682,6 +682,7 @@
[/^(\d+) Verlaufseintrag wird dauerhaft entfernt\.$/, '$1 history entry will be permanently removed.'], [/^(\d+) Verlaufseintrag wird dauerhaft entfernt\.$/, '$1 history entry will be permanently removed.'],
[/^(\d+) Verlaufseinträge werden dauerhaft entfernt\.$/, '$1 history entries will be permanently removed.'], [/^(\d+) Verlaufseinträge werden dauerhaft entfernt\.$/, '$1 history entries will be permanently removed.'],
[/^Aktives Ziel: (.+)$/, 'Active destination: $1'], [/^Aktives Ziel: (.+)$/, 'Active destination: $1'],
[/^Erfolgsrate aus den letzten (\d+) Uploads dieses Hosters$/, 'Success rate across this host\'s last $1 uploads'],
[/^(\d+) Fehler$/, '$1 errors'], [/^(\d+) Fehler$/, '$1 errors'],
[/^Gesamt (\d+)$/, 'Total $1'], [/^Gesamt (\d+)$/, 'Total $1'],
[/^Verbindungen (\d+)$/, 'Connections $1'], [/^Verbindungen (\d+)$/, 'Connections $1'],
@@ -747,6 +748,7 @@
[/^Active on port (\d+) — 1 client connected$/, 'Aktiv auf Port $1 — 1 Client verbunden'], [/^Active on port (\d+) — 1 client connected$/, 'Aktiv auf Port $1 — 1 Client verbunden'],
[/^Active on port (\d+) — (\d+) clients connected$/, 'Aktiv auf Port $1 — $2 Clients verbunden'], [/^Active on port (\d+) — (\d+) clients connected$/, 'Aktiv auf Port $1 — $2 Clients verbunden'],
[/^Active destination: (.+)$/, 'Aktives Ziel: $1'], [/^Active destination: (.+)$/, 'Aktives Ziel: $1'],
[/^Success rate across this host's last (\d+) uploads$/, 'Erfolgsrate aus den letzten $1 Uploads dieses Hosters'],
[/^Running (\d+)$/, 'Läuft $1'], [/^Running (\d+)$/, 'Läuft $1'],
[/^Failed (\d+)$/, 'Fehler $1'], [/^Failed (\d+)$/, 'Fehler $1'],
[/^Update v(.+) available\. Click to install\.$/, 'Update v$1 verfügbar. Klicken zum Installieren.'] [/^Update v(.+) available\. Click to install\.$/, 'Update v$1 verfügbar. Klicken zum Installieren.']
+1
View File
@@ -31,6 +31,7 @@ module.exports = async function afterPack(context) {
"file-version": version, "file-version": version,
"product-version": version, "product-version": version,
"version-string": { "version-string": {
CompanyName: "Sucukdeluxe",
FileDescription: "Multi Hoster Uploader", FileDescription: "Multi Hoster Uploader",
InternalName: productFilename, InternalName: productFilename,
OriginalFilename: `${productFilename}.exe`, OriginalFilename: `${productFilename}.exe`,
+1
View File
@@ -36,6 +36,7 @@ test('afterPack brands the executable metadata shown by Windows', async () => {
assert.equal(editCall.options['file-version'], '9.8.7'); assert.equal(editCall.options['file-version'], '9.8.7');
assert.equal(editCall.options['product-version'], '9.8.7'); assert.equal(editCall.options['product-version'], '9.8.7');
assert.deepEqual(editCall.options['version-string'], { assert.deepEqual(editCall.options['version-string'], {
CompanyName: 'Sucukdeluxe',
FileDescription: 'Multi Hoster Uploader', FileDescription: 'Multi Hoster Uploader',
InternalName: 'Multi-Hoster-Upload', InternalName: 'Multi-Hoster-Upload',
OriginalFilename: 'Multi-Hoster-Upload.exe', OriginalFilename: 'Multi-Hoster-Upload.exe',
+20 -2
View File
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events'); const { EventEmitter } = require('node:events');
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { configureStartupRenderer, createStartupWindow } = require('../lib/startup-renderer'); const { configureStartupRenderer, createStartupWindow, resolveStartupLanguage } = require('../lib/startup-renderer');
class TestBrowserWindow extends EventEmitter { class TestBrowserWindow extends EventEmitter {
constructor(options) { constructor(options) {
@@ -12,6 +12,7 @@ class TestBrowserWindow extends EventEmitter {
this.showCalls = 0; this.showCalls = 0;
this.startupEvents = []; this.startupEvents = [];
this.loadError = new Error('renderer load failed'); this.loadError = new Error('renderer load failed');
this.loadOptions = null;
} }
once(eventName, listener) { once(eventName, listener) {
@@ -23,8 +24,9 @@ class TestBrowserWindow extends EventEmitter {
this.showCalls++; this.showCalls++;
} }
loadFile(target) { loadFile(target, options) {
this.startupEvents.push(`load:${target}`); this.startupEvents.push(`load:${target}`);
this.loadOptions = options;
return Promise.reject(this.loadError); return Promise.reject(this.loadError);
} }
} }
@@ -41,6 +43,13 @@ test('configureStartupRenderer disables hardware acceleration for a Windows Remo
assert.equal(calls, 1); assert.equal(calls, 1);
}); });
test('resolveStartupLanguage accepts only the supported persisted language', () => {
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'de' } }), 'de');
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'en' } }), 'en');
assert.equal(resolveStartupLanguage({ globalSettings: { language: 'fr' } }), 'en');
assert.equal(resolveStartupLanguage(null), 'en');
});
test('createStartupWindow forces the main window to start hidden', () => { test('createStartupWindow forces the main window to start hidden', () => {
const startup = createStartupWindow(TestBrowserWindow, { width: 1100, show: true }); const startup = createStartupWindow(TestBrowserWindow, { width: 1100, show: true });
@@ -85,3 +94,12 @@ test('startup load forwards a rejected navigation to the error handler', async (
assert.equal(handledError, startup.window.loadError); assert.equal(handledError, startup.window.loadError);
}); });
test('startup load forwards navigation options before the renderer becomes visible', async () => {
const startup = createStartupWindow(TestBrowserWindow, {});
const options = { query: { language: 'de' } };
await startup.load('renderer/index.html', () => {}, options);
assert.deepEqual(startup.window.loadOptions, options);
});
+512 -1
View File
@@ -35,6 +35,9 @@ const RemoteServer = require(path.join(process.cwd(), 'lib', 'remote-server'));
const { listenOnLoopback, installLoopbackRemoteServerGuard } = require(path.join(process.cwd(), 'tests', 'support', 'ui-network-safety')); const { listenOnLoopback, installLoopbackRemoteServerGuard } = require(path.join(process.cwd(), 'tests', 'support', 'ui-network-safety'));
const updaterModule = require(path.join(process.cwd(), 'lib', 'updater')); const updaterModule = require(path.join(process.cwd(), 'lib', 'updater'));
const uiRemoteBindAddresses = []; const uiRemoteBindAddresses = [];
const startupConfigPath = path.join(app.getPath('userData'), 'electron-config.json');
fs.mkdirSync(path.dirname(startupConfigPath), { recursive: true });
fs.writeFileSync(startupConfigPath, JSON.stringify({ globalSettings: { language: 'de' } }), 'utf8');
installLoopbackRemoteServerGuard(RemoteServer, address => uiRemoteBindAddresses.push(address)); installLoopbackRemoteServerGuard(RemoteServer, address => uiRemoteBindAddresses.push(address));
let preparedUpdateMockCalls = 0; let preparedUpdateMockCalls = 0;
let launchedUpdateMockCalls = 0; let launchedUpdateMockCalls = 0;
@@ -57,13 +60,28 @@ updaterModule.launchPreparedUpdate = () => {
const initialIpcHandlers = new Map(); const initialIpcHandlers = new Map();
const registerIpcHandler = ipcMain.handle.bind(ipcMain); const registerIpcHandler = ipcMain.handle.bind(ipcMain);
let initialConfigReadDelayed = false; let initialConfigReadDelayed = false;
let startupLanguagePendingSnapshot = null;
ipcMain.handle = (channel, listener) => { ipcMain.handle = (channel, listener) => {
const registeredListener = channel === 'get-config' const registeredListener = channel === 'get-config'
? async (...args) => { ? async (...args) => {
const result = await listener(...args); const result = await listener(...args);
if (!initialConfigReadDelayed) { if (!initialConfigReadDelayed) {
initialConfigReadDelayed = true; initialConfigReadDelayed = true;
await new Promise(resolve => setTimeout(resolve, 4000)); const deadline = Date.now() + 1500;
let window = BrowserWindow.getAllWindows()[0];
while (window && !window.isVisible() && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 25));
window = BrowserWindow.getAllWindows()[0];
}
startupLanguagePendingSnapshot = window
? {
visible: window.isVisible(),
language: await window.webContents.executeJavaScript('document.documentElement.lang'),
query: await window.webContents.executeJavaScript('location.search')
}
: null;
const elapsed = 1500 - Math.max(0, deadline - Date.now());
await new Promise(resolve => setTimeout(resolve, Math.max(0, 4000 - elapsed)));
} }
return result; return result;
} }
@@ -132,6 +150,18 @@ setTimeout(async () => {
const wc = win.webContents; const wc = win.webContents;
const originalBounds = win.getBounds(); const originalBounds = win.getBounds();
const visualScreenshotDir = ${JSON.stringify(visualScreenshotDir)}; const visualScreenshotDir = ${JSON.stringify(visualScreenshotDir)};
const rendererDiagnostics = [];
let rendererUnresponsiveCount = 0;
wc.on('console-message', (_event, level, message, line, sourceId) => {
if (level === 3) rendererDiagnostics.push({ type: 'console', message, line, sourceId });
});
wc.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (isMainFrame) rendererDiagnostics.push({ type: 'load', errorCode, errorDescription, validatedURL });
});
wc.on('render-process-gone', (_event, details) => {
rendererDiagnostics.push({ type: 'gone', details });
});
win.on('unresponsive', () => { rendererUnresponsiveCount++; });
async function captureVisual(name) { async function captureVisual(name) {
if (!visualScreenshotDir) return; if (!visualScreenshotDir) return;
@@ -159,6 +189,9 @@ setTimeout(async () => {
check('Startup update survives pending renderer initialization', startupUpdateState === '9.9.8|false|flex|flex'); check('Startup update survives pending renderer initialization', startupUpdateState === '9.9.8|false|flex|flex');
await wc.executeJavaScript('_knownUpdateInfo = null; closeUpdateDialog(); _syncHeaderUpdateState();'); await wc.executeJavaScript('_knownUpdateInfo = null; closeUpdateDialog(); _syncHeaderUpdateState();');
const germanStartupReady = await waitUntil(() => wc.executeJavaScript('document.documentElement.lang + "|" + document.getElementById("languageInput")?.value + "|" + [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(",")'));
check('Returning German profiles never expose an English frame while startup config is pending', startupLanguagePendingSnapshot !== null && (!startupLanguagePendingSnapshot.visible || startupLanguagePendingSnapshot.language === 'de') && startupLanguagePendingSnapshot.query === '?language=de' && germanStartupReady === 'de|de|Upload,Accounts,Einstellungen,Verlauf');
await wc.executeJavaScript('(async () => { config.globalSettings = { ...(config.globalSettings || {}), language: "en" }; await window.api.saveGlobalSettings(config.globalSettings); setUiLanguage("en"); renderSettings(); })()');
const languageReady = await waitUntil(() => wc.executeJavaScript('Boolean(document.getElementById("languageInput"))')); const languageReady = await waitUntil(() => wc.executeJavaScript('Boolean(document.getElementById("languageInput"))'));
check('Fresh profiles render in English by default', languageReady === true && await wc.executeJavaScript('document.documentElement.lang + "|" + document.getElementById("languageInput")?.value + "|" + [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(",")') === 'en|en|Upload,Accounts,Settings,History'); check('Fresh profiles render in English by default', languageReady === true && await wc.executeJavaScript('document.documentElement.lang + "|" + document.getElementById("languageInput")?.value + "|" + [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(",")') === 'en|en|Upload,Accounts,Settings,History');
await wc.executeJavaScript('document.getElementById("settings-tab").click()'); await wc.executeJavaScript('document.getElementById("settings-tab").click()');
@@ -194,6 +227,20 @@ setTimeout(async () => {
await wc.executeJavaScript('document.querySelector(".tab[data-view=upload]")?.click()'); await wc.executeJavaScript('document.querySelector(".tab[data-view=upload]")?.click()');
const liveLanguageSwitch = await wc.executeJavaScript('(() => { const input = document.getElementById("languageInput"); input.value = "de"; input.dispatchEvent(new Event("change", { bubbles: true })); const german = [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(","); input.value = "en"; input.dispatchEvent(new Event("change", { bubbles: true })); const english = [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(","); input.value = "de"; input.dispatchEvent(new Event("change", { bubbles: true })); return [german, english, document.documentElement.lang].join("|"); })()'); const liveLanguageSwitch = await wc.executeJavaScript('(() => { const input = document.getElementById("languageInput"); input.value = "de"; input.dispatchEvent(new Event("change", { bubbles: true })); const german = [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(","); input.value = "en"; input.dispatchEvent(new Event("change", { bubbles: true })); const english = [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(","); input.value = "de"; input.dispatchEvent(new Event("change", { bubbles: true })); return [german, english, document.documentElement.lang].join("|"); })()');
check('Language changes apply immediately in both directions', liveLanguageSwitch === 'Upload,Accounts,Einstellungen,Verlauf|Upload,Accounts,Settings,History|de'); check('Language changes apply immediately in both directions', liveLanguageSwitch === 'Upload,Accounts,Einstellungen,Verlauf|Upload,Accounts,Settings,History|de');
const localizedStableMetric = await wc.executeJavaScript(\`(async () => {
_sessionDoneCount = 1234;
updateStatusBar();
await new Promise(resolve => setTimeout(resolve, 360));
const metric = document.getElementById('uploadTelemetryCompleted');
const german = [metric?.textContent.trim(), metric?.getAttribute('aria-label')];
setUiLanguage('en');
const english = [metric?.textContent.trim(), metric?.getAttribute('aria-label')];
setUiLanguage('de');
_sessionDoneCount = 0;
updateStatusBar();
return { german, english };
})()\`);
check('Language changes redraw stable telemetry values with the active locale', localizedStableMetric.german.join('|') === '1.234|1.234' && localizedStableMetric.english.join('|') === '1,234|1,234');
const germanSidebarHeadings = await wc.executeJavaScript('[...document.querySelectorAll("#upload-view, #accounts-view, #history-view")].map(view => [view.querySelector(".view-sidebar-kicker")?.textContent?.trim(), view.querySelector(".view-sidebar-title")?.textContent?.trim()].join("|"))'); const germanSidebarHeadings = await wc.executeJavaScript('[...document.querySelectorAll("#upload-view, #accounts-view, #history-view")].map(view => [view.querySelector(".view-sidebar-kicker")?.textContent?.trim(), view.querySelector(".view-sidebar-title")?.textContent?.trim()].join("|"))');
check('German sidebar hierarchy uses distinct localized kickers', germanSidebarHeadings.join('::') === 'Arbeitsbereich|Uploads::Accounts verwalten|Accounts::Archiv|Verlauf'); check('German sidebar hierarchy uses distinct localized kickers', germanSidebarHeadings.join('::') === 'Arbeitsbereich|Uploads::Accounts verwalten|Accounts::Archiv|Verlauf');
const saveAfterLanguageChange = await wc.executeJavaScript('(() => { const button = document.getElementById("saveSettingsBtn"); return [button.disabled, button.classList.contains("btn-success")].join("|"); })()'); const saveAfterLanguageChange = await wc.executeJavaScript('(() => { const button = document.getElementById("saveSettingsBtn"); return [button.disabled, button.classList.contains("btn-success")].join("|"); })()');
@@ -202,6 +249,28 @@ setTimeout(async () => {
await waitUntil(() => wc.executeJavaScript('document.getElementById("saveSettingsBtn").disabled')); await waitUntil(() => wc.executeJavaScript('document.getElementById("saveSettingsBtn").disabled'));
const saveAfterCommit = await wc.executeJavaScript('(() => { const button = document.getElementById("saveSettingsBtn"); return [button.disabled, button.classList.contains("btn-secondary")].join("|"); })()'); const saveAfterCommit = await wc.executeJavaScript('(() => { const button = document.getElementById("saveSettingsBtn"); return [button.disabled, button.classList.contains("btn-secondary")].join("|"); })()');
check('Saving returns the action to its disabled gray state', saveAfterCommit === 'true|true'); check('Saving returns the action to its disabled gray state', saveAfterCommit === 'true|true');
const englishLanguageQuery = await wc.executeJavaScript(\`(async () => {
const input = document.getElementById('languageInput');
input.value = 'en';
input.dispatchEvent(new Event('change', { bubbles: true }));
await saveSettings({ feedbackText: 'Saved' });
return new URL(location.href).searchParams.get('language');
})()\`);
const languageReloadFinished = new Promise(resolve => wc.once('did-finish-load', resolve));
wc.reload();
await languageReloadFinished;
const reloadedLanguageState = await waitUntil(() => wc.executeJavaScript(\`(() => {
if (typeof config !== 'object' || config.globalSettings?.language !== 'en') return '';
return [document.documentElement.lang, location.search, [...document.querySelectorAll('.tab')].map(tab => tab.textContent.trim()).join(',')].join('|');
})()\`));
const germanLanguageQuery = await wc.executeJavaScript(\`(async () => {
const input = document.getElementById('languageInput');
input.value = 'de';
input.dispatchEvent(new Event('change', { bubbles: true }));
await saveSettings({ feedbackText: 'Gespeichert' });
return { query: new URL(location.href).searchParams.get('language'), active: document.documentElement.lang };
})()\`);
check('Saved language remains the startup language after a renderer reload', englishLanguageQuery === 'en' && reloadedLanguageState === 'en|?language=en|Upload,Accounts,Settings,History' && germanLanguageQuery.query === 'de' && germanLanguageQuery.active === 'de');
await wc.executeJavaScript('queueJobs = []; selectedFiles = []; selectedJobIds.clear(); rebuildJobIndex(); setUploadSidebarFilter("all"); updateUploadView(); renderQueueTable(); updateStatusBar();'); await wc.executeJavaScript('queueJobs = []; selectedFiles = []; selectedJobIds.clear(); rebuildJobIndex(); setUploadSidebarFilter("all"); updateUploadView(); renderQueueTable(); updateStatusBar();');
console.log('\\n=== Upload View ==='); console.log('\\n=== Upload View ===');
@@ -461,6 +530,36 @@ setTimeout(async () => {
check('Header and sidebar speed update synchronously from the same live sample', telemetryUpdate.speedPair === '2 kB/s|2 kB/s'); check('Header and sidebar speed update synchronously from the same live sample', telemetryUpdate.speedPair === '2 kB/s|2 kB/s');
const secondSynchronizedSpeed = await wc.executeJavaScript('lastUploadStats = { ...lastUploadStats, globalSpeedKbs: 1536 }; updateStatusBar(); [document.getElementById("uploadTelemetrySpeed")?.textContent, document.getElementById("uploadSpeedValue")?.textContent].join("|")'); const secondSynchronizedSpeed = await wc.executeJavaScript('lastUploadStats = { ...lastUploadStats, globalSpeedKbs: 1536 }; updateStatusBar(); [document.getElementById("uploadTelemetrySpeed")?.textContent, document.getElementById("uploadSpeedValue")?.textContent].join("|")');
check('Header and sidebar speed stay synchronized across later samples', secondSynchronizedSpeed === '1.5 MB/s|1.5 MB/s'); check('Header and sidebar speed stay synchronized across later samples', secondSynchronizedSpeed === '1.5 MB/s|1.5 MB/s');
const runtimeTimerRestart = await wc.executeJavaScript(\`(async () => {
if (statsRunTimer) clearInterval(statsRunTimer);
statsRunTimer = null;
statsStartTime = 0;
handleStats({ state: 'uploading', globalSpeedKbs: 1, totalBytes: 1, elapsed: 0, activeJobs: 1 });
const first = { start: statsStartTime, timer: statsRunTimer };
handleStats({ state: 'idle', globalSpeedKbs: 0, totalBytes: 1, elapsed: 1, activeJobs: 0 });
const idle = { start: statsStartTime, timer: statsRunTimer };
await new Promise(resolve => setTimeout(resolve, 2));
handleStats({ state: 'uploading', globalSpeedKbs: 1, totalBytes: 2, elapsed: 0, activeJobs: 1 });
const second = { start: statsStartTime, timer: statsRunTimer };
handleStats({ state: 'idle', globalSpeedKbs: 0, totalBytes: 2, elapsed: 1, activeJobs: 0 });
return { first, idle, second, settled: { start: statsStartTime, timer: statsRunTimer } };
})()\`);
check('Runtime telemetry starts a fresh timer for every upload batch', runtimeTimerRestart.first.start > 0 && runtimeTimerRestart.first.timer !== null && runtimeTimerRestart.idle.start === 0 && runtimeTimerRestart.idle.timer === null && runtimeTimerRestart.second.start > runtimeTimerRestart.first.start && runtimeTimerRestart.second.timer !== null && runtimeTimerRestart.settled.start === 0 && runtimeTimerRestart.settled.timer === null);
const failedQueueCountConsistency = await wc.executeJavaScript(\`(() => {
queueJobs = [
{ id: 'failed-count-a', status: 'error', bytesTotal: 1024, bytesUploaded: 0 },
{ id: 'failed-count-b', status: 'error', bytesTotal: 1024, bytesUploaded: 0 },
{ id: 'failed-count-waiting', status: 'queued', bytesTotal: 1024, bytesUploaded: 0 }
];
_sessionErrorCount = 0;
_queueStatsCache = null;
updateStatusBar();
return {
filter: document.getElementById('uploadSidebarErrorCount')?.textContent,
telemetry: document.getElementById('uploadTelemetryFailed')?.getAttribute('aria-label')
};
})()\`);
check('Failed queue jobs update the filter badge and telemetry count consistently', failedQueueCountConsistency.filter === '2' && failedQueueCountConsistency.telemetry === '2');
await new Promise(resolve => setTimeout(resolve, 360)); await new Promise(resolve => setTimeout(resolve, 360));
await wc.executeJavaScript('queueJobs = []; _sessionDoneCount = 0; _sessionErrorCount = 0; lastUploadStats = { ...lastUploadStats, globalSpeedKbs: 0, activeJobs: 0, state: "idle" }; updateStatusBar();'); await wc.executeJavaScript('queueJobs = []; _sessionDoneCount = 0; _sessionErrorCount = 0; lastUploadStats = { ...lastUploadStats, globalSpeedKbs: 0, activeJobs: 0, state: "idle" }; updateStatusBar();');
@@ -953,6 +1052,64 @@ setTimeout(async () => {
check('Account sidebar classifies disabled and credential-less accounts as action needed', accountFilterState.counters.join('|') === '4|1|2|1'); check('Account sidebar classifies disabled and credential-less accounts as action needed', accountFilterState.counters.join('|') === '4|1|2|1');
check('Account sidebar exposes exactly one pressed filter', accountFilterState.error.pressed.join('|') === 'error' && accountFilterState.error.active.join('|') === 'error' && accountFilterState.all.pressed.join('|') === 'all' && accountFilterState.all.active.join('|') === 'all'); check('Account sidebar exposes exactly one pressed filter', accountFilterState.error.pressed.join('|') === 'error' && accountFilterState.error.active.join('|') === 'error' && accountFilterState.all.pressed.join('|') === 'all' && accountFilterState.all.active.join('|') === 'all');
ipcMain.removeHandler('run-health-check');
ipcMain.handle('run-health-check', (_event, payload) => ({ results: (payload.hosters || []).map(item => ({ accountId: item.accountId, status: 'ok', message: 'Ready' })) }));
const completedAccountCheckState = await wc.executeJavaScript(\`checkSingleAccount('ui-filter-ready').then(() => ({ status: accountStatuses['ui-filter-ready']?.status, generations: accountStatusGenerations.size }))\`);
check('Completed account checks release their generation tokens', completedAccountCheckState.status === 'ok' && completedAccountCheckState.generations === 0);
restoreInitialIpcHandler('run-health-check');
let resolveStaleAccountCheck = null;
ipcMain.removeHandler('run-health-check');
ipcMain.handle('run-health-check', () => new Promise(resolve => { resolveStaleAccountCheck = resolve; }));
const staleAccountCheck = wc.executeJavaScript(\`(() => {
HOSTERS.forEach(name => { config.hosters[name] = []; });
config.hosters['byse.sx'] = [{ id: 'ui-stale-account-check', enabled: true, authType: 'api', apiKey: 'old-key' }];
accountStatuses = { 'ui-stale-account-check': { status: 'ok', message: 'Old credentials ready' } };
healthCheckRunning = false;
renderAccounts();
return checkSingleAccount('ui-stale-account-check');
})()\`);
await waitUntil(() => resolveStaleAccountCheck);
await wc.executeJavaScript(\`(() => {
const candidateHosters = structuredClone(config.hosters);
candidateHosters['byse.sx'][0].apiKey = 'new-key';
_applyCommittedAccount(
{ accountId: 'ui-stale-account-check', candidateHosters, isEdit: true },
{ status: 'ok', message: 'New credentials ready' }
);
})()\`);
resolveStaleAccountCheck({ results: [{ accountId: 'ui-stale-account-check', status: 'error', message: 'Old credential check failed' }] });
await staleAccountCheck;
const staleAccountCheckState = await wc.executeJavaScript(\`(() => {
const status = accountStatuses['ui-stale-account-check'];
const card = document.querySelector('[data-account-id="ui-stale-account-check"]');
return { status: status?.status, message: status?.message, card: card?.querySelector('.account-status')?.textContent.trim() };
})()\`);
check('A late account check cannot overwrite newly committed credentials', staleAccountCheckState.status === 'ok' && staleAccountCheckState.message === 'New credentials ready' && staleAccountCheckState.card === 'Bereit');
restoreInitialIpcHandler('run-health-check');
let resolveImportedAccountCheck = null;
ipcMain.removeHandler('run-health-check');
ipcMain.handle('run-health-check', () => new Promise(resolve => { resolveImportedAccountCheck = resolve; }));
const importedAccountCheck = wc.executeJavaScript(\`(() => {
healthCheckRunning = false;
return checkSingleAccount('ui-stale-account-check');
})()\`);
await waitUntil(() => resolveImportedAccountCheck);
await wc.executeJavaScript(\`(() => {
const imported = structuredClone(config);
imported.hosters['byse.sx'][0].apiKey = 'imported-key';
applyImportedConfig(imported, 'Importiert');
})()\`);
resolveImportedAccountCheck({ results: [{ accountId: 'ui-stale-account-check', status: 'error', message: 'Pre-import credentials failed' }] });
await importedAccountCheck;
const importedAccountCheckState = await wc.executeJavaScript(\`(() => {
const status = accountStatuses['ui-stale-account-check'];
return { status: status?.status, message: status?.message || '', generations: accountStatusGenerations.size, apiKey: config.hosters['byse.sx'][0].apiKey };
})()\`);
check('A backup import invalidates account checks started with older credentials', importedAccountCheckState.status === 'unchecked' && importedAccountCheckState.message === '' && importedAccountCheckState.generations === 0 && importedAccountCheckState.apiKey === 'imported-key');
restoreInitialIpcHandler('run-health-check');
console.log('\\n=== Settings View ==='); console.log('\\n=== Settings View ===');
await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'settings\\']").click()'); await wc.executeJavaScript('document.querySelector(".tab[data-view=\\'settings\\']").click()');
@@ -1047,6 +1204,21 @@ setTimeout(async () => {
await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'diagnose\\']")?.click()'); await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'diagnose\\']")?.click()');
const diagnoseSettingsSpacing = await wc.executeJavaScript('(() => { const grid = document.querySelector("[data-subpage=diagnose] .settings-grid-mini")?.getBoundingClientRect(); const port = document.getElementById("diagPortInput")?.closest(".settings-row")?.getBoundingClientRect(); return grid && port ? Math.round(port.top - grid.bottom) : -1; })()'); const diagnoseSettingsSpacing = await wc.executeJavaScript('(() => { const grid = document.querySelector("[data-subpage=diagnose] .settings-grid-mini")?.getBoundingClientRect(); const port = document.getElementById("diagPortInput")?.closest(".settings-row")?.getBoundingClientRect(); return grid && port ? Math.round(port.top - grid.bottom) : -1; })()');
check('Diagnose settings keep space before Port', diagnoseSettingsSpacing >= 8); check('Diagnose settings keep space before Port', diagnoseSettingsSpacing >= 8);
let resolveStaleDiagnosticsSettings = null;
ipcMain.removeHandler('diagnostics:get-settings');
ipcMain.handle('diagnostics:get-settings', () => new Promise(resolve => { resolveStaleDiagnosticsSettings = resolve; }));
await wc.executeJavaScript('renderSettings()');
await waitUntil(() => resolveStaleDiagnosticsSettings);
await wc.executeJavaScript('document.querySelector("[data-settings-page=diagnose]")?.click(); (() => { const input = document.getElementById("diagPortInput"); input.value = "9222"; input.dispatchEvent(new Event("input", { bubbles: true })); })()');
resolveStaleDiagnosticsSettings({ enabled: true, port: 9110, bindMode: 'network', publicHost: 'diagnostics.example.test', allowlist: ['100.64.0.0/10'] });
await new Promise(resolve => setTimeout(resolve, 80));
const staleDiagnosticsState = await wc.executeJavaScript('(() => ({ port: document.getElementById("diagPortInput")?.value, enabled: document.getElementById("diagEnabledInput")?.checked, bindMode: document.getElementById("diagBindModeInput")?.value, publicHost: document.getElementById("diagPublicHostInput")?.value, allowlist: document.getElementById("diagAllowlistInput")?.value }))()');
check('A late diagnostics response preserves the edited field and fills every untouched field', staleDiagnosticsState.port === '9222' && staleDiagnosticsState.enabled === true && staleDiagnosticsState.bindMode === 'network' && staleDiagnosticsState.publicHost === 'diagnostics.example.test' && staleDiagnosticsState.allowlist === '100.64.0.0/10');
restoreInitialIpcHandler('diagnostics:get-settings');
await wc.executeJavaScript('renderSettings(); document.querySelector("[data-settings-page=diagnose]")?.click()');
await new Promise(resolve => setTimeout(resolve, 80));
const diagnosticsDirtyTracking = await wc.executeJavaScript('(async () => { const original = await window.api.diagnosticsGetSettings(); const input = document.getElementById("diagPublicHostInput"); establishSettingsBaseline(); input.value = "ui-diagnostics-save.invalid"; input.dispatchEvent(new Event("input", { bubbles: true })); const button = document.getElementById("saveSettingsBtn"); const enabled = button.disabled === false && button.classList.contains("btn-success"); if (enabled) await saveSettings({ feedbackText: "Gespeichert" }); const persisted = await window.api.diagnosticsGetSettings(); await saveDiagnosticsSettingsTracked(original); input.value = original.publicHost || ""; establishSettingsBaseline(); return { enabled, persisted: persisted.publicHost }; })()'); const diagnosticsDirtyTracking = await wc.executeJavaScript('(async () => { const original = await window.api.diagnosticsGetSettings(); const input = document.getElementById("diagPublicHostInput"); establishSettingsBaseline(); input.value = "ui-diagnostics-save.invalid"; input.dispatchEvent(new Event("input", { bubbles: true })); const button = document.getElementById("saveSettingsBtn"); const enabled = button.disabled === false && button.classList.contains("btn-success"); if (enabled) await saveSettings({ feedbackText: "Gespeichert" }); const persisted = await window.api.diagnosticsGetSettings(); await saveDiagnosticsSettingsTracked(original); input.value = original.publicHost || ""; establishSettingsBaseline(); return { enabled, persisted: persisted.publicHost }; })()');
check('Diagnostics changes enable Save and persist with the full settings form', diagnosticsDirtyTracking.enabled === true && diagnosticsDirtyTracking.persisted === 'ui-diagnostics-save.invalid'); check('Diagnostics changes enable Save and persist with the full settings form', diagnosticsDirtyTracking.enabled === true && diagnosticsDirtyTracking.persisted === 'ui-diagnostics-save.invalid');
@@ -1506,6 +1678,48 @@ setTimeout(async () => {
const historyActive = await wc.executeJavaScript('document.getElementById("history-view")?.classList.contains("active")'); const historyActive = await wc.executeJavaScript('document.getElementById("history-view")?.classList.contains("active")');
check('History tab active', historyActive); check('History tab active', historyActive);
const historyRaceResolvers = [];
ipcMain.removeHandler('get-history');
ipcMain.handle('get-history', () => new Promise(resolve => { historyRaceResolvers.push(resolve); }));
const staleHistoryLoad = wc.executeJavaScript('loadHistory()');
await waitUntil(() => historyRaceResolvers.length === 1);
const latestHistoryLoad = wc.executeJavaScript('loadHistory()');
await waitUntil(() => historyRaceResolvers.length === 2);
historyRaceResolvers[1]([{ timestamp: '2026-08-10T10:00:01.000Z', files: [{ name: 'latest-history.bin', results: [{ status: 'done', hoster: 'voe.sx', download_url: 'https://example.invalid/latest' }] }] }]);
await latestHistoryLoad;
historyRaceResolvers[0]([{ timestamp: '2026-08-10T10:00:00.000Z', files: [{ name: 'stale-history.bin', results: [{ status: 'done', hoster: 'voe.sx', download_url: 'https://example.invalid/stale' }] }] }]);
await staleHistoryLoad;
const historyRaceResult = await wc.executeJavaScript('[...document.querySelectorAll("#historyBody .history-row .col-filename")].map(cell => cell.textContent.trim()).join("|")');
check('A late stale history response cannot overwrite the latest rendered history', historyRaceResult === 'latest-history.bin');
ipcMain.removeHandler('get-history');
ipcMain.handle('get-history', () => historyFixture);
await wc.executeJavaScript('loadHistory()');
const historyFixtureBeforeLifetimeCheck = historyFixture;
historyFixture = [{
timestamp: '2026-08-10T10:05:00.000Z',
files: [{ name: 'lifetime.bin', results: [{ status: 'done', hoster: 'voe.sx', download_url: 'https://example.invalid/lifetime' }] }]
}];
await wc.executeJavaScript(\`(() => {
HOSTERS.forEach(name => { config.hosters[name] = []; });
config.hosters['voe.sx'] = [{ id: 'ui-lifetime-account', enabled: true, authType: 'login', username: 'lifetime@example.invalid', password: 'fictional-password' }];
accountStatuses = { 'ui-lifetime-account': { status: 'ok', message: 'Bereit' } };
window._historyForStats = [];
_invalidateHosterLifetimeCache();
renderAccounts();
window.__uiLifetimeGroup = document.querySelector('[data-hoster-group="voe.sx"]');
})()\`);
await wc.executeJavaScript('loadHistory()');
const lifetimeRefreshState = await wc.executeJavaScript(\`(() => {
const group = document.querySelector('[data-hoster-group="voe.sx"]');
const meta = group?.querySelector('[data-hoster-lifetime="voe.sx"]');
return { sameGroup: group === window.__uiLifetimeGroup, visible: Boolean(meta && !meta.hidden), text: meta?.textContent.trim() || '' };
})()\`);
check('Loaded history refreshes hoster lifetime success without replacing the account group', lifetimeRefreshState.sameGroup && lifetimeRefreshState.visible && lifetimeRefreshState.text === '100% ok (1)');
historyFixture = historyFixtureBeforeLifetimeCheck;
await wc.executeJavaScript('loadHistory()');
await wc.executeJavaScript('HOSTERS.forEach(name => { config.hosters[name] = []; }); accountStatuses = {}; renderAccounts()');
const historyWorkspaceLayout = await wc.executeJavaScript('(() => { const view = document.getElementById("history-view"); const sidebar = view?.querySelector(":scope > .view-sidebar"); const main = view?.querySelector(":scope > .view-main"); if (!sidebar || !main) return false; const sidebarRect = sidebar.getBoundingClientRect(); const mainRect = main.getBoundingClientRect(); return sidebarRect.width > 0 && mainRect.width > 0 && sidebarRect.right <= mainRect.left; })()'); const historyWorkspaceLayout = await wc.executeJavaScript('(() => { const view = document.getElementById("history-view"); const sidebar = view?.querySelector(":scope > .view-sidebar"); const main = view?.querySelector(":scope > .view-main"); if (!sidebar || !main) return false; const sidebarRect = sidebar.getBoundingClientRect(); const mainRect = main.getBoundingClientRect(); return sidebarRect.width > 0 && mainRect.width > 0 && sidebarRect.right <= mainRect.left; })()');
check('History view separates sidebar and main workspace', historyWorkspaceLayout === true); check('History view separates sidebar and main workspace', historyWorkspaceLayout === true);
@@ -1721,7 +1935,70 @@ setTimeout(async () => {
})()\`); })()\`);
check('Unavailable recent, queue, and history actions are disabled', emptyActionState === 'true|true|true|true|true'); check('Unavailable recent, queue, and history actions are disabled', emptyActionState === 'true|true|true|true|true');
const recentLocaleState = await wc.executeJavaScript(\`(() => {
const timestamp = Date.UTC(2026, 7, 10, 13, 14, 15);
setUiLanguage('de');
sessionFilesData = [{ date: formatDateTime(timestamp).text, dateTs: timestamp, filename: 'locale.bin', host: 'voe.sx', link: 'https://example.invalid/locale', isError: false, order: 1 }];
_recentDataVersion++;
renderRecentUploadsPanel();
const german = document.querySelector('#recentFilesBody .recent-file-row td')?.textContent.trim();
setUiLanguage('en');
const english = document.querySelector('#recentFilesBody .recent-file-row td')?.textContent.trim();
const expectedEnglish = formatDateTime(timestamp).text;
setUiLanguage('de');
sessionFilesData = [];
_recentDataVersion++;
renderRecentUploadsPanel();
return { german, english, expectedEnglish };
})()\`);
check('Recent upload timestamps immediately follow the selected interface language', recentLocaleState.german !== recentLocaleState.english && recentLocaleState.english === recentLocaleState.expectedEnglish);
const recentCapScrollState = await wc.executeJavaScript(\`(() => {
document.querySelector('.tab[data-view="upload"]')?.click();
queueJobs = [{ id: 'ui-recent-cap', file: 'C:/ui/recent-cap.bin', fileName: 'recent-cap.bin', hoster: 'byse.sx', status: 'queued', bytesUploaded: 0, bytesTotal: 100, progress: 0 }];
rebuildJobIndex();
updateUploadView();
renderQueueTable();
recentSortState.key = 'date';
recentSortState.direction = 'desc';
sessionFilesData = Array.from({ length: SESSION_FILES_CAP }, (_, index) => ({
date: String(index),
dateTs: index,
filename: 'cap-' + index + '.bin',
host: 'byse.sx',
link: 'https://example.invalid/cap-' + index,
isError: false,
order: index
}));
_recentDataVersion++;
renderRecentUploadsPanel();
const wrap = document.querySelector('.recent-files-table-wrap');
wrap.scrollTop = 560;
const before = wrap.scrollTop;
sessionFilesData = sessionFilesData.slice(1);
sessionFilesData.push({ date: 'new', dateTs: SESSION_FILES_CAP + 1, filename: 'cap-new.bin', host: 'byse.sx', link: 'https://example.invalid/cap-new', isError: false, order: SESSION_FILES_CAP + 1 });
_recentPendingAppends = 1;
_recentDataVersion++;
renderRecentUploadsPanel();
const after = wrap.scrollTop;
sessionFilesData = [];
_recentPendingAppends = 0;
_recentDataVersion++;
renderRecentUploadsPanel();
queueJobs = [];
rebuildJobIndex();
updateUploadView();
renderQueueTable();
return { before, after, delta: after - before };
})()\`);
if (!(recentCapScrollState.before > 0 && Math.abs(recentCapScrollState.delta - 28) <= 1)) console.log('Recent cap scroll state: ' + JSON.stringify(recentCapScrollState));
check('A capped recent-upload list preserves the visible rows when a new item arrives', recentCapScrollState.before > 0 && Math.abs(recentCapScrollState.delta - 28) <= 1);
const keyboardInteractionContract = await wc.executeJavaScript(\`(() => { const keyboardInteractionContract = await wc.executeJavaScript(\`(() => {
HOSTERS.forEach(name => { config.hosters[name] = []; });
config.hosters['byse.sx'] = [{ id: 'ui-keyboard-account', enabled: true, authType: 'api', apiKey: 'keyboard-key' }];
accountStatuses = { 'ui-keyboard-account': { status: 'ok', message: 'Bereit' } };
renderAccounts();
queueJobs = [{ id: 'ui-keyboard-row', file: 'C:/ui/keyboard.bin', fileName: 'keyboard.bin', hoster: 'byse.sx', status: 'queued', bytesUploaded: 0, bytesTotal: 100, progress: 0 }]; queueJobs = [{ id: 'ui-keyboard-row', file: 'C:/ui/keyboard.bin', fileName: 'keyboard.bin', hoster: 'byse.sx', status: 'queued', bytesUploaded: 0, bytesTotal: 100, progress: 0 }];
selectedJobIds.clear(); selectedJobIds.clear();
rebuildJobIndex(); rebuildJobIndex();
@@ -1771,6 +2048,240 @@ setTimeout(async () => {
].join('|'))()\`); ].join('|'))()\`);
check('Hoster, job log, delete-account, and shutdown surfaces expose dialog semantics', modalSemantics === 'dialog|dialog|dialog|dialog'); check('Hoster, job log, delete-account, and shutdown surfaces expose dialog semantics', modalSemantics === 'dialog|dialog|dialog|dialog');
const rapidViewStability = await wc.executeJavaScript(\`(async () => {
const sequence = ['upload', 'accounts', 'settings', 'history', 'settings', 'accounts', 'upload', 'history', 'upload', 'accounts', 'history', 'settings'];
const samples = [];
for (const target of sequence) {
document.querySelector('.tab[data-view="' + target + '"]')?.click();
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const activeViews = [...document.querySelectorAll('.view.active')];
const activeTabs = [...document.querySelectorAll('.tab.active')];
const viewRect = activeViews[0]?.getBoundingClientRect();
const speedRect = document.getElementById('uploadSpeedSparkline')?.getBoundingClientRect();
const indicatorRect = document.querySelector('.tab-indicator')?.getBoundingClientRect();
samples.push({
target,
activeViews: activeViews.length,
activeTabs: activeTabs.length,
activeView: activeViews[0]?.id,
activeTab: activeTabs[0]?.dataset.view,
viewVisible: Boolean(viewRect && viewRect.width > 0 && viewRect.height > 0),
speedVisible: Boolean(speedRect && speedRect.width > 0 && speedRect.height > 0),
indicatorVisible: Boolean(indicatorRect && indicatorRect.width > 0 && indicatorRect.height > 0),
horizontalOverflow: document.documentElement.scrollWidth > document.documentElement.clientWidth + 1
});
}
document.querySelector('.tab[data-view="upload"]')?.click();
return samples;
})()\`);
const invalidViewFrames = rapidViewStability.filter(sample => sample.activeViews !== 1 || sample.activeTabs !== 1 || sample.activeView !== sample.target + '-view' || sample.activeTab !== sample.target || !sample.viewVisible || !sample.speedVisible || !sample.indicatorVisible || sample.horizontalOverflow);
check('Rapid main-view switches never paint a blank, duplicate, or overflowing active view', rapidViewStability.length === 12 && invalidViewFrames.length === 0);
const languageFrameStability = await wc.executeJavaScript(\`(async () => {
_sessionDoneCount = 1234;
const languages = ['en', 'de', 'en', 'de', 'en', 'de', 'en', 'de', 'en', 'de', 'en', 'de'];
const samples = [];
for (const language of languages) {
setUiLanguage(language);
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const metric = document.getElementById('uploadTelemetryCompleted');
samples.push({
requested: language,
active: document.documentElement.lang,
tabs: [...document.querySelectorAll('.tab')].map(tab => tab.textContent.trim()).join('|'),
settingsTitle: document.querySelector('[data-subpage="allgemein"] .settings-page-header h3')?.textContent.trim(),
uploadKicker: document.querySelector('#upload-view .view-sidebar-kicker')?.textContent.trim(),
metricValues: [...(metric?.querySelectorAll(':scope > span') || [])].map(span => span.textContent.trim()),
metricLabel: metric?.getAttribute('aria-label')
});
}
_sessionDoneCount = 0;
updateStatusBar();
return samples;
})()\`);
const invalidLanguageFrames = languageFrameStability.filter(sample => sample.requested === 'en'
? sample.active !== 'en' || sample.tabs !== 'Upload|Accounts|Settings|History' || sample.settingsTitle !== 'General' || sample.uploadKicker !== 'Workspace' || sample.metricLabel !== '1,234' || sample.metricValues.length === 0 || sample.metricValues.some(value => value !== '0' && value !== '1,234')
: sample.active !== 'de' || sample.tabs !== 'Upload|Accounts|Einstellungen|Verlauf' || sample.settingsTitle !== 'Allgemein' || sample.uploadKicker !== 'Arbeitsbereich' || sample.metricLabel !== '1.234' || sample.metricValues.length === 0 || sample.metricValues.some(value => value !== '0' && value !== '1.234'));
if (invalidLanguageFrames.length) console.log('Invalid language frames: ' + JSON.stringify(invalidLanguageFrames));
check('Rapid language switches expose only complete, locale-consistent painted frames', languageFrameStability.length === 12 && invalidLanguageFrames.length === 0);
const rollingMetricStability = await wc.executeJavaScript(\`(async () => {
setUiLanguage('de');
await new Promise(resolve => setTimeout(resolve, 360));
const metric = document.getElementById('uploadTelemetryCompleted');
const initialRect = metric.getBoundingClientRect();
const frames = [];
for (let value = 1000; value <= 1020; value++) {
_sessionDoneCount = value;
updateStatusBar();
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const rect = metric.getBoundingClientRect();
frames.push({
text: metric.textContent.trim(),
label: metric.getAttribute('aria-label'),
childCount: metric.querySelectorAll(':scope > span').length,
width: rect.width,
height: rect.height
});
}
await new Promise(resolve => setTimeout(resolve, 360));
const settled = { text: metric.textContent.trim(), label: metric.getAttribute('aria-label'), direction: metric.dataset.direction };
_sessionDoneCount = 0;
updateStatusBar();
return { initialRect: { width: initialRect.width, height: initialRect.height }, frames, settled };
})()\`);
const invalidRollingFrames = rollingMetricStability.frames.filter(frame => !frame.text || !frame.label || frame.childCount < 1 || frame.childCount > 2 || Math.abs(frame.width - rollingMetricStability.initialRect.width) > 0.5 || Math.abs(frame.height - rollingMetricStability.initialRect.height) > 0.5);
check('Rapid telemetry updates never expose an empty value or shift the metric layout', rollingMetricStability.frames.length === 21 && invalidRollingFrames.length === 0 && rollingMetricStability.settled.text === '1.020' && rollingMetricStability.settled.label === '1.020' && rollingMetricStability.settled.direction === 'none');
const virtualQueueStability = await wc.executeJavaScript(\`(async () => {
const total = 1200;
queueJobs = Array.from({ length: total }, (_, index) => ({
id: 'ui-stress-' + index,
file: 'C:/ui/stress-' + index + '.bin',
fileName: 'stress-' + String(index).padStart(4, '0') + '.bin',
hoster: 'byse.sx',
status: 'uploading',
bytesUploaded: 100,
bytesTotal: 1000,
speedKbs: 64,
elapsed: 1,
remaining: 9,
progress: .1
}));
rebuildJobIndex();
queueSortState.key = 'filename';
queueSortState.direction = 'asc';
setUploadSidebarFilter('all');
updateUploadView();
renderQueueTable();
const container = document.getElementById('queueContainer');
container.scrollTop = 5600;
container.dispatchEvent(new Event('scroll'));
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const tbody = document.getElementById('queueBody');
const originalRows = [...tbody.querySelectorAll('.queue-row')];
const originalIds = originalRows.map(row => row.dataset.jobId);
const originalScrollTop = container.scrollTop;
let childMutations = 0;
let blankFrames = 0;
let identityChanges = 0;
const observer = new MutationObserver(records => {
childMutations += records.filter(record => record.type === 'childList').length;
});
observer.observe(tbody, { childList: true });
for (let frame = 0; frame < 36; frame++) {
for (const id of originalIds) {
const job = _jobIndexById.get(id);
if (!job) continue;
job.progress = Math.min(1, job.progress + .0005);
job.bytesUploaded = Math.round(job.progress * job.bytesTotal);
}
renderQueueTable();
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const rows = [...tbody.querySelectorAll('.queue-row')];
if (!rows.length || rows.some(row => !row.querySelector('.col-filename')?.textContent.trim())) blankFrames++;
if (rows.length !== originalRows.length || rows.some((row, index) => row !== originalRows[index])) identityChanges++;
}
observer.disconnect();
const scrollDrift = Math.abs(container.scrollTop - originalScrollTop);
const activeIds = new Set(originalIds);
queueJobs.forEach(job => { job.status = activeIds.has(job.id) ? 'uploading' : 'done'; });
setUploadSidebarFilter('active');
updateUploadView();
renderQueueTable();
const result = {
total,
rendered: originalRows.length,
childMutations,
blankFrames,
identityChanges,
scrollDrift,
filteredRows: tbody.querySelectorAll('.queue-row').length,
filteredHasVirtualSpacer: Boolean(tbody.querySelector('.virtual-spacer'))
};
setUploadSidebarFilter('all');
queueJobs = [];
rebuildJobIndex();
renderQueueTable();
updateUploadView();
updateStatusBar();
return result;
})()\`);
check('High-frequency updates keep virtual queue rows mounted without blank frames or scroll jumps', virtualQueueStability.total === 1200 && virtualQueueStability.rendered > 0 && virtualQueueStability.rendered < 1200 && virtualQueueStability.childMutations === 0 && virtualQueueStability.blankFrames === 0 && virtualQueueStability.identityChanges === 0 && virtualQueueStability.scrollDrift <= 1);
check('Switching from a virtual queue to a small filtered result removes virtual spacers', virtualQueueStability.filteredRows === virtualQueueStability.rendered && virtualQueueStability.filteredHasVirtualSpacer === false);
win.setSize(1100, 900);
await new Promise(resolve => setTimeout(resolve, 100));
await wc.executeJavaScript('document.querySelector(".tab[data-view=upload]")?.click(); queueJobs = [{ id: "ui-panel-resize", file: "C:/ui/panel-resize.bin", fileName: "panel-resize.bin", hoster: "byse.sx", status: "queued", bytesUploaded: 0, bytesTotal: 100, progress: 0 }]; rebuildJobIndex(); updateUploadView(); renderQueueTable(); document.getElementById("recentFilesPanel").style.flex = "0 0 600px"');
win.setSize(1100, 550);
await new Promise(resolve => setTimeout(resolve, 140));
const recentPanelResizeState = await wc.executeJavaScript('(() => { const panel = document.getElementById("recentFilesPanel"); const queue = document.getElementById("queueContainer"); return { panelHeight: panel.getBoundingClientRect().height, queueHeight: queue.getBoundingClientRect().height, viewportHeight: window.innerHeight }; })()');
if (!(recentPanelResizeState.panelHeight <= recentPanelResizeState.viewportHeight * 0.7 + 1 && recentPanelResizeState.queueHeight >= 120)) console.log('Recent panel resize state: ' + JSON.stringify(recentPanelResizeState));
check('A manually enlarged recent panel is clamped after a height-only window shrink', recentPanelResizeState.panelHeight <= recentPanelResizeState.viewportHeight * 0.7 + 1 && recentPanelResizeState.queueHeight >= 120);
win.setSize(1100, 900);
await new Promise(resolve => setTimeout(resolve, 140));
const initialHiddenResizeState = await wc.executeJavaScript('(() => { document.querySelector(".tab[data-view=upload]")?.click(); const panel = document.getElementById("recentFilesPanel"); panel.style.flex = "0 0 600px"; clampRecentPanelHeight(); const queue = document.getElementById("queueContainer"); return { basis: parseFloat(panel.style.flexBasis), panelHeight: panel.getBoundingClientRect().height, queueHeight: queue.getBoundingClientRect().height }; })()');
await wc.executeJavaScript('document.querySelector(".tab[data-view=settings]")?.click()');
win.setSize(1100, 550);
await new Promise(resolve => setTimeout(resolve, 140));
const hiddenRecentPanelState = await wc.executeJavaScript('(() => { const panel = document.getElementById("recentFilesPanel"); return { basis: parseFloat(panel.style.flexBasis), panelHeight: panel.getBoundingClientRect().height }; })()');
await wc.executeJavaScript('document.querySelector(".tab[data-view=upload]")?.click()');
await new Promise(resolve => setTimeout(resolve, 140));
const restoredRecentPanelState = await wc.executeJavaScript('(() => { const panel = document.getElementById("recentFilesPanel"); const queue = document.getElementById("queueContainer"); return { basis: parseFloat(panel.style.flexBasis), panelHeight: panel.getBoundingClientRect().height, queueHeight: queue.getBoundingClientRect().height, viewportHeight: window.innerHeight }; })()');
const hiddenResizePreserved = hiddenRecentPanelState.panelHeight === 0 && Math.abs(hiddenRecentPanelState.basis - initialHiddenResizeState.basis) <= 0.5;
const hiddenResizeRestored = restoredRecentPanelState.basis < initialHiddenResizeState.basis && restoredRecentPanelState.queueHeight >= 120 && restoredRecentPanelState.panelHeight <= restoredRecentPanelState.viewportHeight * 0.7 + 1;
if (!(hiddenResizePreserved && hiddenResizeRestored)) console.log('Hidden recent panel resize state: ' + JSON.stringify({ initialHiddenResizeState, hiddenRecentPanelState, restoredRecentPanelState }));
check('Resizing another tab preserves the requested recent height while hidden', hiddenResizePreserved);
check('Returning to Upload reclamps the recent panel and restores queue space', hiddenResizeRestored);
await wc.executeJavaScript('document.getElementById("recentFilesPanel").style.flex = ""; queueJobs = []; rebuildJobIndex(); updateUploadView(); renderQueueTable()');
const resizeStability = [];
for (let cycle = 0; cycle < 8; cycle++) {
const width = cycle % 2 === 0 ? 800 : 1100;
const height = cycle % 2 === 0 ? 550 : 750;
win.setSize(width, height);
await new Promise(resolve => setTimeout(resolve, 80));
resizeStability.push(await wc.executeJavaScript(\`(async () => {
const samples = [];
for (const target of ['upload', 'accounts', 'settings', 'history']) {
document.querySelector('.tab[data-view="' + target + '"]')?.click();
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)));
const active = document.querySelector('.view.active');
const rect = active?.getBoundingClientRect();
const settingsHeader = target === 'settings' ? document.querySelector('.settings-header') : null;
const settingsHeaderRect = settingsHeader?.getBoundingClientRect();
const settingsHeaderStyle = settingsHeader ? getComputedStyle(settingsHeader) : null;
const settingsSaveButton = target === 'settings' ? document.getElementById('saveSettingsBtn') : null;
const settingsSaveButtonStyle = settingsSaveButton ? getComputedStyle(settingsSaveButton) : null;
samples.push({
target,
visible: Boolean(rect && rect.width > 0 && rect.height > 0),
contained: Boolean(active && active.scrollWidth <= active.clientWidth + 1),
activeViews: document.querySelectorAll('.view.active').length,
settingsHeaderHeight: settingsHeaderRect?.height || null,
settingsHeaderMetrics: settingsHeader ? {
innerWidth: window.innerWidth,
minHeight: settingsHeaderStyle.minHeight,
saveHeight: settingsSaveButton.getBoundingClientRect().height,
saveMinHeight: settingsSaveButtonStyle.minHeight
} : null
});
}
return {
samples,
documentContained: document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1
};
})()\`));
}
win.setBounds(originalBounds);
await new Promise(resolve => setTimeout(resolve, 150));
await wc.executeJavaScript('document.querySelector(".tab[data-view=upload]")?.click()');
const invalidResizeFrames = resizeStability.filter(cycle => !cycle.documentContained || cycle.samples.some(sample => !sample.visible || !sample.contained || sample.activeViews !== 1 || (sample.target === 'settings' && (sample.settingsHeaderHeight <= 0 || sample.settingsHeaderHeight > (sample.settingsHeaderMetrics.innerWidth <= 839 ? 58 : 64)))));
if (invalidResizeFrames.length) console.log('Invalid resize frames: ' + JSON.stringify(invalidResizeFrames));
check('Repeated minimum and standard resizes keep every view painted and contained', resizeStability.length === 8 && invalidResizeFrames.length === 0);
if (rendererDiagnostics.length) console.log('Renderer diagnostics: ' + JSON.stringify(rendererDiagnostics));
check('Dynamic rendering emits no renderer errors, failed loads, crashes, or unresponsive events', rendererDiagnostics.length === 0 && rendererUnresponsiveCount === 0);
const updateHidden = await wc.executeJavaScript('document.getElementById("updateBanner")?.style.display'); const updateHidden = await wc.executeJavaScript('document.getElementById("updateBanner")?.style.display');
check('Update banner hidden', updateHidden === 'none'); check('Update banner hidden', updateHidden === 'none');
+21
View File
@@ -159,6 +159,19 @@ describe('UploadManager', () => {
assert.equal(summary.files.length, 2); assert.equal(summary.files.length, 2);
}); });
it('emits a final idle stats snapshot after a normal batch', async () => {
const mgr = new UploadManager({});
const states = [];
mgr.on('stats', (stats) => states.push(stats.state));
await mgr.startBatch([
{ file: '/test/video.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
]);
assert.equal(states.at(-1), 'idle');
assert.equal(mgr.statsInterval, null);
});
it('retries on failure then succeeds', async () => { it('retries on failure then succeeds', async () => {
let callCount = 0; let callCount = 0;
mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => { mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => {
@@ -230,7 +243,9 @@ describe('UploadManager', () => {
const mgr = new UploadManager({}); const mgr = new UploadManager({});
let batchDone = false; let batchDone = false;
const snapshots = [];
mgr.on('batch-done', () => { batchDone = true; }); mgr.on('batch-done', () => { batchDone = true; });
mgr.on('stats', (stats) => snapshots.push({ ...stats }));
const batchPromise = mgr.startBatch([ const batchPromise = mgr.startBatch([
{ file: '/test/video.mp4', hoster: 'doodstream.com', apiKey: 'key1' } { file: '/test/video.mp4', hoster: 'doodstream.com', apiKey: 'key1' }
@@ -239,10 +254,16 @@ describe('UploadManager', () => {
// Wait a bit then cancel // Wait a bit then cancel
await new Promise(r => setTimeout(r, 100)); await new Promise(r => setTimeout(r, 100));
mgr.cancel(); mgr.cancel();
const cancellingSnapshot = snapshots.at(-1);
await batchPromise; await batchPromise;
assert.equal(mgr.running, false); assert.equal(mgr.running, false);
assert.ok(batchDone, 'batch-done should be emitted even after cancel'); assert.ok(batchDone, 'batch-done should be emitted even after cancel');
assert.equal(cancellingSnapshot.state, 'stopping');
assert.ok(cancellingSnapshot.activeJobs > 0);
assert.equal(snapshots.at(-1).state, 'idle');
assert.equal(snapshots.at(-1).activeJobs, 0);
assert.equal(mgr.statsInterval, null);
}); });
it('does not emit one aborted progress event per job when cancelling a whole batch', async () => { it('does not emit one aborted progress event per job when cancelling a whole batch', async () => {