96e4d28f7c
- #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.
515 lines
18 KiB
JavaScript
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");
|
|
});
|
|
});
|