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:
@@ -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
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,7 +1292,10 @@ class UploadManager extends EventEmitter {
|
||||
|
||||
_startStatsTimer() {
|
||||
if (this.statsInterval) clearInterval(this.statsInterval);
|
||||
this.statsInterval = setInterval(() => {
|
||||
this.statsInterval = setInterval(() => this._emitStats(), 1000);
|
||||
}
|
||||
|
||||
_emitStats() {
|
||||
try {
|
||||
let globalSpeedKbs = 0;
|
||||
let activeCount = 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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+162
-29
@@ -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
|
||||
? `<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}">
|
||||
<div class="account-hoster-group-header" data-hoster-toggle="${name}" role="button" tabindex="0" aria-expanded="${isOpen}">
|
||||
<span class="panel-arrow">▶</span>
|
||||
@@ -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;
|
||||
}
|
||||
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 = `<p class="empty-state history-loading-state">${localizeUiText('Wird geladen…')}</p>`;
|
||||
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`;
|
||||
};
|
||||
|
||||
|
||||
@@ -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.']
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
+512
-1
@@ -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');
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user