fix: stabilize the header on first paint

Pass the installed version with the startup language so the renderer can display it before asynchronous initialization.

Seed and start the upload sparkline immediately, keep a fixed update-action slot across all update states, and remove the delayed automatic update check.

Add first-frame regression coverage for version, speed baseline, update geometry, and startup query behavior.
This commit is contained in:
Sucukdeluxe
2026-08-20 13:27:55 +02:00
parent c4c7907686
commit 6d458e37f3
9 changed files with 83 additions and 25 deletions
+5 -1
View File
@@ -1,6 +1,10 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const { updateSpeedHistory } = require('../lib/speed-history');
const { updateSpeedHistory, createInitialSpeedHistoryState } = require('../lib/speed-history');
test('initial speed history contains a drawable zero baseline', () => {
assert.deepEqual(createInitialSpeedHistoryState(), { display: 0, history: [0, 0] });
});
test('speed history smooths rising and falling samples independently', () => {
const state = { display: 0, history: [] };
+28 -1
View File
@@ -3,7 +3,7 @@ const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const fs = require('node:fs');
const path = require('node:path');
const { configureStartupRenderer, createStartupWindow, resolveStartupLanguage } = require('../lib/startup-renderer');
const { configureStartupRenderer, createStartupWindow, resolveStartupLanguage, createStartupQuery } = require('../lib/startup-renderer');
class TestBrowserWindow extends EventEmitter {
constructor(options) {
@@ -50,6 +50,14 @@ test('resolveStartupLanguage accepts only the supported persisted language', ()
assert.equal(resolveStartupLanguage(null), 'en');
});
test('startup query carries language and installed version into the first renderer frame', () => {
assert.deepEqual(createStartupQuery({ globalSettings: { language: 'de' } }, '2.1.25'), {
language: 'de',
version: '2.1.25'
});
assert.deepEqual(createStartupQuery(null, 'invalid'), { language: 'en', version: '' });
});
test('createStartupWindow forces the main window to start hidden', () => {
const startup = createStartupWindow(TestBrowserWindow, { width: 1100, show: true });
@@ -124,3 +132,22 @@ test('upload sidebar renders and updates the remaining upload size', () => {
assert.match(html, /Verbleibende Größe[\s\S]*id="uploadTelemetryRemainingSize"[^>]*>0 B</u);
assert.match(appSource, /_setUploadTelemetryText\(['"]uploadTelemetryRemainingSize['"],\s*formatBytes\(stats\.bytesRemaining\)\)/u);
});
test('header occupies its final geometry before asynchronous initialization', () => {
const projectRoot = path.join(__dirname, '..');
const html = fs.readFileSync(path.join(projectRoot, 'renderer', 'index.html'), 'utf8');
const appSource = fs.readFileSync(path.join(projectRoot, 'renderer', 'app.js'), 'utf8');
const mainSource = fs.readFileSync(path.join(projectRoot, 'main.js'), 'utf8');
const css = fs.readFileSync(path.join(projectRoot, 'renderer', 'styles.css'), 'utf8');
const updateButton = html.match(/<button class="header-update-button"[^>]*id="headerUpdateBtn"[^>]*>/u)?.[0] || '';
const firstFrameInitialization = appSource.lastIndexOf('\ninitializeStaticHeader();');
const asynchronousInitialization = appSource.lastIndexOf('\ninit().then(');
assert.doesNotMatch(updateButton, /\shidden(?:\s|>)/u);
assert.match(css, /\.header-update-button\s*\{[^}]*width:\s*146px;[^}]*flex:\s*0 0 146px;/su);
assert.match(css, /\.version-badge\s*\{[^}]*min-width:\s*48px;/su);
assert.notEqual(firstFrameInitialization, -1);
assert.ok(firstFrameInitialization < asynchronousInitialization);
assert.match(mainSource, /createStartupQuery\([^,]+,\s*app\.getVersion\(\)\)/u);
assert.doesNotMatch(mainSource, /runAutomaticUpdateCheck\(true\);\s*\},\s*3000\)/u);
});
+6 -6
View File
@@ -190,7 +190,7 @@ setTimeout(async () => {
await wc.executeJavaScript('_knownUpdateInfo = null; closeUpdateDialog(); _syncHeaderUpdateState();');
const germanStartupReady = await waitUntil(() => wc.executeJavaScript('document.documentElement.lang + "|" + document.getElementById("languageInput")?.value + "|" + [...document.querySelectorAll(".tab")].map(tab => tab.textContent.trim()).join(",")'));
check('Returning German profiles never expose an English frame while startup config is pending', startupLanguagePendingSnapshot !== null && (!startupLanguagePendingSnapshot.visible || startupLanguagePendingSnapshot.language === 'de') && startupLanguagePendingSnapshot.query === '?language=de' && germanStartupReady === 'de|de|Upload,Accounts,Einstellungen,Verlauf');
check('Returning German profiles never expose an English frame while startup config is pending', startupLanguagePendingSnapshot !== null && (!startupLanguagePendingSnapshot.visible || startupLanguagePendingSnapshot.language === 'de') && startupLanguagePendingSnapshot.query === ${JSON.stringify(`?language=de&version=${productVersion}`)} && 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');
@@ -270,7 +270,7 @@ setTimeout(async () => {
await saveSettings({ feedbackText: 'Gespeichert' });
return { query: new URL(location.href).searchParams.get('language'), active: document.documentElement.lang };
})()\`);
check('Saved language remains the startup language after a renderer reload', englishLanguageQuery === 'en' && reloadedLanguageState === 'en|?language=en|Upload,Accounts,Settings,History' && germanLanguageQuery.query === 'de' && germanLanguageQuery.active === 'de');
check('Saved language remains the startup language after a renderer reload', englishLanguageQuery === 'en' && reloadedLanguageState === ${JSON.stringify(`en|?language=en&version=${productVersion}|Upload,Accounts,Settings,History`)} && germanLanguageQuery.query === 'de' && germanLanguageQuery.active === 'de');
await wc.executeJavaScript('queueJobs = []; selectedFiles = []; selectedJobIds.clear(); rebuildJobIndex(); setUploadSidebarFilter("all"); updateUploadView(); renderQueueTable(); updateStatusBar();');
console.log('\\n=== Upload View ===');
@@ -291,7 +291,7 @@ setTimeout(async () => {
check('App header exposes the update action', headerUpdateButtonExists);
const initialHeaderUpdateVisibility = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [button?.hidden, getComputedStyle(button).display].join("|"); })()');
check('Header update action stays hidden until an update is available', initialHeaderUpdateVisibility === 'true|none');
check('Header update action keeps its layout slot before an update is available', initialHeaderUpdateVisibility === 'false|flex');
const initialUpdateLabel = await wc.executeJavaScript('document.querySelector("#headerUpdateBtn .header-update-label")?.textContent?.trim()');
check('App header uses the compact update label', initialUpdateLabel === 'Update');
@@ -1199,11 +1199,11 @@ setTimeout(async () => {
})()\`);
await new Promise(resolve => setTimeout(resolve, 100));
const coordinatedUpdateBusy = await wc.executeJavaScript('(() => { const manual = document.getElementById("manualUpdateCheckBtn"); const header = document.getElementById("headerUpdateBtn"); return [manual?.disabled, manual?.getAttribute("aria-busy"), manual?.textContent?.trim(), header?.disabled, header?.getAttribute("aria-busy"), header?.hidden].join("|"); })()');
check('All update entry points share one in-flight check', updateCheckCallCount === 1 && coordinatedUpdateBusy === 'true|true|Prüfe…|true|true|true');
check('All update entry points share one in-flight check', updateCheckCallCount === 1 && coordinatedUpdateBusy === 'true|true|Prüfe…|true|true|false');
updateCheckResolvers.splice(0).forEach(resolve => resolve({ available: false, error: 'Simulierter Netzwerkfehler' }));
await new Promise(resolve => setTimeout(resolve, 150));
const coordinatedUpdateError = await wc.executeJavaScript('(() => { const manual = document.getElementById("manualUpdateCheckBtn"); const header = document.getElementById("headerUpdateBtn"); return [manual?.disabled, header?.disabled, header?.hidden, manual?.textContent?.trim(), document.getElementById("copyToast")?.textContent?.trim()].join("|"); })()');
check('Settings update check uses the shared error contract', coordinatedUpdateError === 'false|false|true|Nach Updates suchen|Updateprüfung fehlgeschlagen');
check('Settings update check uses the shared error contract', coordinatedUpdateError === 'false|false|false|Nach Updates suchen|Updateprüfung fehlgeschlagen');
await wc.executeJavaScript('document.getElementById("copyToast")?.classList.remove("show")');
await wc.executeJavaScript('requestUpdateCheck(); true');
@@ -1211,7 +1211,7 @@ setTimeout(async () => {
updateCheckResolvers.splice(0).forEach(resolve => resolve({ available: false }));
await new Promise(resolve => setTimeout(resolve, 150));
const noUpdateHeaderVisibility = await wc.executeJavaScript('(() => { const button = document.getElementById("headerUpdateBtn"); return [button?.hidden, getComputedStyle(button).display].join("|"); })()');
check('Successful no-update result keeps the header action hidden', noUpdateHeaderVisibility === 'true|none');
check('Successful no-update result keeps the header action in its stable slot', noUpdateHeaderVisibility === 'false|flex');
const settingsNavigation = await wc.executeJavaScript('(() => { const buttons = [...document.querySelectorAll(".settings-nav-button")]; return [buttons.length, buttons.map(button => button.textContent.trim()).join("|"), document.querySelector(".settings-nav-button.active")?.dataset.settingsPage, document.getElementById("settingsSearchInput")?.placeholder].join("::"); })()');
check('Settings use the task-based sidebar navigation', settingsNavigation === '8::Allgemein|Uploads|Automatik|Benachrichtigungen|Logs & Support|Fernsteuerung|Diagnose-Zugriff|Backup & Übertragen::allgemein::Einstellungen durchsuchen');