Files
AgentHub/server/__tests__/update-check-plugin-nogit.test.js
T
nntrivi2001 058c574138 fix(updates): report a plugin build installed beside the running one, never "up to date" on a failed check
A `claude plugin update` run from a terminal (or with only the Claude desktop
app open) unpacks the new build and starts nothing: no SessionStart fires, so
the dashboard kept running the old cache dir across reboots, and with the
forge unreachable the sidebar read "Up to date" (seen on a machine stuck at
1.8.10).

- pluginStatus asks supersededBy for its own root: a recorded sibling install
  sets `installed_not_running: {root, version}` and `update_available`, with
  no network needed
- the sidebar reads "<version> installed, not running"; the update modal keys
  on it, so Update now is offered even with no release tag or sha
- plugin-update-helper hands over to the recorded install first, the newest
  sibling dir only as a fallback
- a plugin install whose release lookup failed reads "Could not check for
  updates" instead of "Up to date"
- docs/PLUGINS.md: the case and the manual hand-over command
2026-10-01 10:09:12 +07:00

423 lines
16 KiB
JavaScript

/**
* @file Regression tests for update detection on a plugin-cache install that is
* NOT a git clone.
*
* This is the case 1.5.1 shipped broken. When a marketplace entry declares
* `"source": "url"` — this project's own does — Claude Code unpacks an archive,
* so the cache directory has no `.git`. `getUpdatesStatus` probed for `.git`
* first and returned "Install directory is not a git clone; check for updates
* manually", skipping the entire release layer: `update_available` was
* permanently false however many releases were published, `plugin_marketplace`
* and `plugin_name` were never set, so `POST /api/updates/apply`'s plugin branch
* could never fire either. The bug read as "up to date" and was therefore
* invisible for as long as the install happened to be current.
*
* These tests pin the git-free path: the repository URL comes from the
* marketplace manifest on disk, version comparison is the only signal, and a
* newer release must flip `update_available` to true.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, before, after, beforeEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
getUpdatesStatus,
pluginMarketplaceUrl,
fetchReleaseFromUrl,
detectPluginInstall,
repoUrlFor,
} = require("../lib/update-check");
let tmpDir;
let prevHome;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "upd-nogit-"));
prevHome = process.env.CLAUDE_HOME;
});
after(() => {
if (prevHome === undefined) delete process.env.CLAUDE_HOME;
else process.env.CLAUDE_HOME = prevHome;
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
/* best effort */
}
});
/**
* Build a fake Claude home containing a marketplace manifest and an
* archive-style plugin cache directory — no `.git` anywhere.
*
* @param {object} opts
* @param {any} [opts.source] the marketplace entry's `source` value
* @param {string|null} [opts.version] package.json version, or null to omit the file
* @param {boolean} [opts.knownMarketplaces] write known_marketplaces.json
* @returns {{root: string, home: string}} the plugin cache root
*/
function makePluginInstall(name, opts = {}) {
const {
source = { source: "url", url: "https://forge.example.com/Owner/Repo.git", ref: "main" },
version = "1.5.1",
knownMarketplaces = true,
repository,
} = opts;
const home = path.join(tmpDir, name, ".claude");
const mktDir = path.join(home, "plugins", "marketplaces", "sg");
const root = path.join(home, "plugins", "cache", "sg", "agenthub", "d4052ed23506");
fs.mkdirSync(path.join(mktDir, ".claude-plugin"), { recursive: true });
fs.mkdirSync(root, { recursive: true });
fs.writeFileSync(
path.join(mktDir, ".claude-plugin", "marketplace.json"),
JSON.stringify({ name: "sg", repository, plugins: [{ name: "agenthub", source }] })
);
if (knownMarketplaces) {
fs.writeFileSync(
path.join(home, "plugins", "known_marketplaces.json"),
JSON.stringify({ sg: { installLocation: mktDir } })
);
}
if (version !== null) {
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ version }));
}
process.env.CLAUDE_HOME = home;
return { root, home };
}
/** A resolved release, as fetchReleaseFromUrl would return it. */
function release(tag) {
return {
ok: true,
tag,
name: tag.replace(/^v/, ""),
url: `https://forge.example.com/Owner/Repo/releases/tag/${tag}`,
published_at: "2026-09-10T09:18:48Z",
notes: "notes",
};
}
describe("repoUrlFor", () => {
it("answers a plugin-cache root from the marketplace manifest, no git", async () => {
const { root } = makePluginInstall("repourl");
assert.equal(await repoUrlFor(root), "https://forge.example.com/Owner/Repo.git");
});
it("answers null for a directory with neither a manifest nor a remote", async () => {
assert.equal(await repoUrlFor(tmpDir), null);
});
});
describe("pluginMarketplaceUrl", () => {
it("reads the plugin's repository URL out of the marketplace manifest", () => {
makePluginInstall("ok");
assert.equal(
pluginMarketplaceUrl("sg", "agenthub"),
"https://forge.example.com/Owner/Repo.git"
);
});
it("falls back to the conventional marketplace location when the index is missing", () => {
// known_marketplaces.json is an index, not the authority — the manifest is.
makePluginInstall("noindex", { knownMarketplaces: false });
assert.equal(
pluginMarketplaceUrl("sg", "agenthub"),
"https://forge.example.com/Owner/Repo.git"
);
});
it("accepts a bare URL string as the source", () => {
makePluginInstall("strsrc", { source: "https://forge.example.com/Owner/Repo.git" });
assert.equal(
pluginMarketplaceUrl("sg", "agenthub"),
"https://forge.example.com/Owner/Repo.git"
);
});
it("falls back to the marketplace's repository for a relative source", () => {
// This project's own entry is `"source": "./"` — the plugin IS the
// marketplace repo. Without this the dashboard answered `no_remote` and
// could never see a release on any plugin-cache install.
makePluginInstall("relsrc", {
source: "./",
repository: "https://forge.example.com/Owner/Repo",
});
assert.equal(pluginMarketplaceUrl("sg", "agenthub"), "https://forge.example.com/Owner/Repo");
});
it("returns null for a relative source when the manifest declares no repository", () => {
makePluginInstall("relnorepo", { source: "./plugins/agenthub" });
assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null);
});
it("returns null for a github owner/repo shorthand rather than guessing a host", () => {
// Inventing github.com here would produce an API URL that 404s and a
// confidently wrong "up to date".
// Not even when the marketplace declares a repository: a shorthand source
// names a DIFFERENT repo, so borrowing the marketplace's is a wrong answer.
makePluginInstall("ghshort", {
source: { source: "github", repo: "Owner/Repo" },
repository: "https://forge.example.com/Owner/Marketplace",
});
assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null);
});
it("returns null when the manifest has no entry for that plugin", () => {
const { home } = makePluginInstall("nomatch");
const manifest = path.join(
home,
"plugins",
"marketplaces",
"sg",
".claude-plugin",
"marketplace.json"
);
fs.writeFileSync(manifest, JSON.stringify({ name: "sg", plugins: [{ name: "other" }] }));
assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null);
});
it("returns null instead of throwing on a malformed manifest", () => {
const { home } = makePluginInstall("badjson");
const manifest = path.join(
home,
"plugins",
"marketplaces",
"sg",
".claude-plugin",
"marketplace.json"
);
fs.writeFileSync(manifest, "{not json");
assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null);
});
it("returns null when nothing is on disk at all", () => {
process.env.CLAUDE_HOME = path.join(tmpDir, "empty-home");
assert.equal(pluginMarketplaceUrl("sg", "agenthub"), null);
});
});
describe("getUpdatesStatus — plugin cache with no .git", () => {
it("checks the release API instead of reporting 'not a git clone'", async () => {
const { root } = makePluginInstall("regression");
assert.equal(fs.existsSync(path.join(root, ".git")), false, "fixture must have no .git");
const s = await getUpdatesStatus(root, { latestRelease: release("v1.5.1") });
// The old behaviour: the not-a-git-clone message, everything else absent.
// Asserted on the message's CONTENT, not on it being null — a plugin
// install now carries its own sentence, and null was only ever a proxy for
// "did not fall through here".
assert.doesNotMatch(
s.message,
/not a git clone/i,
"must not fall through to the not-a-git-clone payload"
);
assert.equal(s.release_check, "ok", "the release layer must actually run");
assert.equal(s.install_kind, "plugin");
assert.equal(s.plugin_marketplace, "sg");
assert.equal(s.plugin_name, "agenthub");
assert.equal(s.local_version, "1.5.1");
assert.equal(s.latest_release, "v1.5.1");
assert.equal(s.situation, "plugin_managed");
});
it("reports up-to-date for the RIGHT reason when versions match", async () => {
const { root } = makePluginInstall("current");
const s = await getUpdatesStatus(root, { latestRelease: release("v1.5.1") });
assert.equal(s.update_available, false);
// The distinction that matters: false because it looked and found nothing,
// not false because it never looked.
assert.equal(s.release_check, "ok");
assert.equal(s.latest_release, "v1.5.1");
});
it("flips update_available to true when a newer release is published", async () => {
// The whole point: before the fix this stayed false forever.
const { root } = makePluginInstall("newer");
const s = await getUpdatesStatus(root, { latestRelease: release("v1.6.0") });
assert.equal(s.update_available, true);
assert.equal(s.latest_release, "v1.6.0");
// The fields POST /apply's plugin branch requires must all be present, or
// the batch can never be spawned.
assert.ok(s.plugin_marketplace && s.plugin_name && s.repo_root);
});
it("carries a human-readable message in every outcome, never a null", async () => {
// `agenthub update-check` prints `message` as its headline. Null here made
// it print the literal "null" on every plugin-cache install — the one kind
// that has no commit count to fall back on.
const cases = [
[makePluginInstall("msg-newer").root, { latestRelease: release("v1.6.0") }, /v1\.6\.0/],
[makePluginInstall("msg-current").root, { latestRelease: release("v1.5.0") }, /newest/i],
[
makePluginInstall("msg-failed").root,
{ latestRelease: { ok: false, reason: "unreachable" } },
/unreachable/,
],
[
makePluginInstall("msg-noversion", { version: null }).root,
{ latestRelease: release("v1.6.0") },
/no version|cannot be compared/i,
],
];
for (const [root, opts, expected] of cases) {
const s = await getUpdatesStatus(root, opts);
assert.equal(typeof s.message, "string", `message must be a string, got ${s.message}`);
assert.match(s.message, expected);
}
});
it("does not treat an older published release as an update", async () => {
const { root } = makePluginInstall("older");
const s = await getUpdatesStatus(root, { latestRelease: release("v1.4.0") });
assert.equal(s.update_available, false);
// Not a dev_build either: a plugin install is whatever shipped, it cannot
// be "ahead" in the way a checkout can.
assert.equal(s.dev_build, false);
});
it("never claims to have checked when the release lookup failed", async () => {
const { root } = makePluginInstall("unreachable");
const s = await getUpdatesStatus(root, {
latestRelease: { ok: false, reason: "unreachable" },
});
assert.equal(s.release_check, "unreachable");
assert.equal(s.update_available, false);
assert.equal(s.latest_release, null);
});
it("reports no_local_version when package.json cannot be read", async () => {
// With nothing to compare against, "up to date" would be a guess.
const { root } = makePluginInstall("noversion", { version: null });
const s = await getUpdatesStatus(root, { latestRelease: release("v1.6.0") });
assert.equal(s.release_check, "no_local_version");
assert.equal(s.update_available, false);
});
it("reports no git-derived numbers rather than fabricating them", async () => {
const { root } = makePluginInstall("nonumbers");
const s = await getUpdatesStatus(root, { latestRelease: release("v1.6.0") });
assert.equal(s.git_repo, false, "honest: there is no clone here");
assert.equal(s.shallow_clone, false);
assert.equal(s.commits_behind_release, null);
assert.equal(s.commits_ahead_of_release, null);
assert.equal(s.release_sha, null);
});
it("prints the claude plugin commands as the manual fallback", async () => {
const { root } = makePluginInstall("manual");
const s = await getUpdatesStatus(root, { latestRelease: release("v1.6.0") });
assert.match(s.manual_command, /claude plugin marketplace update sg/);
assert.match(s.manual_command, /claude plugin update agenthub@sg -y/);
// No git command may be suggested for a directory that is not a clone.
assert.doesNotMatch(s.manual_command, /git /);
});
it("takes the marketplace and plugin from the path, not from a caller", async () => {
const { root } = makePluginInstall("frompath");
assert.deepEqual(detectPluginInstall(root), { marketplace: "sg", plugin: "agenthub" });
});
});
describe("fetchReleaseFromUrl", () => {
let realFetch;
beforeEach(() => {
realFetch = global.fetch;
});
it("never touches the network for a URL that isn't owner/repo shaped", async () => {
global.fetch = () => {
throw new Error("must not fetch for an unusable URL");
};
try {
const res = await fetchReleaseFromUrl("/some/local/path");
assert.equal(res.ok, false);
assert.equal(res.reason, "remote_not_http");
} finally {
global.fetch = realFetch;
}
});
it("reports no_remote for a missing URL instead of throwing", async () => {
global.fetch = () => {
throw new Error("must not fetch without a URL");
};
try {
const res = await fetchReleaseFromUrl(null);
assert.equal(res.ok, false);
assert.equal(res.reason, "no_remote");
} finally {
global.fetch = realFetch;
}
});
it("resolves the tag from a Gitea-shaped releases payload", async () => {
global.fetch = async () => ({
ok: true,
status: 200,
json: async () => ({ tag_name: "v9.9.9", name: "9.9.9", html_url: "u", body: "b" }),
});
try {
const res = await fetchReleaseFromUrl("https://forge.example.com/Owner/Repo.git");
assert.equal(res.ok, true);
assert.equal(res.tag, "v9.9.9");
} finally {
global.fetch = realFetch;
}
});
it("treats 404 as 'no releases yet', not a fault", async () => {
global.fetch = async () => ({ ok: false, status: 404, json: async () => ({}) });
try {
const res = await fetchReleaseFromUrl("https://forge.example.com/Owner/Repo.git");
assert.equal(res.ok, false);
assert.equal(res.reason, "no_releases");
} finally {
global.fetch = realFetch;
}
});
});
describe("a newer build installed beside the running one", () => {
/** Record `installed` as Claude Code's install of agenthub@sg. */
function recordInstall(home, installed) {
fs.writeFileSync(
path.join(home, "plugins", "installed_plugins.json"),
JSON.stringify({ plugins: { "agenthub@sg": [{ scope: "user", installPath: installed }] } })
);
}
it("reports an update even when the release check fails", async () => {
// `claude plugin update` from a terminal unpacks the new build and starts
// nothing: the server keeps running the old directory, and with the forge
// unreachable it used to answer "up to date" forever.
const { root, home } = makePluginInstall("installed-newer", { version: "1.8.10" });
const newer = path.join(path.dirname(root), "aaaaaaaaaaaa");
fs.mkdirSync(newer, { recursive: true });
fs.writeFileSync(path.join(newer, "package.json"), JSON.stringify({ version: "1.8.12" }));
recordInstall(home, newer);
const s = await getUpdatesStatus(root, {
latestRelease: { ok: false, reason: "unreachable" },
});
assert.equal(s.update_available, true);
assert.deepEqual(s.installed_not_running, { root: newer, version: "1.8.12" });
assert.match(s.message, /1\.8\.12 is installed but not running/);
});
it("says nothing extra when the running build is the installed one", async () => {
const { root, home } = makePluginInstall("installed-same", { version: "1.8.12" });
recordInstall(home, root);
const s = await getUpdatesStatus(root, { latestRelease: release("v1.8.12") });
assert.equal(s.update_available, false);
assert.equal(s.installed_not_running, null);
});
});