Files
AgentHub/server/__tests__/update-check-release.test.js
T
nntrivi2001 96e4d28f7c fix: v2.6.1 — panes start in their folder again after an update (#69), Changes panel stacks every repo, clickable paths in chat
- #69: tmux applies a pane's `-c` only while its server's own cwd exists.
  The server was forked from the dashboard's cwd (the plugin cache dir),
  outlives updates, and the old cache dir is pruned — after that every new
  pane started in a deleted directory, where `claude` died at once ("run not
  found") and a shell printed getcwd errors. tmux calls now run from `/`,
  and the pane's sh wrapper cds into its folder itself, which also heals a
  tmux server that is already stuck.
- Changes panel: a workspace's extra folders are sections stacked under the
  workspace folder (VS Code Source Control style), with Close repository /
  Close other repositories on a header's right-click.
- Chat: URLs and absolute paths are links; Claude Code's `[Image: source:]`
  line folds into its turn; uploads under `<data dir>/drops/` are listed.
- Update check: a checkout holding commits a newer release lacks reports
  `diverged_from_release` instead of offering a fast-forward that can only fail.
2026-10-09 17:24:14 +07:00

515 lines
18 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("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");
});
});