diff --git a/README.md b/README.md index eb679d1..e9df568 100644 --- a/README.md +++ b/README.md @@ -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). -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 diff --git a/lib/startup-renderer.js b/lib/startup-renderer.js index ec6e844..aea6694 100644 --- a/lib/startup-renderer.js +++ b/lib/startup-renderer.js @@ -4,6 +4,10 @@ function configureStartupRenderer(app, env = process.env, platform = process.pla return app; } +function resolveStartupLanguage(config) { + return config && config.globalSettings && config.globalSettings.language === 'de' ? 'de' : 'en'; +} + function createStartupWindow(BrowserWindow, options) { const window = new BrowserWindow({ ...options, show: false }); window.once('ready-to-show', () => { @@ -12,10 +16,10 @@ function createStartupWindow(BrowserWindow, options) { return { window, - load(target, onLoadError) { - return window.loadFile(target).catch(onLoadError); + load(target, onLoadError, options) { + return window.loadFile(target, options).catch(onLoadError); } }; } -module.exports = { configureStartupRenderer, createStartupWindow }; +module.exports = { configureStartupRenderer, createStartupWindow, resolveStartupLanguage }; diff --git a/lib/upload-manager.js b/lib/upload-manager.js index 0ae0d11..c1a9fc8 100644 --- a/lib/upload-manager.js +++ b/lib/upload-manager.js @@ -406,8 +406,9 @@ class UploadManager extends EventEmitter { await Promise.allSettled(batch); } - this._stopStatsTimer(); this.running = false; + this._stopStatsTimer(); + this._emitStats(); const files = Array.from(results.values()); const total = tasks.length; @@ -1291,8 +1292,11 @@ class UploadManager extends EventEmitter { _startStatsTimer() { if (this.statsInterval) clearInterval(this.statsInterval); - this.statsInterval = setInterval(() => { - try { + this.statsInterval = setInterval(() => this._emitStats(), 1000); + } + + _emitStats() { + try { let globalSpeedKbs = 0; let activeCount = 0; let inProgressBytes = 0; @@ -1312,8 +1316,7 @@ class UploadManager extends EventEmitter { activeJobs: activeCount, pendingJobs: Object.values(this.semaphores).reduce((sum, semaphore) => sum + semaphore.pending, 0) }); - } catch { /* never let a stats tick crash the timer + caller */ } - }, 1000); + } catch {} } _stopStatsTimer() { @@ -1454,12 +1457,12 @@ class UploadManager extends EventEmitter { cancel() { if (!this.running) return; this.abortController.abort(); - this.stopAfterActive = false; - this.running = false; + this.stopAfterActive = true; for (const controller of this.jobAbortControllers.values()) { if (!controller.signal.aborted) controller.abort(); } this._stopStatsTimer(); + this._emitStats(); } } diff --git a/main.js b/main.js index 64d2b0b..6c9b6f6 100644 --- a/main.js +++ b/main.js @@ -1,7 +1,7 @@ process.env.UV_THREADPOOL_SIZE = process.env.UV_THREADPOOL_SIZE || '8'; const { monitorEventLoopDelay, PerformanceObserver } = require('perf_hooks'); 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); nativeTheme.themeSource = 'dark'; const path = require('path'); @@ -1441,7 +1441,8 @@ function createWindow() { 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; restoreClosePreparation(closePreparationAttempt); }); @@ -1490,10 +1491,12 @@ function createWindow() { 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) => { _writeCrashLog('LOAD FILE FAILED', err); debugLog(`LOAD FILE FAILED: ${err && err.stack ? err.stack : err}`); - }); + }, { query: { language: startupLanguage } }); } function createTray() { diff --git a/package-lock.json b/package-lock.json index c68cd61..d74ad67 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-hoster-uploader", - "version": "2.1.17", + "version": "2.1.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-hoster-uploader", - "version": "2.1.17", + "version": "2.1.18", "dependencies": { "chokidar": "^3.6.0", "undici": "^7.29.0", diff --git a/package.json b/package.json index 8e0ed0c..59ae3f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "multi-hoster-uploader", - "version": "2.1.17", + "version": "2.1.18", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { diff --git a/renderer/app.js b/renderer/app.js index eaa8d01..0604832 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -1,6 +1,6 @@ const HOSTERS = ['doodstream.com', 'voe.sx', 'vidmoly.me', 'byse.sx', 'clouddrop.cc']; const uiLocalizer = window.I18n.createDomLocalizer(document); -uiLocalizer.start('en'); +uiLocalizer.start(new URLSearchParams(window.location.search).get('language')); function setUiLanguage(value) { const language = uiLocalizer.setLanguage(window.I18n.normalizeLanguage(value)); @@ -24,6 +24,9 @@ function formatRemoteClientStatus(port, count) { function refreshLocalizedRuntimeUi() { renderQueueTable(); renderAccounts(); + sessionFilesData.forEach(row => { + if (Number.isFinite(row.dateTs)) row.date = formatDateTime(row.dateTs).text; + }); renderRecentUploadsPanel(); historyRowsData.forEach(row => { if (row.rawTimestamp !== undefined) { @@ -115,6 +118,8 @@ function _maybeLogRendererPerf(activeJobs) { _resetRendererPerf(); } 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 autoHealthCheckEnabled = true; const queuePersistThrottle = (window.ThrottleTimer && window.ThrottleTimer.makeThrottleTimer) @@ -571,6 +576,7 @@ async function init() { // --- Tab switching --- let _historyDirty = false; let _historyEverLoaded = false; +let clampRecentPanelHeight = () => {}; function _isHistoryTabActive() { const tab = document.querySelector('.tab.active'); return !!(tab && tab.dataset.view === 'history'); @@ -601,6 +607,7 @@ function _isHistoryTabActive() { tab.tabIndex = 0; const nextView = viewsById[`${tab.dataset.view}-view`]; if (nextView) nextView.classList.add('active'); + if (tab.dataset.view === 'upload') requestAnimationFrame(clampRecentPanelHeight); activeTab = tab; syncTabIndicator(tab); 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() { const nextStatuses = {}; + const activeIds = new Set(); for (const { account } of getAllAccountsFlat()) { if (account.id) { + activeIds.add(account.id); nextStatuses[account.id] = accountStatuses[account.id] || { status: 'unchecked', message: '' }; } } + for (const accountId of accountStatusGenerations.keys()) { + if (!activeIds.has(accountId)) accountStatusGenerations.delete(accountId); + } 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 }] function getAllAccountsFlat() { const result = []; @@ -1969,7 +1999,8 @@ function renderQueueTable() { if (totalRows < 200) { // Try in-place update if row count matches (fast path) 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 for (let i = 0; i < totalRows; i++) { const tr = existingRows[i]; @@ -2464,6 +2495,8 @@ function applyImportedConfig(importedConfig, message) { } }; hosterSettings = config.hosterSettings || {}; + accountStatusGenerations.clear(); + accountStatuses = {}; ensureAccountStatusEntries(); syncSelectedUploadHosters(); alwaysOnTopState = !!(config.globalSettings && config.globalSettings.alwaysOnTop); @@ -2709,7 +2742,7 @@ document.addEventListener('keydown', (e) => { cancelHosterModal(); 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'); // Ctrl+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)); }, 1000); } - } else if (data.state === 'idle' && statsRunTimer) { - clearInterval(statsRunTimer); + } else if (data.state === 'idle') { + if (statsRunTimer) clearInterval(statsRunTimer); statsRunTimer = null; + statsStartTime = 0; } } @@ -3934,7 +3968,15 @@ function _setRollingUploadMetric(id, value) { const nextText = numericValue.toLocaleString(getUiLocale()); const previousValue = Number(element.dataset.numericValue) || 0; if (previousValue === numericValue) { + const previousText = element.getAttribute('aria-label'); 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; } @@ -4056,7 +4098,7 @@ function updateStatusBar() { _setRollingUploadMetric('uploadTelemetryRemaining', stats.remaining); _setRollingUploadMetric('uploadTelemetryRunning', stats.inProgress); _setRollingUploadMetric('uploadTelemetryCompleted', _sessionDoneCount); - _setRollingUploadMetric('uploadTelemetryFailed', _sessionErrorCount); + _setRollingUploadMetric('uploadTelemetryFailed', Math.max(_sessionErrorCount, stats.errors)); updateUploadSpeedDisplays(); _setUploadTelemetryText('uploadTelemetryEta', etaSeconds > 0 ? formatTime(etaSeconds) : '--:--'); updateUploadSidebarSummary(stats); @@ -4141,12 +4183,17 @@ function showAppChoice({ message, title, confirmText, alternateText, cancelText return showAppDialog({ message, title, confirmText, alternateText, cancelText, showCancel: true }); } -async function executeHealthCheck(hosters, _mode) { +async function executeHealthCheck(hosters, _mode, generations) { renderHealthCheckResults([]); const result = await window.api.runHealthCheck({ hosters }); const rows = result && Array.isArray(result.results) ? result.results : []; - rows.forEach((row) => { - if (!row) return; + const currentRows = rows.filter((row) => { + 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; if (key) { accountStatuses[key] = { @@ -4156,10 +4203,10 @@ async function executeHealthCheck(hosters, _mode) { }; } }); - renderHealthCheckResults(rows); + renderHealthCheckResults(currentRows); renderAccounts(); renderHosterModal(); - return rows; + return currentRows; } async function runHealthCheck(mode = 'manual', requestedHosters = null) { @@ -4180,18 +4227,21 @@ async function runHealthCheck(mode = 'manual', requestedHosters = null) { return []; } healthCheckRunning = true; + const generations = new Map(); // Mark all accounts as checking for (const h of hosters) { const key = typeof h === 'string' ? h : (h.accountId || h.hoster); + generations.set(key, _nextAccountStatusGeneration(key)); accountStatuses[key] = { status: 'checking', message: '', checkedAt: null }; } renderAccounts(); try { - return await executeHealthCheck(hosters, mode); + return await executeHealthCheck(hosters, mode, generations); } catch (err) { renderHealthCheckResults([{ hoster: 'System', status: 'error', message: err.message }]); return []; } finally { + for (const [key, generation] of generations) _finishAccountStatusGeneration(key, generation); healthCheckRunning = false; renderAccounts(); } @@ -4808,6 +4858,7 @@ function renderSettings() { const issuedEl = document.getElementById('diagCodeIssued'); const badgeEl = document.getElementById('diagStatusBadge'); if (!enabledEl) return; + const diagnosticsEditedFields = new Set(); const fmtIssued = (ts) => { if (!ts) return ''; @@ -4828,7 +4879,7 @@ function renderSettings() { const b = document.createElement('button'); b.className = 'btn btn-xs btn-secondary'; b.textContent = h; - b.addEventListener('click', () => { publicHostEl.value = h; markSettingsDirty(); }); + b.addEventListener('click', () => { diagnosticsEditedFields.add(publicHostEl.id); publicHostEl.value = h; markSettingsDirty(); }); suggestChips.appendChild(b); } } else { @@ -4838,18 +4889,18 @@ function renderSettings() { let lastSuggested = []; const applySettings = (s) => { if (!s) return; - enabledEl.checked = !!s.enabled; - portEl.value = s.port || 9110; - modeEl.value = s.bindMode === 'network' ? 'network' : 'local'; - publicHostEl.value = s.publicHost || ''; - allowlistEl.value = Array.isArray(s.allowlist) ? s.allowlist.join('\n') : ''; + if (!diagnosticsEditedFields.has(enabledEl.id)) enabledEl.checked = !!s.enabled; + if (!diagnosticsEditedFields.has(portEl.id)) portEl.value = s.port || 9110; + if (!diagnosticsEditedFields.has(modeEl.id)) modeEl.value = s.bindMode === 'network' ? 'network' : 'local'; + if (!diagnosticsEditedFields.has(publicHostEl.id)) publicHostEl.value = s.publicHost || ''; + if (!diagnosticsEditedFields.has(allowlistEl.id)) allowlistEl.value = Array.isArray(s.allowlist) ? s.allowlist.join('\n') : ''; lastSuggested = Array.isArray(s.suggestedHosts) ? s.suggestedHosts : []; codeEl.value = s.code || ''; issuedEl.textContent = fmtIssued(s.codeIssuedAt); renderModeUi(lastSuggested); if (badgeEl) { - badgeEl.textContent = s.enabled ? 'Aktiv' : 'Inaktiv'; - badgeEl.className = 'panel-status' + (s.enabled ? ' active' : ''); + badgeEl.textContent = enabledEl.checked ? 'Aktiv' : 'Inaktiv'; + badgeEl.className = 'panel-status' + (enabledEl.checked ? ' active' : ''); } }; const refreshStatus = () => { @@ -4876,6 +4927,11 @@ function renderSettings() { 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(() => {}); refreshStatus(); @@ -5153,6 +5209,9 @@ async function performSaveSettings(options = {}) { config.hosterSettings = newHosterSettings; config.globalSettings = globalSettings; hosterSettings = newHosterSettings; + const startupUrl = new URL(window.location.href); + startupUrl.searchParams.set('language', globalSettings.language); + window.history.replaceState(null, '', startupUrl.href); clearTimeout(settingsSaveTimer); settingsSaveTimer = null; @@ -5545,8 +5604,8 @@ function _buildAccountHosterGroupHtml(name, accounts) { accounts.forEach((account, idx) => { cardsHtml += _buildAccountCardHtml(name, account, idx); }); const lifeStat = _hosterLifetimeStat(name); const lifeMeta = lifeStat && lifeStat.total > 0 - ? `${Math.round(lifeStat.rate * 100)}% ok (${lifeStat.total})` - : ''; + ? `${Math.round(lifeStat.rate * 100)}% ok (${lifeStat.total})` + : ``; return `
@@ -5573,6 +5632,22 @@ function _hosterLifetimeStat(name) { } 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() { const bodies = document.querySelectorAll('#accountsList .account-hoster-group-body'); if (!bodies.length) return false; @@ -5777,20 +5852,25 @@ async function checkSingleAccount(accountId) { if (!accountId || healthCheckRunning) return; const found = findAccountById(accountId); if (!found) return; + const generation = _nextAccountStatusGeneration(accountId); healthCheckRunning = true; accountStatuses[accountId] = { status: 'checking', message: '' }; updateAccountCard(accountId); + let nextStatus = null; try { const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId }] }); const rows = result && Array.isArray(result.results) ? result.results : []; 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) { - accountStatuses[accountId] = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' }; + nextStatus = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' }; } finally { healthCheckRunning = false; } + if (!_isCurrentAccountStatusGeneration(accountId, generation) || !findAccountById(accountId)) return; + if (nextStatus) accountStatuses[accountId] = nextStatus; updateAccountCard(accountId); + _finishAccountStatusGeneration(accountId, generation); } async function submitAccountOtp(accountId) { @@ -5809,6 +5889,7 @@ async function submitAccountOtp(accountId) { return; } const submitButton = card?.querySelector('[data-account-otp-submit]'); + const generation = _nextAccountStatusGeneration(accountId); healthCheckRunning = true; accountStatuses[accountId] = { status: 'checking', message: 'OTP wird geprüft…' }; if (otpInput) otpInput.disabled = true; @@ -5816,21 +5897,25 @@ async function submitAccountOtp(accountId) { submitButton.disabled = true; submitButton.textContent = 'Prüfe…'; } + let nextStatus; try { const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId, otp }] }); const row = result && Array.isArray(result.results) ? result.results.find(item => item.accountId === accountId) : null; - accountStatuses[accountId] = row + nextStatus = row ? { status: row.status || 'error', message: row.message || '' } : { status: 'error', message: 'Keine Antwort vom Hoster erhalten' }; } catch (err) { - accountStatuses[accountId] = { status: 'error', message: err.message || 'OTP-Prüfung fehlgeschlagen' }; + nextStatus = { status: 'error', message: err.message || 'OTP-Prüfung fehlgeschlagen' }; } finally { healthCheckRunning = false; - updateAccountCard(accountId); - renderHosterModal(); } + if (!_isCurrentAccountStatusGeneration(accountId, generation) || !findAccountById(accountId)) return; + accountStatuses[accountId] = nextStatus; + updateAccountCard(accountId); + renderHosterModal(); + _finishAccountStatusGeneration(accountId, generation); } // Per-hoster overrides for the login form. VOE only accepts emails — the @@ -5972,6 +6057,7 @@ async function deleteAccount(accountId) { if (Array.isArray(accounts)) { config.hosters[found.name] = accounts.filter(a => a.id !== accountId); } + _invalidateAccountStatusGeneration(accountId); delete accountStatuses[accountId]; // saveConfig is async — close the modal immediately so the UI feels // responsive instead of waiting for the atomic write + safeStorage encrypt. @@ -6190,6 +6276,7 @@ async function _persistAccount(ctx, creds) { function _applyCommittedAccount(persisted, validation) { const { accountId, candidateHosters, isEdit } = persisted; config.hosters = candidateHosters; + _invalidateAccountStatusGeneration(accountId); accountStatuses[accountId] = { status: validation.status, message: validation.message || '' }; ensureAccountStatusEntries(); syncSelectedUploadHosters(); @@ -6327,13 +6414,16 @@ async function confirmHistoryClear() { } } +let _historyLoadGeneration = 0; async function loadHistory() { + const generation = ++_historyLoadGeneration; const container = document.getElementById('historyContainer'); if (container) container.innerHTML = `

${localizeUiText('Wird geladen…')}

`; let history; try { history = await window.api.getHistory(); } catch (error) { + if (generation !== _historyLoadGeneration) return; historyRowsData = []; historySidebarCounts = { total: 0, success: 0, error: 0, skipped: 0 }; updateHistorySidebarSummary(); @@ -6342,10 +6432,12 @@ async function loadHistory() { container?.querySelector('[data-retry-history]')?.addEventListener('click', loadHistory); return; } + if (generation !== _historyLoadGeneration) return; window._historyForStats = history || []; _historyEverLoaded = true; _historyDirty = false; _invalidateHosterLifetimeCache(); + _refreshAccountHosterLifetimeStats(); const retSel = document.getElementById('historyRetentionSelect'); if (retSel) { retSel.value = (config.globalSettings && config.globalSettings.historyRetention) || 'all'; @@ -6498,6 +6590,7 @@ function _renderRecentVirtualRows() { function renderRecentUploadsPanel(_appendOnly = false) { const tbody = document.getElementById('recentFilesBody'); if (!tbody) return; + const pendingAppends = _recentPendingAppends; _recentPendingAppends = 0; const wrap = tbody.closest('.recent-files-table-wrap'); @@ -6511,7 +6604,7 @@ function renderRecentUploadsPanel(_appendOnly = false) { _recentLastRange = { start: -1, end: -1 }; const sig = `${recentSortState.key}|${recentSortState.direction}`; 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; else if (sig === 'date|desc' && added > 0) wrap.scrollTop += added * VIRTUAL_ROW_HEIGHT; } @@ -7779,6 +7872,44 @@ function showCopyToast(msg, durationMs) { if (resizer && panel) { let startY = 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) => { e.preventDefault(); @@ -7790,7 +7921,9 @@ function showCopyToast(msg, durationMs) { const onMove = (e2) => { 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`; }; diff --git a/renderer/i18n.js b/renderer/i18n.js index 25435c4..2f7f9bd 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -682,6 +682,7 @@ [/^(\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.'], [/^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'], [/^Gesamt (\d+)$/, 'Total $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+) — (\d+) clients connected$/, 'Aktiv auf Port $1 — $2 Clients verbunden'], [/^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'], [/^Failed (\d+)$/, 'Fehler $1'], [/^Update v(.+) available\. Click to install\.$/, 'Update v$1 verfügbar. Klicken zum Installieren.'] diff --git a/scripts/afterPack.cjs b/scripts/afterPack.cjs index 67832aa..374a3b7 100644 --- a/scripts/afterPack.cjs +++ b/scripts/afterPack.cjs @@ -31,6 +31,7 @@ module.exports = async function afterPack(context) { "file-version": version, "product-version": version, "version-string": { + CompanyName: "Sucukdeluxe", FileDescription: "Multi Hoster Uploader", InternalName: productFilename, OriginalFilename: `${productFilename}.exe`, diff --git a/tests/package-build-files.test.js b/tests/package-build-files.test.js index aefe7a3..1bd6c17 100644 --- a/tests/package-build-files.test.js +++ b/tests/package-build-files.test.js @@ -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['product-version'], '9.8.7'); assert.deepEqual(editCall.options['version-string'], { + CompanyName: 'Sucukdeluxe', FileDescription: 'Multi Hoster Uploader', InternalName: 'Multi-Hoster-Upload', OriginalFilename: 'Multi-Hoster-Upload.exe', diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index 873d275..06d7dbe 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -3,7 +3,7 @@ const assert = require('node:assert/strict'); const { EventEmitter } = require('node:events'); const fs = require('node:fs'); const path = require('node:path'); -const { configureStartupRenderer, createStartupWindow } = require('../lib/startup-renderer'); +const { configureStartupRenderer, createStartupWindow, resolveStartupLanguage } = require('../lib/startup-renderer'); class TestBrowserWindow extends EventEmitter { constructor(options) { @@ -12,6 +12,7 @@ class TestBrowserWindow extends EventEmitter { this.showCalls = 0; this.startupEvents = []; this.loadError = new Error('renderer load failed'); + this.loadOptions = null; } once(eventName, listener) { @@ -23,8 +24,9 @@ class TestBrowserWindow extends EventEmitter { this.showCalls++; } - loadFile(target) { + loadFile(target, options) { this.startupEvents.push(`load:${target}`); + this.loadOptions = options; return Promise.reject(this.loadError); } } @@ -41,6 +43,13 @@ test('configureStartupRenderer disables hardware acceleration for a Windows Remo 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', () => { 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); }); + +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); +}); diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js index 6e65833..c127cb1 100644 --- a/tests/ui-smoke.js +++ b/tests/ui-smoke.js @@ -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 updaterModule = require(path.join(process.cwd(), 'lib', 'updater')); 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)); let preparedUpdateMockCalls = 0; let launchedUpdateMockCalls = 0; @@ -57,13 +60,28 @@ updaterModule.launchPreparedUpdate = () => { const initialIpcHandlers = new Map(); const registerIpcHandler = ipcMain.handle.bind(ipcMain); let initialConfigReadDelayed = false; +let startupLanguagePendingSnapshot = null; ipcMain.handle = (channel, listener) => { const registeredListener = channel === 'get-config' ? async (...args) => { const result = await listener(...args); if (!initialConfigReadDelayed) { 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; } @@ -132,6 +150,18 @@ setTimeout(async () => { const wc = win.webContents; const originalBounds = win.getBounds(); 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) { if (!visualScreenshotDir) return; @@ -159,6 +189,9 @@ setTimeout(async () => { check('Startup update survives pending renderer initialization', startupUpdateState === '9.9.8|false|flex|flex'); 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"))')); 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()'); @@ -194,6 +227,20 @@ setTimeout(async () => { 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("|"); })()'); 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("|"))'); 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("|"); })()'); @@ -202,6 +249,28 @@ setTimeout(async () => { 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("|"); })()'); 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();'); 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'); 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'); + 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 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 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 ==='); 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()'); 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); + + 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 }; })()'); 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")'); 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; })()'); 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'); + 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(\`(() => { + 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 }]; selectedJobIds.clear(); rebuildJobIndex(); @@ -1771,6 +2048,240 @@ setTimeout(async () => { ].join('|'))()\`); 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'); check('Update banner hidden', updateHidden === 'none'); diff --git a/tests/upload-manager.test.js b/tests/upload-manager.test.js index 1aab75b..ffa5a23 100644 --- a/tests/upload-manager.test.js +++ b/tests/upload-manager.test.js @@ -159,6 +159,19 @@ describe('UploadManager', () => { 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 () => { let callCount = 0; mockUploadFile.mock.mockImplementation(async (hoster, filePath, apiKey, onProgress) => { @@ -230,7 +243,9 @@ describe('UploadManager', () => { const mgr = new UploadManager({}); let batchDone = false; + const snapshots = []; mgr.on('batch-done', () => { batchDone = true; }); + mgr.on('stats', (stats) => snapshots.push({ ...stats })); const batchPromise = mgr.startBatch([ { file: '/test/video.mp4', hoster: 'doodstream.com', apiKey: 'key1' } @@ -239,10 +254,16 @@ describe('UploadManager', () => { // Wait a bit then cancel await new Promise(r => setTimeout(r, 100)); mgr.cancel(); + const cancellingSnapshot = snapshots.at(-1); await batchPromise; assert.equal(mgr.running, false); 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 () => {