Let manual update checks bypass rollout

Automatic desktop update checks still respect rollout admission. Manual checks carry an explicit intent through the app and desktop updater service, and the up-to-date state now shows when the last check completed.
This commit is contained in:
Mohamed Boudra
2026-06-09 15:47:30 +07:00
parent e72b0773e6
commit eca0a5bf67
14 changed files with 809 additions and 284 deletions
@@ -0,0 +1,114 @@
import { describe, expect, it } from "vitest";
import {
createAppUpdateService,
type AppUpdateRuntime,
type AppUpdateRuntimeConfiguration,
type RuntimeUpdateInfo,
} from "./app-update-service";
class FakeAppUpdateRuntime implements AppUpdateRuntime {
private checks: Array<{ isUpdateAvailable: boolean; updateInfo: RuntimeUpdateInfo } | null> = [];
private gate: ((info: RuntimeUpdateInfo) => boolean | Promise<boolean>) | null = null;
configure(input: AppUpdateRuntimeConfiguration): void {
this.gate = input.shouldAdmitUpdate;
}
nextCheck(result: { isUpdateAvailable: boolean; updateInfo: RuntimeUpdateInfo } | null): void {
this.checks.push(result);
}
async checkForUpdates(): Promise<{
isUpdateAvailable: boolean;
updateInfo: RuntimeUpdateInfo;
} | null> {
const result = this.checks.shift() ?? null;
if (!result || !this.gate) return result;
const admitted = await this.gate(result.updateInfo);
return { ...result, isUpdateAvailable: result.isUpdateAvailable && admitted };
}
async downloadUpdate(): Promise<void> {}
quitAndInstall(): void {}
}
function createService(input?: { now?: () => number; bucket?: () => Promise<number> }) {
const runtime = new FakeAppUpdateRuntime();
const service = createAppUpdateService({
runtime,
isPackaged: () => true,
now: input?.now ?? (() => Date.parse("2026-04-28T12:00:00.000Z")),
bucket: input?.bucket ?? (async () => 0.99),
});
return { runtime, service };
}
const rolledOutUpdate = {
version: "1.2.4",
releaseDate: "2026-04-28T00:00:00.000Z",
rolloutHours: 24,
};
describe("app update service", () => {
it("does not expose automatic stable updates before the user is admitted to rollout", async () => {
const { runtime, service } = createService();
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
const result = await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "automatic",
});
expect(result).toEqual({
hasUpdate: false,
readyToInstall: false,
currentVersion: "1.2.3",
latestVersion: "1.2.3",
body: null,
date: null,
});
});
it("exposes manual stable updates even before the user is admitted to rollout", async () => {
const { runtime, service } = createService();
runtime.nextCheck({ isUpdateAvailable: true, updateInfo: rolledOutUpdate });
const result = await service.checkForAppUpdate({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
expect(result).toEqual({
hasUpdate: true,
readyToInstall: false,
currentVersion: "1.2.3",
latestVersion: "1.2.4",
body: null,
date: "2026-04-28T00:00:00.000Z",
});
});
it("trusts the runtime availability decision before comparing versions", async () => {
const { runtime, service } = createService({ bucket: async () => 0 });
runtime.nextCheck({ isUpdateAvailable: false, updateInfo: rolledOutUpdate });
const result = await service.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,
});
});
});