// SPDX-License-Identifier: AGPL-3.0-or-later import assert from 'node:assert/strict'; import {mkdtempSync, readFileSync, rmSync, writeFileSync} from 'node:fs'; import {createRequire} from 'node:module'; import {tmpdir} from 'node:os'; import path from 'node:path'; import {beforeEach, describe, test} from 'node:test'; import {installElectronStub} from './LocalAppTestSupport.test.mjs'; const velopack = { pendingRestart: null, pendingRestartError: null, checkResult: null, checkError: null, checkPromise: null, downloadError: null, downloadProgress: [], downloadPromise: null, installError: null, currentVersion: '2026.800.0', calls: [], constructedWith: [], }; class FakeUpdateManager { constructor(urlOrPath) { velopack.constructedWith.push(urlOrPath); } getCurrentVersion() { return velopack.currentVersion; } getUpdatePendingRestart() { velopack.calls.push('getUpdatePendingRestart'); if (velopack.pendingRestartError != null) throw velopack.pendingRestartError; return velopack.pendingRestart; } async checkForUpdatesAsync() { velopack.calls.push('checkForUpdatesAsync'); if (velopack.checkPromise != null) return await velopack.checkPromise; if (velopack.checkError != null) throw velopack.checkError; return velopack.checkResult; } async downloadUpdateAsync(_update, onProgress) { velopack.calls.push('downloadUpdateAsync'); for (const percent of velopack.downloadProgress) { onProgress(percent); } if (velopack.downloadPromise != null) return await velopack.downloadPromise; if (velopack.downloadError != null) throw velopack.downloadError; } waitExitThenApplyUpdate(update, silent, restart) { velopack.calls.push(`waitExitThenApplyUpdate:${update.id}:${silent}:${restart}`); if (velopack.installError != null) throw velopack.installError; } } const requireFromHere = createRequire(import.meta.url); const VELOPACK_PATH = requireFromHere.resolve('velopack'); requireFromHere.cache[VELOPACK_PATH] = { id: VELOPACK_PATH, filename: VELOPACK_PATH, loaded: true, exports: {UpdateManager: FakeUpdateManager}, }; class FakeAutoUpdater { constructor() { this.handlers = new Map(); this.feedUrls = []; this.checks = 0; this.installs = 0; this.checkError = null; this.installError = null; } on(eventName, listener) { const existing = this.handlers.get(eventName) ?? []; existing.push(listener); this.handlers.set(eventName, existing); return this; } removeListener(eventName, listener) { const existing = this.handlers.get(eventName) ?? []; this.handlers.set( eventName, existing.filter((entry) => entry !== listener), ); return this; } listenerCount(eventName) { return (this.handlers.get(eventName) ?? []).length; } totalListenerCount() { let total = 0; for (const listeners of this.handlers.values()) { total += listeners.length; } return total; } emit(eventName, ...args) { for (const listener of [...(this.handlers.get(eventName) ?? [])]) { listener(...args); } } setFeedURL(options) { this.feedUrls.push(options); } checkForUpdates() { this.checks += 1; if (this.checkError != null) throw this.checkError; } quitAndInstall() { this.installs += 1; if (this.installError != null) throw this.installError; } } const autoUpdater = new FakeAutoUpdater(); const quits = []; const userDataPath = mkdtempSync(path.join(tmpdir(), 'fluxer-shell-self-update-')); const applyStatePath = path.join(userDataPath, 'update-apply-state.json'); process.on('exit', () => { rmSync(userDataPath, {recursive: true, force: true}); }); installElectronStub({ app: { getVersion: () => '2026.800.0', getPath: () => userDataPath, quit: () => { quits.push('quit'); }, }, autoUpdater, }); const { getElectronUpdateFeedUrl, runShellSelfUpdate, SHELL_SELF_UPDATE_CHECK_TIMEOUT_MS, SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS, } = await import('@electron/main/ShellSelfUpdate'); const {getUpdateBaseUrl} = await import('@electron/main/ShellDownloadFormats'); function createHooks() { const calls = []; return { calls, hooks: { onDownloading: (progress) => { calls.push({hook: 'downloading', progress}); }, onRestarting: () => { calls.push({hook: 'restarting'}); }, }, }; } function settle() { return new Promise((resolve) => { setImmediate(resolve); }); } function selfUpdateTest(name, body) { test(name, async () => { const timers = []; const originalSetTimeout = globalThis.setTimeout; const originalClearTimeout = globalThis.clearTimeout; globalThis.setTimeout = (callback, delayMs) => { const timer = { callback, delayMs, cleared: false, unref() { return this; }, }; timers.push(timer); return timer; }; globalThis.clearTimeout = (timer) => { if (timer != null && typeof timer === 'object') { timer.cleared = true; } }; try { await body({ timers, start: (updater, hooks) => runShellSelfUpdate({capability: 'self-update', updater}, hooks), fireTimer: (delayMs) => { const timer = timers.find((entry) => entry.delayMs === delayMs); assert.ok(timer != null, `no timer armed at ${delayMs}ms`); assert.equal(timer.cleared, false, `the timer at ${delayMs}ms was already disarmed`); timer.callback(); }, timerCleared: (delayMs) => timers.find((entry) => entry.delayMs === delayMs)?.cleared ?? null, stillPending: async (promise) => { let resolved = false; void promise.then(() => { resolved = true; }); for (let turn = 0; turn < 8; turn += 1) { await settle(); } return !resolved; }, }); } finally { globalThis.setTimeout = originalSetTimeout; globalThis.clearTimeout = originalClearTimeout; } }); } beforeEach(() => { velopack.pendingRestart = null; velopack.pendingRestartError = null; velopack.checkResult = null; velopack.checkError = null; velopack.checkPromise = null; velopack.downloadError = null; velopack.downloadProgress = []; velopack.downloadPromise = null; velopack.installError = null; velopack.currentVersion = '2026.800.0'; velopack.calls.length = 0; rmSync(applyStatePath, {force: true}); velopack.constructedWith.length = 0; autoUpdater.handlers.clear(); autoUpdater.feedUrls.length = 0; autoUpdater.checks = 0; autoUpdater.installs = 0; autoUpdater.checkError = null; autoUpdater.installError = null; quits.length = 0; }); describe('velopack self update', () => { selfUpdateTest('talks to the same feed the in-app updater uses', async ({start}) => { const {hooks} = createHooks(); await start('velopack', hooks); assert.deepEqual(velopack.constructedWith, [getUpdateBaseUrl()]); }); selfUpdateTest('a feed with nothing newer resolves without ever touching the splash', async ({start}) => { const {calls, hooks} = createHooks(); assert.deepEqual(await start('velopack', hooks), {reason: 'no-update', detail: null}); assert.deepEqual(calls, []); assert.deepEqual(velopack.calls, ['getUpdatePendingRestart', 'checkForUpdatesAsync']); }); selfUpdateTest('a failed check resolves with the reason so the caller can fall back', async ({start}) => { velopack.checkError = new Error('the feed is a 404'); const {hooks} = createHooks(); assert.deepEqual(await start('velopack', hooks), {reason: 'check-failed', detail: 'the feed is a 404'}); }); selfUpdateTest('a failed staging probe never reaches the network', async ({start}) => { velopack.pendingRestartError = new Error('the packages directory is unreadable'); const {hooks} = createHooks(); assert.deepEqual(await start('velopack', hooks), { reason: 'check-failed', detail: 'the packages directory is unreadable', }); assert.deepEqual(velopack.calls, ['getUpdatePendingRestart']); }); selfUpdateTest('a lost update lock resolves as a download failure rather than retrying forever', async ({start}) => { velopack.checkResult = {id: 'v2'}; velopack.downloadProgress = [0, 40]; velopack.downloadError = new Error('another update operation is in progress'); const {calls, hooks} = createHooks(); assert.deepEqual(await start('velopack', hooks), { reason: 'download-failed', detail: 'another update operation is in progress', }); assert.deepEqual(calls, [ {hook: 'downloading', progress: 0}, {hook: 'downloading', progress: 0}, {hook: 'downloading', progress: 40}, ]); }); selfUpdateTest( 'a downloaded update restarts the shell explicitly and never resolves', async ({start, stillPending}) => { velopack.checkResult = {id: 'v2'}; velopack.downloadProgress = [12, 100]; const {calls, hooks} = createHooks(); assert.equal(await stillPending(start('velopack', hooks)), true); assert.deepEqual(calls, [ {hook: 'downloading', progress: 0}, {hook: 'downloading', progress: 12}, {hook: 'downloading', progress: 100}, {hook: 'restarting'}, ]); assert.deepEqual(velopack.calls, [ 'getUpdatePendingRestart', 'checkForUpdatesAsync', 'downloadUpdateAsync', 'waitExitThenApplyUpdate:v2:true:true', ]); assert.deepEqual( quits, ['quit'], 'the restart goes through app.quit so the will-quit cleanup checkpoints the app store', ); }, ); selfUpdateTest('an update already staged on disk skips straight to the restart', async ({start, stillPending}) => { velopack.pendingRestart = {id: 'staged'}; const {calls, hooks} = createHooks(); assert.equal(await stillPending(start('velopack', hooks)), true); assert.deepEqual(calls, [{hook: 'restarting'}]); assert.deepEqual(velopack.calls, ['getUpdatePendingRestart', 'waitExitThenApplyUpdate:staged:true:true']); }); selfUpdateTest('an apply that never landed falls back instead of applying it again', async ({start}) => { writeFileSync(applyStatePath, JSON.stringify({version: '2026.900.0', attemptedAt: 1})); velopack.pendingRestart = {id: 'staged', Version: '2026.900.0'}; const {calls, hooks} = createHooks(); assert.deepEqual(await start('velopack', hooks), { reason: 'install-failed', detail: 'the last downloaded update was never installed', }); assert.deepEqual(calls, []); assert.deepEqual(velopack.calls, []); assert.deepEqual(quits, []); }); selfUpdateTest('an apply that landed is forgotten and the next one is recorded', async ({start, stillPending}) => { writeFileSync(applyStatePath, JSON.stringify({version: '2026.800.0', attemptedAt: 1})); velopack.pendingRestart = {id: 'staged', Version: '2026.900.0'}; const {hooks} = createHooks(); assert.equal(await stillPending(start('velopack', hooks)), true); assert.deepEqual(velopack.calls, ['getUpdatePendingRestart', 'waitExitThenApplyUpdate:staged:true:true']); assert.equal(JSON.parse(readFileSync(applyStatePath, 'utf8')).version, '2026.900.0'); }); selfUpdateTest( 'an install that throws resolves instead of leaving the restart message up forever', async ({start}) => { velopack.pendingRestart = {id: 'staged'}; velopack.installError = new Error('the updater binary is missing'); const {hooks} = createHooks(); assert.deepEqual(await start('velopack', hooks), { reason: 'install-failed', detail: 'the updater binary is missing', }); assert.deepEqual(quits, []); }, ); selfUpdateTest('a check that never answers hits the check deadline', async ({start, fireTimer, timerCleared}) => { let releaseCheck = () => {}; velopack.checkPromise = new Promise((resolve) => { releaseCheck = () => { resolve(null); }; }); const {calls, hooks} = createHooks(); const promise = start('velopack', hooks); await settle(); fireTimer(SHELL_SELF_UPDATE_CHECK_TIMEOUT_MS); assert.deepEqual(await promise, {reason: 'timed-out', detail: 'the update check produced no answer'}); releaseCheck(); await settle(); assert.deepEqual(calls, []); assert.equal(timerCleared(SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS), true); }); selfUpdateTest( 'a download that never finishes hits the total deadline, and the check deadline is already gone', async ({start, fireTimer, timerCleared}) => { velopack.checkResult = {id: 'v2'}; velopack.downloadPromise = new Promise(() => {}); const {hooks} = createHooks(); const promise = start('velopack', hooks); await settle(); assert.equal(timerCleared(SHELL_SELF_UPDATE_CHECK_TIMEOUT_MS), true); fireTimer(SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS); assert.deepEqual(await promise, {reason: 'timed-out', detail: 'the update did not finish'}); }, ); selfUpdateTest( 'a late download failure after a deadline never re-settles the promise', async ({start, fireTimer}) => { velopack.checkResult = {id: 'v2'}; let failDownload = () => {}; velopack.downloadPromise = new Promise((_resolve, reject) => { failDownload = () => { reject(new Error('too late')); }; }); const {calls, hooks} = createHooks(); const promise = start('velopack', hooks); await settle(); fireTimer(SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS); const failure = await promise; failDownload(); await settle(); assert.deepEqual(failure, {reason: 'timed-out', detail: 'the update did not finish'}); assert.deepEqual(await promise, failure); assert.deepEqual(calls, [{hook: 'downloading', progress: 0}]); }, ); }); describe('electron self update', () => { selfUpdateTest('points squirrel at the json feed with an identifying user agent', async ({start}) => { const {hooks} = createHooks(); const promise = start('electron', hooks); autoUpdater.emit('update-not-available'); await promise; assert.equal(getElectronUpdateFeedUrl(), `${getUpdateBaseUrl()}/RELEASES.json`); assert.deepEqual(autoUpdater.feedUrls, [ { url: getElectronUpdateFeedUrl(), serverType: 'json', headers: {'User-Agent': `Fluxer/2026.800.0 (${process.platform}: ${process.arch})`}, }, ]); assert.equal(autoUpdater.checks, 1); }); selfUpdateTest('nothing newer resolves and takes every listener with it', async ({start}) => { const {calls, hooks} = createHooks(); const promise = start('electron', hooks); assert.equal(autoUpdater.totalListenerCount(), 4); autoUpdater.emit('update-not-available'); assert.deepEqual(await promise, {reason: 'no-update', detail: null}); assert.deepEqual(calls, []); assert.equal(autoUpdater.totalListenerCount(), 0); }); selfUpdateTest( 'a check that throws synchronously resolves rather than hanging on a feed that was never set', async ({start}) => { autoUpdater.checkError = new Error('Update URL is not set'); const {hooks} = createHooks(); assert.deepEqual(await start('electron', hooks), {reason: 'check-failed', detail: 'Update URL is not set'}); assert.equal(autoUpdater.totalListenerCount(), 0); }, ); selfUpdateTest('an error before the download is a check failure, after it a download failure', async ({start}) => { const first = start('electron', createHooks().hooks); autoUpdater.emit('error', new Error('dns')); assert.deepEqual(await first, {reason: 'check-failed', detail: 'dns'}); const {calls, hooks} = createHooks(); const second = start('electron', hooks); autoUpdater.emit('update-available'); autoUpdater.emit('error', new Error('the bundle is not signed')); assert.deepEqual(await second, {reason: 'download-failed', detail: 'the bundle is not signed'}); assert.deepEqual(calls, [{hook: 'downloading', progress: null}]); }); selfUpdateTest( 'squirrel gives no progress, so the splash is told to keep its placeholder', async ({start, stillPending, timerCleared}) => { const {calls, hooks} = createHooks(); const promise = start('electron', hooks); autoUpdater.emit('update-available'); assert.deepEqual(calls, [{hook: 'downloading', progress: null}]); assert.equal(timerCleared(SHELL_SELF_UPDATE_CHECK_TIMEOUT_MS), true); assert.equal(await stillPending(promise), true); }, ); selfUpdateTest('a downloaded update restarts the shell and never resolves', async ({start, stillPending}) => { const {calls, hooks} = createHooks(); const promise = start('electron', hooks); autoUpdater.emit('update-available'); autoUpdater.emit('update-downloaded'); assert.deepEqual(calls, [{hook: 'downloading', progress: null}, {hook: 'restarting'}]); assert.equal(autoUpdater.installs, 1); assert.equal(await stillPending(promise), true); }); selfUpdateTest( 'an install that throws resolves instead of stranding the user on the restart message', async ({start}) => { autoUpdater.installError = new Error('the app is on a read only mount'); const {hooks} = createHooks(); const promise = start('electron', hooks); autoUpdater.emit('update-downloaded'); assert.deepEqual(await promise, {reason: 'install-failed', detail: 'the app is on a read only mount'}); assert.equal(autoUpdater.totalListenerCount(), 0); }, ); selfUpdateTest('a silent squirrel hits the check deadline and drops every listener', async ({start, fireTimer}) => { const {hooks} = createHooks(); const promise = start('electron', hooks); fireTimer(SHELL_SELF_UPDATE_CHECK_TIMEOUT_MS); assert.deepEqual(await promise, {reason: 'timed-out', detail: 'the update check produced no answer'}); assert.equal(autoUpdater.totalListenerCount(), 0); autoUpdater.emit('update-downloaded'); assert.equal(autoUpdater.installs, 0); }); selfUpdateTest('a download that never finishes hits the total deadline', async ({start, fireTimer}) => { const {hooks} = createHooks(); const promise = start('electron', hooks); autoUpdater.emit('update-available'); fireTimer(SHELL_SELF_UPDATE_TOTAL_TIMEOUT_MS); assert.deepEqual(await promise, {reason: 'timed-out', detail: 'the update did not finish'}); assert.equal(autoUpdater.totalListenerCount(), 0); }); });