/** * 以子行程執行 scripts/ 底下的腳本或 bin/ 的指令入口,回傳它印出的東西與 exit code。 * 這是本專案唯一的測試接縫:測到的東西就是使用者真正會執行的東西。 */ import { execFile, execFileSync } from 'node:child_process'; import { mkdtempSync, mkdirSync, readFileSync, 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'); /** 套件 manifest。版本只有這一個來源,測試也從同一個地方讀,不另外寫死一份。 */ export function manifest() { return JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8')); } /** * @param {string} name 腳本檔名,例如 "labels-list.js" * @param {string[]} args 具名 flag 陣列 * @param {{env?: Record, cwd?: string, path?: string, root?: string}} opts * env 額外環境變數;path 覆寫 PATH(用來模擬缺少 git / tea) * @returns {Promise<{code: number, stdout: string, stderr: string, json: object}>} */ export async function runScript(name, args = [], opts = {}) { const result = await runNode(join(opts.root ?? repoRoot, 'scripts', name), args, opts); // 腳本的輸出契約是單行 JSON,這裡就地斷言:多印一行或印出非 JSON 都在這裡炸掉 return { ...result, json: parseSingleLine(result.stdout) }; } /** * 以子行程執行 bin/ 的指令入口,第一個參數是子指令。 * * 回傳的 `json` 與 `raw` 是兩種並存的斷言,取用哪一個由測試決定:`json` 取用時才 * 斷言單行 JSON(原樣輸出的子指令碰不到它),`raw` 永遠是逐字的 stdout。 * * @param {string[]} args 子指令與其後的具名 flag * @param {{env?: Record, cwd?: string, path?: string, root?: string}} opts * root 覆寫 plugin 根,用來從測試暫存裡的假 plugin 根啟動 * @returns {Promise<{code: number, stdout: string, stderr: string, json: object, raw: string}>} */ export async function runBin(args = [], opts = {}) { const result = await runNode(join(opts.root ?? repoRoot, 'bin', 'tea-sdlc.js'), args, opts); return { ...result, raw: result.stdout, get json() { return parseSingleLine(result.stdout); }, }; } /** * 真的開一個 node 子行程跑指定檔案。runScript 與 runBin 的共用地基。 * @param {string} file 要執行的檔案絕對路徑 */ function runNode(file, 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, [file, ...args], // 預設 1MB 會在長輸出時砍掉子行程,那是測試工具的限制而非腳本的問題 { cwd, env: childEnv, maxBuffer: 64 * 1024 * 1024 }, (error, stdout, stderr) => { resolve({ code: typeof error?.code === 'number' ? error.code : error ? 1 : 0, stdout, stderr, }); }, ); }); } /** * 「單行 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(); }