Files
tea-sdlc/test/helpers/run-script.js
T
jiantw83andClaude Opus 5 17178f0c4d feat(佈署): 以 npm 裝出 tea-sdlc 指令並產生各平台轉接檔
單一入口 bin/tea-sdlc.js 認四個子指令。第一個位置參數是子指令,其餘 argv 原樣
交出去——既有的 flag 解析拒絕位置參數,所以子指令必須在那之前就被取走。

轉接檔裡沒有路徑,只有一句 tea-sdlc prompt --name <指令名>。正本在哪由 PATH 上
的 tea-sdlc 自己回推:fnm 把 Node 版號寫進全域安裝路徑,寫死路徑的話升一次
Node,七個平台的轉接檔會同時指向不存在的檔案,而且不會有任何錯誤訊息。

prompt 是全專案唯一輸出非 JSON 的路徑,理由只有一個:它的輸出要餵給模型讀。
失敗仍走 envelope——成功是內容,失敗才需要結構。

status 的 ok 不兼差表達環境好壞,健康與否放在 data.healthy:呼叫端要分得出
「status 掛了」與「status 成功查到你環境有問題」。

install 只寫進偵測得到的平台;缺 git/tea 只警告不中止,因為那兩個完全不影響
轉接檔產生,硬擋等於逼使用者為了裝 plugin 先去裝 tea。uninstall 只刪帶產生標記
的檔案,使用者自己寫的同名檔案一律留著並在輸出裡交代。裝哪些指令以 prompts/ 裡
實際存在的正本為準,不是寫死的六個名字——裝出指向不存在正本的轉接檔,使用者只會
看到 PROMPT_NOT_FOUND。

流程正本的 description 前綴在抄進轉接檔之前就檢查:有三個平台關不掉自動觸發,
全靠那句話把 description 窄到不會被誤判,不能等使用者發現誤觸才知道漏了。

議題 #26 #27 #28 #29 #17

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 06:59:37 +00:00

117 lines
4.6 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/ 底下的腳本或 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<string,string>, 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<string,string>, 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();
}