From 3fc8cca329aae9581367cdd2aff6f1154396ee2c Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:56:45 +0200 Subject: [PATCH] release: Multi-Hoster-Upload 2.1.0 Refine navigation feedback, settings controls, application dialogs, history actions, and development reload behavior. Restore hardware-accelerated rendering and expand release verification for the packaged source manifest. --- .gitignore | 1 + lib/startup-renderer.js | 2 +- main.js | 1 + package-lock.json | 4 +- package.json | 3 +- renderer/app.js | 122 ++++++++++++++++--- renderer/index.html | 20 +++- renderer/styles.css | 162 +++++++++++++++++++++++--- scripts/dev-runner.cjs | 124 ++++++++++++++++++++ scripts/verify-public-release.mjs | 2 + tests/public-release-verifier.test.js | 5 +- tests/startup-renderer.test.js | 4 +- tests/ui-smoke.js | 72 ++++++++++++ 13 files changed, 483 insertions(+), 39 deletions(-) create mode 100644 scripts/dev-runner.cjs diff --git a/.gitignore b/.gitignore index d45a107..fe318b2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ +.dev-runner.lock release/ .artifacts/ __pycache__/ diff --git a/lib/startup-renderer.js b/lib/startup-renderer.js index deded51..8b6722f 100644 --- a/lib/startup-renderer.js +++ b/lib/startup-renderer.js @@ -1,5 +1,5 @@ function configureStartupRenderer(app) { - app.disableHardwareAcceleration(); + return app; } function createStartupWindow(BrowserWindow, options) { diff --git a/main.js b/main.js index c2c63ba..118bcb2 100644 --- a/main.js +++ b/main.js @@ -1358,6 +1358,7 @@ async function runHosterHealthCheck(config, requestedChecks) { function createWindow() { const startupWindow = createStartupWindow(BrowserWindow, { + title: 'Multi Hoster Uploader', width: 1100, height: 750, minWidth: 800, diff --git a/package-lock.json b/package-lock.json index b6c33a3..e06ebe2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "multi-hoster-uploader", - "version": "2.0.7", + "version": "2.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "multi-hoster-uploader", - "version": "2.0.7", + "version": "2.1.0", "dependencies": { "chokidar": "^3.6.0", "undici": "^7.29.0", diff --git a/package.json b/package.json index 9b0f19d..3c4192f 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,11 @@ { "name": "multi-hoster-uploader", - "version": "2.0.7", + "version": "2.1.0", "description": "Upload files to doodstream, voe, vidmoly, byse simultaneously", "main": "main.js", "scripts": { "start": "electron .", + "dev": "node scripts/dev-runner.cjs", "test": "node --test tests/*.test.js tests/ui-smoke.js", "test:backup-api": "npm --prefix services/backup-api test", "lint": "eslint .", diff --git a/renderer/app.js b/renderer/app.js index ed77095..e073dbf 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -526,13 +526,26 @@ function _isHistoryTabActive() { const nextView = viewsById[`${tab.dataset.view}-view`]; if (nextView) nextView.classList.add('active'); activeTab = tab; + syncTabIndicator(tab); if (tab.dataset.view === 'history' && (_historyDirty || !_historyEverLoaded)) { loadHistory(); } }; const tabBar = tabs[0] && tabs[0].parentElement; + const tabIndicator = tabBar?.querySelector('.tab-indicator'); + function syncTabIndicator(tab) { + if (!tabIndicator || !tab) return; + tabIndicator.style.width = `${tab.offsetWidth}px`; + tabIndicator.style.transform = `translateX(${tab.offsetLeft}px)`; + } if (tabBar) { + if (tabIndicator) { + tabIndicator.style.transition = 'none'; + syncTabIndicator(activeTab); + requestAnimationFrame(() => { tabIndicator.style.transition = ''; }); + window.addEventListener('resize', () => syncTabIndicator(activeTab)); + } tabBar.addEventListener('click', (e) => handle(e.target)); tabBar.addEventListener('keydown', (e) => { if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(e.key)) return; @@ -562,19 +575,45 @@ function initMenuBar() { menuBar.querySelectorAll('[data-menu-dropdown]').forEach(d => { dropdowns[d.dataset.menuDropdown] = d; }); const triggers = {}; menuBar.querySelectorAll('[data-menu-trigger]').forEach(t => { triggers[t.dataset.menuTrigger] = t; }); + const panelTokens = new WeakMap(); + + function openPanel(panel) { + if (!panel) return; + panelTokens.set(panel, (panelTokens.get(panel) || 0) + 1); + panel.classList.remove('menu-closing', 'menu-opening'); + panel.style.display = ''; + void panel.offsetHeight; + panel.classList.add('menu-opening'); + } + + function closePanel(panel) { + if (!panel || window.getComputedStyle(panel).display === 'none') return; + const token = (panelTokens.get(panel) || 0) + 1; + panelTokens.set(panel, token); + panel.classList.remove('menu-opening', 'menu-closing'); + void panel.offsetHeight; + panel.classList.add('menu-closing'); + const finish = () => { + if (panelTokens.get(panel) !== token) return; + panel.style.display = 'none'; + panel.classList.remove('menu-closing'); + }; + panel.addEventListener('animationend', finish, { once: true }); + setTimeout(finish, 220); + } function closeMenus() { openMenu = null; - for (const k in dropdowns) dropdowns[k].style.display = 'none'; + for (const k in dropdowns) closePanel(dropdowns[k]); for (const k in triggers) triggers[k].classList.remove('open'); - menuBar.querySelectorAll('.menu-submenu-dropdown').forEach(s => { s.style.display = 'none'; }); + menuBar.querySelectorAll('.menu-submenu-dropdown').forEach(closePanel); } function openMenuNamed(name) { if (openMenu === name) return; closeMenus(); openMenu = name; - if (dropdowns[name]) dropdowns[name].style.display = ''; + openPanel(dropdowns[name]); if (triggers[name]) triggers[name].classList.add('open'); if (name === 'einstellungen') _syncMenuSettings(); } @@ -590,8 +629,8 @@ function initMenuBar() { menuBar.querySelectorAll('.menu-submenu').forEach(sm => { const sub = sm.querySelector('.menu-submenu-dropdown'); - sm.addEventListener('mouseenter', () => { if (sub) sub.style.display = ''; }); - sm.addEventListener('mouseleave', () => { if (sub) sub.style.display = 'none'; }); + sm.addEventListener('mouseenter', () => openPanel(sub)); + sm.addEventListener('mouseleave', () => closePanel(sub)); }); menuBar.querySelectorAll('[data-menu-action]').forEach(item => { @@ -3544,6 +3583,33 @@ function renderHealthCheckResults(_results) { if (container) container.innerHTML = ''; } +let _appAlertResolve = null; + +function closeAppAlert() { + const modal = document.getElementById('appAlertModal'); + if (!modal) return; + modal.style.display = 'none'; + modal.setAttribute('aria-hidden', 'true'); + const resolve = _appAlertResolve; + _appAlertResolve = null; + if (resolve) resolve(); +} + +function showAppAlert(message, title = 'Hinweis') { + const modal = document.getElementById('appAlertModal'); + const titleEl = document.getElementById('appAlertTitle'); + const messageEl = document.getElementById('appAlertMessage'); + const confirm = document.getElementById('appAlertConfirmBtn'); + if (!modal || !titleEl || !messageEl || !confirm) return Promise.resolve(); + if (_appAlertResolve) closeAppAlert(); + titleEl.textContent = title; + messageEl.textContent = String(message || ''); + modal.style.display = 'flex'; + modal.setAttribute('aria-hidden', 'false'); + confirm.focus(); + return new Promise(resolve => { _appAlertResolve = resolve; }); +} + async function executeHealthCheck(hosters, _mode) { renderHealthCheckResults([]); const result = await window.api.runHealthCheck({ hosters }); @@ -3578,7 +3644,7 @@ async function runHealthCheck(mode = 'manual', requestedHosters = null) { .map(({ name, account }) => ({ hoster: name, accountId: account.id })); } if (hosters.length === 0) { - if (mode === 'manual') alert('Keine Hoster mit Zugangsdaten für einen Check.'); + if (mode === 'manual') await showAppAlert('Keine Hoster mit Zugangsdaten für einen Check.'); return []; } healthCheckRunning = true; @@ -3662,7 +3728,7 @@ function renderSettings() {
- +
@@ -3750,15 +3816,19 @@ function renderSettings() { pages.automatik.innerHTML = ` ${pageHeader('Automatik', 'Wiederholungen und überwachte Ordner für unbeaufsichtigte Uploads.')}
Unbeaufsichtigter Betrieb
-
+
- - 0 = aus. Nach Batch-Ende werden transiente Fehler (Netzwerk, Hoster-Flake) automatisch bis zu N Runden neu versucht. +
+ + 0 = aus. Nach Batch-Ende werden transiente Fehler (Netzwerk, Hoster-Flake) automatisch bis zu N Runden neu versucht. +
-
+
- - Minuten · jede weitere Runde wartet entsprechend länger +
+ + Minuten · jede weitere Runde wartet entsprechend länger +
@@ -5682,9 +5752,11 @@ function _renderHistoryVirtualRows() { parts.push(escapeHtml(row.filename)); parts.push(''); parts.push(escapeHtml(row.host)); - parts.push(''); + parts.push(''); } if (bottomPad > 0) parts.push(``); tbody.innerHTML = parts.join(''); @@ -5750,6 +5822,13 @@ function renderHistoryTable(container) { renderHistoryTable(container); return; } + const copyButton = e.target.closest('.history-copy-link'); + if (copyButton && container.contains(copyButton)) { + const link = copyButton.closest('.history-row')?.dataset.link; + if (link) { window.api.copyToClipboard(link); showCopyToast('Link kopiert'); } + e.stopPropagation(); + return; + } const row = e.target.closest('.history-row'); if (row && !row.classList.contains('error')) { const link = row.dataset.link; @@ -5971,6 +6050,19 @@ function setupListeners() { }); }); document.getElementById('saveSettingsBtn').addEventListener('click', saveSettings); + document.getElementById('appAlertConfirmBtn').addEventListener('click', closeAppAlert); + document.getElementById('appAlertCloseBtn').addEventListener('click', closeAppAlert); + document.getElementById('appAlertModal').addEventListener('click', event => { + if (event.target.id === 'appAlertModal') closeAppAlert(); + }); + document.addEventListener('keydown', event => { + const modal = document.getElementById('appAlertModal'); + if (modal?.style.display !== 'flex') return; + if (event.key === 'Escape' || event.key === 'Enter') { + event.preventDefault(); + closeAppAlert(); + } + }, true); document.getElementById('clearHistoryBtn').addEventListener('click', async () => { if (!confirm('Verlauf wirklich löschen?')) return; diff --git a/renderer/index.html b/renderer/index.html index ab9b3c5..86424bf 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -4,7 +4,7 @@ - Multi-Hoster-Upload + Multi Hoster Uploader @@ -49,6 +49,7 @@ Verlauf +
@@ -128,7 +129,6 @@
-
@@ -462,6 +462,22 @@ + +
diff --git a/renderer/styles.css b/renderer/styles.css index 2ab002d..fe0a761 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -869,26 +869,47 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel letter-spacing: 0.07em; text-transform: uppercase; } -.settings-search-control { position: relative; } +.settings-search-control { + position: relative; + display: flex; + align-items: center; + height: 40px; +} .settings-search-icon { position: absolute; left: 10px; top: 50%; - transform: translateY(-52%); + width: 14px; + height: 14px; + display: flex; + align-items: center; + justify-content: center; + transform: translateY(-50%); color: var(--text-dim); - font-size: 17px; pointer-events: none; } +.settings-search-icon svg { + display: block; + width: 14px; + height: 14px; + fill: none; + stroke: currentColor; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 1.7; +} #settingsSearchInput { width: 100%; - min-height: 34px; - padding: 7px 9px 7px 31px; + height: 40px; + min-height: 40px; + padding: 0 10px 0 32px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-input); color: var(--text); font: inherit; font-size: 11px; + line-height: 18px; } #settingsSearchInput::placeholder { color: var(--text-dim); } #settingsSearchInput:focus { border-color: var(--accent); outline: none; } @@ -1012,6 +1033,32 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } .settings-subpage .settings-row > label { min-width: 215px; color: var(--text); font-weight: 600; } .settings-subpage .settings-row > .key-input { max-width: none; min-width: 180px; } .settings-subpage .settings-row > .hint { flex: 1 1 220px; line-height: 1.45; } +.settings-subpage .automation-retry-row { + display: grid; + grid-template-columns: 200px minmax(0, 1fr); + align-items: start; + column-gap: 10px; +} +.settings-subpage .automation-retry-row > label { + min-width: 0; + padding-top: 9px; +} +.settings-subpage .automation-retry-control { + display: grid; + justify-items: start; + gap: 8px; + min-width: 0; +} +.settings-subpage .automation-retry-control > .hs-input { + width: 100px; + min-width: 0; + max-width: none; +} +.settings-subpage .automation-retry-control > .hint { + margin: 0; + padding-left: 0; + line-height: 1.45; +} .settings-subpage .settings-row-wide { flex-wrap: wrap; } .settings-subpage .settings-row-wide > .hint { flex-basis: calc(100% - 223px); margin-left: 223px; } .settings-section-label { @@ -1404,6 +1451,38 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } white-space: nowrap; max-width: 300px; } +.history-link-cell { + display: flex; + align-items: center; + gap: 6px; + min-width: 0; +} +.history-link-text { + min-width: 0; + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.history-copy-link { + flex: 0 0 auto; + width: 18px; + height: 18px; + padding: 0; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--text-muted); + font-size: 12px; + line-height: 16px; + cursor: pointer; +} +.history-copy-link:hover, +.history-copy-link:focus-visible { + color: var(--text); + border-color: var(--accent); + background: var(--bg-hover); +} .empty-state { color: var(--text-dim); text-align: center; padding: 40px; font-size: 14px; } @@ -1521,6 +1600,18 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } + + .tab-indicator { + transition-duration: 280ms !important; + } + + .menu-opening { + animation-duration: 180ms !important; + } + + .menu-closing { + animation-duration: 160ms !important; + } } .online-backup-panel { @@ -1694,7 +1785,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } padding: 0; border: 0; background: transparent; - position: static; + position: relative; } .tab { @@ -1723,6 +1814,18 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } background: var(--bg-active); } +.tab-indicator { + position: absolute; + left: 0; + bottom: 0; + width: 0; + height: 2px; + border-radius: 2px 2px 0 0; + background: var(--accent); + pointer-events: none; + transition: transform .2s cubic-bezier(.2, .8, .2, 1), width .2s cubic-bezier(.2, .8, .2, 1); +} + .top-nav-icon, .header-action-icon, .view-sidebar-icon, @@ -1870,6 +1973,30 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } box-shadow: 0 18px 38px rgba(0, 0, 0, .38); } +.menu-dropdown, +.menu-submenu-dropdown { + transform-origin: top; +} + +.menu-opening { + animation: menu-unfold 180ms cubic-bezier(.2, .8, .2, 1) both; +} + +.menu-closing { + pointer-events: none; + animation: menu-fold 160ms cubic-bezier(.4, 0, .8, .2) both; +} + +@keyframes menu-unfold { + from { opacity: .35; clip-path: inset(0 0 100% 0); transform: scaleY(.96); } + to { opacity: 1; clip-path: inset(0); transform: scaleY(1); } +} + +@keyframes menu-fold { + from { opacity: 1; clip-path: inset(0); transform: scaleY(1); } + to { opacity: .2; clip-path: inset(0 0 100% 0); transform: scaleY(.96); } +} + .header-utilities .menu-dropdown { left: auto; right: 0; @@ -2159,7 +2286,7 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } align-items: center; gap: 10px; padding: 7px 10px; - border: 1px solid transparent; + border: 1px solid var(--border); border-radius: 6px; background: transparent; color: var(--text-muted); @@ -2172,12 +2299,13 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } .view-sidebar-item:hover { color: var(--text); + border-color: var(--border-hover); background: var(--bg-raised); } .view-sidebar-item.active { color: var(--text); - border-color: #444548; + border-color: var(--border-hover); background: var(--bg-active); } @@ -2564,10 +2692,8 @@ select.hs-input { max-width: none; width: auto; min-width: 140px; } } .key-input, -.hs-input, -#settingsSearchInput { +.hs-input { min-height: 40px; - padding: 8px 11px; border-color: var(--border); border-radius: 6px; background: var(--bg-input); @@ -2716,6 +2842,13 @@ input[type="checkbox"] { .settings-grid-mini { gap: 8px; + margin-bottom: 8px; +} + +.log-paths-list, +.log-paths-list > div, +.log-paths-list code { + min-width: 0; } .settings-option { @@ -2947,9 +3080,10 @@ input[type="checkbox"] { flex: 1 1 auto; } -.statusbar .sb-state::before, -.statusbar .sb-done-count::before { - background: var(--success); +.statusbar > span.sb-state::before, +.statusbar > span.sb-done-count::before { + background: #43d17b; + box-shadow: 0 0 6px rgba(67, 209, 123, .55); } .statusbar .sb-speed::before, diff --git a/scripts/dev-runner.cjs b/scripts/dev-runner.cjs new file mode 100644 index 0000000..300d45f --- /dev/null +++ b/scripts/dev-runner.cjs @@ -0,0 +1,124 @@ +const path = require('path'); +const fs = require('fs'); +const { spawn } = require('child_process'); +const chokidar = require('chokidar'); + +const root = path.resolve(__dirname, '..'); +const electron = require('electron'); +const lockPath = path.join(root, '.dev-runner.lock'); +const watched = [ + 'main.js', + 'preload.js', + 'preload-drop-target.js', + path.join(root, 'lib'), + path.join(root, 'renderer') +].map(target => path.isAbsolute(target) ? target : path.join(root, target)); + +let child = null; +let restartTimer = null; +let stopping = false; +let lockHandle = null; + +function processExists(pid) { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +function acquireLock() { + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + lockHandle = fs.openSync(lockPath, 'wx'); + fs.writeFileSync(lockHandle, String(process.pid)); + return true; + } catch (error) { + if (error.code !== 'EEXIST') return false; + let ownerPid = 0; + try { ownerPid = Number.parseInt(fs.readFileSync(lockPath, 'utf8'), 10); } catch {} + if (processExists(ownerPid)) return false; + try { fs.unlinkSync(lockPath); } catch {} + } + } + return false; +} + +function releaseLock() { + if (lockHandle !== null) { + try { fs.closeSync(lockHandle); } catch {} + lockHandle = null; + } + try { + if (Number.parseInt(fs.readFileSync(lockPath, 'utf8'), 10) === process.pid) fs.unlinkSync(lockPath); + } catch {} +} + +function startApp() { + child = spawn(electron, ['.', '--dev'], { + cwd: root, + stdio: 'inherit', + windowsHide: false + }); + child.once('exit', (code, signal) => { + child = null; + if (!stopping && code !== 0 && signal !== 'SIGTERM') process.exitCode = code || 1; + }); +} + +function stopApp(done) { + if (!child || !child.pid) { + done(); + return; + } + const pid = child.pid; + child = null; + if (process.platform === 'win32') { + const killer = spawn('taskkill', ['/pid', String(pid), '/t', '/f'], { stdio: 'ignore', windowsHide: true }); + killer.once('close', done); + return; + } + process.kill(pid, 'SIGTERM'); + done(); +} + +function restartApp() { + if (stopping) return; + stopApp(startApp); +} + +function scheduleRestart() { + clearTimeout(restartTimer); + restartTimer = setTimeout(restartApp, 180); +} + +if (!acquireLock()) { + process.stderr.write('A Multi-Hoster hot-dev runner is already active.\n'); + process.exit(0); +} + +const watcher = chokidar.watch(watched, { + ignoreInitial: true, + usePolling: true, + interval: 100, + awaitWriteFinish: { stabilityThreshold: 250, pollInterval: 50 } +}); +watcher.on('all', (_event, file) => { + process.stdout.write(`[hotdev] renderer change detected: ${file}\n`); + scheduleRestart(); +}); + +function shutdown() { + if (stopping) return; + stopping = true; + clearTimeout(restartTimer); + watcher.close().finally(() => stopApp(() => { releaseLock(); process.exit(0); })); +} + +process.once('SIGINT', shutdown); +process.once('SIGTERM', shutdown); +process.once('exit', () => { stopping = true; releaseLock(); }); + +startApp(); diff --git a/scripts/verify-public-release.mjs b/scripts/verify-public-release.mjs index a7ae178..3d13235 100644 --- a/scripts/verify-public-release.mjs +++ b/scripts/verify-public-release.mjs @@ -65,6 +65,7 @@ const sourceFiles = [ 'renderer/index.html', 'renderer/styles.css', 'scripts/afterPack.cjs', + 'scripts/dev-runner.cjs', 'scripts/release-plan.mjs', 'scripts/verify-public-release.mjs', 'services/backup-api/package-lock.json', @@ -126,6 +127,7 @@ const textExtensions = new Set(['.cjs', '.css', '.html', '.js', '.json', '.md', const binaryExtensions = new Set(['.ico', '.png']); const expectedScripts = { start: 'electron .', + dev: 'node scripts/dev-runner.cjs', test: 'node --test tests/*.test.js tests/ui-smoke.js', 'test:backup-api': 'npm --prefix services/backup-api test', lint: 'eslint .', diff --git a/tests/public-release-verifier.test.js b/tests/public-release-verifier.test.js index e6ed67c..f0c9654 100644 --- a/tests/public-release-verifier.test.js +++ b/tests/public-release-verifier.test.js @@ -18,7 +18,8 @@ const rootFiles = [ 'preload.js' ]; const directoryRoots = ['assets', 'lib', 'renderer', 'services/backup-api', 'tests']; -const scriptFiles = ['scripts/afterPack.cjs', 'scripts/release-plan.mjs', 'scripts/verify-public-release.mjs']; +const scriptFiles = ['scripts/afterPack.cjs', 'scripts/dev-runner.cjs', 'scripts/release-plan.mjs', 'scripts/verify-public-release.mjs']; +const currentVersion = require('../package.json').version; function copyDirectory(source, destination) { fs.mkdirSync(destination, { recursive: true }); @@ -48,7 +49,7 @@ function createStage() { return stage; } -function verify(stage, version = '2.0.7') { +function verify(stage, version = currentVersion) { return spawnSync(process.execPath, ['scripts/verify-public-release.mjs', '--source-only', '--version', version], { cwd: stage, encoding: 'utf8' diff --git a/tests/startup-renderer.test.js b/tests/startup-renderer.test.js index 92555bc..7d24b3a 100644 --- a/tests/startup-renderer.test.js +++ b/tests/startup-renderer.test.js @@ -27,10 +27,10 @@ class TestBrowserWindow extends EventEmitter { } } -test('configureStartupRenderer disables hardware acceleration', () => { +test('configureStartupRenderer leaves hardware acceleration enabled', () => { let calls = 0; configureStartupRenderer({ disableHardwareAcceleration() { calls++; } }); - assert.equal(calls, 1); + assert.equal(calls, 0); }); test('createStartupWindow forces the main window to start hidden', () => { diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js index 72c17a5..ecd628b 100644 --- a/tests/ui-smoke.js +++ b/tests/ui-smoke.js @@ -195,6 +195,24 @@ setTimeout(async () => { const tabStops = await wc.executeJavaScript('[...document.querySelectorAll(".tab")].map(el => el.tabIndex).join("|")'); check('Tab navigation exposes one keyboard stop', tabStops === '0|-1|-1|-1'); + await wc.executeJavaScript('document.querySelector("[data-menu-trigger=datei]")?.click()'); + await new Promise(resolve => setTimeout(resolve, 60)); + const mainMenuOpeningMotion = await wc.executeJavaScript('(() => { const menu = document.querySelector("[data-menu-dropdown=datei]"); if (!menu) return "missing"; const style = getComputedStyle(menu); const clip = style.clipPath; return [style.display !== "none", clip !== "none" && !/^inset\\(0(px)?\\)$/.test(clip), style.transform !== "none", parseFloat(style.animationDuration) >= .12].join("|"); })()'); + check('Header dropdown visibly unfolds from top to bottom', mainMenuOpeningMotion === 'true|true|true|true'); + await new Promise(resolve => setTimeout(resolve, 160)); + await wc.executeJavaScript('document.querySelector(".menu-submenu")?.dispatchEvent(new MouseEvent("mouseenter"))'); + await new Promise(resolve => setTimeout(resolve, 60)); + const submenuOpeningMotion = await wc.executeJavaScript('(() => { const menu = document.querySelector(".menu-submenu-dropdown"); if (!menu) return "missing"; const style = getComputedStyle(menu); const clip = style.clipPath; return [style.display !== "none", clip !== "none" && !/^inset\\(0(px)?\\)$/.test(clip), style.transform !== "none", parseFloat(style.animationDuration) >= .12].join("|"); })()'); + check('Nested header menu visibly unfolds from top to bottom', submenuOpeningMotion === 'true|true|true|true'); + await new Promise(resolve => setTimeout(resolve, 160)); + await wc.executeJavaScript('document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }))'); + await new Promise(resolve => setTimeout(resolve, 60)); + const mainMenuClosingMotion = await wc.executeJavaScript('(() => { const menu = document.querySelector("[data-menu-dropdown=datei]"); if (!menu) return "missing"; const style = getComputedStyle(menu); const clip = style.clipPath; return [style.display !== "none", menu.classList.contains("menu-closing"), clip !== "none" && !/^inset\\(0(px)?\\)$/.test(clip)].join("|"); })()'); + check('Header dropdown remains visible while folding from bottom to top', mainMenuClosingMotion === 'true|true|true'); + await new Promise(resolve => setTimeout(resolve, 160)); + const mainMenuClosed = await wc.executeJavaScript('getComputedStyle(document.querySelector("[data-menu-dropdown=datei]")).display'); + check('Header dropdown is hidden after its closing motion', mainMenuClosed === 'none'); + const dropVisible = await wc.executeJavaScript('document.getElementById("dropZone")?.style.display !== "none"'); check('Drop zone visible (no files)', dropVisible); @@ -206,9 +224,20 @@ setTimeout(async () => { const sbState = await wc.executeJavaScript('document.getElementById("sbState")?.textContent'); check('Statusbar: Bereit', sbState === 'Bereit'); + const readyDotColor = await wc.executeJavaScript('getComputedStyle(document.getElementById("sbState"), "::before").backgroundColor'); + check('Ready status uses a green indicator', readyDotColor === 'rgb(67, 209, 123)'); const version = await wc.executeJavaScript('document.getElementById("versionLabel")?.textContent'); check('Version label present', version && version.startsWith('v')); + const versionMonogram = await wc.executeJavaScript('document.querySelector(".version-monogram")'); + check('Header version badge has no meaningless monogram', versionMonogram === null); + const windowTitle = await wc.executeJavaScript('document.title'); + check('Window uses the Multi Hoster Uploader title', windowTitle === 'Multi Hoster Uploader'); + const appAlertState = await wc.executeJavaScript('showAppAlert("Keine Hoster mit Zugangsdaten für einen Check."); (() => { const modal = document.getElementById("appAlertModal"); return [modal?.style.display, modal?.getAttribute("aria-hidden"), document.getElementById("appAlertTitle")?.textContent, document.getElementById("appAlertMessage")?.textContent, document.activeElement?.id].join("|"); })()'); + check('Hoster check uses the styled app dialog', appAlertState === 'flex|false|Hinweis|Keine Hoster mit Zugangsdaten für einen Check.|appAlertConfirmBtn'); + await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn")?.click(); true'); + const appAlertClosed = await wc.executeJavaScript('document.getElementById("appAlertModal")?.style.display'); + check('Styled app dialog closes with its confirmation action', appAlertClosed === 'none'); const localizedQueueHeaders = await wc.executeJavaScript('[...document.querySelectorAll("#queueTable thead th")].map(el => el.childNodes[0]?.textContent.trim()).join("|")'); check('Upload table labels are consistently German', localizedQueueHeaders === 'Dateiname|Hochgeladen / Größe|Hoster|Status|Zeit|Rest|Geschwindigkeit|Fortschritt'); @@ -228,6 +257,9 @@ setTimeout(async () => { const uploadSidebarInformation = await wc.executeJavaScript('(() => { const sidebar = document.querySelector("#upload-view > .view-sidebar")?.getBoundingClientRect(); const section = document.querySelector("#upload-view .view-sidebar-section")?.getBoundingClientRect(); return Boolean(sidebar && section && section.top >= sidebar.top + sidebar.height * 0.55 && document.getElementById("uploadSidebarAccountsCount")); })()'); check('Upload sidebar keeps availability information in its lower area', uploadSidebarInformation === true); + const uploadSidebarBorders = await wc.executeJavaScript('(() => { const items = [...document.querySelectorAll("#upload-view .view-sidebar-item")]; const visible = items.every(item => { const style = getComputedStyle(item); return style.borderTopWidth === "1px" && style.borderTopStyle === "solid" && !style.borderTopColor.endsWith(", 0)"); }); const active = items.find(item => item.classList.contains("active")); const inactive = items.find(item => !item.classList.contains("active")); return [items.length, visible, active && inactive && getComputedStyle(active).borderTopColor !== getComputedStyle(inactive).borderTopColor].join("|"); })()'); + check('Upload sidebar filters use visible borders with a stronger active state', uploadSidebarBorders === '5|true|true'); + const uploadFrameFit = await wc.executeJavaScript('(() => { const view = document.getElementById("upload-view")?.getBoundingClientRect(); const status = document.getElementById("statusbar")?.getBoundingClientRect(); return Boolean(view && status && status.height > 0 && view.bottom <= status.top + 1 && status.bottom <= window.innerHeight + 1); })()'); check('Upload view and statusbar fit inside the viewport', uploadFrameFit === true); @@ -327,6 +359,18 @@ setTimeout(async () => { const keyboardTab = await wc.executeJavaScript('document.getElementById("upload-tab").focus(); document.getElementById("upload-tab").dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true })); document.querySelector(".tab.active")?.textContent?.trim() + "|" + document.activeElement?.id'); check('Arrow keys move and activate main tabs', keyboardTab === 'Accounts|accounts-tab'); + await wc.executeJavaScript('window.__uiTabIndicatorStart = document.querySelector(".tab-indicator")?.getBoundingClientRect().left; document.getElementById("history-tab")?.click()'); + await new Promise(resolve => setTimeout(resolve, 90)); + const tabIndicatorInFlight = await wc.executeJavaScript('(() => { const indicator = document.querySelector(".tab-indicator"); const target = document.getElementById("history-tab"); const start = window.__uiTabIndicatorStart; if (!indicator || !target || !Number.isFinite(start)) return "missing"; const current = indicator.getBoundingClientRect().left; const targetLeft = target.getBoundingClientRect().left; const duration = parseFloat(getComputedStyle(indicator).transitionDuration); return [current > start + 2 && current < targetLeft - 2, duration >= .15].join("|"); })()'); + check('Main navigation indicator remains visibly in motion while gliding right', tabIndicatorInFlight === 'true|true'); + await new Promise(resolve => setTimeout(resolve, 150)); + const tabIndicatorAtHistory = await wc.executeJavaScript('(() => { const indicator = document.querySelector(".tab-indicator"); const tab = document.getElementById("history-tab"); if (!indicator || !tab) return "missing"; const indicatorRect = indicator.getBoundingClientRect(); const tabRect = tab.getBoundingClientRect(); const style = getComputedStyle(indicator); return [Math.abs(indicatorRect.left - tabRect.left) <= 1, Math.abs(indicatorRect.width - tabRect.width) <= 1, style.transitionProperty.includes("transform")].join("|"); })()'); + check('Main navigation indicator glides to a tab selected on the right', tabIndicatorAtHistory === 'true|true|true'); + await wc.executeJavaScript('document.getElementById("upload-tab")?.click()'); + await new Promise(resolve => setTimeout(resolve, 240)); + const tabIndicatorAtUpload = await wc.executeJavaScript('(() => { const indicator = document.querySelector(".tab-indicator"); const tab = document.getElementById("upload-tab"); if (!indicator || !tab) return "missing"; const indicatorRect = indicator.getBoundingClientRect(); const tabRect = tab.getBoundingClientRect(); return [Math.abs(indicatorRect.left - tabRect.left) <= 1, Math.abs(indicatorRect.width - tabRect.width) <= 1].join("|"); })()'); + check('Main navigation indicator glides back to a tab selected on the left', tabIndicatorAtUpload === 'true|true'); + const ctxHidden = await wc.executeJavaScript('document.getElementById("contextMenu")?.style.display'); check('Context menu hidden', ctxHidden === 'none'); @@ -533,9 +577,31 @@ setTimeout(async () => { const settingsNavigation = await wc.executeJavaScript('(() => { const buttons = [...document.querySelectorAll(".settings-nav-button")]; return [buttons.length, buttons.map(button => button.textContent.trim()).join("|"), document.querySelector(".settings-nav-button.active")?.dataset.settingsPage, document.getElementById("settingsSearchInput")?.placeholder].join("::"); })()'); check('Settings use the task-based sidebar navigation', settingsNavigation === '8::Allgemein|Uploads|Automatik|Benachrichtigungen|Logs & Support|Fernsteuerung|Diagnose-Zugriff|Backup & Übertragen::allgemein::Einstellungen durchsuchen'); + await wc.executeJavaScript('document.querySelector("[data-settings-page=\\\'automatik\\\']")?.click()'); + const automationInputAlignment = await wc.executeJavaScript('(() => { const first = document.getElementById("autoRetryRoundsInput")?.getBoundingClientRect(); const second = document.getElementById("autoRetryDelayMinInput")?.getBoundingClientRect(); const firstHintEl = document.getElementById("autoRetryRoundsInput")?.closest(".automation-retry-row")?.querySelector(".hint"); const secondHintEl = document.getElementById("autoRetryDelayMinInput")?.closest(".automation-retry-row")?.querySelector(".hint"); const firstHint = firstHintEl?.getBoundingClientRect(); const secondHint = secondHintEl?.getBoundingClientRect(); if (!first || !second || !firstHint || !secondHint || !firstHintEl || !secondHintEl) return "missing"; const firstTextLeft = firstHint.left + parseFloat(getComputedStyle(firstHintEl).paddingLeft); const secondTextLeft = secondHint.left + parseFloat(getComputedStyle(secondHintEl).paddingLeft); return [Math.round(Math.abs(first.left - second.left)), Math.round(first.width), Math.round(second.width), firstHint.top >= first.bottom + 6, secondHint.top >= second.bottom + 6, Math.round(Math.abs(firstTextLeft - first.left)) <= 1, Math.round(Math.abs(secondTextLeft - second.left)) <= 1].join("|"); })()'); + check('Automation retry hints start directly below their aligned inputs', automationInputAlignment === '0|100|100|true|true|true|true'); const settingsSidebarInformation = await wc.executeJavaScript('(() => { const feedback = document.getElementById("saveFeedback"); const sidebar = document.querySelector(".settings-sidebar"); const status = document.querySelector(".settings-sidebar-status"); return Boolean(feedback && sidebar?.contains(feedback) && status && !document.querySelector(".settings-header #saveFeedback")); })()'); check('Settings sidebar owns the persistent save information', settingsSidebarInformation === true); + const settingsSearchPadding = await wc.executeJavaScript('parseFloat(getComputedStyle(document.getElementById("settingsSearchInput")).paddingLeft)'); + check('Settings search text clears the search icon', settingsSearchPadding >= 24); + const settingsSearchIconAlignment = await wc.executeJavaScript('(() => { const icon = document.querySelector(".settings-search-icon"); const style = getComputedStyle(icon); return [style.display, style.alignItems, Boolean(icon?.querySelector("svg"))].join("|"); })()'); + check('Settings search icon aligns to the input text line', settingsSearchIconAlignment === 'flex|center|true'); + const settingsSearchControlGeometry = await wc.executeJavaScript('(() => { const control = document.querySelector(".settings-search-control"); const input = document.getElementById("settingsSearchInput"); const icon = document.querySelector(".settings-search-icon"); const svg = icon?.querySelector("svg"); if (!control || !input || !icon || !svg) return "missing"; const controlRect = control.getBoundingClientRect(); const inputRect = input.getBoundingClientRect(); const iconRect = icon.getBoundingClientRect(); const inputStyle = getComputedStyle(input); const iconStyle = getComputedStyle(icon); return [Math.round(controlRect.height), Math.round(inputRect.height), Math.round(Math.abs((inputRect.top + inputRect.height / 2) - (iconRect.top + iconRect.height / 2))), svg.getAttribute("viewBox"), inputStyle.lineHeight, inputStyle.paddingTop, inputStyle.paddingBottom, iconStyle.display, iconStyle.alignItems].join("|"); })()'); + check('Settings search control keeps icon and text on one shared center line', settingsSearchControlGeometry === '40|40|0|0 0 24 24|18px|0px|0px|flex|center'); + + await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'logs\\']")?.click()'); + await new Promise(resolve => setTimeout(resolve, 100)); + const logPathLayout = await wc.executeJavaScript('(() => { const block = document.getElementById("logPathsBlock")?.getBoundingClientRect(); const rows = [...document.querySelectorAll("#logPathsList > div")]; const visible = rows.length > 0 && rows.every(row => { const rect = row.getBoundingClientRect(); const code = row.querySelector("code")?.getBoundingClientRect(); const button = row.querySelector("button")?.getBoundingClientRect(); return block && rect.right <= block.right + 1 && code && button && code.right <= button.left - 6 && button.right <= block.right + 1; }); return [rows.length, visible].join("|"); })()'); + check('Log file rows keep paths and buttons inside the Diagnose panel', logPathLayout === '4|true'); + + await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'remote\\']")?.click()'); + const remoteSettingsSpacing = await wc.executeJavaScript('(() => { const grid = document.querySelector("[data-subpage=remote] .settings-grid-mini")?.getBoundingClientRect(); const port = document.getElementById("remotePortInput")?.closest(".settings-row")?.getBoundingClientRect(); return grid && port ? Math.round(port.top - grid.bottom) : -1; })()'); + check('Remote settings keep space before Port', remoteSettingsSpacing >= 8); + + await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'diagnose\\']")?.click()'); + const diagnoseSettingsSpacing = await wc.executeJavaScript('(() => { const grid = document.querySelector("[data-subpage=diagnose] .settings-grid-mini")?.getBoundingClientRect(); const port = document.getElementById("diagPortInput")?.closest(".settings-row")?.getBoundingClientRect(); return grid && port ? Math.round(port.top - grid.bottom) : -1; })()'); + check('Diagnose settings keep space before Port', diagnoseSettingsSpacing >= 8); await captureVisual('03-settings.png'); @@ -994,6 +1060,12 @@ setTimeout(async () => { check('History sidebar filters successful and failed rows without dropping source data', historyFilterState.success.rows.join('|') === 'ok.bin' && historyFilterState.success.errors === 0 && historyFilterState.error.rows.join('|') === 'bad.bin|stopped.bin' && historyFilterState.error.errors === 2 && historyFilterState.all.rows.length === 3 && historyFilterState.sourceLength === 3); check('History sidebar exposes exactly one pressed filter', historyFilterState.success.pressed.join('|') === 'success' && historyFilterState.success.active.join('|') === 'success' && historyFilterState.error.pressed.join('|') === 'error' && historyFilterState.error.active.join('|') === 'error' && historyFilterState.all.pressed.join('|') === 'all' && historyFilterState.all.active.join('|') === 'all'); + const historyCopyControls = await wc.executeJavaScript('(() => { const rows = [...document.querySelectorAll("#historyBody .history-row")]; const buttons = rows.map(row => row.querySelector(".history-copy-link")); const inside = buttons.every(button => { const cell = button?.closest(".col-link"); const cellRect = cell?.getBoundingClientRect(); const buttonRect = button?.getBoundingClientRect(); return cellRect && buttonRect && buttonRect.right <= cellRect.right + 1 && buttonRect.left >= cellRect.left; }); return [buttons.length, buttons.every(button => button?.getAttribute("aria-label") === "Link kopieren"), inside].join("|"); })()'); + check('History links expose an in-cell copy action', historyCopyControls === '3|true|true'); + const historyCopyAction = await wc.executeJavaScript('document.querySelector(".history-copy-link")?.click(); document.getElementById("copyToast")?.textContent?.trim()'); + check('History copy action confirms the copied link', historyCopyAction === 'Link kopiert'); + await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")'); + const historyErrorContrast = await wc.executeJavaScript(\`(() => { document.querySelector('[data-history-filter="error"]').click(); const row = document.querySelector('#historyBody .history-row.error');