0a1dcfeb7f
- Stop (a tab's or pane's X, Close tab) now archives the session it held (DELETE /api/run/:id?archive=1); the new Sleep (moon left of the X, and in the tab, pane and session right-click menus) stops the pane but keeps the session listed to resume later. Shared endRuns in client/src/lib/stopSession.ts. - Git identity (#82): an account keeps an ED25519 AND an RSA key (AWS CodeCommit takes only RSA), every ssh git offers both, and a member's ~/.gitconfig gets core.sshCommand so a git typed in a terminal uses them. - Update dialog: Auto fix starts a Claude Code session on the install folder with the failed update's error. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
332 lines
12 KiB
TypeScript
332 lines
12 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();
|
|
const ensureMock = vi.fn();
|
|
const startMock = vi.fn();
|
|
const navigateMock = vi.fn();
|
|
|
|
vi.mock("react-router-dom", async (importOriginal) => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
useNavigate: () => navigateMock,
|
|
}));
|
|
|
|
vi.mock("../lanes/startSession", () => ({
|
|
startInFreeTab: (...args: unknown[]) => startMock(...args),
|
|
}));
|
|
|
|
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),
|
|
},
|
|
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(<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 — 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(<UpdateNotifier />);
|
|
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(<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("Check for updates re-opens a modal closed with Later", async () => {
|
|
render(<UpdateNotifier />);
|
|
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(<UpdateNotifier />);
|
|
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(<UpdateNotifier />);
|
|
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");
|
|
});
|
|
});
|