diff --git a/server/__tests__/gh.test.js b/server/__tests__/gh.test.js new file mode 100644 index 00000000..8e5691dd --- /dev/null +++ b/server/__tests__/gh.test.js @@ -0,0 +1,65 @@ +/** + * @file Tests for server/lib/gh.js's execFile wrapper against real fake `gh` + * scripts on PATH — the same fixture style server/__tests__/lane-lifecycle.test.js + * uses for a fake `claude` binary, since this codebase's server tests use real + * subprocesses instead of mocking child_process. + * @author Nguyễn Ngọc Trí Vĩ + */ +const { describe, it, 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 ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "ccam-gh-")); +const { gh } = require("../lib/gh"); + +/** Puts a fake `gh` script on PATH for the duration of one test. */ +function stubGh(scriptBody) { + const bin = path.join(ROOT, `bin-${Date.now()}-${Math.random().toString(36).slice(2)}`); + fs.mkdirSync(bin, { recursive: true }); + const bin_gh = path.join(bin, "gh"); + fs.writeFileSync(bin_gh, `#!/usr/bin/env bash\n${scriptBody}\n`); + fs.chmodSync(bin_gh, 0o755); + const originalPath = process.env.PATH; + process.env.PATH = `${bin}${path.delimiter}${originalPath}`; + return () => { + process.env.PATH = originalPath; + }; +} + +after(() => fs.rmSync(ROOT, { recursive: true, force: true })); + +describe("gh()", () => { + it("returns stdout on success", async () => { + const restore = stubGh('echo "$@" >&2\necho \'{"ok":true}\''); + try { + const result = await gh(ROOT, ["run", "list"]); + assert.match(result.stdout, /"ok":true/); + } finally { + restore(); + } + }); + + it("throws with .gh details on a non-zero exit", async () => { + const restore = stubGh('echo "boom" >&2\nexit 7'); + try { + await assert.rejects( + () => gh(ROOT, ["run", "list"]), + (err) => err.gh.code === 7 && /boom/.test(err.gh.stderr) + ); + } finally { + restore(); + } + }); + + it("throws ENOGH when gh is not on PATH", async () => { + const originalPath = process.env.PATH; + process.env.PATH = ROOT; // a directory with no gh binary in it + try { + await assert.rejects(() => gh(ROOT, ["run", "list"]), { code: "ENOGH" }); + } finally { + process.env.PATH = originalPath; + } + }); +}); diff --git a/server/lib/gh.js b/server/lib/gh.js new file mode 100644 index 00000000..e662a969 --- /dev/null +++ b/server/lib/gh.js @@ -0,0 +1,36 @@ +/** + * @file Thin execFile wrapper around the GitHub CLI (`gh`), used by + * server/lib/ci-status.js to read Actions run state. Mirrors worktree.js's + * git() helper's shape (cwd-scoped, throws with structured .gh details on + * failure) but has no env-scrubbing to do — gh doesn't read GIT_* variables. + * @author Nguyễn Ngọc Trí Vĩ + */ + +const { execFile } = require("node:child_process"); +const { promisify } = require("node:util"); + +const execFileAsync = promisify(execFile); + +/** + * Run `gh ` in `cwd`. Resolves `{ stdout, stderr }` on a zero exit. + * On failure, throws an Error with `.gh = { args, code, stderr }` — `.code` + * is `"ENOGH"` when the binary itself is missing, otherwise the process's + * exit code. + * + * @param {string} cwd + * @param {string[]} args + * @returns {Promise<{stdout: string, stderr: string}>} + */ +async function gh(cwd, args) { + try { + const result = await execFileAsync("gh", args, { cwd, maxBuffer: 8 * 1024 * 1024 }); + return { stdout: result.stdout, stderr: result.stderr }; + } catch (err) { + const error = new Error(`gh ${args[0]} failed`); + error.code = err.code === "ENOENT" ? "ENOGH" : err.code; + error.gh = { args, code: err.code, stderr: err.stderr || "" }; + throw error; + } +} + +module.exports = { gh };