Files
tea-sdlc/test/helpers/run-script.js
T
jiantw83andClaude Opus 5 b3c9c38d8e test(腳本契約): 以子行程與 stub server 驗證契約與四層前置檢查
測試一律以子行程執行腳本、比對 stdout 的單行 JSON 與 exit code,因為那正是
七個平台共用的實際呼叫方式,不會因內部重構而破碎。

Gitea 以本機 stub server 替身並錄下每一筆請求,藉此斷言「腳本到底發了哪些
請求」——包含 --dry-run 不得發出任何請求、前一層檢查沒過就不再往下打、
寫入權探針不得挾帶任何要寫入的欄位。git 則在 .tmp/ 下的臨時 repo 跑真實
指令,比 mock 可信且成本低。

lib-exports.test.js 是唯一直接 import lib 的例外:冪等查重與 git 執行點在
CLI 邊界上還沒有消費者,檔頭已註明等 #4 與 #10 落地後即可縮小或移除。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 12:21:41 +08:00

79 lines
2.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 以子行程執行 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<string,string>, 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();
}