cc85e35439
Update detection was a commit count against the default branch, which says nothing about whether a *version* shipped and is pure noise on the plugin cache (a depth-1 clone, where every distance is a graft artefact). It now reads the forge's own releases API and compares the published tag to this install's package.json version, falling back to the commit count only when no release can be read — with the reason in release_check, so "up to date" never silently means "we could not look". Two states a commit count cannot express: dev_build (ahead of the newest release, missing nothing from it) now reads "Dev build - N ahead" instead of a false "Up to date"; and on a shallow clone every commits_*_release field is null and dev_build is never asserted, because the numbers would be meaningless. POST /api/updates/apply gains a second mode, chosen by install kind and never by the request body. A checkout still fast-forwards onto the release tag, rebuilds and self-restarts. A ~/.claude/plugins/cache/ install is refused by applyUpdate (that directory is Claude Code's to replace) and handed instead to a detached chain: claude plugin marketplace update -> claude plugin update -y -> plugin-bootstrap.js --force from the NEW cache dir, which retires this server and starts its replacement. Both write the HTTP response before anything stops the process. The chain records every outcome to plugin-update.json in the runtime dir, outside the cache the update replaces, so the next server reports it as last_plugin_update: a failed chain is otherwise indistinguishable from a slow one, and the tab would poll a server that never returns. /reload-plugins is deliberately not in the chain - it is REPL-scoped to the session that runs it, so no background process can perform it, and plugin-bootstrap derives PLUGIN_ROOT from its own __dirname, which is what makes it unnecessary for the dashboard. Also rebrands the sidebar subtitle, splash and page title to SmartGift Solution, and corrects three ARCHITECTURE.md statements that claimed /apply did not exist. Verified: server 1683/1683, client 486/486, build, prettier, file-header audit, openapi.yaml regenerated.
482 lines
17 KiB
JavaScript
482 lines
17 KiB
JavaScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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("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");
|
|
});
|
|
});
|