18614f378d
`agenthub update-check` prints `message` as its headline, so every plugin-cache install saw the literal "null" where the verdict belongs — and that is the one install kind with no commit count to fall back on, so the line carried nothing at all. The plugin branch of getUpdatesStatus compares versions exactly like the checkout branch does; it now builds the same four sentences from what it already computed (newer release / newest / no local version to compare / release lookup failed). The nogit regression test asserted `message === null` as a proxy for "did not fall through to the not-a-git-clone payload" — it now asserts that on the message's content instead, which is what it always meant.
353 lines
13 KiB
JavaScript
353 lines
13 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,
|
|
} = 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,
|
|
} = 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", 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("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("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".
|
|
makePluginInstall("ghshort", { source: { source: "github", repo: "Owner/Repo" } });
|
|
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;
|
|
}
|
|
});
|
|
});
|