/** * @file Tests for the RELEASE layer of update detection: version comparison, * forge-API URL derivation from a git remote, plugin-cache recognition, and the * shallow-clone guard. Every scenario injects `options.latestRelease` instead of * letting the module reach the network, so the suite is offline and * deterministic; the one case that must NOT be reachable (a local file-path * remote) is asserted to short-circuit before any request is attempted. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = require("node:test"); const assert = require("node:assert/strict"); const fs = require("fs"); const os = require("os"); const path = require("path"); const { execFileSync } = require("child_process"); const { getUpdatesStatus, applyUpdate, compareVersions, releaseApiUrl, detectPluginInstall, fetchLatestRelease, isShallowClone, } = require("../lib/update-check"); // Same scrub as update-check.test.js: a parent git hook's GIT_* vars override // an explicit cwd, which would point these commands at the real repo. const GIT_ENV = { ...process.env }; for (const k of [ "GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY", "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_PREFIX", "GIT_NAMESPACE", "GIT_CONFIG_PARAMETERS", ]) { delete GIT_ENV[k]; } const IDENT = ["-c", "user.email=t@t", "-c", "user.name=t"]; function git(cwd, args) { return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf8", env: GIT_ENV, }).trim(); } /** * A repo with `version` in package.json, one commit, an optional tag, and a * real pushed `origin` so `origin/main` resolves — without remote-tracking * refs, getUpdatesStatus returns its "could not resolve remote ref" payload * and never reaches the release layer under test. The remote is a local bare * path on purpose: `releaseApiUrl` rejects it, so nothing here can touch the * network even if a test forgets to inject `latestRelease`. */ function makeVersionedRepo(parent, dir, version, tag) { const remote = path.join(parent, `${dir}-remote.git`); fs.mkdirSync(remote, { recursive: true }); execFileSync("git", ["-c", "init.defaultBranch=main", "init", "--bare", remote], { stdio: "ignore", env: GIT_ENV, }); const repo = path.join(parent, dir); fs.mkdirSync(repo, { recursive: true }); execFileSync("git", ["-c", "init.defaultBranch=main", "init", repo], { stdio: "ignore", env: GIT_ENV, }); fs.writeFileSync(path.join(repo, "package.json"), JSON.stringify({ name: "fx", version })); git(repo, [...IDENT, "add", "."]); git(repo, [...IDENT, "commit", "-m", "release commit"]); if (tag) git(repo, ["tag", tag]); git(repo, ["remote", "add", "origin", remote]); git(repo, ["push", "-u", "origin", "main"]); return repo; } /** * Repoints `origin` at an https URL AFTER the push, so `origin/main` still * resolves (the refs survive a URL change) while `releaseApiUrl` now produces * an endpoint — which is what lets a stubbed `fetch` be reached at all. */ function setHttpOrigin(repo) { git(repo, ["remote", "set-url", "origin", "https://git.example.com/Owner/Repo.git"]); return repo; } let tmpDir; before(() => { tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "updrel-")); }); after(() => { try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { // ignore } }); describe("compareVersions", () => { it("orders numeric triples", () => { assert.equal(compareVersions("1.6.0", "1.5.0"), 1); assert.equal(compareVersions("1.5.0", "1.6.0"), -1); assert.equal(compareVersions("1.5.0", "1.5.0"), 0); assert.equal(compareVersions("1.5.1", "1.5.0"), 1); assert.equal(compareVersions("2.0.0", "1.99.99"), 1); }); it("treats a leading v as noise — this repo tags both ways", () => { // Real tags in this repository: `v1.5.0` and `1.4.6`. assert.equal(compareVersions("v1.5.0", "1.5.0"), 0); assert.equal(compareVersions("v1.5.0", "1.4.6"), 1); assert.equal(compareVersions("1.4.6", "v1.5.0"), -1); }); it("compares only the release triple, ignoring prerelease/build suffixes", () => { assert.equal(compareVersions("1.5.0-rc.1", "1.5.0"), 0); assert.equal(compareVersions("1.5.0+build9", "1.5.0"), 0); }); it("treats missing or junk components as zero rather than NaN", () => { assert.equal(compareVersions("2", "1.9.9"), 1); assert.equal(compareVersions("1.5", "1.5.0"), 0); assert.equal(compareVersions("junk", "0.0.0"), 0); }); }); describe("releaseApiUrl", () => { it("derives the Gitea endpoint from an https remote", () => { assert.equal( releaseApiUrl("https://git.smartgift.io.vn/Smartgift-AI/AgentHub.git"), "https://git.smartgift.io.vn/api/v1/repos/Smartgift-AI/AgentHub/releases/latest" ); }); it("handles a missing .git suffix, a trailing slash, and embedded credentials", () => { const want = "https://git.example.com/api/v1/repos/Owner/Repo/releases/latest"; assert.equal(releaseApiUrl("https://git.example.com/Owner/Repo"), want); assert.equal(releaseApiUrl("https://git.example.com/Owner/Repo/"), want); assert.equal(releaseApiUrl("https://oauth2:tok@git.example.com/Owner/Repo.git"), want); }); it("converts scp-style and ssh:// remotes to the https API host", () => { const want = "https://git.example.com/api/v1/repos/Owner/Repo/releases/latest"; assert.equal(releaseApiUrl("git@git.example.com:Owner/Repo.git"), want); assert.equal(releaseApiUrl("ssh://git@git.example.com:2222/Owner/Repo.git"), want); }); it("returns null for anything not owner/repo shaped — this is what keeps local-path remotes offline", () => { assert.equal(releaseApiUrl("/tmp/some/bare-remote.git"), null); assert.equal(releaseApiUrl("../relative/remote"), null); assert.equal(releaseApiUrl(""), null); // No dot in the host: a bare hostname is not a forge we can call. assert.equal(releaseApiUrl("https://localhost/Owner/Repo.git"), null); }); }); describe("fetchLatestRelease", () => { it("reports remote_not_http for a local file-path remote, without a network call", async () => { const repo = path.join(tmpDir, "localremote"); fs.mkdirSync(repo, { recursive: true }); execFileSync("git", ["-c", "init.defaultBranch=main", "init", repo], { stdio: "ignore", env: GIT_ENV, }); git(repo, ["remote", "add", "origin", path.join(tmpDir, "nowhere.git")]); // Any attempt to reach the network would throw here, since fetch is gone. const realFetch = global.fetch; global.fetch = () => { throw new Error("fetchLatestRelease must not call fetch for a non-http remote"); }; try { const res = await fetchLatestRelease(repo); assert.equal(res.ok, false); assert.equal(res.reason, "remote_not_http"); } finally { global.fetch = realFetch; } }); it("reports no_remote when the repo has no remotes at all", async () => { const repo = path.join(tmpDir, "noremote"); fs.mkdirSync(repo, { recursive: true }); execFileSync("git", ["-c", "init.defaultBranch=main", "init", repo], { stdio: "ignore", env: GIT_ENV, }); const res = await fetchLatestRelease(repo); assert.equal(res.ok, false); assert.equal(res.reason, "no_remote"); }); it("maps a 404 to no_releases rather than an error — a repo with no releases is normal", async () => { const repo = setHttpOrigin(makeVersionedRepo(tmpDir, "rel404", "1.0.0", null)); const realFetch = global.fetch; global.fetch = async () => ({ ok: false, status: 404, json: async () => ({}) }); try { const res = await fetchLatestRelease(repo); assert.equal(res.ok, false); assert.equal(res.reason, "no_releases"); } finally { global.fetch = realFetch; } }); it("never throws when the request fails — it resolves to unreachable", async () => { const repo = setHttpOrigin(makeVersionedRepo(tmpDir, "relboom", "1.0.0", null)); const realFetch = global.fetch; global.fetch = async () => { throw new Error("ENOTFOUND"); }; try { const res = await fetchLatestRelease(repo); assert.equal(res.ok, false); assert.equal(res.reason, "unreachable"); } finally { global.fetch = realFetch; } }); it("caps release notes so a huge changelog can't bloat every status poll", async () => { const repo = setHttpOrigin(makeVersionedRepo(tmpDir, "relbig", "1.0.0", null)); const realFetch = global.fetch; global.fetch = async () => ({ ok: true, status: 200, json: async () => ({ tag_name: "v9.0.0", body: "x".repeat(10_000) }), }); try { const res = await fetchLatestRelease(repo); assert.equal(res.ok, true); assert.equal(res.notes.length, 4000); } finally { global.fetch = realFetch; } }); }); describe("detectPluginInstall", () => { it("extracts the marketplace and plugin name from a plugin cache path", () => { assert.deepEqual( detectPluginInstall("/home/u/.claude/plugins/cache/sg/agenthub/eb6356932438"), { marketplace: "sg", plugin: "agenthub", } ); }); it("returns null for an ordinary checkout", () => { assert.equal(detectPluginInstall("/home/u/Projects/agenthub"), null); // A directory merely NAMED like the cache is not the cache. assert.equal(detectPluginInstall("/home/u/plugins/cache/sg/agenthub"), null); }); }); describe("getUpdatesStatus — release drives update_available", () => { it("reports the release as available when its tag outranks the local version", async () => { const repo = makeVersionedRepo(tmpDir, "behind", "1.5.0", "v1.5.0"); const status = await getUpdatesStatus(repo, { skipFetch: true, latestRelease: { ok: true, tag: "v1.6.0", name: "1.6.0", url: "u", published_at: null, notes: null, }, }); assert.equal(status.update_available, true); assert.equal(status.situation, "release_available"); assert.equal(status.latest_release, "v1.6.0"); assert.equal(status.local_version, "1.5.0"); assert.equal(status.dev_build, false); // Moves onto the TAG, and fetches tags first so the tag exists locally. assert.match(status.manual_command, /git fetch origin --tags/); assert.match(status.manual_command, /git merge --ff-only v1\.6\.0/); }); it("does not offer a fast-forward when HEAD holds commits the newer release lacks", async () => { // The release squashed the local wip commits into a new one: neither side // contains the other, so `merge --ff-only` could only fail. const repo = makeVersionedRepo(tmpDir, "diverged", "1.5.0", null); git(repo, ["checkout", "-b", "rel"]); fs.writeFileSync( path.join(repo, "package.json"), JSON.stringify({ name: "fx", version: "1.6.0" }) ); git(repo, [...IDENT, "commit", "-am", "release"]); git(repo, ["tag", "v1.6.0"]); git(repo, ["checkout", "main"]); fs.writeFileSync(path.join(repo, "wip.txt"), "wip"); git(repo, [...IDENT, "add", "."]); git(repo, [...IDENT, "commit", "-m", "wip"]); const status = await getUpdatesStatus(repo, { skipFetch: true, latestRelease: { ok: true, tag: "v1.6.0", name: "1.6.0", url: "u", published_at: null, notes: null, }, }); assert.equal(status.update_available, true); assert.equal(status.situation, "diverged_from_release"); assert.doesNotMatch(status.manual_command, /--ff-only/); assert.match(status.situation_note, /v1\.6\.0/); }); it("is up to date — not a dev build — when local version equals the release and no commits are ahead", async () => { const repo = makeVersionedRepo(tmpDir, "level", "1.5.0", "v1.5.0"); const status = await getUpdatesStatus(repo, { skipFetch: true, latestRelease: { ok: true, tag: "v1.5.0", name: "1.5.0", url: null, published_at: null, notes: null, }, }); assert.equal(status.update_available, false); assert.equal(status.dev_build, false); assert.equal(status.commits_ahead_of_release, 0); assert.equal(status.commits_behind_release, 0); }); it("reports dev_build with a commit count when the checkout is ahead of the newest release", async () => { const repo = makeVersionedRepo(tmpDir, "ahead", "1.5.0", "v1.5.0"); fs.writeFileSync(path.join(repo, "extra.txt"), "unreleased work\n"); git(repo, [...IDENT, "add", "."]); git(repo, [...IDENT, "commit", "-m", "unreleased"]); const status = await getUpdatesStatus(repo, { skipFetch: true, latestRelease: { ok: true, tag: "v1.5.0", name: "1.5.0", url: null, published_at: null, notes: null, }, }); assert.equal(status.update_available, false, "a dev build is not an available update"); assert.equal(status.dev_build, true); assert.equal(status.commits_ahead_of_release, 1); assert.equal(status.commits_behind_release, 0); assert.match(status.message, /Dev build: 1 commit/); }); it("does not call a release ahead when the local version already outranks it", async () => { const repo = makeVersionedRepo(tmpDir, "newer", "2.0.0", "v2.0.0"); const status = await getUpdatesStatus(repo, { skipFetch: true, latestRelease: { ok: true, tag: "v1.5.0", name: "1.5.0", url: null, published_at: null, notes: null, }, }); assert.equal(status.update_available, false); }); it("records why the release lookup failed and falls back to the commit count", async () => { const repo = makeVersionedRepo(tmpDir, "fallback", "1.5.0", "v1.5.0"); const status = await getUpdatesStatus(repo, { skipFetch: true, latestRelease: { ok: false, reason: "unreachable" }, }); assert.equal(status.release_check, "unreachable"); assert.equal(status.latest_release, null); assert.equal(status.dev_build, false); // Not "up to date" by silence: the reason is on the payload for the UI. assert.match(status.message, /tip of the canonical default branch|commit\(s\) on/); }); }); describe("getUpdatesStatus — shallow clone", () => { it("returns null commit distances and never claims a dev build", async () => { // The Claude Code plugin cache is a depth-1 clone. Before this guard it // reported "306 behind AND 1 ahead" of a tag that is a plain ancestor. const origin = makeVersionedRepo(tmpDir, "deep-origin", "1.5.0", "v1.5.0"); for (let i = 0; i < 3; i += 1) { fs.writeFileSync(path.join(origin, `f${i}.txt`), `${i}\n`); git(origin, [...IDENT, "add", "."]); git(origin, [...IDENT, "commit", "-m", `c${i}`]); } const shallow = path.join(tmpDir, "shallow-clone"); execFileSync("git", ["clone", "--depth", "1", `file://${origin}`, shallow], { stdio: "ignore", env: GIT_ENV, }); assert.equal(await isShallowClone(shallow), true, "fixture must actually be shallow"); assert.equal(await isShallowClone(origin), false); const status = await getUpdatesStatus(shallow, { skipFetch: true, latestRelease: { ok: true, tag: "v1.5.0", name: "1.5.0", url: null, published_at: null, notes: null, }, }); assert.equal(status.shallow_clone, true); assert.equal(status.commits_behind_release, null); assert.equal(status.commits_ahead_of_release, null); assert.equal(status.dev_build, false); // Version compare still works, and it is the only signal used here. assert.equal(status.update_available, false); }); it("still reports a newer release on a shallow clone, from versions alone", async () => { const origin = makeVersionedRepo(tmpDir, "deep-origin2", "1.5.0", "v1.5.0"); const shallow = path.join(tmpDir, "shallow-clone2"); execFileSync("git", ["clone", "--depth", "1", `file://${origin}`, shallow], { stdio: "ignore", env: GIT_ENV, }); const status = await getUpdatesStatus(shallow, { skipFetch: true, latestRelease: { ok: true, tag: "v1.7.0", name: "1.7.0", url: null, published_at: null, notes: null, }, }); assert.equal(status.shallow_clone, true); assert.equal(status.update_available, true); assert.equal(status.commits_behind_release, null); }); }); describe("applyUpdate — plugin cache is Claude Code's to replace", () => { it("refuses with reason plugin_managed and touches nothing", async () => { // Build the repo underneath a path that looks like the plugin cache, since // that path IS the signal (see detectPluginInstall). const cacheRoot = path.join(tmpDir, "home", ".claude", "plugins", "cache", "sg", "agenthub"); fs.mkdirSync(cacheRoot, { recursive: true }); const repo = setHttpOrigin(makeVersionedRepo(cacheRoot, "abc123def456", "1.5.0", "v1.5.0")); const headBefore = git(repo, ["rev-parse", "HEAD"]); const status = await getUpdatesStatus(repo, { skipFetch: true, latestRelease: { ok: true, tag: "v1.9.0", name: "1.9.0", url: null, published_at: null, notes: null, }, }); assert.equal(status.install_kind, "plugin"); assert.equal(status.plugin_marketplace, "sg"); assert.equal(status.situation, "plugin_managed"); assert.equal(status.update_available, true); // The printed commands are Claude Code's own update path, not git. assert.match(status.manual_command, /claude plugin marketplace update sg/); assert.match(status.manual_command, /claude plugin update agenthub@sg -y/); assert.match(status.situation_note, /reload-plugins/); // applyUpdate hits the network-free path only because it re-reads status; // stub fetch so the refusal is decided by the path, not by connectivity. const realFetch = global.fetch; global.fetch = async () => ({ ok: true, status: 200, json: async () => ({ tag_name: "v1.9.0" }), }); try { const result = await applyUpdate(repo); assert.equal(result.applied, false); assert.equal(result.reason, "plugin_managed"); } finally { global.fetch = realFetch; } assert.equal(git(repo, ["rev-parse", "HEAD"]), headBefore, "HEAD must be untouched"); }); });