/** * @file Tests for server/lib/commit-message.js against a real git repo and a * fake `claude` script: only staged, in-lane, non-secret changes reach the * model; its argv keeps tools, hooks and persistence off; its answer is * cleaned; and every failure is a coded error. * @author Nguyễn Ngọc Trí Vĩ */ const { describe, it, before, after } = 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 { execFileSync } = require("node:child_process"); const { suggestCommitMessage, buildInput } = require("../lib/commit-message"); const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-commitmsg-")); const REPO = path.join(ROOT, "repo"); const SUB = path.join(REPO, "sub"); const ARGS = path.join(ROOT, "args.txt"); const STDIN = path.join(ROOT, "stdin.txt"); const g = (...args) => { const env = { ...process.env }; delete env.GIT_DIR; delete env.GIT_INDEX_FILE; execFileSync("git", ["-c", "user.email=t@h", "-c", "user.name=t", ...args], { cwd: REPO, env }); }; const write = (file, body) => { fs.mkdirSync(path.dirname(path.join(REPO, file)), { recursive: true }); fs.writeFileSync(path.join(REPO, file), body); }; /** A fake claude: records argv and stdin, prints `reply`, exits `code`. */ function fakeClaude(reply, code = 0) { const bin = path.join(ROOT, `claude-${Math.random().toString(36).slice(2)}`); fs.writeFileSync( bin, `#!/bin/sh\nprintf '%s\\n' "$@" > '${ARGS}'\ncat > '${STDIN}'\nprintf '${reply}'\nexit ${code}\n`, { mode: 0o755 } ); return bin; } before(() => { fs.mkdirSync(REPO); g("init", "-q", "-b", "main"); write("a.txt", "one\n"); g("add", "-A"); g("commit", "-q", "-m", "feat: seed"); }); after(() => fs.rmSync(ROOT, { recursive: true, force: true })); describe("commit-message", () => { it("refuses with ENOSTAGED when nothing is staged", async () => { write("a.txt", "one\nunstaged\n"); await assert.rejects(buildInput(REPO), { code: "ENOSTAGED" }); }); it("sends only staged, non-secret changes and keeps tools/hooks/persistence off", async () => { write("b.txt", "staged line\n"); write(".claude/settings.local.json", '{"env":{"KEY":"sk-secret"}}\n'); g("add", "-f", "b.txt", ".claude/settings.local.json"); const msg = await suggestCommitMessage(REPO, { bin: fakeClaude("```\\nfeat: add b\\n```") }); assert.equal(msg, "feat: add b"); const stdin = fs.readFileSync(STDIN, "utf8"); assert.match(stdin, /feat: seed/); assert.match(stdin, /staged line/); assert.doesNotMatch(stdin, /unstaged|sk-secret|settings\.local/); const argv = fs.readFileSync(ARGS, "utf8").split("\n"); for (const flag of ["-p", "--safe-mode", "--no-session-persistence", "--tools"]) { assert.ok(argv.includes(flag), flag); } assert.equal(argv[argv.indexOf("--tools") + 1], ""); }); it("scopes a subdirectory lane to its own staged files", async () => { write("sub/c.txt", "inside\n"); g("add", "sub/c.txt"); const input = await buildInput(SUB); assert.match(input, /inside/); assert.doesNotMatch(input, /staged line/); }); it("with paths, reads exactly those files' working-tree changes, untracked included", async () => { write("a.txt", "one\nticked edit\n"); write("new.txt", "brand new\n"); write("other.txt", "not ticked\n"); write(".claude/settings.local.json", '{"env":{"KEY":"sk-ticked"}}\n'); const input = await buildInput(REPO, ["a.txt", "new.txt", ".claude/settings.local.json"]); assert.match(input, /ticked edit/); assert.match(input, /brand new/); assert.doesNotMatch(input, /not ticked|staged line|sk-ticked|settings\.local/); await assert.rejects(buildInput(REPO, ["nope.txt"]), { code: "EBADPATH" }); await assert.rejects(buildInput(REPO, []), { code: "ENOSTAGED" }); }); it("reports a failing or silent CLI as EAI", async () => { await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("boom", 1) }), { code: "EAI", }); await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("") }), { code: "EAI" }); }); });