30bc6debde
An archive-sourced marketplace entry ("source": "url" — this project's own)
makes Claude Code unpack the plugin rather than clone it, so the cache dir
has no .git. getUpdatesStatus probed for .git first and returned "not a git
clone; check for updates manually", skipping the entire release layer:
update_available stayed false however many releases were published, and
plugin_marketplace/plugin_name were never set, so POST /api/updates/apply's
plugin branch could never fire (it answered 409, claiming nothing newer was
published). The bug reads as "up to date", so it stayed invisible for as
long as the install happened to be current.
The plugin branch now runs BEFORE the .git probe and answers without git at
all: the repository URL comes from the marketplace manifest on disk
(known_marketplaces.json -> .claude-plugin/marketplace.json -> source.url,
null rather than a guessed host for a github owner/repo shorthand), and
package.json is the only local comparison signal. A failed release lookup
is reported as release_check, never as "nothing newer".
UI/CLI: the "not a git clone" note is gated on install_kind !== "plugin", so
the Update now button is offered where the dashboard can actually check and
apply.
197 lines
7.0 KiB
TypeScript
197 lines
7.0 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("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();
|
|
});
|
|
});
|