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:
2026-09-17 12:21:41 +08:00
co-authored by Claude Opus 5
parent 373b7bcc2d
commit b3c9c38d8e
8 changed files with 937 additions and 0 deletions
+78
View File
@@ -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();
}
+91
View File
@@ -0,0 +1,91 @@
/**
* 假的 Gitea:測試時用 TEA_SDLC_API_BASE 指向它,腳本便不會碰到真的伺服器。
* 路由以 "METHOD /path" 為鍵,值可以是 {status, body} 或一個接 (req) 的函式。
* 每一筆進來的請求都會被記錄,讓測試可以斷言「腳本到底發了哪些請求」。
*/
import { createServer } from 'node:http';
/**
* 啟動一台假 Gitea。
* @param {Record<string, object|Function>} routes 路由表,鍵為 "METHOD /path"
* @returns {Promise<{base: string, requests: object[], close: Function}>}
*/
export async function startStubGitea(routes = {}) {
/** 收到的請求,依序記錄;測試靠它斷言請求次數與內容 */
const requests = [];
const server = createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
const url = new URL(req.url, 'http://127.0.0.1');
const key = `${req.method} ${url.pathname}`;
requests.push({
method: req.method,
path: url.pathname,
query: Object.fromEntries(url.searchParams),
authorization: req.headers.authorization ?? null,
body: raw ? safeParse(raw) : null,
});
const route = routes[key];
if (route === undefined) {
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ message: `stub has no route for ${key}` }));
return;
}
const { status = 200, body = null } =
typeof route === 'function' ? route(requests.at(-1)) : route;
res.writeHead(status, { 'content-type': 'application/json' });
res.end(body === null ? '' : JSON.stringify(body));
});
});
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
return {
base: `http://127.0.0.1:${port}/api/v1`,
requests,
close: () => new Promise((resolve) => server.close(resolve)),
};
}
/**
* 一台「什麼都正常」的 Gitea 的路由表,測試再以 overrides 局部覆寫成想要的壞掉樣子。
* @param {string} repo 目標 repo,格式 owner/name
* @param {Record<string, object|Function>} overrides 要覆寫的路由
*/
export function healthyRoutes(repo, overrides = {}) {
return {
'GET /api/v1/user': { status: 200, body: { login: 'tester' } },
[`GET /api/v1/repos/${repo}`]: {
status: 200,
body: {
full_name: repo,
has_issues: true,
permissions: { admin: false, push: true, pull: true },
internal_tracker: { enable_time_tracker: true },
},
},
// 寫入權探針:有 issues 寫入權時,Gitea 會通過權限中介層後才發現議題不存在
[`PATCH /api/v1/repos/${repo}/issues/0`]: { status: 404, body: { message: 'issue does not exist' } },
[`GET /api/v1/repos/${repo}/labels`]: {
status: 200,
body: [
{ id: 55, name: 'ready-for-agent', color: '0e8a16', description: '可交給 agent 處理' },
{ id: 56, name: '進行中', color: 'fbca04', description: '' },
],
},
...overrides,
};
}
function safeParse(raw) {
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
+31
View File
@@ -0,0 +1,31 @@
/**
* 在 .tmp/ 底下開一個真的 git repo。
* git 操作不做 mock:在臨時 repo 上跑真的 git 比假的 git 可信,成本也低。
*/
import { execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { tmpRoot } from './run-script.js';
/**
* @returns {{dir: string, cleanup: Function}} dir 為已有一顆 commit 的 git repo
*/
export function makeTempRepo() {
mkdirSync(tmpRoot, { recursive: true });
const dir = mkdtempSync(join(tmpRoot, 'repo-'));
const git = (...args) =>
execFileSync('git', args, {
cwd: dir,
encoding: 'utf8',
env: { ...process.env, GIT_CONFIG_GLOBAL: '/dev/null', GIT_CONFIG_SYSTEM: '/dev/null' },
});
git('init', '-q', '-b', 'master');
git('config', 'user.name', 'tester');
git('config', 'user.email', 'tester@example.com');
writeFileSync(join(dir, 'README.md'), '# temp\n');
git('add', '-A');
git('commit', '-qm', 'initial');
return { dir, cleanup: () => rmSync(dir, { recursive: true, force: true }) };
}