diff --git a/apps/desktop/src/main/lib/auto-updater.test.ts b/apps/desktop/src/main/lib/auto-updater.test.ts new file mode 100644 index 00000000000..6788b44e54c --- /dev/null +++ b/apps/desktop/src/main/lib/auto-updater.test.ts @@ -0,0 +1,93 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test"; +import { EventEmitter } from "node:events"; + +class FakeAutoUpdater extends EventEmitter { + autoDownload = false; + autoInstallOnAppQuit = false; + disableDifferentialDownload = false; + allowDowngrade = false; + setFeedURL = mock(() => {}); + checkForUpdates = mock(() => Promise.resolve(null)); + quitAndInstall = mock(() => {}); +} + +const fakeAutoUpdater = new FakeAutoUpdater(); + +mock.module("electron-updater", () => ({ + autoUpdater: fakeAutoUpdater, +})); + +mock.module("electron", () => ({ + app: { + getPath: mock(() => ""), + getName: mock(() => "test-app"), + getVersion: mock(() => "1.0.0"), + getAppPath: mock(() => ""), + isPackaged: false, + isReady: mock(() => true), + whenReady: mock(() => Promise.resolve()), + }, + dialog: { + showMessageBox: mock(() => Promise.resolve({ response: 0 })), + }, +})); + +mock.module("main/index", () => ({ + setSkipQuitConfirmation: mock(() => {}), +})); + +// auto-updater short-circuits setupAutoUpdater on non-mac/linux hosts, so +// pin the platform here to keep the tests portable across CI runners. +mock.module("shared/constants", () => ({ + PLATFORM: { IS_MAC: true, IS_WINDOWS: false, IS_LINUX: false }, +})); + +const autoUpdater = await import("./auto-updater"); +const { AUTO_UPDATE_STATUS } = await import("shared/auto-update"); + +describe("installUpdate", () => { + beforeEach(() => { + fakeAutoUpdater.removeAllListeners(); + fakeAutoUpdater.quitAndInstall.mockClear(); + fakeAutoUpdater.checkForUpdates.mockClear(); + fakeAutoUpdater.setFeedURL.mockClear(); + autoUpdater.setupAutoUpdater(); + // The module is a singleton; emit a network-shaped error so the + // handler resets isInstalling and maps status back to IDLE without + // tripping the real ERROR path (which would also clear the cache). + fakeAutoUpdater.emit("error", new Error("ECONNRESET reset")); + }); + + test("ignores install requests when no update is ready", () => { + expect(autoUpdater.getUpdateStatus().status).not.toBe( + AUTO_UPDATE_STATUS.READY, + ); + + autoUpdater.installUpdate(); + + expect(fakeAutoUpdater.quitAndInstall).not.toHaveBeenCalled(); + }); + + test("collapses repeat install clicks into a single quitAndInstall call", () => { + fakeAutoUpdater.emit("update-downloaded", { version: "9.9.9" }); + expect(autoUpdater.getUpdateStatus().status).toBe(AUTO_UPDATE_STATUS.READY); + + autoUpdater.installUpdate(); + autoUpdater.installUpdate(); + autoUpdater.installUpdate(); + + expect(fakeAutoUpdater.quitAndInstall).toHaveBeenCalledTimes(1); + }); + + test("clears the in-flight guard when Squirrel surfaces an error", () => { + fakeAutoUpdater.emit("update-downloaded", { version: "9.9.9" }); + autoUpdater.installUpdate(); + expect(fakeAutoUpdater.quitAndInstall).toHaveBeenCalledTimes(1); + + fakeAutoUpdater.emit("error", new Error("squirrel failed")); + fakeAutoUpdater.emit("update-downloaded", { version: "9.9.9" }); + autoUpdater.installUpdate(); + + expect(fakeAutoUpdater.quitAndInstall).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/desktop/src/main/lib/auto-updater.ts b/apps/desktop/src/main/lib/auto-updater.ts index 0171a91cddd..74535630215 100644 --- a/apps/desktop/src/main/lib/auto-updater.ts +++ b/apps/desktop/src/main/lib/auto-updater.ts @@ -82,6 +82,7 @@ function isNetworkError(error: Error | string): boolean { let currentStatus: AutoUpdateStatus = AUTO_UPDATE_STATUS.IDLE; let currentVersion: string | undefined; let isDismissed = false; +let isInstalling = false; function emitStatus( status: AutoUpdateStatus, @@ -111,6 +112,24 @@ export function installUpdate(): void { emitStatus(AUTO_UPDATE_STATUS.IDLE); return; } + // MacUpdater.quitAndInstall() registers a fresh native-updater + // `update-downloaded` listener each time it runs before Squirrel.Mac has + // finished staging. Without this guard, repeat clicks fan out into + // parallel quitAndInstall calls once Squirrel fires — racing to swap + // the binary and leaving the app on the old version. + if (isInstalling) { + console.info( + "[auto-updater] Install already in progress, ignoring duplicate request", + ); + return; + } + if (currentStatus !== AUTO_UPDATE_STATUS.READY) { + console.warn( + `[auto-updater] Install ignored: update not ready (status=${currentStatus})`, + ); + return; + } + isInstalling = true; setSkipQuitConfirmation(); autoUpdater.quitAndInstall(false, true); } @@ -242,6 +261,8 @@ export function setupAutoUpdater(): void { ); autoUpdater.on("error", (error) => { + // Allow retry if Squirrel surfaces an error instead of actually quitting. + isInstalling = false; if (isNetworkError(error)) { console.info("[auto-updater] Network unavailable, will retry later"); emitStatus(AUTO_UPDATE_STATUS.IDLE);