diff --git a/packages/desktop/src/features/auto-updater.test.ts b/packages/desktop/src/features/auto-updater.test.ts index 3c28e61c1..d4c42c032 100644 --- a/packages/desktop/src/features/auto-updater.test.ts +++ b/packages/desktop/src/features/auto-updater.test.ts @@ -4,24 +4,99 @@ import path from "node:path"; import { UUID } from "builder-util-runtime"; import { describe, expect, it, vi } from "vitest"; +const { autoUpdaterMock } = vi.hoisted(() => { + const handlers = new Map void>(); + return { + autoUpdaterMock: { + handlers, + logger: { + debug: vi.fn(), + error: vi.fn((message: unknown) => console.error(message)), + info: vi.fn(), + warn: vi.fn(), + }, + checkForUpdates: vi.fn(), + downloadUpdate: vi.fn(), + on: vi.fn((event: string, handler: (value: unknown) => void) => { + handlers.set(event, handler); + }), + quitAndInstall: vi.fn(), + }, + }; +}); + vi.mock("electron", () => ({ app: { getPath: vi.fn(), + isPackaged: true, }, })); vi.mock("electron-updater", () => ({ - autoUpdater: {}, + autoUpdater: autoUpdaterMock, })); import { bucketFromStagingUserId, + checkForAppUpdate, resolveStagingUserId, rolloutManifestSchema, shouldAdmitToRollout, shouldInstallAppUpdateOnQuit, } from "./auto-updater"; +describe("checkForAppUpdate", () => { + it("treats an unpublished channel manifest as an unavailable update", async () => { + const error = Object.assign(new Error("Cannot find latest-mac.yml"), { + code: "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND", + }); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + autoUpdaterMock.checkForUpdates.mockImplementationOnce(async () => { + autoUpdaterMock.logger.error(error); + autoUpdaterMock.handlers.get("error")?.(error); + throw error; + }); + + const result = await checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + + expect(result).toEqual({ + hasUpdate: false, + readyToInstall: false, + currentVersion: "1.2.3", + latestVersion: "1.2.3", + body: null, + date: null, + errorMessage: null, + }); + expect(consoleError).not.toHaveBeenCalled(); + consoleError.mockRestore(); + }); + + it("keeps genuine updater failures visible", async () => { + const error = new Error("network down"); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined); + autoUpdaterMock.checkForUpdates.mockImplementationOnce(async () => { + autoUpdaterMock.logger.error(error); + autoUpdaterMock.handlers.get("error")?.(error); + throw error; + }); + + const result = await checkForAppUpdate({ + currentVersion: "1.2.3", + releaseChannel: "stable", + intent: "manual", + }); + + expect(result.errorMessage).toBe("network down"); + expect(consoleError).toHaveBeenCalled(); + consoleError.mockRestore(); + }); +}); + describe("shouldInstallAppUpdateOnQuit", () => { it("keeps Linux AppImage updates on the manual install path", () => { expect(shouldInstallAppUpdateOnQuit({ platform: "linux", isAppImage: true })).toBe(false); diff --git a/packages/desktop/src/features/auto-updater.ts b/packages/desktop/src/features/auto-updater.ts index c9b9a983b..d0e5f432d 100644 --- a/packages/desktop/src/features/auto-updater.ts +++ b/packages/desktop/src/features/auto-updater.ts @@ -33,6 +33,17 @@ export { let cachedStagingUserIdPromise: Promise | null = null; +const UPDATE_CHANNEL_NOT_PUBLISHED_CODE = "ERR_UPDATER_CHANNEL_FILE_NOT_FOUND"; + +function isUpdateChannelNotPublished(error: unknown): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === UPDATE_CHANNEL_NOT_PUBLISHED_CODE + ); +} + export function shouldAdmitToRollout(args: { channel: AppReleaseChannel; rolloutHours: number | undefined; @@ -109,6 +120,18 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime { if (this.configured) return; this.configured = true; + // electron-updater logs every emitted error before consumers can classify it. + // Paseo reports genuine check, runtime, and install failures through the + // callbacks below, so leave internal error logging disabled to avoid both + // duplicate logs and expected missing-channel noise. + const updaterLogger = autoUpdater.logger; + autoUpdater.logger = { + debug: updaterLogger?.debug ? (message) => updaterLogger.debug?.(message) : undefined, + error: () => undefined, + info: (message) => updaterLogger?.info(message), + warn: (message) => updaterLogger?.warn(message), + }; + autoUpdater.on("update-available", (info) => { input.onUpdateAvailable(info as RuntimeUpdateInfo); }); @@ -119,17 +142,23 @@ class ElectronAppUpdateRuntime implements AppUpdateRuntime { input.onUpdateNotAvailable(); }); autoUpdater.on("error", (error) => { + if (isUpdateChannelNotPublished(error)) return; input.onError(error); }); } async checkForUpdates(): Promise { - const result = await autoUpdater.checkForUpdates(); - if (!result) return null; - return { - isUpdateAvailable: result.isUpdateAvailable, - updateInfo: result.updateInfo as RuntimeUpdateInfo, - }; + try { + const result = await autoUpdater.checkForUpdates(); + if (!result) return null; + return { + isUpdateAvailable: result.isUpdateAvailable, + updateInfo: result.updateInfo as RuntimeUpdateInfo, + }; + } catch (error) { + if (isUpdateChannelNotPublished(error)) return null; + throw error; + } } downloadUpdate(): Promise {