/** * 以子行程執行 scripts/ 底下的腳本,回傳它印出的 JSON 與 exit code。 * 這是本專案唯一的測試接縫:測到的東西就是使用者真正會執行的東西。 */ import { execFile, execFileSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, symlinkSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; /** 本檔位置 → repo 根,讓測試不依賴 cwd */ export const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'); /** 所有測試暫存的落點;已被 .gitignore 忽略,也不會被 node --test 探索到 */ export const tmpRoot = join(repoRoot, '.tmp'); /** * @param {string} name 腳本檔名,例如 "labels-list.js" * @param {string[]} args 具名 flag 陣列 * @param {{env?: Record, cwd?: string, path?: string}} opts * env 額外環境變數;path 覆寫 PATH(用來模擬缺少 git / tea) * @returns {Promise<{code: number, stdout: string, stderr: string, json: object}>} */ export function runScript(name, args = [], opts = {}) { const { env = {}, cwd = repoRoot, path } = opts; // 先把繼承來的 TEA_SDLC_* 清乾淨,測試結果才不會隨開發者的 shell 而變 const inherited = Object.fromEntries( Object.entries(process.env).filter(([k]) => !k.startsWith('TEA_SDLC_')), ); const childEnv = { ...inherited, ...env }; if (path !== undefined) childEnv.PATH = path; return new Promise((resolve) => { execFile( process.execPath, [join(repoRoot, 'scripts', name), ...args], { cwd, env: childEnv }, (error, stdout, stderr) => { resolve({ code: typeof error?.code === 'number' ? error.code : error ? 1 : 0, stdout, stderr, json: parseSingleLine(stdout), }); }, ); }); } /** * 腳本的輸出契約是「單行 JSON」。這裡順便把契約本身斷言掉: * 多印一行、印出非 JSON,都會在這裡就炸掉。 */ function parseSingleLine(stdout) { const lines = stdout.split('\n').filter((l) => l.trim() !== ''); if (lines.length !== 1) { throw new Error(`預期單行 JSON,實際印出 ${lines.length} 行:\n${stdout}`); } return JSON.parse(lines[0]); } /** * 造一個只放得下指定執行檔的 PATH,用來讓「缺少 git / tea」的情境精確成立。 * 直接從真實 PATH 裡剔除目錄行不通:git、tea、node 常住在同一個 /usr/bin。 * @param {string[]} binaries 要保留的執行檔名 * @returns {string} 只含一個目錄的 PATH */ export function pathWithOnly(binaries) { mkdirSync(tmpRoot, { recursive: true }); const dir = mkdtempSync(join(tmpRoot, 'path-')); for (const binary of binaries) { symlinkSync(which(binary), join(dir, binary)); } return dir; } function which(binary) { return execFileSync('sh', ['-c', `command -v ${binary}`], { encoding: 'utf8' }).trim(); }