Treat unpublished desktop updates as unavailable (#3076)

* fix(desktop): treat unpublished updates as unavailable

GitHub releases can become visible before their platform updater manifests finish publishing. Treat that specific upstream condition as an unavailable update while preserving Paseo reporting for genuine updater failures.

* refactor(desktop): inject updater runtime dependency

Exercise updater event and rejection behavior through a typed fake at the runtime boundary instead of coordinating a hoisted module singleton in tests.

* fix(desktop): preserve lazy updater initialization

* revert(desktop): drop updater runtime refactor
This commit is contained in:
Mohamed Boudra
2026-08-09 11:20:16 +02:00
committed by GitHub
parent fd9cc5d693
commit c77a6761af
2 changed files with 111 additions and 7 deletions
@@ -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<string, (value: unknown) => 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);
+35 -6
View File
@@ -33,6 +33,17 @@ export {
let cachedStagingUserIdPromise: Promise<string> | 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<RuntimeUpdateCheckResult | null> {
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<unknown> {