Harden renderer recovery and UI contracts
This commit is contained in:
+165
-131
@@ -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 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">☰</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;
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user