feat: desktop app auto updates with automatic, ask first or never modes

This commit is contained in:
LukeGus committed 2026-10-11 12:35:43 -05:00
1 parent 8403449669
commit 1aa1bde5f7
33 files changed
+1728 -177

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { updateToastFor, type DesktopUpdateState } from "@/lib/desktop-updates";
function state(patch: Partial<DesktopUpdateState>): DesktopUpdateState {
return {
kind: "auto",
mode: "prompt",
currentVersion: "26.10.1",
status: "available",
version: "26.10.2",
releaseUrl: null,
progress: null,
error: null,
...patch,
};
}
describe("updateToastFor", () => {
it("asks before downloading in ask first mode", () => {
expect(updateToastFor(state({}))).toBe("prompt");
});
it("stays quiet while automatic mode downloads", () => {
expect(updateToastFor(state({ mode: "automatic" }))).toBeNull();
expect(
updateToastFor(state({ mode: "automatic", status: "downloading" })),
).toBeNull();
});
it("offers a restart once downloaded", () => {
expect(updateToastFor(state({ status: "downloaded" }))).toBe("ready");
expect(
updateToastFor(state({ mode: "automatic", status: "downloaded" })),
).toBe("ready");
});
it("links the release for installs that can not update", () => {
expect(updateToastFor(state({ kind: "manual" }))).toBe("manual");
});
it("never toasts when updates are off", () => {
expect(updateToastFor(state({ mode: "never" }))).toBeNull();
expect(
updateToastFor(state({ mode: "never", status: "downloaded" })),
).toBeNull();
expect(updateToastFor(null)).toBeNull();
});
});
@@ -93,6 +93,22 @@ describe("CORE_STEPS", () => {
]);
});
it("asks how the desktop app updates when it has an updater", () => {
const win = window as unknown as { electronAPI?: unknown };
win.electronAPI = { isElectron: true, updates: {} };
try {
expect(ids(ctx({ isDesktop: true }))).toEqual([
"desktop-sync",
"desktop-updates",
"preset",
"appearance",
]);
expect(ids(ctx())).not.toContain("desktop-updates");
} finally {
delete win.electronAPI;
}
});
it("offers account security only when there is something to set up", () => {
expect(ids(ctx({ canEnrollSecondFactor: true }))).toEqual([
"preset",
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
act,
cleanup,
fireEvent,
render,
screen,
} from "@testing-library/react";
import { DesktopUpdatesCard } from "@/settings/DesktopUpdatesCard";
import type { DesktopUpdateState } from "@/lib/desktop-updates";
type TestWindow = { electronAPI?: unknown };
function state(patch: Partial<DesktopUpdateState> = {}): DesktopUpdateState {
return {
kind: "auto",
mode: "prompt",
currentVersion: "26.10.1",
status: "idle",
version: null,
releaseUrl: null,
progress: null,
error: null,
...patch,
};
}
function bridge(initial: DesktopUpdateState) {
return {
getState: vi.fn(async () => initial),
check: vi.fn(async () => initial),
download: vi.fn(async () => initial),
install: vi.fn(async () => true),
setMode: vi.fn(async () => initial),
onState: vi.fn(() => () => {}),
};
}
async function renderWith(initial: DesktopUpdateState) {
const api = bridge(initial);
(window as TestWindow).electronAPI = { isElectron: true, updates: api };
await act(async () => {
render(<DesktopUpdatesCard />);
});
return api;
}
afterEach(() => {
cleanup();
delete (window as TestWindow).electronAPI;
});
describe("DesktopUpdatesCard", () => {
it("renders nothing in the browser", () => {
const { container } = render(<DesktopUpdatesCard />);
expect(container.innerHTML).toBe("");
});
it("checks on demand", async () => {
const api = await renderWith(state());
fireEvent.click(screen.getByText("desktopUpdates.checkNow"));
expect(api.check).toHaveBeenCalled();
});
it("offers the download in ask first mode", async () => {
const api = await renderWith(
state({ status: "available", version: "26.10.2" }),
);
fireEvent.click(screen.getByText("desktopUpdates.download"));
expect(api.download).toHaveBeenCalled();
});
it("restarts into a downloaded update", async () => {
const api = await renderWith(
state({ status: "downloaded", version: "26.10.2" }),
);
fireEvent.click(screen.getByText("desktopUpdates.restart"));
expect(api.install).toHaveBeenCalled();
});
it("links the release for installs that can not update", async () => {
await renderWith(
state({
kind: "manual",
status: "available",
version: "26.10.2",
releaseUrl: "https://example.com/r",
}),
);
expect(
screen.getByText("desktopUpdates.download").closest("a"),
).toHaveProperty("href", "https://example.com/r");
});
it("hides on dev builds", async () => {
await renderWith(state({ kind: "disabled" }));
expect(screen.queryByText("desktopUpdates.title")).toBeNull();
});
});
@@ -33,6 +33,16 @@ describe("settings pages", () => {
expect(admin).toContain("security");
});
it("shows Updates to everyone on desktop but only admins in the browser", () => {
const ids = (isAdmin: boolean, isElectron: boolean) =>
visibleSettingsPages(CORE_SETTINGS_PAGES, { isAdmin, isElectron }).map(
(page) => page.id,
);
expect(ids(false, true)).toContain("updates");
expect(ids(false, false)).not.toContain("updates");
expect(ids(true, false)).toContain("updates");
});
it("finds a page by a label shown on it, not only its name", () => {
const hits = searchSettingsPages(CORE_SETTINGS_PAGES, "theme", translate);
expect(hits.some((hit) => hit.page.id === "appearance")).toBe(true);
+5 -1
View File
@@ -38,8 +38,12 @@ describe("onboarding seen map", () => {
});
it("legacy seen covers every core step at launch", () => {
// Steps added later stay out so upgraded users still see them.
const addedLater = ["desktop-updates"];
expect(Object.keys(LEGACY_SEEN).sort()).toEqual(
Object.keys(CORE_ONBOARDING_STEPS).sort(),
Object.keys(CORE_ONBOARDING_STEPS)
.filter((key) => !addedLater.includes(key))
.sort(),
);
});
});