feat: improve live update and upload feedback
CI / verify (push) Has been cancelled

Refresh release metadata immediately before downloading an update and keep automatic checks current while the app remains open.

Accept desktop file drops from the first renderer frame, preserve early and rapid drops until initialization completes, and show the live remaining upload size in the sidebar.

Add regression coverage for update freshness, startup drag-and-drop ordering, localization, and remaining-byte retry behavior. Bump the application to v2.1.25.
This commit is contained in:
Sucukdeluxe
2026-08-20 10:40:58 +02:00
parent d7c9f287e4
commit c4c7907686
16 changed files with 351 additions and 82 deletions
+5
View File
@@ -11,6 +11,11 @@ test('translates permanent source deletion controls to English', () => {
assert.equal(translateText('Dauerhaftes Löschen aktivieren', 'en'), 'Enable permanent deletion');
});
test('translates the remaining upload size label', () => {
assert.equal(translateText('Verbleibende Größe', 'en'), 'Remaining size');
assert.equal(translateText('Remaining size', 'de'), 'Verbleibende Größe');
});
test('English is the fallback language and German remains selectable', () => {
assert.equal(normalizeLanguage(), 'en');
assert.equal(normalizeLanguage('fr'), 'en');
+15
View File
@@ -21,3 +21,18 @@ test('queue totals keep skipped and aborted jobs out of remaining work', () => {
});
assert.equal(stats.remainingSize, 160);
});
test('remaining bytes decrease with progress and return when a failed job is queued again', () => {
const jobs = [
{ status: 'queued', bytesTotal: 1024, bytesUploaded: 0 },
{ status: 'uploading', bytesTotal: 2048, bytesUploaded: 512 },
{ status: 'error', bytesTotal: 4096, bytesUploaded: 1024 }
];
assert.equal(calculateQueueStats(jobs).bytesRemaining, 2560);
jobs[1].bytesUploaded = 1536;
assert.equal(calculateQueueStats(jobs).bytesRemaining, 1536);
jobs[2].status = 'queued';
jobs[2].bytesUploaded = 0;
assert.equal(calculateQueueStats(jobs).bytesRemaining, 5632);
});
+21
View File
@@ -29,4 +29,25 @@ describe('serialized runner', () => {
assert.equal(flushed, true);
assert.deepEqual(calls, ['start:first', 'end:first', 'start:second', 'end:second']);
});
it('queues work before readiness and drains every task in arrival order', async () => {
const { createReadySerializedRunner } = require('../lib/serialized-runner');
const calls = [];
const runner = createReadySerializedRunner(async (value) => {
calls.push(value);
return value;
});
const first = runner.run('first');
const second = runner.run('second');
await new Promise((resolve) => setImmediate(resolve));
assert.equal(runner.isReady, false);
assert.deepEqual(calls, []);
assert.equal(runner.ready(), true);
assert.equal(runner.ready(), false);
assert.deepEqual(await Promise.all([first, second]), ['first', 'second']);
assert.deepEqual(calls, ['first', 'second']);
assert.equal(runner.isReady, true);
});
});
+21
View File
@@ -103,3 +103,24 @@ test('startup load forwards navigation options before the renderer becomes visib
assert.deepEqual(startup.window.loadOptions, options);
});
test('desktop drag and drop is accepted before asynchronous renderer initialization', () => {
const projectRoot = path.join(__dirname, '..');
const appSource = fs.readFileSync(path.join(projectRoot, 'renderer', 'app.js'), 'utf8');
const earlyBinding = appSource.lastIndexOf('\nsetupDragDrop();');
const initialization = appSource.lastIndexOf('\ninit().then(');
assert.notEqual(earlyBinding, -1);
assert.notEqual(initialization, -1);
assert.ok(earlyBinding < initialization);
assert.match(appSource, /dataTransfer\.dropEffect\s*=\s*['"]copy['"]/u);
});
test('upload sidebar renders and updates the remaining upload size', () => {
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');
assert.match(html, /Verbleibende Größe[\s\S]*id="uploadTelemetryRemainingSize"[^>]*>0 B</u);
assert.match(appSource, /_setUploadTelemetryText\(['"]uploadTelemetryRemainingSize['"],\s*formatBytes\(stats\.bytesRemaining\)\)/u);
});
+5 -5
View File
@@ -217,7 +217,7 @@ setTimeout(async () => {
const englishSidebarHeadings = await wc.executeJavaScript('[...document.querySelectorAll("#upload-view, #accounts-view, #history-view")].map(view => [view.querySelector(".view-sidebar-kicker")?.textContent?.trim(), view.querySelector(".view-sidebar-title")?.textContent?.trim()].join("|"))');
check('English sidebar hierarchy uses distinct translated kickers', englishSidebarHeadings.join('::') === 'Workspace|Uploads::Manage accounts|Accounts::Archive|History');
const englishTelemetryLabels = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-label")].map(el => el.textContent.trim()).join("|")');
check('English upload telemetry is fully localized', englishTelemetryLabels === 'Total|Connections|Remaining|Running|Completed|Failed|Speed|ETA');
check('English upload telemetry is fully localized', englishTelemetryLabels === 'Total|Connections|Remaining|Remaining size|Running|Completed|Failed|Speed|ETA');
const englishLayoutFits = await wc.executeJavaScript('(() => { const states = [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const view = document.querySelector(".view.active"); return view && view.scrollWidth <= view.clientWidth + 1; }); document.querySelector(".tab[data-view=upload]")?.click(); return states.every(Boolean) && document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1; })()');
check('English labels fit every main view without horizontal overflow', englishLayoutFits === true);
const speedSparklineAcrossTabs = await wc.executeJavaScript('(() => [...document.querySelectorAll(".tab")].map(tab => { tab.click(); const widget = document.getElementById("uploadSpeedSparkline"); const rect = widget?.getBoundingClientRect(); const style = widget && getComputedStyle(widget); return Boolean(widget && !widget.classList.contains("is-hidden") && style.visibility === "visible" && style.opacity === "1" && rect.width > 0 && rect.height > 0); }))()');
@@ -447,10 +447,10 @@ setTimeout(async () => {
check('Recent panel labels are consistently German', localizedRecentTabs === 'Dateien|Statistik');
const localizedTelemetry = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-label")].map(el => el.textContent.trim()).join("|")');
check('Upload telemetry exposes all eight German labels', localizedTelemetry === 'Gesamt|Verbindungen|Verbleibend|Läuft|Fertig|Fehler|Geschwindigkeit|ETA');
check('Upload telemetry exposes all nine German labels', localizedTelemetry === 'Gesamt|Verbindungen|Verbleibend|Verbleibende Größe|Läuft|Fertig|Fehler|Geschwindigkeit|ETA');
const initialTelemetryValues = await wc.executeJavaScript('[...document.querySelectorAll("#uploadTelemetry .upload-telemetry-value")].map(el => el.getAttribute("aria-label") || el.textContent.trim()).join("|")');
check('Upload telemetry starts with stable empty values', initialTelemetryValues === '0|0|0|0|0|0|0 B/s|--:--');
check('Upload telemetry starts with stable empty values', initialTelemetryValues === '0|0|0|0 B|0|0|0|0 B/s|--:--');
const previewQueueCounts = await wc.executeJavaScript(\`(() => {
queueJobs = [{ id: 'existing-done', file: 'C:/ui/existing-done.bin', fileName: 'existing-done.bin', hoster: 'doodstream.com', status: 'done', bytesUploaded: 1024, bytesTotal: 1024, speedKbs: 0, elapsed: 1, remaining: 0, progress: 1 }];
@@ -517,7 +517,7 @@ setTimeout(async () => {
const total = document.getElementById('uploadTelemetryTotal');
const rolling = total?.querySelectorAll(':scope > span').length;
return {
values: ['Total', 'Connections', 'Remaining', 'Running', 'Completed', 'Failed', 'Speed', 'Eta'].map(key => {
values: ['Total', 'Connections', 'Remaining', 'RemainingSize', 'Running', 'Completed', 'Failed', 'Speed', 'Eta'].map(key => {
const element = document.getElementById('uploadTelemetry' + key);
return element?.getAttribute('aria-label') || element?.textContent.trim();
}).join('|'),
@@ -526,7 +526,7 @@ setTimeout(async () => {
speedPair: [document.getElementById('uploadTelemetrySpeed')?.textContent, document.getElementById('uploadSpeedValue')?.textContent].join('|')
};
})()\`);
check('Upload telemetry reflects queue and session activity', telemetryUpdate.values === '4|1|2|1|7|2|2 kB/s|00:03');
check('Upload telemetry reflects queue and session activity', telemetryUpdate.values === '4|1|2|5.00 KB|1|7|2|2 kB/s|00:03');
check('Changing integer telemetry rolls vertically', telemetryUpdate.rolling === 2 && telemetryUpdate.direction === 'up');
check('Header and sidebar speed update synchronously from the same live sample', telemetryUpdate.speedPair === '2 kB/s|2 kB/s');
const secondSynchronizedSpeed = await wc.executeJavaScript('lastUploadStats = { ...lastUploadStats, globalSpeedKbs: 1536 }; updateStatusBar(); [document.getElementById("uploadTelemetrySpeed")?.textContent, document.getElementById("uploadSpeedValue")?.textContent].join("|")');
+110 -1
View File
@@ -4,9 +4,10 @@ const path = require('node:path');
const fs = require('node:fs');
const os = require('node:os');
const crypto = require('node:crypto');
const Module = require('node:module');
const { pathToFileURL } = require('node:url');
const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, pickSetupAsset, parseLatestYml } = require('../lib/updater');
const { isNewer, resolveReleaseVersion, fetchGithubReleaseNotes, prepareUpdate, launchPreparedUpdate, pickSetupAsset, parseLatestYml, createUpdateAnnouncementState } = require('../lib/updater');
const releasePlanUrl = pathToFileURL(path.resolve(__dirname, '../scripts/release-plan.mjs')).href;
test('bridge title resolves product version instead of transport tag', () => {
@@ -86,6 +87,114 @@ test('update preparation writes a verified installer without launching it', asyn
}
});
test('update preparation refreshes a cached release before downloading the installer', async () => {
const updaterPath = require.resolve('../lib/updater');
const originalLoad = Module._load;
const originalFetch = global.fetch;
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-refresh-test-'));
const installer = Buffer.alloc(128 * 1024, 0);
installer[0] = 0x4d;
installer[1] = 0x5a;
const sha512 = crypto.createHash('sha512').update(installer).digest('base64');
const oldVersion = '2.1.23';
const latestVersion = '2.1.24';
const createRelease = version => ({
name: `Multi-Hoster-Upload v${version}`,
tag_name: `v${version}`,
html_url: `https://update.invalid/releases/${version}`,
body: `Release ${version}`,
assets: [
{
name: `Multi-Hoster-Upload Setup ${version}.exe`,
size: installer.length,
browser_download_url: `https://update.invalid/${version}/setup.exe`
},
{
name: 'latest.yml',
browser_download_url: `https://update.invalid/${version}/latest.yml`
}
]
});
const createResponse = payload => ({
ok: true,
status: 200,
text: async () => JSON.stringify(payload),
json: async () => payload
});
const createInstallerResponse = () => ({
ok: true,
status: 200,
body: {
getReader: () => {
let served = false;
return {
read: async () => {
if (served) return { done: true };
served = true;
return { done: false, value: installer };
}
};
}
}
});
const createManifestResponse = version => ({
ok: true,
status: 200,
text: async () => `version: ${version}\npath: Multi-Hoster-Upload Setup ${version}.exe\nsha512: ${sha512}\nsize: ${installer.length}\n`
});
Module._load = function load(request, parent, isMain) {
if (request === 'electron') return { app: { getVersion: () => '2.1.22', getPath: () => tempDir } };
return originalLoad.call(this, request, parent, isMain);
};
delete require.cache[updaterPath];
const isolatedUpdater = require(updaterPath);
try {
global.fetch = async url => {
const value = String(url);
if (value.includes('/api/v1/repos/')) return createResponse([createRelease(oldVersion)]);
if (value.includes('api.github.com')) return createResponse({ body: `Release ${oldVersion}` });
throw new Error(`Unexpected initial request: ${value}`);
};
const oldCheck = await isolatedUpdater.checkForUpdate();
assert.equal(oldCheck.remoteVersion, oldVersion);
global.fetch = async url => {
const value = String(url);
if (value.includes('/api/v1/repos/')) return createResponse([createRelease(latestVersion)]);
if (value.includes('api.github.com')) return createResponse({ body: `Release ${latestVersion}` });
if (value.endsWith(`${oldVersion}/latest.yml`)) return createManifestResponse(oldVersion);
if (value.endsWith(`${latestVersion}/latest.yml`)) return createManifestResponse(latestVersion);
if (value.endsWith(`${oldVersion}/setup.exe`) || value.endsWith(`${latestVersion}/setup.exe`)) return createInstallerResponse();
throw new Error(`Unexpected refreshed request: ${value}`);
};
const prepared = await isolatedUpdater.prepareUpdate(null, { tempDir });
assert.equal(prepared.remoteVersion, latestVersion);
assert.equal(prepared.assetName, `Multi-Hoster-Upload Setup ${latestVersion}.exe`);
} finally {
global.fetch = originalFetch;
Module._load = originalLoad;
delete require.cache[updaterPath];
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
test('an update found before renderer readiness remains announceable afterwards', () => {
const state = createUpdateAnnouncementState();
const update = { available: true, remoteVersion: '2.1.24' };
assert.equal(state.canAnnounce(update, false), false);
assert.equal(state.canAnnounce(update, true), true);
assert.equal(state.canAnnounce(update, true), true);
state.markAnnounced(update);
assert.equal(state.canAnnounce(update, true), false);
state.reset();
assert.equal(state.canAnnounce(update, true), true);
});
test('update preparation fails closed when checksum metadata is unavailable', async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'mhu-updater-test-'));
try {