Harden renderer recovery and UI contracts

This commit is contained in:
Sucukdeluxe
2026-08-13 21:48:11 +02:00
parent 86d10aad1f
commit 7fed7a7588
5 changed files with 440 additions and 233 deletions
+167 -133
View File
@@ -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 = `<span class="hint">Fehler: ${escapeHtml(err.message || String(err))}</span>`;
el.innerHTML = `<span class="hint">${escapeHtml(formatLocalizedError('Fehler', err))}</span>`;
}
}
@@ -4711,24 +4737,9 @@ function renderSettings() {
</div>
<div class="settings-row">
<label>Sichtbarkeit</label>
<select class="hs-input settings-autosave" id="diagBindModeInput" style="width:auto">
<option value="local">Nur lokal (127.0.0.1) Tunnel/VPN</option>
<option value="network">Im Netzwerk (0.0.0.0) Allowlist nötig</option>
</select>
<output class="diagnostics-bind-address" id="diagBindAddress">127.0.0.1</output>
</div>
<div class="settings-row">
<label>Adresse für den Code</label>
<input type="text" class="hs-input settings-autosave" id="diagPublicHostInput" placeholder="127.0.0.1 oder Tunnel-/Tailscale-Adresse" style="flex:1">
</div>
<div class="settings-row" id="diagSuggestRow" style="display:none">
<label></label>
<div id="diagSuggestChips" style="display:flex;gap:6px;flex-wrap:wrap"></div>
</div>
<div class="settings-row" id="diagAllowlistRow" style="display:none;align-items:flex-start">
<label>Allowlist (IP/CIDR, eine pro Zeile)</label>
<textarea class="hs-input settings-autosave" id="diagAllowlistInput" rows="3" style="flex:1;font-family:monospace" placeholder="100.64.0.0/10&#10;203.0.113.5"></textarea>
</div>
<div class="settings-row"><span class="hint" id="diagBindHint"></span></div>
<div class="settings-row"><span class="hint" id="diagBindHint">Bindet nur an <code>127.0.0.1</code>. Fernzugriff nur über einen Tunnel (z.B. Tailscale/SSH).</span></div>
<div class="settings-row">
<label>Verbindungs-Code</label>
<input type="text" class="key-input" id="diagCodeInput" value="" readonly style="flex:1" placeholder="(aktivieren zum Erzeugen)">
@@ -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 <code>0.0.0.0</code>. Nur IPs/CIDRs aus der Allowlist dürfen verbinden (Loopback immer) — zusätzlich zum Token. Über Tailscale: trage deinen Tailnet-Bereich ein (z.B. <code>100.64.0.0/10</code>) und die Tailscale-IP/MagicDNS oben als Code-Adresse. Transport ist plaintext über den Tunnel — Tailscale/WireGuard verschlüsselt.'
: 'Bindet nur an <code>127.0.0.1</code>. 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 = '<span style="color:#f59e0b">Netzwerkmodus braucht mindestens eine IP/CIDR in der Allowlist — sonst bleibt es fail-closed auf Loopback.</span>'; }
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) {
<div class="account-card-drag-handle" role="button" tabindex="0" aria-label="Priorität ändern. Alt und Pfeil nach oben oder unten verwenden" title="Ziehen oder Alt und Pfeiltasten zum Sortieren">&#9776;</div>
<div class="account-card-info">
<div class="account-card-title">${escapeHtml(getAccountDisplayName(name, account))} <span class="account-priority-badge">${priorityLabel}</span> ${sessionPausedBadge}</div>
<div class="account-card-subtitle" title="${escapeAttr(subtitleText)}">${escapeHtml(subtitleText)}${st.message && !isDisabled ? `${escapeHtml(st.message)}` : ''}${checkedText}</div>
<div class="account-card-subtitle" title="${escapeAttr(subtitleText)}">${escapeHtml(subtitleText)}${statusMessage ? `${escapeHtml(statusMessage)}` : ''}${checkedText}</div>
${otpAction}
</div>
<span class="account-status status-${statusClass}">
@@ -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 = `<div class="empty-state history-error-state" role="alert"><strong>${escapeHtml(localizeUiText('Verlauf konnte nicht geladen werden.'))}</strong><span>${escapeHtml(error?.message || String(error))}</span><button class="btn btn-secondary" type="button" data-retry-history>${escapeHtml(localizeUiText('Erneut versuchen'))}</button></div>`;
if (container) container.innerHTML = `<div class="empty-state history-error-state" role="alert"><strong>${escapeHtml(localizeUiText('Verlauf konnte nicht geladen werden.'))}</strong><span>${escapeHtml(getLocalizedErrorDetail(error))}</span><button class="btn btn-secondary" type="button" data-retry-history>${escapeHtml(localizeUiText('Erneut versuchen'))}</button></div>`;
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 {}
+20 -3
View File
@@ -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...'],
+5 -44
View File
@@ -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 {
+12 -4
View File
@@ -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', () => {
+236 -49
View File
@@ -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');