Files
AgentHub/client/src/components/__tests__/UpdateNotifier.test.tsx
T
nntrivi2001 6715fbd971 feat(settings): data-safe editors, deep-linked search, Data/About/Cost pages
- EditorModal loads once per path (a parent re-render no longer reloads the
  file over the user's typing), refuses Save after a failed load, opens a
  truncated (>256 KB) file read-only, and asks before overwriting a file
  that changed on disk or could not be re-read.
- Settings/Hooks editors unset only keys from the snapshot taken at Edit,
  warn when the file changed underneath, keep a renamed key's value, and
  refuse a new key named like a redacted one (env) or a read-only hook
  event, which used to replace the secret/hooks.
- Project scope shows its resolved folder and is disabled inside the
  plugin cache. Account-profile, pricing-row and pet deletes confirm;
  two-click actions disarm after 3 s; unsaved drafts ask before closing.
- Search indexes every Claude Code / Codex sub-tab, opens it via ?tab= and
  scrolls to + highlights the row; the active config tab is in the URL.
- General splits into General / Data / About; Pricing moves to a Cost
  group with one shared load; config tabs group as Content / Extensions /
  Config with arrow-key roving; Promise.allSettled per tab.
- Data: browser-preferences export/import (allowlisted keys), per-section
  reset, housekeeping dry-run first, clear-all behind a typed phrase.
  Server: purge days must be a whole number >= 1 (INVALID_PURGE_DAYS) and
  dry_run returns exactly what the real run would delete.
- Alerts can be edited; webhooks/alerts/remote-source failures toast;
  copies go through copyText; modals get dialog roles and useDialog.
- Sagi: Ctrl/Cmd+B only when shown and not typing, default spot clear of
  row controls, arrow keys move it, reduced motion honoured; the dead
  /run launch link goes to the workspace.
2026-10-05 13:22:08 +07:00

249 lines
9.2 KiB
TypeScript

/**
* @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ĩ <vinnt@smartgift.vn>
*/
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<Record<string, unknown>>();
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(<UpdateNotifier />);
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(<UpdateNotifier />);
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(<UpdateNotifier />);
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(<UpdateNotifier />);
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(<UpdateNotifier />);
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(<UpdateNotifier />);
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(<UpdateNotifier />);
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(<UpdateNotifier />);
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 — closing vs skipping", () => {
it("Escape only closes for now; Skip this version is the persistent dismiss", async () => {
const { unmount } = render(<UpdateNotifier />);
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(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:skipVersion")));
expect(localStorage.getItem("agent-monitor-update-dismissed-sha")).toBe("bbb");
});
it("ignores Escape while an update is applying", async () => {
applyMock.mockReturnValue(new Promise(() => {}));
render(<UpdateNotifier />);
await userEvent.click(await screen.findByText(i18n.t("updates:updateNow")));
await userEvent.keyboard("{Escape}");
expect(screen.getByText(i18n.t("updates:title"))).toBeInTheDocument();
});
});