Files
AgentHub/server/__tests__/update-check.test.js
T
nntrivi2001 f0dc1445fc fix: v2.6.2 — Windows panes no longer read as dead (psmux ignores -F), Update now's setup works under cmd.exe
- psmux prints `name: 1 windows (created …)` whatever `list-sessions -F`
  asks for. Read as names, every live pane looked gone and the heal beat
  closed its run (and its tab) within seconds while the session kept
  running. tmux.js (and the Update all helper) now take the text before the
  first `:` — a session name never holds one.
- `npm run setup` used `(cd client && npm install)`; cmd.exe's ( ) is no
  subshell, so `npm run link-cli` ran inside client/ ("Missing script") and
  Update now failed on Windows. Now `npm --prefix client install`.
- Update now: an npm step names itself in its error and shows the END of
  npm's output (the error, not the warnings), says when it hit the time
  limit, and gets 20 min instead of 5.
2026-10-10 12:01:01 +07:00

248 lines
9.5 KiB
JavaScript

/**
* @file Branch- and fork-aware tests for getUpdatesStatus(). Each scenario
* builds throw-away git repos in a tmp dir and asserts the payload shape is
* accurate to the user's situation. skipFetch:true keeps these tests offline.
* @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, npmInvocation, execNpm } = require("../lib/update-check");
// Strip GIT_* vars a parent git hook (e.g. the pre-commit hook running this
// very suite) sets in its own environment — those leak to every child
// process and override an explicit `cwd`, so without this a git command
// meant for this test's throwaway tmp repo silently operates on the real
// repo running the hook instead.
const GIT_ENV = { ...process.env };
delete GIT_ENV.GIT_DIR;
delete GIT_ENV.GIT_WORK_TREE;
delete GIT_ENV.GIT_INDEX_FILE;
delete GIT_ENV.GIT_COMMON_DIR;
delete GIT_ENV.GIT_OBJECT_DIRECTORY;
delete GIT_ENV.GIT_ALTERNATE_OBJECT_DIRECTORIES;
delete GIT_ENV.GIT_PREFIX;
delete GIT_ENV.GIT_NAMESPACE;
delete GIT_ENV.GIT_CONFIG_PARAMETERS;
function git(cwd, args) {
return execFileSync("git", args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
encoding: "utf8",
env: GIT_ENV,
}).trim();
}
function makeBareRemote(parent, name) {
const repo = path.join(parent, `${name}.git`);
fs.mkdirSync(repo, { recursive: true });
// -c init.defaultBranch=master works on every git that supports -c init.*,
// i.e. far older than --initial-branch.
execFileSync("git", ["-c", "init.defaultBranch=master", "init", "--bare", repo], {
stdio: "ignore",
env: GIT_ENV,
});
return repo;
}
function makeWorkingRepo(parent, dir, originUrl) {
const repo = path.join(parent, dir);
fs.mkdirSync(repo, { recursive: true });
execFileSync("git", ["-c", "init.defaultBranch=master", "init", repo], {
stdio: "ignore",
env: GIT_ENV,
});
fs.writeFileSync(path.join(repo, "README.md"), "fixture\n");
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "add", "."]);
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"]);
git(repo, ["remote", "add", "origin", originUrl]);
git(repo, ["push", "-u", "origin", "master"]);
return repo;
}
let tmpDir;
before(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "updcheck-"));
});
after(() => {
try {
fs.rmSync(tmpDir, { recursive: true, force: true });
} catch {
// ignore
}
});
describe("getUpdatesStatus — local on canonical default branch", () => {
it("with origin only: tracks_canonical=true, command pulls --ff-only", async () => {
const remote = makeBareRemote(tmpDir, "canon1");
const work = makeWorkingRepo(tmpDir, "work1", remote);
const result = await getUpdatesStatus(work, { skipFetch: true });
assert.equal(result.git_repo, true);
assert.equal(result.canonical_remote, "origin");
assert.equal(result.remote_ref, "origin/master");
assert.equal(result.current_branch, "master");
assert.equal(result.tracking_upstream, "origin/master");
assert.equal(result.tracks_canonical, true);
assert.equal(result.situation, "tracking_canonical");
assert.equal(result.situation_note, null);
assert.match(result.manual_command, /git pull --ff-only/);
});
});
describe("getUpdatesStatus — local on a feature branch", () => {
it("does NOT suggest git pull (would pull feature, not master)", async () => {
const remote = makeBareRemote(tmpDir, "canon2");
const work = makeWorkingRepo(tmpDir, "work2", remote);
git(work, ["checkout", "-b", "feature/foo"]);
const result = await getUpdatesStatus(work, { skipFetch: true });
assert.equal(result.current_branch, "feature/foo");
assert.equal(result.tracks_canonical, false);
assert.equal(result.situation, "feature_branch");
assert.ok(result.situation_note, "expected a situation_note for feature branches");
assert.match(result.manual_command, /git fetch origin/);
assert.doesNotMatch(
result.manual_command,
/git pull/,
"must not suggest git pull — would pull feature branch, not canonical"
);
assert.doesNotMatch(
result.manual_command,
/git merge --ff-only/,
"must not auto-merge canonical into the feature branch"
);
});
});
describe("getUpdatesStatus — fork layout (origin = fork, upstream = canonical)", () => {
it("ignores a stray upstream remote and tracks origin only", async () => {
const fork = makeBareRemote(tmpDir, "fork3");
const upstream = makeBareRemote(tmpDir, "upstream3");
const work = makeWorkingRepo(tmpDir, "work3", fork);
// Add a second remote AFTER the working clone so origin stays the fork.
git(work, ["remote", "add", "upstream", upstream]);
git(work, ["push", "upstream", "master"]);
const result = await getUpdatesStatus(work, { skipFetch: true });
// This build tracks its own repository. A remote named `upstream` pointing
// at somebody else's copy must never become the update source.
assert.equal(result.canonical_remote, "origin");
assert.equal(result.remote_ref, "origin/master");
assert.equal(result.current_branch, "master");
assert.equal(result.tracking_upstream, "origin/master");
assert.equal(result.tracks_canonical, true);
assert.doesNotMatch(result.manual_command, /upstream/);
});
});
describe("getUpdatesStatus — detached HEAD", () => {
it("reports detached_head and only suggests fetch", async () => {
const remote = makeBareRemote(tmpDir, "canon4");
const work = makeWorkingRepo(tmpDir, "work4", remote);
const sha = git(work, ["rev-parse", "HEAD"]);
git(work, ["checkout", sha]);
const result = await getUpdatesStatus(work, { skipFetch: true });
assert.equal(result.current_branch, null);
assert.equal(result.situation, "detached_head");
assert.match(result.manual_command, /git fetch origin/);
assert.doesNotMatch(result.manual_command, /git pull/);
});
});
describe("getUpdatesStatus — no remotes configured", () => {
it("returns a soft no-remotes payload", async () => {
const repo = path.join(tmpDir, "noremote");
fs.mkdirSync(repo, { recursive: true });
execFileSync("git", ["-c", "init.defaultBranch=master", "init", repo], {
stdio: "ignore",
env: GIT_ENV,
});
fs.writeFileSync(path.join(repo, "README.md"), "lonely\n");
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "add", "."]);
git(repo, ["-c", "user.email=t@t", "-c", "user.name=t", "commit", "-m", "init"]);
const result = await getUpdatesStatus(repo, { skipFetch: true });
assert.equal(result.git_repo, true);
assert.equal(result.update_available, false);
assert.match(result.message, /No git remotes configured/);
});
});
describe("npmInvocation (the checkout's Update now)", () => {
it("drops NODE_ENV so `npm run setup` keeps the client's build toolchain", () => {
// The server defaults NODE_ENV=production; npm then omits devDependencies,
// and vite/typescript are dev deps — the `npm run build` after it failed.
const npm = npmInvocation(["run", "setup"], { NODE_ENV: "production", PATH: "/usr/bin" });
assert.equal(npm.env.NODE_ENV, undefined);
assert.equal(npm.env.PATH.split(path.delimiter).at(-1), "/usr/bin");
});
it("runs the node's own npm, never a bare npm.cmd through execFile", () => {
// execFile("npm.cmd") with no shell is EINVAL on Node >= 20.12.2 (Windows).
const npm = npmInvocation(["run", "build"], {});
assert.ok(npm.file === process.execPath || npm.shell === (process.platform === "win32"));
assert.deepEqual(npm.args.slice(-2), ["run", "build"]);
});
});
describe("npm run setup (the checkout's Update now)", () => {
it("never changes directory: cmd.exe's ( ) is no subshell, so a `cd` leaks into the next step", () => {
// On Windows `(cd client && npm install) && npm run link-cli` ran
// link-cli inside client/ — "Missing script" — and Update now failed.
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "..", "package.json"), "utf8"));
assert.doesNotMatch(pkg.scripts.setup, /\bcd\b/);
assert.match(pkg.scripts.setup, /npm --prefix client install/);
});
});
describe("execNpm (the checkout's Update now)", () => {
let dir;
before(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), "exec-npm-"));
const noise = "for (let i = 0; i < 60; i++) console.error('npm warn noise ' + i);";
fs.writeFileSync(
path.join(dir, "package.json"),
JSON.stringify({
name: "fx",
version: "1.0.0",
scripts: {
fail: `node -e "${noise} console.error('ERR the real cause'); process.exit(1)"`,
slow: `node -e "setTimeout(() => {}, 60000)"`,
},
})
);
});
after(() => fs.rmSync(dir, { recursive: true, force: true }));
it("names the failed step and keeps the end of npm's output, where the error is", async () => {
await assert.rejects(execNpm(dir, ["run", "fail"]), (err) => {
assert.match(err.message, /^npm run fail failed/);
assert.match(err.message, /ERR the real cause/);
assert.doesNotMatch(err.message, /noise 0\b/);
return true;
});
});
it("says a step was stopped at the time limit instead of echoing its warnings", async () => {
await assert.rejects(execNpm(dir, ["run", "slow"], { timeout: 3000 }), (err) => {
assert.match(err.message, /npm run slow did not finish within/);
return true;
});
});
});