Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
498 lines
17 KiB
TypeScript
498 lines
17 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
classifyUpdaterFailure,
|
|
DesktopUpdateController,
|
|
type ElectronAutoUpdater,
|
|
initialUpdateState,
|
|
MIN_CHECK_INTERVAL_MS,
|
|
reduceUpdateState,
|
|
shouldCheck,
|
|
type UpdaterEvent,
|
|
updaterSupport,
|
|
} from "./auto-update.js";
|
|
|
|
const NOW = "2026-08-22T12:00:00.000Z";
|
|
const packaged = { packaged: true, version: "0.1.0" };
|
|
const clock = { now: () => 1_000, iso: () => NOW };
|
|
|
|
function apply(events: UpdaterEvent[], env = packaged) {
|
|
return events.reduce(
|
|
(state, event) => reduceUpdateState(state, event, NOW),
|
|
initialUpdateState(env),
|
|
);
|
|
}
|
|
|
|
function fakeUpdater(overrides: Partial<ElectronAutoUpdater> = {}) {
|
|
const listeners = new Map<string, (payload: unknown) => void>();
|
|
const updater: ElectronAutoUpdater = {
|
|
autoDownload: true,
|
|
autoInstallOnAppQuit: false,
|
|
allowDowngrade: true,
|
|
allowPrerelease: true,
|
|
disableWebInstaller: false,
|
|
on: vi.fn((event, listener) => {
|
|
listeners.set(event, listener);
|
|
return updater;
|
|
}),
|
|
checkForUpdates: vi.fn(async () => undefined),
|
|
downloadUpdate: vi.fn(async () => undefined),
|
|
quitAndInstall: vi.fn(),
|
|
...overrides,
|
|
};
|
|
return {
|
|
updater,
|
|
emit(event: string, payload?: unknown) {
|
|
listeners.get(event)?.(payload);
|
|
},
|
|
};
|
|
}
|
|
|
|
function deferred() {
|
|
let resolve!: () => void;
|
|
const promise = new Promise<void>((done) => {
|
|
resolve = done;
|
|
});
|
|
return { promise, resolve };
|
|
}
|
|
|
|
describe("updaterSupport", () => {
|
|
it("only runs in an installed build that has not opted out", () => {
|
|
expect(updaterSupport(packaged).supported).toBe(true);
|
|
expect(updaterSupport({ packaged: false, version: "0.1.0" }).supported).toBe(false);
|
|
expect(updaterSupport({ ...packaged, disabled: true }).supported).toBe(false);
|
|
});
|
|
|
|
it("starts unsupported builds in a state that explains itself", () => {
|
|
const state = initialUpdateState({ packaged: false, version: "0.1.0" });
|
|
expect(state).toMatchObject({ phase: "unsupported", currentVersion: "0.1.0" });
|
|
expect(state.message).toContain("installed build");
|
|
});
|
|
});
|
|
|
|
describe("classifyUpdaterFailure", () => {
|
|
it("treats a repository with no releases as an absent feed, not a fault", () => {
|
|
for (const message of [
|
|
"HttpError: 404 Not Found",
|
|
"Cannot find latest.yml in the latest release",
|
|
"No published versions on GitHub",
|
|
]) {
|
|
expect(classifyUpdaterFailure(new Error(message)).kind, message).toBe("no-releases");
|
|
}
|
|
});
|
|
|
|
it("stays silent when the machine is simply offline", () => {
|
|
for (const message of ["getaddrinfo ENOTFOUND github.com", "net::ERR_INTERNET_DISCONNECTED"]) {
|
|
expect(classifyUpdaterFailure(new Error(message))).toEqual({
|
|
kind: "offline",
|
|
message: null,
|
|
});
|
|
}
|
|
});
|
|
|
|
it("surfaces signing failures without leaking the updater's raw URL or path", () => {
|
|
const failure = classifyUpdaterFailure(
|
|
new Error("Code signature at https://private.invalid/update.zip did not pass validation"),
|
|
);
|
|
expect(failure.kind).toBe("signature");
|
|
expect(failure.message).toContain("could not be verified");
|
|
expect(failure.message).not.toContain("private.invalid");
|
|
});
|
|
|
|
it("keeps unclassified updater detail out of the renderer bridge", () => {
|
|
const failure = classifyUpdaterFailure(new Error("disk full at /Users/example/private"));
|
|
expect(failure).toEqual({
|
|
kind: "other",
|
|
message: "The update could not be completed. Try again later.",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("reduceUpdateState", () => {
|
|
it("walks from a check to a downloaded release", () => {
|
|
const state = apply([
|
|
{ type: "check-start" },
|
|
{ type: "available", version: "0.2.0" },
|
|
{ type: "download-start" },
|
|
{ type: "progress", percent: 42.6 },
|
|
{ type: "downloaded", version: "0.2.0" },
|
|
]);
|
|
expect(state).toMatchObject({
|
|
phase: "ready",
|
|
availableVersion: "0.2.0",
|
|
percent: 100,
|
|
});
|
|
expect(state.message).toBeNull();
|
|
});
|
|
|
|
it("clamps progress to a percentage while downloading", () => {
|
|
expect(
|
|
apply([
|
|
{ type: "available", version: "0.2.0" },
|
|
{ type: "progress", percent: -5 },
|
|
]).percent,
|
|
).toBe(0);
|
|
expect(
|
|
apply([
|
|
{ type: "available", version: "0.2.0" },
|
|
{ type: "download-start" },
|
|
{ type: "progress", percent: 250 },
|
|
]).percent,
|
|
).toBe(100);
|
|
});
|
|
|
|
it("ignores progress outside an active download", () => {
|
|
expect(apply([{ type: "progress", percent: 40 }])).toMatchObject({
|
|
phase: "idle",
|
|
percent: null,
|
|
});
|
|
expect(
|
|
apply([
|
|
{ type: "check-start" },
|
|
{ type: "failed", error: new Error("network down"), userInitiated: true },
|
|
{ type: "progress", percent: 50 },
|
|
]),
|
|
).toMatchObject({ phase: "error", percent: null });
|
|
});
|
|
|
|
it("clears an offer when a completed check finds nothing", () => {
|
|
const state = apply([
|
|
{ type: "available", version: "0.2.0" },
|
|
{ type: "check-start" },
|
|
{ type: "not-available" },
|
|
]);
|
|
expect(state).toMatchObject({ phase: "idle", availableVersion: null, checkedAt: NOW });
|
|
});
|
|
|
|
it("goes quiet on a missing feed instead of nagging every launch", () => {
|
|
const state = apply([
|
|
{ type: "check-start" },
|
|
{ type: "failed", error: new Error("HttpError: 404 Not Found"), userInitiated: false },
|
|
]);
|
|
expect(state.phase).toBe("unsupported");
|
|
expect(state.message).toContain("No desktop releases");
|
|
expect(reduceUpdateState(state, { type: "check-start" }, NOW)).toBe(state);
|
|
expect(shouldCheck(state, 10_000_000, 0)).toBe(false);
|
|
});
|
|
|
|
it("keeps a verified download when the updater emits a late failure", () => {
|
|
const ready = apply([
|
|
{ type: "downloaded", version: "0.2.0" },
|
|
{ type: "failed", error: new Error("socket hang up"), userInitiated: false },
|
|
]);
|
|
expect(ready).toMatchObject({ phase: "ready", availableVersion: "0.2.0", percent: 100 });
|
|
});
|
|
|
|
it("keeps a verified download ready after an install failure", () => {
|
|
const failed = apply([
|
|
{ type: "downloaded", version: "0.2.0" },
|
|
{
|
|
type: "failed",
|
|
error: new Error("quitAndInstall failed"),
|
|
userInitiated: true,
|
|
installFailed: true,
|
|
},
|
|
]);
|
|
expect(failed).toMatchObject({
|
|
phase: "ready",
|
|
availableVersion: "0.2.0",
|
|
message: "The update could not be completed. Try again later.",
|
|
});
|
|
});
|
|
|
|
it("does not let a stray check-start interrupt an in-flight download", () => {
|
|
const downloading = apply([
|
|
{ type: "download-start" },
|
|
{ type: "progress", percent: 40 },
|
|
{ type: "check-start" },
|
|
]);
|
|
expect(downloading).toMatchObject({ phase: "downloading", percent: 40 });
|
|
});
|
|
|
|
it("says nothing about being offline unless the user asked", () => {
|
|
const offline = new Error("getaddrinfo ENOTFOUND github.com");
|
|
expect(apply([{ type: "failed", error: offline, userInitiated: false }]).message).toBeNull();
|
|
expect(apply([{ type: "failed", error: offline, userInitiated: true }]).message).toContain(
|
|
"Could not reach",
|
|
);
|
|
});
|
|
|
|
it("reports other failures with a safe generic message", () => {
|
|
const state = apply([{ type: "failed", error: new Error("disk full"), userInitiated: false }]);
|
|
expect(state).toMatchObject({
|
|
phase: "error",
|
|
message: "The update could not be completed. Try again later.",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("shouldCheck", () => {
|
|
it("allows the first check and then rate-limits", () => {
|
|
const idle = initialUpdateState(packaged);
|
|
expect(shouldCheck(idle, 1_000, 0)).toBe(true);
|
|
expect(shouldCheck(idle, 1_000, 900)).toBe(false);
|
|
expect(shouldCheck(idle, MIN_CHECK_INTERVAL_MS + 1_000, 1_000)).toBe(true);
|
|
});
|
|
|
|
it("does not replace work, an available update, or a downloaded update", () => {
|
|
for (const state of [
|
|
apply([{ type: "check-start" }]),
|
|
apply([{ type: "available", version: "0.2.0" }]),
|
|
apply([{ type: "download-start" }]),
|
|
apply([{ type: "downloaded", version: "0.2.0" }]),
|
|
]) {
|
|
expect(shouldCheck(state, 10_000_000, 0)).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("lets a manual check retry after an empty feed when the install supports updates", () => {
|
|
const absent = apply([
|
|
{ type: "check-start" },
|
|
{ type: "failed", error: new Error("HttpError: 404 Not Found"), userInitiated: false },
|
|
]);
|
|
expect(
|
|
shouldCheck(absent, 1_000, 1_000, {
|
|
userInitiated: true,
|
|
environmentSupportsUpdates: true,
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
shouldCheck(absent, 1_000, 1_000, {
|
|
userInitiated: true,
|
|
environmentSupportsUpdates: false,
|
|
}),
|
|
).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("DesktopUpdateController", () => {
|
|
it("locks the updater to stable, upgrade-only, signed installer behavior", async () => {
|
|
const fake = fakeUpdater();
|
|
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
|
|
|
|
await controller.check(false);
|
|
|
|
expect(fake.updater).toMatchObject({
|
|
autoDownload: false,
|
|
autoInstallOnAppQuit: true,
|
|
allowPrerelease: false,
|
|
allowDowngrade: false,
|
|
disableWebInstaller: true,
|
|
});
|
|
});
|
|
|
|
it("serializes concurrent checks across the asynchronous updater load", async () => {
|
|
const check = deferred();
|
|
const fake = fakeUpdater({ checkForUpdates: vi.fn(() => check.promise) });
|
|
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
|
|
|
|
const first = controller.check(false);
|
|
const second = controller.check(true);
|
|
await vi.waitFor(() => expect(fake.updater.checkForUpdates).toHaveBeenCalledTimes(1));
|
|
expect(second).toBe(first);
|
|
check.resolve();
|
|
await first;
|
|
});
|
|
|
|
it("automatically downloads one verified stable update and tracks progress", async () => {
|
|
let fake: ReturnType<typeof fakeUpdater>;
|
|
fake = fakeUpdater({
|
|
checkForUpdates: vi.fn(async () => {
|
|
fake.emit("checking-for-update");
|
|
fake.emit("update-available", { version: "0.2.0" });
|
|
}),
|
|
downloadUpdate: vi.fn(async () => {
|
|
fake.emit("download-progress", { percent: 48.8 });
|
|
fake.emit("update-downloaded", { version: "0.2.0" });
|
|
}),
|
|
});
|
|
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
|
|
|
|
await controller.check(false);
|
|
await vi.waitFor(() => expect(fake.updater.downloadUpdate).toHaveBeenCalledTimes(1));
|
|
await vi.waitFor(() => expect(controller.state().phase).toBe("ready"));
|
|
expect(controller.state()).toMatchObject({
|
|
availableVersion: "0.2.0",
|
|
percent: 100,
|
|
});
|
|
});
|
|
|
|
it("joins manual downloads to the automatic download already in flight", async () => {
|
|
const download = deferred();
|
|
let fake: ReturnType<typeof fakeUpdater>;
|
|
fake = fakeUpdater({
|
|
checkForUpdates: vi.fn(async () => {
|
|
fake.emit("checking-for-update");
|
|
fake.emit("update-available", { version: "0.2.0" });
|
|
}),
|
|
downloadUpdate: vi.fn(() => download.promise),
|
|
});
|
|
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
|
|
|
|
await controller.check(false);
|
|
const first = controller.download();
|
|
const second = controller.download();
|
|
expect(second).toBe(first);
|
|
expect(fake.updater.downloadUpdate).toHaveBeenCalledTimes(1);
|
|
download.resolve();
|
|
await first;
|
|
});
|
|
|
|
it("does not treat later background failures as user-requested", async () => {
|
|
let now = 1_000;
|
|
let fake: ReturnType<typeof fakeUpdater>;
|
|
fake = fakeUpdater({
|
|
checkForUpdates: vi.fn(async () => {
|
|
fake.emit("checking-for-update");
|
|
if (vi.mocked(fake.updater.checkForUpdates).mock.calls.length === 1) {
|
|
fake.emit("update-not-available");
|
|
} else {
|
|
fake.emit("error", new Error("getaddrinfo ENOTFOUND github.com"));
|
|
}
|
|
}),
|
|
});
|
|
const controller = new DesktopUpdateController(packaged, async () => fake.updater, {
|
|
now: () => now,
|
|
iso: () => NOW,
|
|
});
|
|
|
|
await controller.check(true);
|
|
now += MIN_CHECK_INTERVAL_MS;
|
|
await controller.check(false);
|
|
|
|
expect(controller.state()).toMatchObject({ phase: "idle", message: null });
|
|
});
|
|
|
|
it("runs installation at most once", async () => {
|
|
let fake: ReturnType<typeof fakeUpdater>;
|
|
fake = fakeUpdater({
|
|
checkForUpdates: vi.fn(async () => {
|
|
fake.emit("checking-for-update");
|
|
fake.emit("update-available", { version: "0.2.0" });
|
|
}),
|
|
downloadUpdate: vi.fn(async () => {
|
|
fake.emit("update-downloaded", { version: "0.2.0" });
|
|
}),
|
|
});
|
|
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
|
|
await controller.check(false);
|
|
await vi.waitFor(() => expect(controller.state().phase).toBe("ready"));
|
|
|
|
await Promise.all([controller.install(), controller.install()]);
|
|
expect(fake.updater.quitAndInstall).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("retries installation after a synchronous failure without downloading again", async () => {
|
|
let fake: ReturnType<typeof fakeUpdater>;
|
|
fake = fakeUpdater({
|
|
checkForUpdates: vi.fn(async () => {
|
|
fake.emit("checking-for-update");
|
|
fake.emit("update-available", { version: "0.2.0" });
|
|
}),
|
|
downloadUpdate: vi.fn(async () => {
|
|
fake.emit("update-downloaded", { version: "0.2.0" });
|
|
}),
|
|
quitAndInstall: vi.fn(() => {
|
|
throw new Error("quitAndInstall failed");
|
|
}),
|
|
});
|
|
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
|
|
await controller.check(false);
|
|
await vi.waitFor(() => expect(controller.state().phase).toBe("ready"));
|
|
|
|
const state = await controller.install();
|
|
expect(state).toMatchObject({
|
|
phase: "ready",
|
|
message: "The update could not be completed. Try again later.",
|
|
});
|
|
expect(await controller.install()).toMatchObject({
|
|
phase: "ready",
|
|
message: "The update could not be completed. Try again later.",
|
|
});
|
|
vi.mocked(fake.updater.quitAndInstall).mockImplementation(() => undefined);
|
|
expect(await controller.install()).toMatchObject({ phase: "ready", message: null });
|
|
expect(fake.updater.quitAndInstall).toHaveBeenCalledTimes(3);
|
|
expect(fake.updater.downloadUpdate).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("reports asynchronous installation errors and permits retry", async () => {
|
|
const onInstallFailure = vi.fn();
|
|
let fake: ReturnType<typeof fakeUpdater>;
|
|
fake = fakeUpdater({
|
|
checkForUpdates: vi.fn(async () => {
|
|
fake.emit("checking-for-update");
|
|
fake.emit("update-available", { version: "0.2.0" });
|
|
}),
|
|
downloadUpdate: vi.fn(async () => {
|
|
fake.emit("update-downloaded", { version: "0.2.0" });
|
|
}),
|
|
});
|
|
const controller = new DesktopUpdateController(
|
|
packaged,
|
|
async () => fake.updater,
|
|
clock,
|
|
onInstallFailure,
|
|
);
|
|
await controller.check(false);
|
|
await vi.waitFor(() => expect(controller.state().phase).toBe("ready"));
|
|
|
|
expect(await controller.install()).toMatchObject({ phase: "ready", message: null });
|
|
expect(fake.updater.quitAndInstall).toHaveBeenCalledTimes(1);
|
|
|
|
fake.emit("error", new Error("installer failed after returning"));
|
|
expect(controller.state()).toMatchObject({
|
|
phase: "ready",
|
|
availableVersion: "0.2.0",
|
|
message: "The update could not be completed. Try again later.",
|
|
});
|
|
expect(onInstallFailure).toHaveBeenCalledTimes(1);
|
|
|
|
expect(await controller.install()).toMatchObject({ phase: "ready", message: null });
|
|
expect(fake.updater.quitAndInstall).toHaveBeenCalledTimes(2);
|
|
expect(fake.updater.downloadUpdate).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("ignores late updater errors while ready when install was never started", async () => {
|
|
let fake: ReturnType<typeof fakeUpdater>;
|
|
fake = fakeUpdater({
|
|
checkForUpdates: vi.fn(async () => {
|
|
fake.emit("checking-for-update");
|
|
fake.emit("update-available", { version: "0.2.0" });
|
|
}),
|
|
downloadUpdate: vi.fn(async () => {
|
|
fake.emit("update-downloaded", { version: "0.2.0" });
|
|
}),
|
|
});
|
|
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
|
|
await controller.check(false);
|
|
await vi.waitFor(() => expect(controller.state().phase).toBe("ready"));
|
|
const ready = controller.state();
|
|
|
|
fake.emit("error", new Error("socket hang up"));
|
|
expect(controller.state()).toEqual(ready);
|
|
expect(await controller.install()).toMatchObject({ phase: "ready", message: null });
|
|
expect(fake.updater.quitAndInstall).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("lets a manual check escape a prior empty-feed freeze", async () => {
|
|
let fake: ReturnType<typeof fakeUpdater>;
|
|
fake = fakeUpdater({
|
|
checkForUpdates: vi.fn(async () => {
|
|
fake.emit("checking-for-update");
|
|
if (vi.mocked(fake.updater.checkForUpdates).mock.calls.length === 1) {
|
|
fake.emit("error", new Error("HttpError: 404 Not Found"));
|
|
} else {
|
|
fake.emit("update-not-available");
|
|
}
|
|
}),
|
|
});
|
|
const controller = new DesktopUpdateController(packaged, async () => fake.updater, clock);
|
|
|
|
await controller.check(false);
|
|
expect(controller.state().phase).toBe("unsupported");
|
|
|
|
await controller.check(true);
|
|
expect(fake.updater.checkForUpdates).toHaveBeenCalledTimes(2);
|
|
expect(controller.state()).toMatchObject({ phase: "idle", message: null });
|
|
});
|
|
});
|