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>
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* 以子行程執行 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();
|
||||
}
|
||||
Reference in New Issue
Block a user