/** * @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(); const ensureMock = vi.fn(); const startMock = vi.fn(); const navigateMock = vi.fn(); vi.mock("react-router-dom", async (importOriginal) => ({ ...(await importOriginal>()), useNavigate: () => navigateMock, })); vi.mock("../lanes/startSession", () => ({ startInFreeTab: (...args: unknown[]) => startMock(...args), })); 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), }, lanes: { ensure: (...args: unknown[]) => ensureMock(...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(); ensureMock.mockReset().mockResolvedValue({ lane: { id: 7 }, created: true }); startMock.mockReset().mockResolvedValue("agenthub-lane-7-1"); navigateMock.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("offers Update now for a plugin-cache install, noting the session still needs a reload", async () => { // The button works here via the detached `claude plugin update` chain — but // it moves only the dashboard server, so the note about /reload-plugins is // part of the promise, not decoration. statusMock.mockResolvedValue({ ...BASE_STATUS, situation: "plugin_managed", install_kind: "plugin", plugin_marketplace: "sg", plugin_name: "agenthub", shallow_clone: true, commits_behind: 0, local_version: "1.5.0", release_check: "ok", latest_release: "v1.6.0", manual_command: "claude plugin marketplace update sg && claude plugin update agenthub@sg -y", }); render(); await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" })); expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument(); expect(screen.getByText(i18n.t("updates:pluginAutoNote"))).toBeInTheDocument(); }); it("still offers the button when a plugin install has no git clone at all", async () => { // The 1.5.1 bug: an archive-sourced marketplace entry leaves no .git, so // git_repo is legitimately false. Gating the UI on that reported "not a git // clone; check manually" for an install the dashboard can check AND update. statusMock.mockResolvedValue({ ...BASE_STATUS, git_repo: false, situation: "plugin_managed", install_kind: "plugin", plugin_marketplace: "sg", plugin_name: "agenthub", commits_behind: 0, local_version: "1.5.1", release_check: "ok", latest_release: "v1.6.0", }); render(); await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" })); expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument(); expect(screen.queryByText(i18n.t("updates:notGit"))).not.toBeInTheDocument(); }); it("offers Update now for a build installed from a terminal, even with no release read", async () => { // `claude plugin update` started nothing and the forge was unreachable: // no release tag and no sha to key the modal on, yet a newer build sits // on disk. That used to read as "up to date" with no way to switch. statusMock.mockResolvedValue({ ...BASE_STATUS, git_repo: false, situation: "plugin_managed", install_kind: "plugin", plugin_marketplace: "sg", plugin_name: "agenthub", remote_sha: null, commits_behind: 0, local_version: "1.8.10", release_check: "unreachable", latest_release: null, installed_not_running: { root: "/cache/sg/agenthub/new", version: "1.8.12" }, }); render(); await screen.findByText(i18n.t("updates:installedTitle", { version: "1.8.12" })); expect( screen.getByText(i18n.t("updates:releaseSubtitle", { current: "1.8.10" })) ).toBeInTheDocument(); expect(screen.getByText(i18n.t("updates:updateNow"))).toBeInTheDocument(); }); it("surfaces a previous plugin chain's failure so a dead update isn't silent", async () => { statusMock.mockResolvedValue({ ...BASE_STATUS, situation: "plugin_managed", install_kind: "plugin", plugin_marketplace: "sg", plugin_name: "agenthub", shallow_clone: true, commits_behind: 0, local_version: "1.5.0", release_check: "ok", latest_release: "v1.6.0", last_plugin_update: { ok: false, step: "resolve_claude", error: "the `claude` CLI is not on this server's PATH", finished_at: new Date().toISOString(), }, }); render(); await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" })); expect(screen.getByText(/resolve_claude/)).toBeInTheDocument(); }); it("titles the modal with the release version when one was read", async () => { statusMock.mockResolvedValue({ ...BASE_STATUS, situation: "release_available", release_check: "ok", local_version: "1.5.0", latest_release: "v1.6.0", commits_behind_release: 3, commits_ahead_of_release: 0, }); render(); await screen.findByText(i18n.t("updates:releaseTitle", { version: "v1.6.0" })); // A release IS fast-forwardable on a checkout, so the button stays. expect(screen.getByText(i18n.t("updates:updateNow"))).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(); }); }); describe("UpdateNotifier — plugin chain verdict", () => { it("stops Restarting when the chain fails and the old server stays up", async () => { const PLUGIN: UpdateStatusPayload = { ...BASE_STATUS, situation: "plugin_managed", install_kind: "plugin", plugin_marketplace: "sg", plugin_name: "agenthub", commits_behind: 0, local_version: "1.5.0", release_check: "ok", latest_release: "v1.6.0", }; statusMock.mockResolvedValue(PLUGIN); applyMock.mockResolvedValue({ ...PLUGIN, applied: true, mode: "plugin" }); render(); await userEvent.click(await screen.findByText(i18n.t("updates:updateNow"))); await screen.findAllByText(i18n.t("updates:pluginUpdating")); // The old server answers again, now carrying this chain's failed verdict. statusMock.mockResolvedValue({ ...PLUGIN, last_plugin_update: { ok: false, step: "plugin_update", error: "boom", finished_at: new Date().toISOString(), }, }); expect(await screen.findByText(/plugin_update/, {}, { timeout: 4000 })).toBeInTheDocument(); expect(screen.queryByText(i18n.t("updates:pluginUpdating"))).not.toBeInTheDocument(); }); }); describe("UpdateNotifier — closing vs skipping", () => { it("Escape only closes for now; Skip this version is the persistent dismiss", async () => { const { unmount } = render(); await screen.findByText(i18n.t("updates:title")); await userEvent.keyboard("{Escape}"); await waitFor(() => expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument() ); expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBeNull(); unmount(); render(); await userEvent.click(await screen.findByText(i18n.t("updates:skipVersion"))); expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBe("bbb"); }); it("Check for updates re-opens a modal closed with Later", async () => { render(); await userEvent.click(await screen.findByRole("button", { name: i18n.t("updates:later") })); await waitFor(() => expect(screen.queryByText(i18n.t("updates:title"))).not.toBeInTheDocument() ); window.dispatchEvent(new Event("dashboard:reset-update-dismissal")); expect(await screen.findByText(i18n.t("updates:title"))).toBeInTheDocument(); }); it("ignores Escape while an update is applying", async () => { applyMock.mockReturnValue(new Promise(() => {})); render(); await userEvent.click(await screen.findByText(i18n.t("updates:updateNow"))); await userEvent.keyboard("{Escape}"); expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument(); }); }); describe("UpdateNotifier — Auto fix", () => { it("offers no Not now button, and Auto fix only once an update failed", async () => { applyMock.mockRejectedValue(new Error("Command failed: git merge --ff-only v2.8.0")); render(); await screen.findByText(i18n.t("updates:updateNow")); expect(screen.queryByText(i18n.t("updates:later"))).not.toBeInTheDocument(); expect(screen.queryByText(i18n.t("updates:autoFix"))).not.toBeInTheDocument(); await userEvent.click(screen.getByText(i18n.t("updates:updateNow"))); await userEvent.click(await screen.findByText(i18n.t("updates:autoFix"))); await waitFor(() => expect(navigateMock).toHaveBeenCalled()); expect(ensureMock).toHaveBeenCalledWith( expect.objectContaining({ cwd: "/repo", exact: true, tool: "claude" }) ); const [laneId, body] = startMock.mock.calls[0] ?? []; expect(laneId).toBe(7); expect(body.prompt).toContain("git merge --ff-only v2.8.0"); expect(body.prompt).toContain(BASE_STATUS.manual_command); expect(navigateMock).toHaveBeenCalledWith("/workspace?lane=7&run=agenthub-lane-7-1"); }); });