/** * @file UpdateNotifier.test.tsx * @description Pins the "Update now" self-apply flow added on top of the * existing manual-command modal: the button only renders when the checkout * situation is fast-forwardable, clicking it calls `api.updates.apply()`, * and a successful `applied: true` response flips the modal into a * "restarting" state that polls `api.updates.status()` until it resolves. * * @author Nguyễn Ngọc Trí Vĩ */ import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import i18n from "i18next"; import { UpdateNotifier } from "../UpdateNotifier"; import type { UpdateStatusPayload } from "../../lib/types"; const statusMock = vi.fn(); const checkMock = vi.fn(); const applyMock = vi.fn(); vi.mock("../../lib/api", async (importOriginal) => { const actual = await importOriginal>(); return { ...actual, api: { updates: { status: (...args: unknown[]) => statusMock(...args), check: (...args: unknown[]) => checkMock(...args), apply: (...args: unknown[]) => applyMock(...args), }, }, }; }); const BASE_STATUS: UpdateStatusPayload = { git_repo: true, update_available: true, repo_root: "/repo", remote_ref: "origin/main", canonical_remote: "origin", current_branch: "main", tracking_upstream: "origin/main", tracks_canonical: true, situation: "tracking_canonical", situation_note: null, local_sha: "aaa", remote_sha: "bbb", commits_behind: 2, manual_command: 'cd "/repo" && git pull --ff-only && npm run setup', message: "2 commit(s) on origin/main not in your checkout.", }; beforeEach(() => { statusMock.mockReset().mockResolvedValue(BASE_STATUS); checkMock.mockReset().mockResolvedValue(BASE_STATUS); applyMock.mockReset(); try { localStorage.clear(); } catch { // Some CI environments stub a non-functional localStorage; the component // already tolerates that (see UpdateNotifier's own try/catch), so tests do too. } }); describe("UpdateNotifier — Update now", () => { it("shows the Update now button for a fast-forwardable checkout and applies on click", async () => { applyMock.mockResolvedValue({ ...BASE_STATUS, applied: true, update_available: false }); render(); const updateBtn = await screen.findByText(i18n.t("updates:updateNow")); await userEvent.click(updateBtn); await waitFor(() => expect(applyMock).toHaveBeenCalled()); // Appears both in the status banner and the button label while restarting. const restartingNodes = await screen.findAllByText(i18n.t("updates:restarting")); expect(restartingNodes.length).toBeGreaterThan(0); }); it("hides the Update now button for a non-fast-forwardable branch", async () => { statusMock.mockResolvedValue({ ...BASE_STATUS, situation: "feature_branch", tracks_canonical: false, }); render(); await screen.findByText(i18n.t("updates:title")); expect(screen.queryByText(i18n.t("updates:updateNow"))).not.toBeInTheDocument(); }); it("surfaces the decline reason instead of restarting on a 409", async () => { applyMock.mockResolvedValue({ ...BASE_STATUS, applied: false, reason: "not_fast_forwardable", }); render(); const updateBtn = await screen.findByText(i18n.t("updates:updateNow")); await userEvent.click(updateBtn); expect(await screen.findByText(i18n.t("updates:reasonNotFastForwardable"))).toBeInTheDocument(); expect(screen.queryByText(i18n.t("updates:restarting"))).not.toBeInTheDocument(); }); });