From 7fed7a7588c7f4dac3dc76e776da8b80e58fa3d6 Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:48:11 +0200 Subject: [PATCH] Harden renderer recovery and UI contracts --- renderer/app.js | 300 ++++++++++++++++++++++++-------------------- renderer/i18n.js | 23 +++- renderer/styles.css | 49 +------- tests/i18n.test.js | 16 ++- tests/ui-smoke.js | 285 +++++++++++++++++++++++++++++++++-------- 5 files changed, 440 insertions(+), 233 deletions(-) diff --git a/renderer/app.js b/renderer/app.js index 8a96d69..3fc6251 100644 --- a/renderer/app.js +++ b/renderer/app.js @@ -16,6 +16,25 @@ function localizeUiText(value) { return window.I18n.translateText(value, uiLocalizer.getLanguage()); } +function getLocalizedErrorDetail(error) { + const raw = String(error?.message || error || '').trim(); + if (raw) { + const translated = localizeUiText(raw); + if (translated !== raw) return translated; + const oppositeLanguage = uiLocalizer.getLanguage() === 'de' ? 'en' : 'de'; + if (window.I18n.translateText(raw, oppositeLanguage) !== raw) return raw; + } + return localizeUiText('Unbekannter Fehler'); +} + +function formatLocalizedError(prefix, error) { + return `${localizeUiText(prefix)}: ${getLocalizedErrorDetail(error)}`; +} + +function getCopiedLinksMessage(count) { + return count === 1 ? '1 Link kopiert' : `${count} Links kopiert`; +} + function formatRemoteClientStatus(port, count) { const clients = count === 1 ? '1 Client' : `${count} Clients`; return localizeUiText(`Aktiv auf Port ${port} — ${clients} verbunden`); @@ -402,7 +421,7 @@ async function init() { try { config = await window.api.getConfig(); } catch (error) { - await showAppAlert(error.message || String(error), 'Zugangsdaten gesperrt'); + await showAppAlert(getLocalizedErrorDetail(error), 'Zugangsdaten gesperrt'); throw error; } setUiLanguage(config.globalSettings?.language); @@ -773,8 +792,8 @@ async function _handleMenuAction(action) { const res = await window.api.createSupportBundle(); if (res && res.ok) showCopyToast(`Diagnose-Paket gespeichert (${(res.bytes / 1024).toFixed(1)} KB)`); else if (res && res.canceled) showCopyToast('Abgebrochen'); - else showCopyToast(`Fehler: ${(res && res.error) || 'unbekannt'}`); - } catch (err) { showCopyToast(`Fehler: ${err.message || err}`); } + else showCopyToast(formatLocalizedError('Fehler', res?.error)); + } catch (err) { showCopyToast(formatLocalizedError('Fehler', err)); } break; } case 'check-updates': { @@ -1252,8 +1271,10 @@ function restoreQueueStateFromConfig() { rebuildJobIndex(); } -function buildPersistedQueueState() { - const persistableJobs = queueJobs.filter(job => !['done', 'skipped'].includes(job.status)); +function buildPersistedQueueState({ historyPersisted = true } = {}) { + const persistableJobs = historyPersisted + ? queueJobs.filter(job => !['done', 'skipped'].includes(job.status)) + : queueJobs; const selectedFileMap = new Map(selectedFiles.map(file => [file.path, file])); for (const job of persistableJobs) { @@ -1266,7 +1287,7 @@ function buildPersistedQueueState() { } } - if (selectedFileMap.size === 0 && queueJobs.every(job => ['done', 'skipped'].includes(job.status))) { + if (historyPersisted && selectedFileMap.size === 0 && queueJobs.every(job => ['done', 'skipped'].includes(job.status))) { return null; } @@ -2221,7 +2242,8 @@ function getAccountLabel(job) { } function getStatusText(job) { - const shortErr = job.error ? String(job.error).replace(/\s+/g, ' ').slice(0, 100) : ''; + const rawError = job.error ? String(job.error).replace(/\s+/g, ' ').slice(0, 100) : ''; + const shortErr = rawError ? getLocalizedErrorDetail(rawError) : ''; const acc = getAccountLabel(job); const accSuffix = acc ? ` · ${acc}` : ''; let text; @@ -2237,7 +2259,7 @@ function getStatusText(job) { } case 'done': text = 'Fertig'; break; case 'aborted': text = shortErr || 'Abgebrochen'; break; - case 'error': text = shortErr ? (/^Fehlgeschlagen(?::|$)/.test(shortErr) ? shortErr : `Fehlgeschlagen: ${shortErr}`) : 'Fehlgeschlagen'; break; + case 'error': text = shortErr ? (/^(?:Fehlgeschlagen|Failed)(?::|$)/.test(shortErr) ? shortErr : `Fehlgeschlagen: ${shortErr}`) : 'Fehlgeschlagen'; break; case 'skipped': text = shortErr ? `Übersprungen: ${shortErr}` : 'Übersprungen'; break; default: text = job.status; } @@ -2464,7 +2486,7 @@ async function exportAllRecentFiles() { ]); if (result && result.ok) showCopyToast(`${rows.length} Einträge exportiert`); } catch (err) { - await showAppAlert('Export fehlgeschlagen: ' + (err.message || err), 'Export fehlgeschlagen'); + await showAppAlert(formatLocalizedError('Export fehlgeschlagen', err), 'Export fehlgeschlagen'); } } @@ -2477,7 +2499,7 @@ function getSelectedRecentLinks() { function copySelectedRecentLinks() { const links = getSelectedRecentLinks(); - if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); } + if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(getCopiedLinksMessage(links.length)); } } // --- Backup export / import --- @@ -2510,7 +2532,7 @@ async function doBackupExport() { showCopyToast('Backup exportiert'); } } catch (err) { - await showAppAlert('Export fehlgeschlagen: ' + (err.message || err), 'Export fehlgeschlagen'); + await showAppAlert(formatLocalizedError('Export fehlgeschlagen', err), 'Export fehlgeschlagen'); } } @@ -2564,7 +2586,7 @@ async function persistImportedQueueState() { } function showImportQueuePersistenceError(error) { - showCopyToast(`Import übernommen. Warteschlange konnte nicht vollständig gespeichert werden: ${error.message || error}`, 8000); + showCopyToast(formatLocalizedError('Import übernommen. Warteschlange konnte nicht vollständig gespeichert werden', error), 8000); } function setOnlineBackupStatus(message, state = '') { @@ -2755,10 +2777,10 @@ async function doBackupImport(legacyPassword) { } if (queuePersistenceError) showImportQueuePersistenceError(queuePersistenceError); } else if (result.error) { - await showAppAlert('Import fehlgeschlagen: ' + result.error, 'Import fehlgeschlagen'); + await showAppAlert(formatLocalizedError('Import fehlgeschlagen', result.error), 'Import fehlgeschlagen'); } } catch (err) { - await showAppAlert('Import fehlgeschlagen: ' + (err.message || err), 'Import fehlgeschlagen'); + await showAppAlert(formatLocalizedError('Import fehlgeschlagen', err), 'Import fehlgeschlagen'); } } @@ -2830,7 +2852,7 @@ async function handleContextAction(action) { startSelectedUpload(); } else if (action === 'copy-links') { const links = getSelectedJobLinks(); - if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(`${links.length} Links kopiert`); } + if (links.length) { window.api.copyToClipboard(links.join('\n')); showCopyToast(getCopiedLinksMessage(links.length)); } } else if (action === 'retry-selected') { retrySelectedJobs(); } else if (action === 'show-log') { @@ -2958,7 +2980,7 @@ async function completeSourceCleanupFinalization(data) { return window.api.completeUploadFinalization({ finalizationId: data.finalizationId, pendingQueue: data.historyPersisted !== true || queueJobs.some((job) => !['done', 'skipped'].includes(job.status)) - ? buildPersistedQueueState() + ? buildPersistedQueueState({ historyPersisted: data.historyPersisted === true }) : null }); }; @@ -3038,7 +3060,7 @@ async function startUpload(opts) { persistQueueStateSoon(); if (result && result.error) { - await showAppAlert(result.error, 'Upload-Start fehlgeschlagen'); + await showAppAlert(getLocalizedErrorDetail(result.error), 'Upload-Start fehlgeschlagen'); uploading = false; updateQueueActionButtons(); updateStatusBar(); @@ -3047,7 +3069,7 @@ async function startUpload(opts) { uploading = false; updateQueueActionButtons(); updateStatusBar(); - await showAppAlert(`Upload-Start fehlgeschlagen: ${err.message}`, 'Upload-Start fehlgeschlagen'); + await showAppAlert(formatLocalizedError('Upload-Start fehlgeschlagen', err), 'Upload-Start fehlgeschlagen'); } } @@ -3088,7 +3110,7 @@ async function startSelectedUpload(explicitJobs) { sourceCleanupGroups: cleanupPreparation.groups }); } catch (err) { - showCopyToast(`Jobs konnten nicht hinzugefügt werden: ${err.message}`); + showCopyToast(formatLocalizedError('Jobs konnten nicht hinzugefügt werden', err)); return; } @@ -3116,7 +3138,7 @@ async function startSelectedUpload(explicitJobs) { if (alreadyInBatch > 0) toastParts.push(`${alreadyInBatch} bereits im Batch`); if (skipped > 0) toastParts.push(`${skipped} ohne gueltigen Account`); if (result && result.error) { - showCopyToast(`Jobs konnten nicht hinzugefügt werden: ${result.error}`); + showCopyToast(formatLocalizedError('Jobs konnten nicht hinzugefügt werden', result.error)); } else if (toastParts.length > 0) { showCopyToast(`Jobs: ${toastParts.join(', ')}`); } else { @@ -3161,7 +3183,7 @@ async function startSelectedUpload(explicitJobs) { persistQueueStateSoon(); if (result && result.error) { - await showAppAlert(result.error, 'Upload-Start fehlgeschlagen'); + await showAppAlert(getLocalizedErrorDetail(result.error), 'Upload-Start fehlgeschlagen'); uploading = false; updateQueueActionButtons(); updateStatusBar(); @@ -3170,7 +3192,7 @@ async function startSelectedUpload(explicitJobs) { uploading = false; updateQueueActionButtons(); updateStatusBar(); - await showAppAlert(`Upload-Start fehlgeschlagen: ${err.message}`, 'Upload-Start fehlgeschlagen'); + await showAppAlert(formatLocalizedError('Upload-Start fehlgeschlagen', err), 'Upload-Start fehlgeschlagen'); } } @@ -3810,16 +3832,20 @@ function maybeAddSessionFile(job) { function applySummaryResults(summary) { const files = Array.isArray(summary?.files) ? summary.files : []; - // Build a (fileName + hoster) → job map once so the per-result lookup is O(1) - // instead of O(|queueJobs|). Big batches (hundreds of files × multiple hosters) - // otherwise become O(n²). - const jobByKey = new Map(); + const jobById = new Map(); + const jobsByLegacyKey = new Map(); for (const j of queueJobs) { - jobByKey.set(`${j.fileName}\u0001${j.hoster}`, j); + jobById.set(j.id, j); + const key = `${j.fileName}\u0001${j.hoster}`; + const candidates = jobsByLegacyKey.get(key); + if (candidates) candidates.push(j); + else jobsByLegacyKey.set(key, [j]); } for (const file of files) { for (const result of file.results || []) { - const job = jobByKey.get(`${file.name}\u0001${result.hoster}`); + const hasJobId = result.jobId !== undefined && result.jobId !== null && result.jobId !== ''; + const candidates = hasJobId ? null : jobsByLegacyKey.get(`${file.name}\u0001${result.hoster}`); + const job = hasJobId ? jobById.get(result.jobId) : (candidates?.length === 1 ? candidates[0] : null); if (!job) continue; if (result.status === 'done') { job.status = 'done'; @@ -4381,7 +4407,7 @@ async function _renderLogPathsList(el) { }); }); } catch (err) { - el.innerHTML = `Fehler: ${escapeHtml(err.message || String(err))}`; + el.innerHTML = `${escapeHtml(formatLocalizedError('Fehler', err))}`; } } @@ -4711,24 +4737,9 @@ function renderSettings() {
- + 127.0.0.1
-
- - -
- - -
+
Bindet nur an 127.0.0.1. Fernzugriff nur über einen Tunnel (z.B. Tailscale/SSH).
@@ -4843,7 +4854,7 @@ function renderSettings() { if (!activeButton || activeButton.hidden || !content.querySelector('.settings-subpage.active')) { activateSettingsPage((activeButton && !activeButton.hidden ? activeButton : visibleButtons[0]).dataset.settingsPage); } else { - _syncSidebarIndicator(activeButton, true); + _syncSidebarIndicator(activeButton); } }); @@ -4864,7 +4875,7 @@ function renderSettings() { ? `Test erfolgreich gesendet (HTTP ${res.status}).` : `Test fehlgeschlagen: ${(res && (res.error || 'HTTP ' + res.status)) || 'unbekannt'}`; } catch (err) { - if (hint) hint.textContent = `Test fehlgeschlagen: ${err.message || err}`; + if (hint) hint.textContent = formatLocalizedError('Test fehlgeschlagen', err); } finally { testWebhookBtn.disabled = false; testWebhookBtn.textContent = prev; @@ -4891,10 +4902,10 @@ function renderSettings() { } else if (res && res.canceled) { if (hint) hint.textContent = 'Abgebrochen.'; } else { - if (hint) hint.textContent = `Fehler: ${(res && res.error) || 'unbekannt'}`; + if (hint) hint.textContent = formatLocalizedError('Fehler', res?.error); } } catch (err) { - if (hint) hint.textContent = `Fehler: ${err.message || err}`; + if (hint) hint.textContent = formatLocalizedError('Fehler', err); } finally { sbBtn.disabled = false; sbBtn.textContent = prevText; @@ -4954,13 +4965,6 @@ function renderSettings() { (function wireDiagnostics() { const enabledEl = document.getElementById('diagEnabledInput'); const portEl = document.getElementById('diagPortInput'); - const modeEl = document.getElementById('diagBindModeInput'); - const publicHostEl = document.getElementById('diagPublicHostInput'); - const allowlistEl = document.getElementById('diagAllowlistInput'); - const allowlistRow = document.getElementById('diagAllowlistRow'); - const suggestRow = document.getElementById('diagSuggestRow'); - const suggestChips = document.getElementById('diagSuggestChips'); - const bindHintEl = document.getElementById('diagBindHint'); const codeEl = document.getElementById('diagCodeInput'); const issuedEl = document.getElementById('diagCodeIssued'); const badgeEl = document.getElementById('diagStatusBadge'); @@ -4971,40 +4975,12 @@ function renderSettings() { if (!ts) return ''; try { return 'Code erstellt: ' + new Date(ts).toLocaleString(getUiLocale()); } catch { return ''; } }; - const parseAllowlist = () => allowlistEl.value.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); - const renderModeUi = (suggestedHosts) => { - const network = modeEl.value === 'network'; - allowlistRow.style.display = network ? '' : 'none'; - bindHintEl.innerHTML = network - ? 'Bindet an 0.0.0.0. Nur IPs/CIDRs aus der Allowlist dürfen verbinden (Loopback immer) — zusätzlich zum Token. Über Tailscale: trage deinen Tailnet-Bereich ein (z.B. 100.64.0.0/10) und die Tailscale-IP/MagicDNS oben als Code-Adresse. Transport ist plaintext über den Tunnel — Tailscale/WireGuard verschlüsselt.' - : 'Bindet nur an 127.0.0.1. Fernzugriff nur über einen Tunnel (z.B. Tailscale/SSH) — die sicherste Variante.'; - const hosts = Array.isArray(suggestedHosts) ? suggestedHosts : []; - if (hosts.length) { - suggestRow.style.display = ''; - suggestChips.innerHTML = ''; - for (const h of hosts) { - const b = document.createElement('button'); - b.className = 'btn btn-xs btn-secondary'; - b.textContent = h; - b.addEventListener('click', () => { diagnosticsEditedFields.add(publicHostEl.id); publicHostEl.value = h; markSettingsDirty(); }); - suggestChips.appendChild(b); - } - } else { - suggestRow.style.display = 'none'; - } - }; - let lastSuggested = []; const applySettings = (s) => { if (!s) return; if (!diagnosticsEditedFields.has(enabledEl.id)) enabledEl.checked = !!s.enabled; if (!diagnosticsEditedFields.has(portEl.id)) portEl.value = s.port || 9110; - if (!diagnosticsEditedFields.has(modeEl.id)) modeEl.value = s.bindMode === 'network' ? 'network' : 'local'; - if (!diagnosticsEditedFields.has(publicHostEl.id)) publicHostEl.value = s.publicHost || ''; - if (!diagnosticsEditedFields.has(allowlistEl.id)) allowlistEl.value = Array.isArray(s.allowlist) ? s.allowlist.join('\n') : ''; - lastSuggested = Array.isArray(s.suggestedHosts) ? s.suggestedHosts : []; codeEl.value = s.code || ''; issuedEl.textContent = fmtIssued(s.codeIssuedAt); - renderModeUi(lastSuggested); if (badgeEl) { badgeEl.textContent = enabledEl.checked ? 'Aktiv' : 'Inaktiv'; badgeEl.className = 'panel-status' + (enabledEl.checked ? ' active' : ''); @@ -5016,8 +4992,7 @@ function renderSettings() { if (!el || !st) return; if (st.running) { const last = st.lastAccess ? new Date(st.lastAccess).toLocaleString(getUiLocale()) : '—'; - const scope = st.bindMode === 'network' ? `Netzwerk (Allowlist: ${st.allowlistCount})` : 'nur lokal'; - el.textContent = `Aktiv auf ${st.bindAddress}:${st.port} (${scope}) — ${st.clientCount} ${st.clientCount === 1 ? 'Client' : 'Clients'} — Letzter Zugriff: ${last}`; + el.textContent = `Aktiv auf 127.0.0.1:${st.port} (nur lokal) — ${st.clientCount} ${st.clientCount === 1 ? 'Client' : 'Clients'} — Letzter Zugriff: ${last}`; el.style.color = '#10b981'; } else { el.textContent = 'Nicht aktiv'; @@ -5025,16 +5000,8 @@ function renderSettings() { } }).catch(() => {}); }; - const validate = () => { - const allowlist = parseAllowlist(); - if (enabledEl.checked && modeEl.value === 'network' && allowlist.length === 0) { - if (bindHintEl) { bindHintEl.innerHTML = 'Netzwerkmodus braucht mindestens eine IP/CIDR in der Allowlist — sonst bleibt es fail-closed auf Loopback.'; } - return false; - } - return true; - }; - [enabledEl, portEl, modeEl, publicHostEl, allowlistEl].forEach(element => { + [enabledEl, portEl].forEach(element => { const markEdited = () => diagnosticsEditedFields.add(element.id); element.addEventListener('input', markEdited); element.addEventListener('change', markEdited); @@ -5042,11 +5009,8 @@ function renderSettings() { window.api.diagnosticsGetSettings().then(applySettings).catch(() => {}); refreshStatus(); - enabledEl.addEventListener('change', () => { if (validate()) markSettingsDirty(); }); - portEl.addEventListener('change', () => { if (validate()) markSettingsDirty(); }); - modeEl.addEventListener('change', () => { renderModeUi(lastSuggested); if (validate()) markSettingsDirty(); }); - publicHostEl.addEventListener('change', () => { if (validate()) markSettingsDirty(); }); - allowlistEl.addEventListener('change', () => { if (validate()) markSettingsDirty(); }); + enabledEl.addEventListener('change', markSettingsDirty); + portEl.addEventListener('change', markSettingsDirty); document.getElementById('diagCopyCodeBtn').addEventListener('click', async () => { if (!codeEl.value) return; await window.api.copyToClipboard(codeEl.value); @@ -5307,9 +5271,9 @@ async function performSaveSettings(options = {}) { saves.push(saveDiagnosticsSettingsTracked({ enabled: diagnosticsEnabled.checked, port: Math.min(65535, Math.max(1024, parseInt(document.getElementById('diagPortInput')?.value, 10) || 9110)), - bindMode: document.getElementById('diagBindModeInput')?.value === 'network' ? 'network' : 'local', - publicHost: document.getElementById('diagPublicHostInput')?.value.trim() || '', - allowlist: (document.getElementById('diagAllowlistInput')?.value || '').split(/\r?\n/).map(value => value.trim()).filter(Boolean) + bindMode: 'local', + publicHost: '127.0.0.1', + allowlist: [] })); } await Promise.all(saves); @@ -5408,7 +5372,11 @@ function _buildAccountCardHtml(name, account, idx) { // disambiguator for accounts that otherwise look identical (e.g. two byse // API-key accounts where you can't tell what's what from the masked key). const subtitleText = (userLabel ? `Label: ${userLabel} • ` : '') + credLabel; - const checkedText = st.checkedAt ? ` • geprüft ${new Date(st.checkedAt).toLocaleTimeString(getUiLocale(), { hour: '2-digit', minute: '2-digit' })}` : ''; + const checkedLabel = uiLocalizer.getLanguage() === 'de' ? 'geprüft' : 'checked'; + const checkedText = st.checkedAt ? ` • ${checkedLabel} ${new Date(st.checkedAt).toLocaleTimeString(getUiLocale(), { hour: '2-digit', minute: '2-digit' })}` : ''; + const statusMessage = st.message && !isDisabled + ? (['error', 'warn', 'otp_required'].includes(st.status) ? getLocalizedErrorDetail(st.message) : localizeUiText(st.message)) + : ''; const toggleLabel = isDisabled ? 'Aktivieren' : 'Deaktivieren'; const priorityLabel = idx === 0 ? 'Primär' : `Fallback #${idx}`; @@ -5428,7 +5396,7 @@ function _buildAccountCardHtml(name, account, idx) {
- + ${otpAction}
@@ -5968,9 +5936,9 @@ async function checkSingleAccount(accountId) { const result = await window.api.runHealthCheck({ hosters: [{ hoster: found.name, accountId }] }); const rows = result && Array.isArray(result.results) ? result.results : []; const row = rows.find(r => r.accountId === accountId); - if (row) nextStatus = { status: row.status || 'error', message: row.message || '' }; + if (row) nextStatus = { status: row.status || 'error', message: row.message || '', checkedAt: row.checkedAt || result.checkedAt || new Date().toISOString() }; } catch (err) { - nextStatus = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen' }; + nextStatus = { status: 'error', message: err.message || 'Prüfung fehlgeschlagen', checkedAt: new Date().toISOString() }; } finally { healthCheckRunning = false; } @@ -6011,10 +5979,10 @@ async function submitAccountOtp(accountId) { ? result.results.find(item => item.accountId === accountId) : null; nextStatus = row - ? { status: row.status || 'error', message: row.message || '' } - : { status: 'error', message: 'Keine Antwort vom Hoster erhalten' }; + ? { status: row.status || 'error', message: row.message || '', checkedAt: row.checkedAt || result.checkedAt || new Date().toISOString() } + : { status: 'error', message: 'Keine Antwort vom Hoster erhalten', checkedAt: result?.checkedAt || new Date().toISOString() }; } catch (err) { - nextStatus = { status: 'error', message: err.message || 'OTP-Prüfung fehlgeschlagen' }; + nextStatus = { status: 'error', message: err.message || 'OTP-Prüfung fehlgeschlagen', checkedAt: new Date().toISOString() }; } finally { healthCheckRunning = false; } @@ -6384,7 +6352,7 @@ function _applyCommittedAccount(persisted, validation) { const { accountId, candidateHosters, isEdit } = persisted; config.hosters = candidateHosters; _invalidateAccountStatusGeneration(accountId); - accountStatuses[accountId] = { status: validation.status, message: validation.message || '' }; + accountStatuses[accountId] = { status: validation.status, message: validation.message || '', checkedAt: validation.checkedAt || new Date().toISOString() }; ensureAccountStatusEntries(); syncSelectedUploadHosters(); if (isEdit) { @@ -6486,20 +6454,53 @@ function syncHistoryClearAction() { syncDataActionState(); } +let historyClearReturnFocus = null; +let historyClearInertState = []; + +function setHistoryClearBackgroundInert(active) { + const modal = document.getElementById('historyClearModal'); + if (!modal) return; + if (active) { + if (historyClearInertState.length > 0) return; + historyClearInertState = Array.from(document.body.children) + .filter(element => element !== modal) + .map(element => ({ element, inert: element.inert })); + historyClearInertState.forEach(({ element }) => { element.inert = true; }); + return; + } + historyClearInertState.forEach(({ element, inert }) => { + if (element.isConnected) element.inert = inert; + }); + historyClearInertState = []; +} + +function getHistoryClearFocusable() { + const modal = document.getElementById('historyClearModal'); + if (!modal) return []; + return Array.from(modal.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')) + .filter(element => !element.hidden && window.getComputedStyle(element).display !== 'none' && window.getComputedStyle(element).visibility !== 'hidden'); +} + function closeHistoryClearModal() { const modal = document.getElementById('historyClearModal'); if (!modal) return; modal.style.display = 'none'; modal.setAttribute('aria-hidden', 'true'); - document.getElementById('clearHistoryBtn')?.focus(); + setHistoryClearBackgroundInert(false); + const returnFocus = historyClearReturnFocus; + historyClearReturnFocus = null; + if (returnFocus?.isConnected && !returnFocus.disabled) returnFocus.focus(); + else document.getElementById('clearHistoryBtn')?.focus(); } function openHistoryClearModal() { const button = document.getElementById('clearHistoryBtn'); const modal = document.getElementById('historyClearModal'); if (!modal || !button || button.disabled) return; + historyClearReturnFocus = button; modal.style.display = 'flex'; modal.setAttribute('aria-hidden', 'false'); + setHistoryClearBackgroundInert(true); document.getElementById('cancelHistoryClearBtn')?.focus(); } @@ -6514,7 +6515,7 @@ async function confirmHistoryClear() { await loadHistory(); closeHistoryClearModal(); } catch (error) { - showCopyToast(error.message || String(error)); + showCopyToast(getLocalizedErrorDetail(error)); } finally { confirmButton.disabled = false; cancelButton.disabled = false; @@ -6535,7 +6536,7 @@ async function loadHistory() { historySidebarCounts = { total: 0, success: 0, error: 0, skipped: 0 }; updateHistorySidebarSummary(); syncHistoryClearAction(); - if (container) container.innerHTML = ``; + if (container) container.innerHTML = ``; container?.querySelector('[data-retry-history]')?.addEventListener('click', loadHistory); return; } @@ -6602,7 +6603,7 @@ async function exportHistory() { if (!result || result.canceled) return; if (!result.ok) { - await showAppAlert(result.error || 'Export fehlgeschlagen.', 'Export fehlgeschlagen'); + await showAppAlert(getLocalizedErrorDetail(result.error), 'Export fehlgeschlagen'); return; } @@ -6998,7 +6999,7 @@ async function recoverWindowClose(generation, attempt, originalError) { try { restored = await waitForClosePreparationStep(window.api.finishClosePreparation({ ready: false, attempt })); } catch (error) { - if (isCurrentClosePreparation(generation, attempt)) showCopyToast(error.message || String(error), 8000); + if (isCurrentClosePreparation(generation, attempt)) showCopyToast(getLocalizedErrorDetail(error), 8000); return; } if (!isCurrentClosePreparation(generation, attempt)) return; @@ -7015,7 +7016,7 @@ async function recoverWindowClose(generation, attempt, originalError) { if (failedConfigWriteOperations.length !== 0) throw new Error('Nicht alle Einstellungen konnten gespeichert werden'); })); } catch (error) { - if (isCurrentClosePreparation(generation, attempt)) showCopyToast(error.message || String(error), 8000); + if (isCurrentClosePreparation(generation, attempt)) showCopyToast(getLocalizedErrorDetail(error), 8000); return; } if (!isCurrentClosePreparation(generation, attempt)) return; @@ -7023,7 +7024,7 @@ async function recoverWindowClose(generation, attempt, originalError) { activeClosePreparationAttempt = null; closePreparationState = 'open'; setClosePreparationUi(false); - showCopyToast(originalError.message || String(originalError), 8000); + showCopyToast(getLocalizedErrorDetail(originalError), 8000); } function prepareForWindowClose(attempt) { @@ -7204,9 +7205,29 @@ function setupListeners() { }); document.addEventListener('keydown', event => { const modal = document.getElementById('historyClearModal'); - if (modal?.style.display !== 'flex' || event.key !== 'Escape') return; - event.preventDefault(); - closeHistoryClearModal(); + if (modal?.style.display !== 'flex') return; + if (event.key === 'Escape') { + event.preventDefault(); + event.stopImmediatePropagation(); + closeHistoryClearModal(); + return; + } + if (event.key !== 'Tab') return; + const focusable = getHistoryClearFocusable(); + if (!focusable.length) { + event.preventDefault(); + modal.querySelector('[role="dialog"]')?.focus(); + return; + } + const first = focusable[0]; + const last = focusable.at(-1); + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } }, true); document.getElementById('exportHistoryBtn').addEventListener('click', exportHistory); document.getElementById('exportSessionReportBtn').addEventListener('click', async () => { @@ -7299,7 +7320,7 @@ function setupListeners() { } catch (error) { historyRetentionSelect.value = prev; syncHistoryRetentionPicker(); - showCopyToast(error.message || String(error)); + showCopyToast(getLocalizedErrorDetail(error)); } }); } @@ -7449,6 +7470,19 @@ function _formatUpdateReleaseNotes(value) { return output.join('\n'); } +function _selectUpdateReleaseNotes(info) { + const language = uiLocalizer.getLanguage(); + const candidates = [info?.releaseNotesLocalized, info?.releaseNotesByLanguage, info?.releaseNotes]; + const localized = candidates.find(value => value && typeof value === 'object' && !Array.isArray(value)); + if (localized) { + if (typeof localized[language] === 'string') return { value: localized[language], language }; + if (typeof localized.en === 'string') return { value: localized.en, language: 'en' }; + const first = Object.entries(localized).find(([, value]) => typeof value === 'string'); + if (first) return { value: first[1], language: first[0] }; + } + return { value: typeof info?.releaseNotes === 'string' ? info.releaseNotes : '', language: 'en' }; +} + function showUpdateBanner(info) { if (!info) return; _knownUpdateInfo = { ...info, available: true }; @@ -7470,8 +7504,10 @@ function showUpdateBanner(info) { message.hidden = false; } if (notes && notesBody) { - const releaseNotes = _formatUpdateReleaseNotes(info.releaseNotes); + const selectedNotes = _selectUpdateReleaseNotes(info); + const releaseNotes = _formatUpdateReleaseNotes(selectedNotes.value); notesBody.textContent = releaseNotes.length > 2400 ? `${releaseNotes.slice(0, 2399)}…` : releaseNotes; + notesBody.lang = selectedNotes.language; notes.hidden = !releaseNotes; } if (installButton) { @@ -7493,39 +7529,34 @@ function handleUpdateProgress(data) { _setUpdateDialogBusy(true); _setUpdateProgress(0, 'Download 0%'); if (message) message.hidden = true; - if (button) button.textContent = 'Download 0%'; } else if (progress.stage === 'downloading') { const percent = Math.max(0, Math.min(100, Math.round(Number(progress.percent) || 0))); _updateInstallBusy = true; _setUpdateDialogBusy(true); _setUpdateProgress(percent, `Download ${percent}%`); if (message) message.hidden = true; - if (button) button.textContent = `Download ${percent}%`; } else if (progress.stage === 'verifying') { _updateInstallBusy = true; _setUpdateDialogBusy(true); _setUpdateProgress(100, 'Prüfen…'); if (message) message.hidden = true; - if (button) button.textContent = 'Prüfen…'; } else if (progress.stage === 'prepared') { _updateInstallBusy = true; _setUpdateDialogBusy(true); _setUpdateProgress(100, 'Neustart…'); if (message) message.hidden = true; - if (button) button.textContent = 'Neustart…'; } else if (progress.stage === 'launching' || progress.stage === 'done') { _updateInstallBusy = true; _setUpdateDialogBusy(true); _setUpdateProgress(100, 'Neustart…'); if (message) message.hidden = true; - if (button) button.textContent = 'Neustart…'; } else if (progress.stage === 'error') { _updateInstallBusy = false; _setUpdateDialogBusy(false); _setUpdateProgress(0, 'Update fehlgeschlagen'); if (message) { message.hidden = false; - message.textContent = `Update fehlgeschlagen: ${String(progress.error || 'Unbekannter Fehler').slice(0, 400)}`; + message.textContent = formatLocalizedError('Update fehlgeschlagen', progress.error); } if (button) { button.disabled = false; @@ -7670,9 +7701,7 @@ async function installKnownUpdate() { _setUpdateDialogBusy(true); _setUpdateProgress(0, 'Download 0%'); const message = document.getElementById('updateMessage'); - const button = document.getElementById('installUpdateBtn'); if (message) message.hidden = true; - if (button) button.textContent = 'Download 0%'; try { await persistQueueStateNow(); const result = await window.api.installUpdate(); @@ -8113,13 +8142,18 @@ window.api.onPrepareClose(prepareForWindowClose); init().then(() => { window.api.signalCloseHandshakeReady(); }).catch((err) => { + const message = err && err.message !== null && err.message !== undefined ? String(err.message) : String(err || 'Unknown error'); + const stack = err && err.stack !== null && err.stack !== undefined ? String(err.stack) : ''; + try { + window.api.signalRendererInitializationFailed({ message, stack }); + } catch {} try { if (window.api && window.api.debugLog) window.api.debugLog(`init failed: ${err && err.stack ? err.stack : err}`); const root = document.getElementById('app') || document.body; if (root) { const banner = document.createElement('div'); banner.style.cssText = 'position:fixed;top:0;left:0;right:0;background:#5a1e1e;color:#fff;padding:8px;z-index:99999;font-family:sans-serif;font-size:13px'; - banner.textContent = 'Initialisierung fehlgeschlagen: ' + (err && err.message ? err.message : err) + ' — bitte Diagnose-Paket exportieren oder Programm neu starten.'; + banner.textContent = `${formatLocalizedError('Initialisierung fehlgeschlagen', err)} ${localizeUiText('— bitte Diagnose-Paket exportieren oder Programm neu starten.')}`; root.appendChild(banner); } } catch {} diff --git a/renderer/i18n.js b/renderer/i18n.js index 4b54b77..bb5c6ac 100644 --- a/renderer/i18n.js +++ b/renderer/i18n.js @@ -24,6 +24,7 @@ ['Maximale Größe (MB)', 'Maximum size (MB)'], ['Bildschirm und Eingabesteuerung bleiben gesperrt.', 'Screen and input control remain locked.'], ['Bindet nur an', 'Binds only to'], + ['. Fernzugriff nur über einen Tunnel (z.B. Tailscale/SSH).', '. Remote access is only available through a tunnel (for example, Tailscale/SSH).'], ['Bindet an', 'Binds to'], ['. Nur IPs/CIDRs aus der Allowlist dürfen verbinden (Loopback immer) — zusätzlich zum Token. Über Tailscale: trage deinen Tailnet-Bereich ein (z.B.', '. Only IPs/CIDRs from the allowlist may connect (loopback is always allowed), in addition to the token. For Tailscale, enter your tailnet range, e.g.'], [') und die Tailscale-IP/MagicDNS oben als Code-Adresse. Transport ist plaintext über den Tunnel — Tailscale/WireGuard verschlüsselt.', ') and enter the Tailscale IP or MagicDNS name above as the code address. Transport is plaintext through the tunnel; Tailscale/WireGuard provides encryption.'], @@ -472,6 +473,9 @@ ['Kein gültiger Account für diesen Hoster', 'No valid account is available for this host'], ['Keine gültigen Zugangsdaten für die gewählten Hoster.', 'No valid credentials are available for the selected hosts.'], ['Unbekannter Fehler', 'Unknown error'], + ['Zugriff verweigert', 'Access denied'], + ['Datei beschädigt', 'File is damaged'], + ['Konfiguration fehlt', 'Configuration is missing'], ['Kein Log-Pfad gefunden', 'No log path was found'], ['Ungültige URL (muss mit http(s):// beginnen)', 'Invalid URL (must start with http(s)://)'], ['Backup-Datei ist zu groß oder ungültig', 'The backup file is too large or invalid'], @@ -549,6 +553,10 @@ ['Backup importiert', 'Backup imported'], ['Import fehlgeschlagen', 'Import failed'], ['Upload-Start fehlgeschlagen', 'Failed to start upload'], + ['Initialisierung fehlgeschlagen', 'Initialization failed'], + ['Import übernommen. Warteschlange konnte nicht vollständig gespeichert werden', 'Import applied. The queue could not be saved completely'], + ['Jobs konnten nicht hinzugefügt werden', 'Jobs could not be added'], + ['Test fehlgeschlagen', 'Test failed'], ['erneut versuchbar', 'retryable'], ['manuell', 'manual'], ['Abgebrochen.', 'Canceled.'], @@ -632,15 +640,19 @@ const core = text.slice(leading.length, text.length - trailing.length); const exact = target === 'en' ? deToEn.get(core) : enToDe.get(core); if (exact) return `${leading}${exact}${trailing}`; + const translateErrorDetail = (detail) => { + const translated = target === 'en' ? deToEn.get(detail) : enToDe.get(detail); + return translated || (target === 'en' ? 'Unknown error' : 'Unbekannter Fehler'); + }; const patterns = target === 'en' ? [ [/^Update v(.+) verfügbar$/, 'Update v$1 available'], [/^Wiederhergestellte Warteschlange startet in (.+) s \((.+) Jobs\)\.$/, 'Restored queue starts in $1 s ($2 jobs).'], [/^Login ok, Upload-Form bereit \(Dateifeld: (.+)\)$/, 'Login successful, upload form ready (file field: $1)'], [/^Klartext-Backup ist kein gültiges JSON: (.+)$/, 'Plain JSON backup is not valid JSON: $1'], - [/^Export fehlgeschlagen: (.+)$/, 'Export failed: $1'], - [/^Import fehlgeschlagen: (.+)$/, 'Import failed: $1'], - [/^Initialisierung fehlgeschlagen: (.+)$/, 'Initialization failed: $1'], + [/^Export fehlgeschlagen: (.+)$/, (_, detail) => `Export failed: ${translateErrorDetail(detail)}`], + [/^Import fehlgeschlagen: (.+)$/, (_, detail) => `Import failed: ${translateErrorDetail(detail)}`], + [/^Initialisierung fehlgeschlagen: (.+)$/, (_, detail) => `Initialization failed: ${translateErrorDetail(detail)}`], [/^Quelldatei-Schutz konnte nicht vorbereitet werden: (.+)$/, 'Source file protection could not be prepared: $1'], [/^Clouddrop: API-Antwort war kein JSON (.+)$/, 'Clouddrop: API response was not JSON $1'], [/^Clouddrop: Datei nicht lesbar: (.+)$/, 'Clouddrop: File cannot be read: $1'], @@ -740,6 +752,11 @@ [/^Update v(.+) verfügbar\. Klicken zum Installieren\.$/, 'Update v$1 available. Click to install.'] ] : [ + [/^Login successful, upload form ready \(file field: (.+)\)$/, 'Login ok, Upload-Form bereit (Dateifeld: $1)'], + [/^Plain JSON backup is not valid JSON: (.+)$/, 'Klartext-Backup ist kein gültiges JSON: $1'], + [/^Export failed: (.+)$/, (_, detail) => `Export fehlgeschlagen: ${translateErrorDetail(detail)}`], + [/^Import failed: (.+)$/, (_, detail) => `Import fehlgeschlagen: ${translateErrorDetail(detail)}`], + [/^Initialization failed: (.+)$/, (_, detail) => `Initialisierung fehlgeschlagen: ${translateErrorDetail(detail)}`], [/^Update v(.+) available$/, 'Update v$1 verfügbar'], [/^Sleep in (\d+)s\.\.\.$/, 'Ruhezustand in $1s...'], [/^Shut down in (\d+)s\.\.\.$/, 'Herunterfahren in $1s...'], diff --git a/renderer/styles.css b/renderer/styles.css index d0dffba..ce36c18 100644 --- a/renderer/styles.css +++ b/renderer/styles.css @@ -498,7 +498,7 @@ body.col-resizing, body.col-resizing * { cursor: col-resize !important; user-sel font-size: 10px; font-weight: 600; } -.status-badge.status-preview { color: var(--text-muted); } +.status-badge.status-preview { color: var(--success); background: rgba(0, 184, 148, 0.15); } .status-badge.status-queued { color: var(--text-muted); background: rgba(255,255,255,0.05); } .status-badge.status-getting-server { color: var(--accent); } .status-badge.status-uploading { color: #5dabf7; background: rgba(93, 171, 247, 0.15); } @@ -1736,40 +1736,6 @@ 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; - } - - .view-sidebar-indicator, - .settings-nav-indicator { - transition-duration: 220ms !important; - } - - .language-picker-indicator { - transition-duration: 240ms !important; - } - - .progress-bar-fill { - transition-duration: 360ms !important; - } - - .menu-opening { - animation-duration: 180ms !important; - } - - .menu-closing { - animation-duration: 160ms !important; - } - - .account-collapse { - transition-duration: 220ms, 160ms, 0s !important; - } - - .account-hoster-group-header .panel-arrow, - .account-hoster-settings-header .panel-arrow { - transition-duration: 180ms !important; - } } .online-backup-panel { @@ -3089,8 +3055,7 @@ input[type="checkbox"] { .settings-nav-button { min-height: 36px; padding: 7px 10px; - border: 1px solid transparent; - border-left: 1px solid transparent; + border: 1px solid var(--border); border-radius: 6px; color: var(--text-muted); font-size: 12px; @@ -4004,13 +3969,9 @@ input[type="checkbox"] { } .upload-speed-sparkline { - width: 64px; - display: flex; - justify-content: flex-end; - } - - .upload-speed-sparkline canvas { - display: none; + width: 118px; + display: grid; + grid-template-columns: minmax(0, 1fr) max-content; } .settings-layout { diff --git a/tests/i18n.test.js b/tests/i18n.test.js index 9722eb9..0a3c7b0 100644 --- a/tests/i18n.test.js +++ b/tests/i18n.test.js @@ -119,6 +119,11 @@ test('rare account, backup, update, and confirmation states translate in both di ['Backup importiert', 'Backup imported'], ['Import fehlgeschlagen', 'Import failed'], ['Upload-Start fehlgeschlagen', 'Failed to start upload'], + ['Initialisierung fehlgeschlagen', 'Initialization failed'], + ['Import übernommen. Warteschlange konnte nicht vollständig gespeichert werden', 'Import applied. The queue could not be saved completely'], + ['Jobs konnten nicht hinzugefügt werden', 'Jobs could not be added'], + ['Test fehlgeschlagen', 'Test failed'], + ['. Fernzugriff nur über einen Tunnel (z.B. Tailscale/SSH).', '. Remote access is only available through a tunnel (for example, Tailscale/SSH).'], ['erneut versuchbar', 'retryable'], ['manuell', 'manual'], ['Abgebrochen.', 'Canceled.'], @@ -144,12 +149,15 @@ test('interpolated rare errors translate without leaking German copy', () => { const cases = [ ['Login ok, Upload-Form bereit (Dateifeld: file)', 'Login successful, upload form ready (file field: file)'], ['Klartext-Backup ist kein gültiges JSON: Unexpected token', 'Plain JSON backup is not valid JSON: Unexpected token'], - ['Export fehlgeschlagen: Zugriff verweigert', 'Export failed: Zugriff verweigert'], - ['Import fehlgeschlagen: Datei beschädigt', 'Import failed: Datei beschädigt'], - ['Initialisierung fehlgeschlagen: Konfiguration fehlt', 'Initialization failed: Konfiguration fehlt'] + ['Export fehlgeschlagen: Zugriff verweigert', 'Export failed: Access denied'], + ['Import fehlgeschlagen: Datei beschädigt', 'Import failed: File is damaged'], + ['Initialisierung fehlgeschlagen: Konfiguration fehlt', 'Initialization failed: Configuration is missing'] ]; - for (const [german, english] of cases) assert.equal(translateText(german, 'en'), english, german); + for (const [german, english] of cases) { + assert.equal(translateText(german, 'en'), english, german); + assert.equal(translateText(english, 'de'), german, english); + } }); test('main-process user-facing copy contains no mojibake', () => { diff --git a/tests/ui-smoke.js b/tests/ui-smoke.js index 2c58785..725341c 100644 --- a/tests/ui-smoke.js +++ b/tests/ui-smoke.js @@ -26,6 +26,9 @@ if (visualScreenshotDir) fs.mkdirSync(visualScreenshotDir, { recursive: true }); // Create a temp script that the real Electron app will execute via --eval const testScript = ` const { app, BrowserWindow, ipcMain, dialog } = require('electron'); +const isolatedUserDataPath = app.getPath('userData'); +const setAppPath = app.setPath.bind(app); +app.setPath = (name, value) => setAppPath(name, name === 'userData' ? isolatedUserDataPath : value); app.setVersion(${JSON.stringify(productVersion)}); const fs = require('fs'); const net = require('net'); @@ -61,9 +64,19 @@ const initialIpcHandlers = new Map(); const registerIpcHandler = ipcMain.handle.bind(ipcMain); let initialConfigReadDelayed = false; let startupLanguagePendingSnapshot = null; +let failNextConfigRead = false; +let rendererInitializationFailureSignal = null; +const captureRendererInitializationFailure = (_event, details) => { + rendererInitializationFailureSignal = details; +}; +ipcMain.on('app:renderer-initialization-failed', captureRendererInitializationFailure); ipcMain.handle = (channel, listener) => { const registeredListener = channel === 'get-config' ? async (...args) => { + if (failNextConfigRead) { + failNextConfigRead = false; + throw new Error('Injected renderer initialization failure'); + } const result = await listener(...args); if (!initialConfigReadDelayed) { initialConfigReadDelayed = true; @@ -147,7 +160,22 @@ setTimeout(async () => { const windows = BrowserWindow.getAllWindows(); if (windows.length === 0) { console.log('ERROR: No windows found'); process.exit(1); } const win = windows[0]; + if (win.isFullScreen()) win.setFullScreen(false); + if (win.isMaximized()) win.unmaximize(); const wc = win.webContents; + if (!wc.debugger.isAttached()) wc.debugger.attach('1.3'); + await wc.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }] }); + const setWindowBounds = async bounds => { + if (win.isMaximized()) win.unmaximize(); + win.setBounds(bounds); + await waitUntil(() => { + const current = win.getBounds(); + return current.x === bounds.x && current.y === bounds.y && current.width === bounds.width && current.height === bounds.height; + }, 1500); + await new Promise(resolve => setTimeout(resolve, 100)); + }; + const startupBounds = win.getBounds(); + await setWindowBounds({ ...startupBounds, width: 1100, height: 750 }); const originalBounds = win.getBounds(); const visualScreenshotDir = ${JSON.stringify(visualScreenshotDir)}; const rendererDiagnostics = []; @@ -193,7 +221,7 @@ setTimeout(async () => { check('Returning German profiles never expose an English frame while startup config is pending', startupLanguagePendingSnapshot !== null && (!startupLanguagePendingSnapshot.visible || startupLanguagePendingSnapshot.language === 'de') && startupLanguagePendingSnapshot.query === '?language=de' && germanStartupReady === 'de|de|Upload,Accounts,Einstellungen,Verlauf'); await wc.executeJavaScript('(async () => { config.globalSettings = { ...(config.globalSettings || {}), language: "en" }; await window.api.saveGlobalSettings(config.globalSettings); setUiLanguage("en"); renderSettings(); })()'); const languageReady = await waitUntil(() => wc.executeJavaScript('Boolean(document.getElementById("languageInput"))')); - check('Fresh profiles render in English by default', languageReady === true && await wc.executeJavaScript('document.documentElement.lang + "|" + document.getElementById("languageInput")?.value + "|" + [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(",")') === 'en|en|Upload,Accounts,Settings,History'); + check('Runtime language switching renders the complete English interface', 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()'); const languagePickerContract = await wc.executeJavaScript('(() => { const picker = document.getElementById("languagePicker"); const select = document.getElementById("languageInput"); const indicator = picker?.querySelector(".language-picker-indicator"); const buttons = [...(picker?.querySelectorAll(".language-option") || [])]; return [select?.hidden, buttons.length, buttons.map(button => button.dataset.language).join(","), buttons.map(button => button.getAttribute("aria-pressed")).join(","), Boolean(buttons[0]?.querySelector(".language-flag-en") && buttons[1]?.querySelector(".language-flag-de")), indicator ? parseFloat(getComputedStyle(indicator).transitionDuration) > 0 : false].join("|"); })()'); check('Language uses a two-option animated flag picker instead of a visible dropdown', languagePickerContract === 'true|2|en,de|true,false|true|true'); @@ -330,11 +358,10 @@ setTimeout(async () => { const menuWindowBounds = win.getBounds(); const submenuReachability = {}; for (const [label, width, height] of [['standard', 1100, 750], ['minimum', 800, 550]]) { - win.setSize(width, height); - await new Promise(resolve => setTimeout(resolve, 80)); + await setWindowBounds({ ...win.getBounds(), width, height }); submenuReachability[label] = await wc.executeJavaScript('(() => { const parent = document.querySelector("[data-menu-dropdown=datei]"); const target = document.querySelector(".menu-submenu-dropdown [data-menu-action=backup-export]"); if (!parent || !target) return "missing"; const rect = target.getBoundingClientRect(); const hit = document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2); return [getComputedStyle(parent).clipPath, hit === target || target.contains(hit)].join("|"); })()'); } - win.setBounds(menuWindowBounds); + await setWindowBounds(menuWindowBounds); check('Backup submenu is painted and reachable at the standard window size', submenuReachability.standard === 'none|true'); check('Backup submenu is painted and reachable at the minimum window size', submenuReachability.minimum === 'none|true'); await wc.executeJavaScript('document.body.dispatchEvent(new MouseEvent("mousedown", { bubbles: true }))'); @@ -355,8 +382,9 @@ setTimeout(async () => { fs.writeFileSync(desktopDropFixture, Buffer.from('desktop drop fixture')); const desktopDropPoint = await wc.executeJavaScript('(() => { const rect = document.querySelector(".upload-workspace")?.getBoundingClientRect(); return rect ? { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + Math.min(rect.height / 2, 180)) } : null; })()'); let desktopDropState = null; + const desktopDropDebuggerWasAttached = wc.debugger.isAttached(); try { - wc.debugger.attach('1.3'); + if (!desktopDropDebuggerWasAttached) wc.debugger.attach('1.3'); const dragData = { items: [{ mimeType: 'text/uri-list', data: 'file:///' + desktopDropFixture.replace(/\\\\/g, '/') }], files: [desktopDropFixture], @@ -369,7 +397,7 @@ setTimeout(async () => { desktopDropState = await wc.executeJavaScript('(() => ({ modal: document.getElementById("hosterModal")?.style.display, paths: _pendingFiles.map(file => file.path) }))()'); await wc.executeJavaScript('cancelHosterModal()'); } finally { - if (wc.debugger.isAttached()) wc.debugger.detach(); + if (!desktopDropDebuggerWasAttached && wc.debugger.isAttached()) wc.debugger.detach(); try { fs.unlinkSync(desktopDropFixture); } catch {} } check('Desktop file drop reaches the upload selection with its native path', desktopDropState?.modal === 'flex' && desktopDropState.paths.length === 1 && desktopDropState.paths[0] === desktopDropFixture); @@ -397,8 +425,9 @@ setTimeout(async () => { await wc.executeJavaScript('(() => { selectedFiles = [{ path: "C:/ui/existing.bin", name: "existing.bin", size: 16 }]; queueJobs = [{ id: "ui-existing-drop-row", file: "C:/ui/existing.bin", fileName: "existing.bin", hoster: "doodstream.com", status: "preview", bytesUploaded: 0, bytesTotal: 16, speedKbs: 0, elapsed: 0, remaining: 0, progress: 0 }]; rebuildJobIndex(); updateUploadView(); renderQueueTable(); })()'); const populatedDropPoint = await wc.executeJavaScript('(() => { const rect = document.getElementById("queueShell")?.getBoundingClientRect(); return rect ? { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + Math.min(140, rect.height / 3)) } : null; })()'); let populatedDropState = null; + const populatedDropDebuggerWasAttached = wc.debugger.isAttached(); try { - wc.debugger.attach('1.3'); + if (!populatedDropDebuggerWasAttached) wc.debugger.attach('1.3'); const dragData = { items: [{ mimeType: 'text/uri-list', data: 'file:///' + populatedDropFixture.replace(/\\\\/g, '/') }], files: [populatedDropFixture], @@ -411,7 +440,7 @@ setTimeout(async () => { populatedDropState = await wc.executeJavaScript('(() => ({ modal: document.getElementById("hosterModal")?.style.display, paths: _pendingFiles.map(file => file.path) }))()'); await wc.executeJavaScript('cancelHosterModal(); selectedFiles = []; queueJobs = []; rebuildJobIndex(); _queueStatsCache = null; updateUploadView(); renderQueueTable(); updateStatusBar();'); } finally { - if (wc.debugger.isAttached()) wc.debugger.detach(); + if (!populatedDropDebuggerWasAttached && wc.debugger.isAttached()) wc.debugger.detach(); try { fs.unlinkSync(populatedDropFixture); } catch {} } check('Desktop file drop still reaches upload selection while the queue is populated', populatedDropState?.modal === 'flex' && populatedDropState.paths.length === 1 && populatedDropState.paths[0] === populatedDropFixture); @@ -421,8 +450,9 @@ setTimeout(async () => { await wc.executeJavaScript('(() => { selectedFiles = [{ path: ' + JSON.stringify(duplicateDropFixture) + ', name: "mhu-duplicate-drop.mkv", size: 28 }]; queueJobs = [{ id: "ui-duplicate-drop-row", file: ' + JSON.stringify(duplicateDropFixture) + ', fileName: "mhu-duplicate-drop.mkv", hoster: "doodstream.com", status: "done", bytesUploaded: 28, bytesTotal: 28, speedKbs: 0, elapsed: 1, remaining: 0, progress: 100 }]; rebuildJobIndex(); updateUploadView(); renderQueueTable(); const toast = document.getElementById("copyToast"); toast.textContent = ""; toast.classList.remove("show"); })()'); const duplicateDropPoint = await wc.executeJavaScript('(() => { const rect = document.getElementById("queueShell")?.getBoundingClientRect(); return rect ? { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + Math.min(140, rect.height / 3)) } : null; })()'); let duplicateDropState = null; + const duplicateDropDebuggerWasAttached = wc.debugger.isAttached(); try { - wc.debugger.attach('1.3'); + if (!duplicateDropDebuggerWasAttached) wc.debugger.attach('1.3'); const dragData = { items: [{ mimeType: 'text/uri-list', data: 'file:///' + duplicateDropFixture.replace(/\\\\/g, '/') }], files: [duplicateDropFixture], @@ -435,7 +465,7 @@ setTimeout(async () => { duplicateDropState = await wc.executeJavaScript('(() => ({ modal: document.getElementById("hosterModal")?.style.display, pending: _pendingFiles.length, toast: document.getElementById("copyToast")?.textContent, shown: document.getElementById("copyToast")?.classList.contains("show") }))()'); await wc.executeJavaScript('selectedFiles = []; queueJobs = []; rebuildJobIndex(); _queueStatsCache = null; updateUploadView(); renderQueueTable(); updateStatusBar();'); } finally { - if (wc.debugger.isAttached()) wc.debugger.detach(); + if (!duplicateDropDebuggerWasAttached && wc.debugger.isAttached()) wc.debugger.detach(); try { fs.unlinkSync(duplicateDropFixture); } catch {} } check('Dropping a file already in the upload jobs explains the duplicate instead of doing nothing', duplicateDropState?.modal === 'none' && duplicateDropState.pending === 0 && duplicateDropState.shown === true && duplicateDropState.toast === 'Auswahl ist bereits in den Upload-Aufträgen.'); @@ -496,6 +526,8 @@ setTimeout(async () => { return result; })()\`); check('Adding preview files immediately refreshes upload counts before the batch starts', previewQueueCounts.queue === 'done|preview|preview|preview' && previewQueueCounts.sidebar === '4|0|3|1|0' && previewQueueCounts.telemetry === '4|3|0'); + const readyStatusColors = await wc.executeJavaScript('(() => { const preview = document.createElement("span"); const done = document.createElement("span"); preview.className = "status-badge status-preview"; done.className = "status-badge status-done"; preview.textContent = "Bereit"; done.textContent = "Fertig"; document.body.append(preview, done); const previewStyle = getComputedStyle(preview); const doneStyle = getComputedStyle(done); const result = { previewColor: previewStyle.color, previewBackground: previewStyle.backgroundColor, doneColor: doneStyle.color, doneBackground: doneStyle.backgroundColor }; preview.remove(); done.remove(); return result; })()'); + check('Ready uses the same semantic green treatment as completed uploads', readyStatusColors.previewColor === readyStatusColors.doneColor && readyStatusColors.previewBackground === readyStatusColors.doneBackground && readyStatusColors.previewBackground !== 'rgba(0, 0, 0, 0)'); const sidebarBadgeStyle = await wc.executeJavaScript(\`(() => { const badge = document.getElementById('uploadSidebarAllCount'); @@ -933,8 +965,7 @@ setTimeout(async () => { const fallbackAccountFocus = await wc.executeJavaScript('(() => { const trigger = document.querySelector("[data-account-empty-add]") || document.getElementById("addAccountBtn"); trigger.focus(); trigger.click(); document.querySelector("[data-account-empty-add]")?.remove(); document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); return document.activeElement?.id; })()'); check('Account modal restores stable focus after list rerender', fallbackAccountFocus === 'addAccountBtn'); - win.setSize(1280, 720); - await new Promise(resolve => setTimeout(resolve, 80)); + await setWindowBounds({ ...win.getBounds(), width: 1280, height: 720 }); const emptyAccountsGeometry = await wc.executeJavaScript(\`(() => { HOSTERS.forEach(name => { config.hosters[name] = []; }); @@ -1079,7 +1110,7 @@ setTimeout(async () => { check('Filtered account state hides unmatched groups without clipping the visible group', filteredAccountsGeometry.visibleGroupCount === 1 && filteredAccountsGeometry.hiddenGroupCount === 3 && filteredAccountsGeometry.groupsContained); await captureVisual('02-accounts-filtered-1280x720.png'); await wc.executeJavaScript('document.querySelector("[data-accounts-sidebar-filter=all]")?.click()'); - win.setBounds(originalBounds); + await setWindowBounds(originalBounds); const mixedGroupStatus = await wc.executeJavaScript(\`(() => { config.hosters['byse.sx'] = [ @@ -1163,9 +1194,31 @@ setTimeout(async () => { 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); + ipcMain.handle('run-health-check', (_event, payload) => { + const accountId = payload.hosters?.[0]?.accountId; + const failed = accountId === 'ui-filter-error'; + return { + checkedAt: failed ? '2030-02-03T04:05:06.000Z' : '2030-01-02T03:04:05.000Z', + results: (payload.hosters || []).map(item => ({ accountId: item.accountId, status: failed ? 'error' : 'ok', message: failed ? 'Check failed' : 'Ready' })) + }; + }); + const completedAccountCheckState = await wc.executeJavaScript(\`(async () => { + await checkSingleAccount('ui-filter-ready'); + const ready = accountStatuses['ui-filter-ready']; + const readyGerman = document.querySelector('[data-account-id="ui-filter-ready"] .account-card-subtitle')?.textContent || ''; + await checkSingleAccount('ui-filter-error'); + const failed = accountStatuses['ui-filter-error']; + const failedGerman = document.querySelector('[data-account-id="ui-filter-error"] .account-card-subtitle')?.textContent || ''; + setUiLanguage('en'); + renderAccounts(); + const readyEnglish = document.querySelector('[data-account-id="ui-filter-ready"] .account-card-subtitle')?.textContent || ''; + const failedEnglish = document.querySelector('[data-account-id="ui-filter-error"] .account-card-subtitle')?.textContent || ''; + setUiLanguage('de'); + renderAccounts(); + return { ready, failed, readyGerman, failedGerman, readyEnglish, failedEnglish, generations: accountStatusGenerations.size }; + })()\`); + check('Single-account checks retain the main-process checkedAt timestamp for success and failure', completedAccountCheckState.ready?.status === 'ok' && completedAccountCheckState.ready?.checkedAt === '2030-01-02T03:04:05.000Z' && completedAccountCheckState.failed?.status === 'error' && completedAccountCheckState.failed?.checkedAt === '2030-02-03T04:05:06.000Z' && completedAccountCheckState.generations === 0); + check('Single-account check timestamps use the active interface language', /geprüft \\d{2}:\\d{2}/.test(completedAccountCheckState.readyGerman) && /geprüft \\d{2}:\\d{2}/.test(completedAccountCheckState.failedGerman) && /checked \\d{2}:\\d{2}/.test(completedAccountCheckState.readyEnglish) && /checked \\d{2}:\\d{2}/.test(completedAccountCheckState.failedEnglish)); restoreInitialIpcHandler('run-health-check'); let resolveStaleAccountCheck = null; @@ -1185,17 +1238,17 @@ setTimeout(async () => { candidateHosters['byse.sx'][0].apiKey = 'new-key'; _applyCommittedAccount( { accountId: 'ui-stale-account-check', candidateHosters, isEdit: true }, - { status: 'ok', message: 'New credentials ready' } + { status: 'ok', message: 'New credentials ready', checkedAt: '2032-01-02T03:04:05.000Z' } ); })()\`); - resolveStaleAccountCheck({ results: [{ accountId: 'ui-stale-account-check', status: 'error', message: 'Old credential check failed' }] }); + resolveStaleAccountCheck({ checkedAt: '2031-01-02T03:04:05.000Z', 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() }; + return { status: status?.status, message: status?.message, checkedAt: status?.checkedAt, 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'); + check('A late account check cannot overwrite newly committed credentials or its newer timestamp', staleAccountCheckState.status === 'ok' && staleAccountCheckState.message === 'New credentials ready' && staleAccountCheckState.checkedAt === '2032-01-02T03:04:05.000Z' && staleAccountCheckState.card === 'Bereit'); restoreInitialIpcHandler('run-health-check'); let resolveImportedAccountCheck = null; @@ -1265,6 +1318,8 @@ setTimeout(async () => { const settingsIndicatorContract = await wc.executeJavaScript('(() => { const indicator = document.querySelector(".settings-navigation > .settings-nav-indicator"); const active = document.querySelector(".settings-nav-button.active"); if (!indicator || !active) return "missing"; const indicatorStyle = getComputedStyle(indicator); const activeStyle = getComputedStyle(active); const indicatorRect = indicator.getBoundingClientRect(); const activeRect = active.getBoundingClientRect(); return [activeStyle.backgroundColor === "rgba(0, 0, 0, 0)", indicatorStyle.borderTopWidth === "1px", parseFloat(indicatorStyle.transitionDuration) >= .15, Math.abs(indicatorRect.top - activeRect.top) <= 1, Math.abs(indicatorRect.height - activeRect.height) <= 1].join("|"); })()'); check('Settings navigation moves its active surface onto one sliding indicator', settingsIndicatorContract === 'true|true|true|true|true'); + const settingsNavigationBorders = await wc.executeJavaScript('(() => { const buttons = [...document.querySelectorAll(".settings-nav-button:not([hidden])")]; const active = buttons.find(button => button.classList.contains("active")); const inactive = buttons.filter(button => button !== active); const visibleBorder = button => { const style = getComputedStyle(button); return style.borderTopWidth === "1px" && style.borderTopStyle === "solid" && style.borderTopColor !== "rgba(0, 0, 0, 0)"; }; return { inactive: inactive.length > 0 && inactive.every(visibleBorder), activeTransparent: active ? getComputedStyle(active).borderTopColor === "rgba(0, 0, 0, 0)" : false }; })()'); + check('Settings navigation gives every inactive destination a visible individual frame', settingsNavigationBorders.inactive && settingsNavigationBorders.activeTransparent); await wc.executeJavaScript('window.__uiSettingsIndicatorStart = document.querySelector(".settings-nav-indicator")?.getBoundingClientRect().top; document.querySelector("[data-settings-page=backup]")?.click()'); await new Promise(resolve => setTimeout(resolve, 90)); @@ -1279,6 +1334,19 @@ setTimeout(async () => { check('Settings indicator remains visibly in motion while gliding up', settingsIndicatorMovingUp === true); await new Promise(resolve => setTimeout(resolve, 170)); + await wc.executeJavaScript('document.querySelector("[data-settings-page=backup]")?.click()'); + await new Promise(resolve => setTimeout(resolve, 240)); + await wc.executeJavaScript('window.__uiSettingsSearchStart = document.querySelector(".settings-nav-indicator")?.getBoundingClientRect().top; (() => { const input = document.getElementById("settingsSearchInput"); input.value = "backup"; input.dispatchEvent(new Event("input", { bubbles: true })); })()'); + await new Promise(resolve => setTimeout(resolve, 90)); + const settingsSearchMovingUp = await wc.executeJavaScript('(() => { const indicator = document.querySelector(".settings-nav-indicator"); const target = document.querySelector("[data-settings-page=backup]"); const start = window.__uiSettingsSearchStart; if (!indicator || !target || !Number.isFinite(start)) return false; const current = indicator.getBoundingClientRect().top; const targetTop = target.getBoundingClientRect().top; return current < start - 2 && current > targetTop + 2; })()'); + check('Settings indicator remains visibly in motion when search reflows the active destination upward', settingsSearchMovingUp === true); + await new Promise(resolve => setTimeout(resolve, 170)); + await wc.executeJavaScript('window.__uiSettingsFilteredTop = document.querySelector(".settings-nav-indicator")?.getBoundingClientRect().top; (() => { const input = document.getElementById("settingsSearchInput"); input.value = ""; input.dispatchEvent(new Event("input", { bubbles: true })); })()'); + await new Promise(resolve => setTimeout(resolve, 90)); + const settingsSearchMovingDown = await wc.executeJavaScript('(() => { const indicator = document.querySelector(".settings-nav-indicator"); const target = document.querySelector("[data-settings-page=backup]"); const start = window.__uiSettingsFilteredTop; if (!indicator || !target || !Number.isFinite(start)) return false; const current = indicator.getBoundingClientRect().top; const targetTop = target.getBoundingClientRect().top; return current > start + 2 && current < targetTop - 2; })()'); + check('Settings indicator remains visibly in motion when clearing search restores its position', settingsSearchMovingDown === true); + await new Promise(resolve => setTimeout(resolve, 170)); + 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'); @@ -1314,6 +1382,8 @@ setTimeout(async () => { await wc.executeJavaScript('document.querySelector("[data-settings-page=\\'diagnose\\']")?.click()'); const diagnoseSettingsSpacing = await wc.executeJavaScript('(() => { const grid = document.querySelector("[data-subpage=diagnose] .settings-grid-mini")?.getBoundingClientRect(); const port = document.getElementById("diagPortInput")?.closest(".settings-row")?.getBoundingClientRect(); return grid && port ? Math.round(port.top - grid.bottom) : -1; })()'); check('Diagnose settings keep space before Port', diagnoseSettingsSpacing >= 8); + const diagnosticsLoopbackContract = await wc.executeJavaScript('(() => { const page = document.querySelector("[data-subpage=diagnose]"); const text = page?.innerText || ""; return { hasMode: Boolean(document.getElementById("diagBindModeInput")), hasAllowlist: Boolean(document.getElementById("diagAllowlistInput")), address: document.getElementById("diagBindAddress")?.textContent?.trim(), hasTunnel: /Tunnel/.test(text), hasNetworkChoice: /0\.0\.0\.0|Im Netzwerk|Allowlist/.test(text) }; })()'); + check('Diagnostics exposes only fixed loopback access through a tunnel', diagnosticsLoopbackContract.hasMode === false && diagnosticsLoopbackContract.hasAllowlist === false && diagnosticsLoopbackContract.address === '127.0.0.1' && diagnosticsLoopbackContract.hasTunnel && diagnosticsLoopbackContract.hasNetworkChoice === false); let resolveStaleDiagnosticsSettings = null; ipcMain.removeHandler('diagnostics:get-settings'); @@ -1323,14 +1393,14 @@ setTimeout(async () => { 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'); + const staleDiagnosticsState = await wc.executeJavaScript('(() => ({ port: document.getElementById("diagPortInput")?.value, enabled: document.getElementById("diagEnabledInput")?.checked, bindAddress: document.getElementById("diagBindAddress")?.textContent?.trim(), hasPublicHostControl: Boolean(document.getElementById("diagPublicHostInput")), hasModeControl: Boolean(document.getElementById("diagBindModeInput")), hasAllowlistControl: Boolean(document.getElementById("diagAllowlistInput")) }))()'); + check('A late legacy network response cannot restore non-loopback diagnostics controls', staleDiagnosticsState.port === '9222' && staleDiagnosticsState.enabled === true && staleDiagnosticsState.bindAddress === '127.0.0.1' && staleDiagnosticsState.hasPublicHostControl === false && staleDiagnosticsState.hasModeControl === false && staleDiagnosticsState.hasAllowlistControl === false); restoreInitialIpcHandler('diagnostics:get-settings'); await wc.executeJavaScript('renderSettings(); document.querySelector("[data-settings-page=diagnose]")?.click()'); await new Promise(resolve => setTimeout(resolve, 80)); - const diagnosticsDirtyTracking = await wc.executeJavaScript('(async () => { const original = await window.api.diagnosticsGetSettings(); const input = document.getElementById("diagPublicHostInput"); establishSettingsBaseline(); input.value = "ui-diagnostics-save.invalid"; input.dispatchEvent(new Event("input", { bubbles: true })); const button = document.getElementById("saveSettingsBtn"); const enabled = button.disabled === false && button.classList.contains("btn-success"); if (enabled) await saveSettings({ feedbackText: "Gespeichert" }); const persisted = await window.api.diagnosticsGetSettings(); await saveDiagnosticsSettingsTracked(original); input.value = original.publicHost || ""; establishSettingsBaseline(); return { enabled, persisted: persisted.publicHost }; })()'); - check('Diagnostics changes enable Save and persist with the full settings form', diagnosticsDirtyTracking.enabled === true && diagnosticsDirtyTracking.persisted === 'ui-diagnostics-save.invalid'); + const diagnosticsDirtyTracking = await wc.executeJavaScript('(async () => { const original = await window.api.diagnosticsGetSettings(); const input = document.getElementById("diagPortInput"); establishSettingsBaseline(); input.value = String(original.port === 9223 ? 9224 : 9223); input.dispatchEvent(new Event("input", { bubbles: true })); const expectedPort = Number(input.value); 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, bindMode: "local", publicHost: "127.0.0.1", allowlist: [] }); input.value = String(original.port || 9110); establishSettingsBaseline(); return { enabled, expectedPort, persisted }; })()'); + check('Diagnostics changes persist a loopback-only contract with the full settings form', diagnosticsDirtyTracking.enabled === true && diagnosticsDirtyTracking.persisted.port === diagnosticsDirtyTracking.expectedPort && diagnosticsDirtyTracking.persisted.publicHost === '127.0.0.1' && diagnosticsDirtyTracking.persisted.bindMode === 'local' && Array.isArray(diagnosticsDirtyTracking.persisted.allowlist) && diagnosticsDirtyTracking.persisted.allowlist.length === 0); await captureVisual('03-settings.png'); @@ -1872,6 +1942,11 @@ setTimeout(async () => { const sourceCleanupPromotedJobs = sourceCleanupFinalizationPayload?.pendingQueue?.queueJobs || []; const sourceCleanupRollbackOk = sourceCleanupRollback.available === true && sourceCleanupRollback.result === false && sourceCleanupBeforeJobs.length === 2 && sourceCleanupBeforeJobs.every(job => !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupProvisionalHosters') && !Object.prototype.hasOwnProperty.call(job, 'sourceCleanupCompletedHosters') && (job.sourceCleanupConfirmedHosters || []).length === 0) && sourceCleanupPromotedJobs.length === 2 && sourceCleanupPromotedJobs.every(job => (job.sourceCleanupConfirmedHosters || []).join('|') === 'voe.sx') && sourceCleanupAfterJobs.length === 2 && sourceCleanupAfterJobs.every(job => (job.sourceCleanupConfirmedHosters || []).length === 0); check('Final queue persistence promotes only inside the handshake and rolls back failed saves', sourceCleanupRollbackOk); + const terminalRecoveryState = await wc.executeJavaScript('(() => { selectedFiles = []; queueJobs = [{ id: "ui-terminal-done", file: "C:/ui/terminal-done.bin", fileName: "terminal-done.bin", hoster: "voe.sx", status: "done", bytesTotal: 41, error: null, result: { download_url: "https://example.invalid/terminal-done", embed_url: "https://example.invalid/embed-terminal-done", file_code: "terminal-code" } }, { id: "ui-terminal-skipped", file: "C:/ui/terminal-skipped.bin", fileName: "terminal-skipped.bin", hoster: "byse.sx", status: "skipped", bytesTotal: 42, error: "Size limit", result: null }]; rebuildJobIndex(); return { normal: buildPersistedQueueState(), recovery: buildPersistedQueueState({ historyPersisted: false }) }; })()'); + const terminalRecoveryJobs = terminalRecoveryState.recovery?.queueJobs || []; + check('History failure keeps terminal queue results and links restart-recoverable', terminalRecoveryState.normal === null && terminalRecoveryState.recovery !== null && terminalRecoveryState.recovery.selectedFiles.length === 2 && terminalRecoveryJobs.length === 2 && terminalRecoveryJobs[0].id === 'ui-terminal-done' && terminalRecoveryJobs[0].status === 'done' && terminalRecoveryJobs[0].result?.download_url === 'https://example.invalid/terminal-done' && terminalRecoveryJobs[0].result?.embed_url === 'https://example.invalid/embed-terminal-done' && terminalRecoveryJobs[0].result?.file_code === 'terminal-code' && terminalRecoveryJobs[1].status === 'skipped' && terminalRecoveryJobs[1].error === 'Size limit'); + const finalSummaryCorrelation = await wc.executeJavaScript('(() => { queueJobs = [{ id: "summary-exact-a", file: "C:/ui/shared-a.bin", fileName: "shared.bin", hoster: "voe.sx", status: "preview", bytesTotal: 10 }, { id: "summary-exact-b", file: "C:/ui/shared-b.bin", fileName: "shared.bin", hoster: "voe.sx", status: "preview", bytesTotal: 11 }, { id: "summary-ambiguous", file: "C:/ui/shared-c.bin", fileName: "shared.bin", hoster: "voe.sx", status: "preview", bytesTotal: 12 }, { id: "summary-legacy-unique", file: "C:/ui/unique.bin", fileName: "unique.bin", hoster: "byse.sx", status: "preview", bytesTotal: 13 }]; rebuildJobIndex(); applySummaryResults({ files: [{ name: "shared.bin", size: 10, results: [{ jobId: "summary-exact-a", hoster: "voe.sx", status: "done", download_url: "https://example.invalid/exact-a" }, { jobId: "missing-summary-id", hoster: "voe.sx", status: "error", error: "Must not use legacy fallback" }, { hoster: "voe.sx", status: "error", error: "Ambiguous legacy result" }] }, { name: "different-name.bin", size: 11, results: [{ jobId: "summary-exact-b", hoster: "different.invalid", status: "done", download_url: "https://example.invalid/exact-b" }] }, { name: "unique.bin", size: 13, results: [{ hoster: "byse.sx", status: "done", download_url: "https://example.invalid/legacy-unique" }] }] }); const result = queueJobs.map(job => ({ id: job.id, status: job.status, error: job.error || null, link: job.result?.download_url || null })); queueJobs = []; selectedFiles = []; rebuildJobIndex(); renderQueueTable(); return result; })()'); + check('Final summary correlates by exact jobId and uses legacy identity only for one unique candidate', finalSummaryCorrelation[0].status === 'done' && finalSummaryCorrelation[0].link === 'https://example.invalid/exact-a' && finalSummaryCorrelation[1].status === 'done' && finalSummaryCorrelation[1].link === 'https://example.invalid/exact-b' && finalSummaryCorrelation[2].status === 'preview' && finalSummaryCorrelation[2].error === null && finalSummaryCorrelation[3].status === 'done' && finalSummaryCorrelation[3].link === 'https://example.invalid/legacy-unique'); restoreInitialIpcHandler('complete-upload-finalization'); await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")'); @@ -1983,6 +2058,53 @@ setTimeout(async () => { return { queueLabel, recentLabel }; })()\`); check('Copy-link context labels count only links that can actually be copied', copyableLinkContextLabels.queueLabel === 'Link kopieren' && copyableLinkContextLabels.recentLabel === 'Link kopieren'); + const singularCopyFeedback = await wc.executeJavaScript(\`(async () => { + const previousJobs = queueJobs; + const previousRecent = sessionFilesData; + setUiLanguage('de'); + sessionFilesData = [{ order: 2101, link: 'https://example.invalid/recent-one', isError: false }, { order: 2102, link: '', isError: true }]; + selectedRecentIds.clear(); + selectedRecentIds.add(2101); + selectedRecentIds.add(2102); + copySelectedRecentLinks(); + const recentGerman = document.getElementById('copyToast')?.textContent.trim(); + setUiLanguage('en'); + copySelectedRecentLinks(); + const recentEnglish = document.getElementById('copyToast')?.textContent.trim(); + queueJobs = [{ id: 'queue-copy-one', status: 'done', result: { download_url: 'https://example.invalid/queue-one' } }, { id: 'queue-copy-empty', status: 'done', result: null }]; + rebuildJobIndex(); + setUploadSidebarFilter('all'); + selectedJobIds.clear(); + selectedJobIds.add('queue-copy-one'); + selectedJobIds.add('queue-copy-empty'); + await handleContextAction('copy-links'); + const queueEnglish = document.getElementById('copyToast')?.textContent.trim(); + setUiLanguage('de'); + await handleContextAction('copy-links'); + const queueGerman = document.getElementById('copyToast')?.textContent.trim(); + queueJobs = previousJobs; + sessionFilesData = previousRecent; + selectedJobIds.clear(); + selectedRecentIds.clear(); + rebuildJobIndex(); + return { recentGerman, recentEnglish, queueGerman, queueEnglish }; + })()\`); + check('One actually copied link uses singular feedback in queue and recent views in both languages', singularCopyFeedback.recentGerman === '1 Link kopiert' && singularCopyFeedback.recentEnglish === '1 link copied' && singularCopyFeedback.queueGerman === '1 Link kopiert' && singularCopyFeedback.queueEnglish === '1 link copied'); + + let exportErrorDetail = 'Zugriff verweigert'; + ipcMain.removeHandler('save-text-file'); + ipcMain.handle('save-text-file', () => { throw new Error(exportErrorDetail); }); + await wc.executeJavaScript('setUiLanguage("en"); sessionFilesData = [{ order: 2201, timestamp: "2030-01-02T03:04:05.000Z", host: "voe.sx", link: "https://example.invalid/export", filename: "export.bin", isError: false }]; void (window.__uiExportErrorPromise = exportAllRecentFiles())'); + await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal")?.style.display === "flex"')); + const englishExportError = await wc.executeJavaScript('document.getElementById("appAlertMessage")?.textContent?.trim()'); + await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn")?.click(); window.__uiExportErrorPromise'); + exportErrorDetail = 'Access denied'; + await wc.executeJavaScript('setUiLanguage("de"); void (window.__uiExportErrorPromise = exportAllRecentFiles())'); + await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal")?.style.display === "flex"')); + const germanExportError = await wc.executeJavaScript('document.getElementById("appAlertMessage")?.textContent?.trim()'); + await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn")?.click(); window.__uiExportErrorPromise; delete window.__uiExportErrorPromise'); + restoreInitialIpcHandler('save-text-file'); + check('Dynamic export errors never mix German and English interface text', englishExportError === 'Export failed: Unknown error' && germanExportError === 'Export fehlgeschlagen: Unbekannter Fehler'); const historySidebarInformation = await wc.executeJavaScript('(() => { const sidebar = document.querySelector("#history-view > .view-sidebar")?.getBoundingClientRect(); const section = document.querySelector("#history-view .view-sidebar-section")?.getBoundingClientRect(); const retention = document.getElementById("historySidebarRetention")?.textContent?.trim(); return Boolean(sidebar && section && section.top >= sidebar.top + sidebar.height * 0.55 && retention === "Alles behalten"); })()'); check('History sidebar shows the active retention in its lower area', historySidebarInformation === true); @@ -2025,6 +2147,11 @@ setTimeout(async () => { check('History clear uses a red enabled action and opens the styled confirmation dialog with safe default focus', historyClearAction === 'true|false|0|flex|false|Verlauf löschen?|cancelHistoryClearBtn'); const historyClearMessage = await wc.executeJavaScript('document.getElementById("historyClearModalMessage")?.textContent?.trim()'); check('History clear dialog explains that deletion is permanent', historyClearMessage === 'Alle Verlaufseinträge werden dauerhaft gelöscht. Dieser Vorgang kann nicht rückgängig gemacht werden.'); + const historyClearModalKeyboard = await wc.executeJavaScript('(() => { const modal = document.getElementById("historyClearModal"); const close = document.getElementById("closeHistoryClearModalBtn"); const confirm = document.getElementById("confirmHistoryClearBtn"); const backgroundInert = document.querySelector(".app-header")?.inert === true && document.getElementById("history-view")?.inert === true; confirm.focus(); confirm.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true })); const forwardFocus = document.activeElement?.id; close.focus(); close.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true })); return { backgroundInert, forwardFocus, backwardFocus: document.activeElement?.id, inside: modal?.contains(document.activeElement) }; })()'); + check('History clear dialog traps keyboard focus and isolates the background', historyClearModalKeyboard.backgroundInert && historyClearModalKeyboard.forwardFocus === 'closeHistoryClearModalBtn' && historyClearModalKeyboard.backwardFocus === 'confirmHistoryClearBtn' && historyClearModalKeyboard.inside); + const historyClearEscapeState = await wc.executeJavaScript('document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true, cancelable: true })); (() => ({ display: document.getElementById("historyClearModal")?.style.display, focus: document.activeElement?.id, headerInert: document.querySelector(".app-header")?.inert, viewInert: document.getElementById("history-view")?.inert }))()'); + check('Closing the history clear dialog restores its trigger focus and background', historyClearEscapeState.display === 'none' && historyClearEscapeState.focus === 'clearHistoryBtn' && historyClearEscapeState.headerInert === false && historyClearEscapeState.viewInert === false); + await wc.executeJavaScript('document.getElementById("clearHistoryBtn")?.click()'); await captureVisual('04-history-clear-modal.png'); await wc.executeJavaScript('document.getElementById("confirmHistoryClearBtn")?.click(); true'); await waitUntil(() => wc.executeJavaScript('document.getElementById("clearHistoryBtn")?.disabled')); @@ -2475,20 +2602,16 @@ setTimeout(async () => { 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 setWindowBounds({ ...win.getBounds(), width: 1100, height: 900 }); 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)); + await setWindowBounds({ ...win.getBounds(), width: 1100, height: 550 }); 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)); + await setWindowBounds({ ...win.getBounds(), width: 1100, height: 900 }); 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)); + await setWindowBounds({ ...win.getBounds(), width: 1100, height: 550 }); 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)); @@ -2504,8 +2627,7 @@ setTimeout(async () => { 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)); + await setWindowBounds({ ...win.getBounds(), width, height }); resizeStability.push(await wc.executeJavaScript(\`(async () => { const samples = []; for (const target of ['upload', 'accounts', 'settings', 'history']) { @@ -2538,8 +2660,7 @@ setTimeout(async () => { }; })()\`)); } - win.setBounds(originalBounds); - await new Promise(resolve => setTimeout(resolve, 150)); + await setWindowBounds(originalBounds); 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)); @@ -2552,8 +2673,7 @@ setTimeout(async () => { const queueProgressVisibility = {}; for (const [label, width, height] of [['standard', 1100, 750], ['minimum', 800, 550]]) { - win.setSize(width, height); - await new Promise(resolve => setTimeout(resolve, 150)); + await setWindowBounds({ ...win.getBounds(), width, height }); queueProgressVisibility[label] = await wc.executeJavaScript(\`(() => { document.querySelector('.tab[data-view="upload"]').click(); selectedFiles = []; @@ -2596,6 +2716,14 @@ setTimeout(async () => { document.querySelector('.tab[data-view="upload"]').click(); const telemetry = document.getElementById('uploadTelemetry'); const availability = document.getElementById('uploadAvailability'); + const speedGraphs = [...document.querySelectorAll('.tab')].map(tab => { + tab.click(); + const header = document.querySelector('.app-header')?.getBoundingClientRect(); + const widget = document.getElementById('uploadSpeedSparkline')?.getBoundingClientRect(); + const canvas = document.getElementById('uploadSpeedCanvas')?.getBoundingClientRect(); + return Boolean(header && widget && canvas && canvas.width > 0 && canvas.height > 0 && widget.left >= header.left && widget.right <= header.right + 1); + }); + document.querySelector('.tab[data-view="upload"]').click(); return { settingsSidebarFits: fits(settingsSidebar), settingsSearchFits: fits(settingsSearch), @@ -2603,11 +2731,11 @@ setTimeout(async () => { autoCheckVisible, accountsMainFits, telemetryVisible: Boolean(telemetry && getComputedStyle(telemetry).display !== 'none'), - availabilityVisible: Boolean(availability && getComputedStyle(availability).display !== 'none') + availabilityVisible: Boolean(availability && getComputedStyle(availability).display !== 'none'), + speedGraphs }; })()\`); - win.setBounds(originalBounds); - await new Promise(resolve => setTimeout(resolve, 150)); + await setWindowBounds(originalBounds); await wc.executeJavaScript('queueJobs = []; rebuildJobIndex(); updateUploadView(); renderQueueTable(); updateStatusBar();'); check('Upload progress stays visible at the standard window size', queueProgressVisibility.standard.headerVisible && queueProgressVisibility.standard.cellVisible); check('Upload progress stays visible at the minimum window size', queueProgressVisibility.minimum.headerVisible && queueProgressVisibility.minimum.cellVisible); @@ -2620,8 +2748,47 @@ setTimeout(async () => { if (!(minimumResponsiveContract.autoCheckVisible && minimumResponsiveContract.accountsMainFits)) console.log('Minimum responsive contract: ' + JSON.stringify(minimumResponsiveContract)); check('Minimum Accounts keeps auto-check reachable and content contained', minimumResponsiveContract.autoCheckVisible && minimumResponsiveContract.accountsMainFits); check('Minimum Uploads preserves availability and telemetry information', minimumResponsiveContract.telemetryVisible && minimumResponsiveContract.availabilityVisible); + check('Minimum window keeps the speed graph visible and contained on every main tab', minimumResponsiveContract.speedGraphs.length === 4 && minimumResponsiveContract.speedGraphs.every(Boolean)); - const updateOverlayState = await wc.executeJavaScript('_knownUpdateInfo = { available: true, remoteVersion: "9.9.9" }; _syncHeaderUpdateState(); document.getElementById("headerUpdateBtn").focus(); showUpdateBanner({ remoteVersion: "9.9.9", releaseNotes: "\\\\n\\\\n\\\\n## New in this version\\\\n\\\\n\\\\n### Menus and navigation\\\\n\\\\n- Added live language switching.\\\\n- Improved settings layout.\\\\n\\\\n\\\\n" }); (() => { const overlay = document.getElementById("updateBanner"); const dialog = overlay?.querySelector(".update-dialog"); const button = document.getElementById("headerUpdateBtn"); return [overlay?.classList.contains("update-overlay"), overlay?.style.display, dialog?.getAttribute("role"), dialog?.getAttribute("aria-modal"), button?.hidden, getComputedStyle(button).display].join("|"); })()'); + let reducedMotionState = null; + const debuggerWasAttached = wc.debugger.isAttached(); + try { + if (!debuggerWasAttached) wc.debugger.attach('1.3'); + await wc.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'reduce' }] }); + reducedMotionState = await wc.executeJavaScript('(() => { const temporary = ["progress-bar-fill", "account-collapse"].map(className => { const element = document.createElement("div"); element.className = className; document.body.append(element); return element; }); const seconds = value => value.split(",").map(Number.parseFloat); const selectors = [".tab-indicator", ".view-sidebar-indicator", ".settings-nav-indicator", ".language-picker-indicator", ".progress-bar-fill", ".account-collapse"]; const elements = selectors.map(selector => document.querySelector(selector)); const transitionDurations = elements.flatMap(element => element ? seconds(getComputedStyle(element).transitionDuration) : []); const menu = document.querySelector("[data-menu-dropdown=datei]"); menu.classList.add("menu-opening"); const animationDurations = seconds(getComputedStyle(menu).animationDuration); menu.classList.remove("menu-opening"); temporary.forEach(element => element.remove()); return { media: matchMedia("(prefers-reduced-motion: reduce)").matches, missing: selectors.filter((selector, index) => !elements[index]), transitionDurations, animationDurations }; })()'); + } finally { + await wc.debugger.sendCommand('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-reduced-motion', value: 'no-preference' }] }).catch(() => {}); + if (!debuggerWasAttached && wc.debugger.isAttached()) wc.debugger.detach(); + } + check('Reduced-motion preference suppresses the central interface animations', reducedMotionState?.media === true && reducedMotionState.missing.length === 0 && reducedMotionState.transitionDurations.length > 0 && reducedMotionState.transitionDurations.every(duration => duration <= 0.001) && reducedMotionState.animationDurations.every(duration => duration <= 0.001)); + + rendererInitializationFailureSignal = null; + failNextConfigRead = true; + const rendererInitializationFailureListeners = ipcMain.listeners('app:renderer-initialization-failed'); + ipcMain.removeAllListeners('app:renderer-initialization-failed'); + ipcMain.on('app:renderer-initialization-failed', captureRendererInitializationFailure); + const failedInitializationLoad = new Promise(resolve => wc.once('did-finish-load', resolve)); + wc.reload(); + await failedInitializationLoad; + await waitUntil(() => wc.executeJavaScript('document.getElementById("appAlertModal")?.style.display === "flex"')); + await wc.executeJavaScript('document.getElementById("appAlertConfirmBtn")?.click()'); + const initializationFailureSignal = await waitUntil(() => rendererInitializationFailureSignal, 3000); + const recoveryLoad = new Promise(resolve => wc.once('did-finish-load', resolve)); + wc.reload(); + await recoveryLoad; + const initializationRecovery = await waitUntil(async () => { + try { + return await wc.executeJavaScript('typeof config === "object" && Boolean(document.querySelector(".app-header"))'); + } catch { + return false; + } + }, 5000); + ipcMain.removeAllListeners('app:renderer-initialization-failed'); + rendererInitializationFailureListeners.forEach(listener => ipcMain.on('app:renderer-initialization-failed', listener)); + check('Renderer initialization failures notify main with serializable details', typeof initializationFailureSignal?.message === 'string' && initializationFailureSignal.message.includes('Injected renderer initialization failure') && typeof initializationFailureSignal.stack === 'string'); + check('Renderer initialization failure recovery restores the real interface', initializationRecovery === true); + + const updateOverlayState = await wc.executeJavaScript('_knownUpdateInfo = { available: true, remoteVersion: "9.9.9" }; _syncHeaderUpdateState(); document.getElementById("headerUpdateBtn").focus(); showUpdateBanner({ remoteVersion: "9.9.9", releaseNotes: { de: "\\\\n\\\\n\\\\n## Neu in dieser Version\\\\n\\\\n\\\\n### Menüs und Navigation\\\\n\\\\n- Direkter Sprachwechsel hinzugefügt.\\\\n- Einstellungsdarstellung verbessert.\\\\n\\\\n\\\\n", en: "## New in this version\\\\n\\\\n### Menus and navigation\\\\n\\\\n- Added live language switching.\\\\n- Improved settings layout." } }); (() => { const overlay = document.getElementById("updateBanner"); const dialog = overlay?.querySelector(".update-dialog"); const button = document.getElementById("headerUpdateBtn"); return [overlay?.classList.contains("update-overlay"), overlay?.style.display, dialog?.getAttribute("role"), dialog?.getAttribute("aria-modal"), button?.hidden, getComputedStyle(button).display].join("|"); })()'); check('Available update opens an accessible update dialog', updateOverlayState === 'true|flex|dialog|true|false|flex'); await new Promise(resolve => setTimeout(resolve, 100)); @@ -2648,8 +2815,8 @@ setTimeout(async () => { const updateDialogActions = await wc.executeJavaScript('[document.getElementById("dismissUpdateBtn")?.textContent?.trim(), document.getElementById("installUpdateBtn")?.textContent?.trim()].join("|")'); check('Update dialog offers cancel and install actions', updateDialogActions === 'Abbrechen|Jetzt installieren'); - const updateDialogChangelog = await wc.executeJavaScript('(() => { const title = document.querySelector(".update-release-notes-title"); const body = document.getElementById("updateReleaseNotesBody"); const titleRect = title?.getBoundingClientRect(); const bodyRect = body?.getBoundingClientRect(); return { hidden: document.getElementById("updateReleaseNotes")?.hidden, title: title?.textContent?.trim(), body: body?.textContent, gap: bodyRect && titleRect ? bodyRect.top - titleRect.bottom : null }; })()'); - check('Update dialog renders a compact normalized changelog', updateDialogChangelog.hidden === false && updateDialogChangelog.title === 'Changelog' && updateDialogChangelog.body === 'New in this version\\n\\nMenus and navigation\\n\\n• Added live language switching.\\n• Improved settings layout.' && updateDialogChangelog.gap <= 10); + const updateDialogChangelog = await wc.executeJavaScript('(() => { const title = document.querySelector(".update-release-notes-title"); const body = document.getElementById("updateReleaseNotesBody"); const titleRect = title?.getBoundingClientRect(); const bodyRect = body?.getBoundingClientRect(); return { hidden: document.getElementById("updateReleaseNotes")?.hidden, title: title?.textContent?.trim(), body: body?.textContent, language: body?.lang, gap: bodyRect && titleRect ? bodyRect.top - titleRect.bottom : null }; })()'); + check('German update dialog selects compact localized release notes without changing their content', updateDialogChangelog.hidden === false && updateDialogChangelog.title === 'Changelog' && updateDialogChangelog.body === 'Neu in dieser Version\\n\\nMenüs und Navigation\\n\\n• Direkter Sprachwechsel hinzugefügt.\\n• Einstellungsdarstellung verbessert.' && updateDialogChangelog.language === 'de' && updateDialogChangelog.gap <= 10); const updateHeaderHint = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [button?.textContent?.trim(), button?.getAttribute("aria-label"), button?.dataset.tooltip].join("|"); })()'); check('Available update gives the header action a matching hint', updateHeaderHint === 'Update verfügbar|Update v9.9.9 verfügbar. Klicken zum Installieren.|Update v9.9.9 verfügbar. Klicken zum Installieren.'); @@ -2675,13 +2842,33 @@ setTimeout(async () => { headerHidden: header.hidden, messageHidden: document.getElementById('updateMessage').hidden, messageText: document.getElementById('updateMessage').textContent, + buttonText: document.getElementById('installUpdateBtn').textContent, + visibleProgressText: document.getElementById('updateProgressText').textContent, progressLabel: progress.getAttribute('aria-label'), progressText: progress.getAttribute('aria-valuetext') }; })()\`); check('Busy update keeps its progress dialog open', busyUpdateState.display === 'flex' && busyUpdateState.hidden === 'false' && busyUpdateState.closeDisabled === true && busyUpdateState.dismissDisabled === true && busyUpdateState.headerHidden === false); check('Update progress exposes an accessible live value', busyUpdateState.progressLabel === 'Update-Fortschritt' && busyUpdateState.progressText === 'Download 50%'); - check('Busy update shows progress only below the bar', busyUpdateState.messageHidden === true && busyUpdateState.messageText === 'Update v9.9.9 verfügbar'); + check('Busy update shows progress only below the bar', busyUpdateState.messageHidden === true && busyUpdateState.messageText === 'Update v9.9.9 verfügbar' && busyUpdateState.visibleProgressText === 'Download 50%' && busyUpdateState.buttonText === 'Jetzt installieren'); + + const updateProgressSurfaces = await wc.executeJavaScript(\`(() => { + const phases = [ + { stage: 'starting', expected: 'Download 0%' }, + { stage: 'downloading', percent: 50, expected: 'Download 50%' }, + { stage: 'verifying', expected: 'Prüfen…' }, + { stage: 'prepared', expected: 'Neustart…' }, + { stage: 'launching', expected: 'Neustart…' } + ]; + return phases.map(phase => { + showUpdateBanner({ remoteVersion: '9.9.9' }); + handleUpdateProgress(phase); + const surfaces = [document.getElementById('updateProgressText'), document.getElementById('installUpdateBtn'), document.getElementById('updateMessage')]; + const visibleMatches = surfaces.filter(element => element && !element.hidden && getComputedStyle(element).display !== 'none' && element.textContent.trim() === phase.expected).length; + return { stage: phase.stage, visibleMatches, button: document.getElementById('installUpdateBtn')?.textContent.trim() }; + }); + })()\`); + check('Every update phase has exactly one visible progress status surface', updateProgressSurfaces.every(state => state.visibleMatches === 1 && state.button === 'Jetzt installieren')); const updateErrorRecovery = await wc.executeJavaScript('handleUpdateProgress({ stage: "error", error: "Netzwerkfehler" }); document.getElementById("dismissUpdateBtn").click(); document.getElementById("updateBanner").style.display + "|" + document.getElementById("updateCloseBtn").disabled + "|" + document.getElementById("dismissUpdateBtn").disabled + "|" + document.getElementById("headerUpdateBtn").hidden'); check('Update errors restore all close actions', updateErrorRecovery === 'none|false|false|false'); @@ -2697,7 +2884,7 @@ setTimeout(async () => { ipcMain.removeHandler('save-pending-queue'); ipcMain.handle('save-pending-queue', () => { throw new Error('update queue save failed'); }); const updateSaveFailure = await wc.executeJavaScript('showUpdateBanner({ remoteVersion: "9.9.9" }); installKnownUpdate().then(() => ({ busy: _updateInstallBusy, message: document.getElementById("updateMessage")?.textContent || "" }))'); - check('Update preparation stops before install IPC when queue persistence fails', installUpdateIpcCalls === 0 && updateSaveFailure.busy === false && updateSaveFailure.message.includes('update queue save failed')); + check('Update preparation stops before install IPC when queue persistence fails', installUpdateIpcCalls === 0 && updateSaveFailure.busy === false && updateSaveFailure.message === 'Update fehlgeschlagen: Unbekannter Fehler'); await wc.executeJavaScript('handleUpdateProgress({ stage: "error", error: "Test cleanup" }); closeUpdateDialog()'); ipcMain.removeHandler('app:install-update'); if (initialInstallUpdateHandler) registerIpcHandler('app:install-update', initialInstallUpdateHandler); @@ -2717,10 +2904,10 @@ setTimeout(async () => { check('Escape closes only the topmost update dialog', stackedDialogState === 'none|flex'); if (visualScreenshotDir) { - win.setSize(800, 550); + await setWindowBounds({ ...win.getBounds(), width: 800, height: 550 }); await wc.executeJavaScript('document.querySelector(".tab[data-view=\\\'settings\\\']").click(); document.querySelector("[data-settings-page=\\\'allgemein\\\']")?.click(); (() => { const search = document.getElementById("settingsSearchInput"); if (search) { search.value = ""; search.dispatchEvent(new Event("input", { bubbles: true })); } document.querySelector(".settings-content")?.scrollTo(0, 0); })()'); await captureVisual('06-settings-800x550.png'); - win.setBounds(originalBounds); + await setWindowBounds(originalBounds); } restoreInitialIpcHandler('save-global-settings'); @@ -2853,7 +3040,7 @@ try { const result = execFileSync( electronPath, [`--user-data-dir=${userDataPath}`, '--require', injectPath, mainPath], - { cwd: path.join(__dirname, '..'), timeout: 60000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } + { cwd: path.join(__dirname, '..'), timeout: 120000, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } ); console.log(result); const isolatedConfigPath = path.join(userDataPath, 'electron-config.json');