Files
paseo/packages/desktop/src/features/auto-updater.test.ts
T
Mohamed Boudra 9e90b86afa Restore reliable macOS desktop updates (#4322)
* fix(desktop): restore macOS app updates

Electron 41's Squirrel helper can leave the update handoff dormant on current macOS, so Paseo quits cleanly but relaunches the old bundle. Electron 44 wakes ShipIt through its XPC service.

Include updater lifecycle events and ShipIt state in app diagnostics so future handoff failures identify the current version, target version, cache paths, and installer output.

* fix(desktop): harden updater diagnostics

Keep each ShipIt file's evidence available when another read fails, carry the service-selected version into updater lifecycle logs, and make clean workspace installs resolve the Electron runtime used by desktop tests.

* refactor(desktop): name updater install options

* refactor(desktop): name updater log payloads

* refactor(desktop): name clipboard payload

* fix(desktop): surface updater lookup failures

* test(desktop): await guest focus transition

* fix(desktop): guard macOS update compatibility

* test(release): validate desktop manifests before publish

* test(desktop): await populated daemon pid file
2026-09-04 22:17:39 +02:00

320 lines
9.3 KiB
TypeScript

import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
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: autoUpdaterMock,
}));
import {
bucketFromStagingUserId,
checkForAppUpdate,
createAppUpdateLifecycleLogger,
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();
});
it("logs the update handoff with current and selected target versions", () => {
const info = vi.fn();
const lifecycleLog = createAppUpdateLifecycleLogger({ info });
lifecycleLog.checkStarted({
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
lifecycleLog.checkCompleted({
currentVersion: "1.2.3",
targetVersion: "1.2.5",
releaseChannel: "stable",
intent: "manual",
hasUpdate: true,
readyToInstall: true,
errorMessage: null,
});
lifecycleLog.updateDownloaded("1.2.4");
lifecycleLog.downloadRequested("1.2.5");
lifecycleLog.quitAndInstallRequested({
targetVersion: "1.2.5",
isSilent: false,
isForceRunAfter: true,
});
expect(info).toHaveBeenCalledWith("[auto-updater] check started", {
currentVersion: "1.2.3",
releaseChannel: "stable",
intent: "manual",
});
expect(info).toHaveBeenCalledWith("[auto-updater] update downloaded", {
targetVersion: "1.2.4",
});
expect(info).toHaveBeenCalledWith("[auto-updater] download requested", {
targetVersion: "1.2.5",
});
expect(info).toHaveBeenCalledWith("[auto-updater] quitAndInstall requested", {
targetVersion: "1.2.5",
isSilent: false,
isForceRunAfter: true,
});
});
});
describe("shouldInstallAppUpdateOnQuit", () => {
it("keeps Linux AppImage updates on the manual install path", () => {
expect(shouldInstallAppUpdateOnQuit({ platform: "linux", isAppImage: true })).toBe(false);
expect(shouldInstallAppUpdateOnQuit({ platform: "linux", isAppImage: false })).toBe(true);
expect(shouldInstallAppUpdateOnQuit({ platform: "darwin", isAppImage: false })).toBe(true);
expect(shouldInstallAppUpdateOnQuit({ platform: "win32", isAppImage: false })).toBe(true);
});
});
describe("shouldAdmitToRollout", () => {
it("admits beta, missing rollout hours, zero-hour rollout, and missing release date", () => {
expect(
shouldAdmitToRollout({
channel: "beta",
rolloutHours: 24,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T01:00:00.000Z"),
bucket: 0.99,
}),
).toBe(true);
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: undefined,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T01:00:00.000Z"),
bucket: 0.99,
}),
).toBe(true);
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: 0,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T01:00:00.000Z"),
bucket: 0.99,
}),
).toBe(true);
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: 24,
releaseDate: undefined,
now: Date.parse("2026-04-28T01:00:00.000Z"),
bucket: 0.99,
}),
).toBe(true);
});
it("blocks future releases and respects the linear threshold mid-rollout", () => {
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: 24,
releaseDate: "2026-04-28T02:00:00.000Z",
now: Date.parse("2026-04-28T01:00:00.000Z"),
bucket: 0,
}),
).toBe(false);
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: 24,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T12:00:00.000Z"),
bucket: 0.49,
}),
).toBe(true);
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: 24,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T12:00:00.000Z"),
bucket: 0.51,
}),
).toBe(false);
});
it("blocks the bucket-zero client at exact release time, admits as soon as time advances", () => {
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: 24,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T00:00:00.000Z"),
bucket: 0,
}),
).toBe(false);
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: 24,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-28T00:00:00.001Z"),
bucket: 0,
}),
).toBe(true);
});
it("admits the highest-bucket client at and past the rollout end", () => {
const maxBucket = (0x100000000 - 1) / 0x100000000;
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: 24,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2026-04-29T00:00:00.000Z"),
bucket: maxBucket,
}),
).toBe(true);
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: 24,
releaseDate: "2026-04-28T00:00:00.000Z",
now: Date.parse("2027-04-28T00:00:00.000Z"),
bucket: maxBucket,
}),
).toBe(true);
});
it("admits when releaseDate is unparseable", () => {
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: 24,
releaseDate: "not a date",
now: Date.parse("2026-04-28T12:00:00.000Z"),
bucket: 0.99,
}),
).toBe(true);
});
it("treats garbage manifest rollout fields as missing and admits", () => {
const parsed = rolloutManifestSchema.parse({
rolloutHours: "not a number",
releaseDate: 12345,
});
expect(
shouldAdmitToRollout({
channel: "stable",
rolloutHours: parsed.rolloutHours,
releaseDate: parsed.releaseDate,
now: Date.parse("2026-04-28T12:00:00.000Z"),
bucket: 0.99,
}),
).toBe(true);
});
it("maps the maximum 32-bit slot to a bucket strictly less than 1", () => {
const allOnes = "ffffffff-ffff-ffff-ffff-ffffffffffff";
const allZeros = "00000000-0000-0000-0000-000000000000";
expect(bucketFromStagingUserId(allOnes)).toBeLessThan(1);
expect(bucketFromStagingUserId(allOnes)).toBeGreaterThan(0.999);
expect(bucketFromStagingUserId(allZeros)).toBe(0);
});
it("creates and then reuses the on-disk staging user id", async () => {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "paseo-updater-id-"));
const filePath = path.join(tempDir, ".updaterId");
try {
const first = await resolveStagingUserId(filePath);
const stored = (await readFile(filePath, "utf8")).trim();
const second = await resolveStagingUserId(filePath);
expect(UUID.check(stored)).toBeTruthy();
expect(second).toBe(first);
} finally {
await rm(tempDir, { force: true, recursive: true });
}
});
});