process.stdout.write 之後立刻 process.exit 會截斷輸出——stdout 接到 pipe 時 寫入是非同步的。改為等 write 的 callback 回來再退出。實測舊寫法在約 83KB 處 被切斷,新增的回歸測試以 8000 筆標籤覆蓋這條路徑。 findIssueByTitle 的翻頁原本沒有上限,Gitea 若持續回滿一頁就會無限打下去。 加上 200 頁上限,超過即以 DEDUPE_LIMIT 報錯而非無聲回 null——無聲回 null 會 讓呼叫端把既有議題再建一次,正好是冪等查重要防的事。 parseFlags 取值時不再於三元運算式內遞增迴圈變數,改為獨立敘述。 測試工具的 maxBuffer 調高到 64MB:預設 1MB 會在長輸出時砍掉子行程,那是測試 工具的限制而非腳本的問題。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
80 lines
3.0 KiB
JavaScript
80 lines
3.0 KiB
JavaScript
/**
|
||
* 以子行程執行 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],
|
||
// 預設 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: 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();
|
||
}
|