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) => 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 }; }; 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(); 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(); 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(); 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); }); });