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