Files
Termix/src/ui/tests/user/ElectronVersionCheck.test.tsx
T

205 lines
6.4 KiB
TypeScript
Raw Normal View History

+5
2026-08-19 14:12:06 -05:00
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import {
render,
screen,
waitFor,
cleanup,
fireEvent,
} from "@testing-library/react";
const mainAxios = vi.hoisted(() => ({
checkElectronUpdate: vi.fn(),
}));
vi.mock("@/main-axios.ts", () => mainAxios);
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts ? `${key}:${JSON.stringify(opts)}` : key,
}),
}));
// The whole component is a no-op outside Electron, so every test here needs the
// desktop build.
vi.mock("@/lib/electron", () => ({ isElectron: () => true }));
import { ElectronVersionCheck } from "../../user/ElectronVersionCheck";
const DISMISS_KEY = "electron-version-check-dismissed";
type ElectronTestWindow = Window &
typeof globalThis & {
electronAPI?: { getAppVersion?: () => Promise<string | undefined> };
};
function updateAvailable(localVersion: string, remoteVersion: string) {
return {
success: true,
status: "requires_update" as const,
localVersion,
remoteVersion,
latest_release: {
tag_name: `v${remoteVersion}`,
name: `Termix ${remoteVersion}`,
published_at: "2026-07-01T00:00:00Z",
html_url: `https://github.com/Termix-SSH/Termix/releases/tag/v${remoteVersion}`,
body: "",
},
};
}
function upToDate(version: string) {
return {
success: true,
status: "up_to_date" as const,
localVersion: version,
remoteVersion: version,
};
}
// Renders and waits until the check has resolved and the modal body is on
// screen, so that a later `not.toHaveBeenCalled()` is not vacuously true.
async function renderAndSettle(onContinue: () => void) {
render(<ElectronVersionCheck onContinue={onContinue} />);
await waitFor(() => {
expect(mainAxios.checkElectronUpdate).toHaveBeenCalledTimes(1);
});
await waitFor(() => {
expect(screen.getByText("common.continue")).toBeTruthy();
});
}
beforeEach(() => {
localStorage.clear();
mainAxios.checkElectronUpdate.mockReset();
(window as ElectronTestWindow).electronAPI = {
getAppVersion: vi.fn(async () => "2.6.0"),
} as unknown as ElectronTestWindow["electronAPI"];
});
afterEach(() => {
cleanup();
localStorage.clear();
delete (window as ElectronTestWindow).electronAPI;
});
describe("ElectronVersionCheck - dismissal is keyed on the offered version", () => {
it("still prompts when the stored dismissal is the local version and a newer release exists", async () => {
// Regression: the dismissal used to store the LOCAL version, and the
// up-to-date branch wrote it with no user action at all. A user who ran
// 2.6.0 while it was current had "2.6.0" stored, so once 2.6.1 shipped the
// modal was skipped on every launch - suppressed for exactly the users who
// had not updated yet.
localStorage.setItem(DISMISS_KEY, "2.6.0");
mainAxios.checkElectronUpdate.mockResolvedValue(
updateAvailable("2.6.0", "2.6.1"),
);
const onContinue = vi.fn();
await renderAndSettle(onContinue);
expect(onContinue).not.toHaveBeenCalled();
expect(screen.getByText("versionCheck.updateRequired")).toBeTruthy();
});
it("stores the remote version when the user dismisses the prompt", async () => {
mainAxios.checkElectronUpdate.mockResolvedValue(
updateAvailable("2.6.0", "2.6.1"),
);
const onContinue = vi.fn();
await renderAndSettle(onContinue);
fireEvent.click(screen.getByText("common.continue"));
await waitFor(() => {
expect(localStorage.getItem(DISMISS_KEY)).toBe("2.6.1");
});
expect(onContinue).toHaveBeenCalledTimes(1);
});
it("skips the prompt on a later launch offering the same remote version", async () => {
localStorage.setItem(DISMISS_KEY, "2.6.1");
mainAxios.checkElectronUpdate.mockResolvedValue(
updateAvailable("2.6.0", "2.6.1"),
);
const onContinue = vi.fn();
render(<ElectronVersionCheck onContinue={onContinue} />);
await waitFor(() => {
expect(onContinue).toHaveBeenCalledTimes(1);
});
});
it("prompts again once a newer release than the dismissed one appears", async () => {
localStorage.setItem(DISMISS_KEY, "2.6.1");
mainAxios.checkElectronUpdate.mockResolvedValue(
updateAvailable("2.6.0", "2.7.0"),
);
const onContinue = vi.fn();
await renderAndSettle(onContinue);
expect(onContinue).not.toHaveBeenCalled();
expect(screen.getByText("versionCheck.updateRequired")).toBeTruthy();
});
it("keys a dismissed beta prompt on the remote version too", async () => {
// Local ahead of the published release: dismissing must still record what
// was offered, so the next release re-prompts.
mainAxios.checkElectronUpdate.mockResolvedValue({
success: true,
status: "beta" as const,
localVersion: "2.7.0-beta",
remoteVersion: "2.6.1",
});
const onContinue = vi.fn();
await renderAndSettle(onContinue);
// Both the modal title and the alert heading use this key.
expect(screen.getAllByText("versionCheck.betaVersion").length).toBe(2);
fireEvent.click(screen.getByText("common.continue"));
await waitFor(() => {
expect(localStorage.getItem(DISMISS_KEY)).toBe("2.6.1");
});
});
it("continues without prompting when up to date, recording the offered version", async () => {
mainAxios.checkElectronUpdate.mockResolvedValue(upToDate("2.6.1"));
const onContinue = vi.fn();
render(<ElectronVersionCheck onContinue={onContinue} />);
await waitFor(() => {
expect(onContinue).toHaveBeenCalledTimes(1);
});
expect(localStorage.getItem(DISMISS_KEY)).toBe("2.6.1");
});
it("does not record a dismissal when the check itself fails", async () => {
mainAxios.checkElectronUpdate.mockResolvedValue({
success: false,
error: "Check failed",
});
const onContinue = vi.fn();
await renderAndSettle(onContinue);
// No offered version exists, so nothing may be written automatically and
// nothing may be suppressed.
expect(localStorage.getItem(DISMISS_KEY)).toBeNull();
expect(onContinue).not.toHaveBeenCalled();
fireEvent.click(screen.getByText("common.continue"));
// Dismissing falls back to the local version, which cannot match a later
// release and so cannot suppress its prompt.
await waitFor(() => {
expect(localStorage.getItem(DISMISS_KEY)).toBe("2.6.0");
});
expect(onContinue).toHaveBeenCalledTimes(1);
});
});