diff --git a/src/main/updates/autoUpdater.test.ts b/src/main/updates/autoUpdater.test.ts index 729b2683..f2e5061e 100644 --- a/src/main/updates/autoUpdater.test.ts +++ b/src/main/updates/autoUpdater.test.ts @@ -1,17 +1,28 @@ -import { describe, expect, it, vi } from "vitest"; - -const autoUpdaterMock = vi.hoisted(() => ({ - autoDownload: false, - autoInstallOnAppQuit: true, - forceDevUpdateConfig: false, - allowPrerelease: false, - channel: "", - checkForUpdates: vi.fn<() => Promise>(), - downloadUpdate: vi.fn<() => Promise>(), - on: vi.fn<(event: string, listener: (...args: unknown[]) => void) => void>(), - quitAndInstall: vi.fn<(isSilent?: boolean, isForceRunAfter?: boolean) => void>(), - setFeedURL: vi.fn<(options: unknown) => void>(), -})); +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const autoUpdaterMock = vi.hoisted(() => { + const handlers = new Map void>(); + return { + autoDownload: false, + autoInstallOnAppQuit: true, + forceDevUpdateConfig: false, + allowPrerelease: false, + channel: "", + checkForUpdates: vi.fn<() => Promise>(), + downloadUpdate: vi.fn<() => Promise>(), + on: vi.fn<(event: string, listener: (...args: unknown[]) => void) => void>( + (event, listener) => { + handlers.set(event, listener); + }, + ), + quitAndInstall: vi.fn<(isSilent?: boolean, isForceRunAfter?: boolean) => void>(), + setFeedURL: vi.fn<(options: unknown) => void>(), + /** Test helper: invoke a registered electron-updater event listener. */ + emit(event: string, ...args: unknown[]) { + handlers.get(event)?.(...args); + }, + }; +}); vi.mock("electron-updater", () => ({ autoUpdater: autoUpdaterMock, @@ -19,7 +30,20 @@ vi.mock("electron-updater", () => ({ import { createAutoUpdaterController } from "./autoUpdater"; +const INITIAL_CHECK_DELAY_MS = 30_000; +const PERIODIC_CHECK_INTERVAL_MS = 60 * 60 * 1_000; + describe("createAutoUpdaterController", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + autoUpdaterMock.checkForUpdates.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + it("runs the install hook before quitAndInstall", () => { const beforeInstall = vi.fn<() => void>(); const controller = createAutoUpdaterController( @@ -37,4 +61,58 @@ describe("createAutoUpdaterController", () => { ); expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledWith(process.platform === "win32", true); }); + + it("runs an initial check after launch and then keeps checking on the hourly interval", async () => { + const controller = createAutoUpdaterController(vi.fn(), "stable", false); + controller.initialize(); + + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(INITIAL_CHECK_DELAY_MS); + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1); + + // Settle the in-flight flag (nothing new found) so the next tick may run. + autoUpdaterMock.emit("update-not-available"); + + await vi.advanceTimersByTimeAsync(PERIODIC_CHECK_INTERVAL_MS); + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(2); + }); + + it("skips a periodic check while a check or download is still in flight", async () => { + const controller = createAutoUpdaterController(vi.fn(), "stable", false); + controller.initialize(); + + await vi.advanceTimersByTimeAsync(INITIAL_CHECK_DELAY_MS); + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1); + + // Simulate a check that found an update and is mid-download (no terminal + // event yet), so the updater is still busy when the interval fires. + autoUpdaterMock.emit("checking-for-update"); + autoUpdaterMock.emit("download-progress", { + percent: 42, + bytesPerSecond: 1000, + transferred: 420, + total: 1000, + }); + + await vi.advanceTimersByTimeAsync(PERIODIC_CHECK_INTERVAL_MS); + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1); + }); + + it("stops checking once an update is downloaded and after install", async () => { + const controller = createAutoUpdaterController(vi.fn(), "stable", false); + controller.initialize(); + + await vi.advanceTimersByTimeAsync(INITIAL_CHECK_DELAY_MS); + autoUpdaterMock.emit("update-downloaded", { version: "1.2.3" }); + + // An update is staged for install — no point polling further. + await vi.advanceTimersByTimeAsync(PERIODIC_CHECK_INTERVAL_MS); + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1); + + // Installing clears the interval, so advancing time does nothing more. + controller.installUpdate(); + await vi.advanceTimersByTimeAsync(PERIODIC_CHECK_INTERVAL_MS); + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/main/updates/autoUpdater.ts b/src/main/updates/autoUpdater.ts index 8f9999c1..2f09018e 100644 --- a/src/main/updates/autoUpdater.ts +++ b/src/main/updates/autoUpdater.ts @@ -3,6 +3,19 @@ import type { LightcodeChannel } from "@/shared/channel"; import type { UpdateStatus } from "@/shared/ipc"; import type { LightcodeDiagnosticTags } from "@/shared/diagnostics/sentryPrivacy"; +/** + * Delay before the first update check once the app is ready. Matches VS Code's + * update service, which waits ~30s after startup before its first check. + */ +const INITIAL_CHECK_DELAY_MS = 30_000; + +/** + * Cadence for recurring background update checks while the app keeps running. + * Modeled on VS Code's update service, which polls hourly after startup so a + * long-lived window still discovers releases without ever being restarted. + */ +const PERIODIC_CHECK_INTERVAL_MS = 60 * 60 * 1_000; + export interface AutoUpdaterController { initialize(): void; checkForUpdate(): Promise; @@ -18,6 +31,26 @@ export function createAutoUpdaterController( beforeInstall: () => void = () => {}, ): AutoUpdaterController { let initialized = false; + // True while a check or download is in flight; gates the periodic timer so a + // scheduled tick never stacks a redundant check on top of an active one. + let checkInFlight = false; + // True once an update is downloaded and waiting to install; we stop polling + // until the user restarts to apply it. + let updateReady = false; + let periodicTimer: ReturnType | null = null; + + // Fire a background check, but only when the updater is otherwise idle. Used + // by both the initial launch check and the recurring interval. + function runScheduledCheck(): void { + if (checkInFlight || updateReady) { + return; + } + checkInFlight = true; + void autoUpdater.checkForUpdates().catch((error: unknown) => { + checkInFlight = false; + reportError(error, { "lightcode.feature_area": "updates" }); + }); + } function initialize(): void { if (initialized) { @@ -42,15 +75,20 @@ export function createAutoUpdaterController( } autoUpdater.on("checking-for-update", () => { + checkInFlight = true; sendStatus({ type: "checking" }); }); autoUpdater.on("update-available", (info) => { + // A download starts automatically (autoDownload), so stay "in flight". + checkInFlight = true; sendStatus({ type: "update-available", version: info.version }); }); autoUpdater.on("update-not-available", () => { + checkInFlight = false; sendStatus({ type: "update-not-available" }); }); autoUpdater.on("download-progress", (progress) => { + checkInFlight = true; sendStatus({ type: "downloading", percent: progress.percent, @@ -60,18 +98,23 @@ export function createAutoUpdaterController( }); }); autoUpdater.on("update-downloaded", (info) => { + checkInFlight = false; + updateReady = true; sendStatus({ type: "downloaded", version: info.version }); }); autoUpdater.on("error", (error) => { + checkInFlight = false; reportError(error, { "lightcode.feature_area": "updates" }); sendStatus({ type: "error", message: error.message }); }); - setTimeout(() => { - void autoUpdater.checkForUpdates().catch((error: unknown) => { - reportError(error, { "lightcode.feature_area": "updates" }); - }); - }, 3000); + // First check ~30s after launch, then keep checking hourly so an app that + // is never restarted still surfaces new releases (the sidebar install + // affordance reacts to the resulting status). + setTimeout(runScheduledCheck, INITIAL_CHECK_DELAY_MS); + periodicTimer = setInterval(runScheduledCheck, PERIODIC_CHECK_INTERVAL_MS); + // Don't let the recurring timer keep the process alive on its own. + periodicTimer.unref?.(); } async function checkForUpdate(): Promise { @@ -95,6 +138,11 @@ export function createAutoUpdaterController( } function installUpdate(): void { + // Stop the recurring check so it can't race quitAndInstall. + if (periodicTimer) { + clearInterval(periodicTimer); + periodicTimer = null; + } beforeInstall(); autoUpdater.quitAndInstall(process.platform === "win32", true); }