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
+165
View File
@@ -0,0 +1,165 @@
/**
* 所有腳本共用的介面契約:具名 flag 輸入、單行 JSON 輸出、--dry-run、
* 以及「從自身檔案位置回推 plugin 根」而非依賴 cwd。
* 以 labels-list 當代表,因為契約由 lib 提供,每支腳本表現一致。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { runScript } from './helpers/run-script.js';
import { startStubGitea, healthyRoutes } from './helpers/stub-gitea.js';
const REPO = 'plugins/tea-sdlc';
async function withStub(t, overrides = {}) {
const stub = await startStubGitea(healthyRoutes(REPO, overrides));
t.after(() => stub.close());
return stub;
}
const envFor = (stub) => ({ TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' });
// ── 輸入:具名 flag ────────────────────────────────────────────────
test('缺少必填 flag 時失敗,並指名缺的是哪一個', async (t) => {
const stub = await withStub(t);
const { code, json } = await runScript('labels-list.js', [], { env: envFor(stub) });
assert.equal(code, 1);
assert.equal(json.ok, false);
assert.equal(json.error.code, 'MISSING_FLAG');
assert.match(json.error.message, /--repo/);
});
test('不認得的 flag 一律拒絕,不默默忽略', async (t) => {
const stub = await withStub(t);
const { json } = await runScript('labels-list.js', ['--repo', REPO, '--verbose'], {
env: envFor(stub),
});
assert.equal(json.error.code, 'UNKNOWN_FLAG');
assert.match(json.error.message, /--verbose/);
});
test('位置參數一律拒絕:輸入只接受具名 flag', async (t) => {
const stub = await withStub(t);
const { json } = await runScript('labels-list.js', [REPO], { env: envFor(stub) });
assert.equal(json.error.code, 'UNKNOWN_FLAG');
});
test('--repo 格式不是 owner/name 時失敗', async (t) => {
const stub = await withStub(t);
const { json } = await runScript('labels-list.js', ['--repo', 'tea-sdlc'], { env: envFor(stub) });
assert.equal(json.error.code, 'BAD_REPO');
});
test('--key=value 與 --key value 兩種寫法等價', async (t) => {
const stub = await withStub(t);
const a = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
const b = await runScript('labels-list.js', [`--repo=${REPO}`], { env: envFor(stub) });
assert.deepEqual(a.json, b.json);
});
// ── 輸出:單行 JSON ───────────────────────────────────────────────
test('成功時輸出 {ok:true, data},且沒有 error 欄位', async (t) => {
const stub = await withStub(t);
const { json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.equal(json.ok, true);
assert.ok(json.data);
assert.equal('error' in json, false);
});
test('失敗時輸出 {ok:false, error:{code,message}},且沒有 data 欄位', async (t) => {
const stub = await withStub(t);
const { json } = await runScript('labels-list.js', [], { env: envFor(stub) });
assert.equal(json.ok, false);
assert.equal(typeof json.error.code, 'string');
assert.equal(typeof json.error.message, 'string');
assert.equal('data' in json, false);
});
test('失敗時 exit code 為 1,成功為 0', async (t) => {
const stub = await withStub(t);
const bad = await runScript('labels-list.js', [], { env: envFor(stub) });
const good = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
assert.equal(bad.code, 1);
assert.equal(good.code, 0);
});
test('例外不會以 stack trace 洩漏出去,一律收斂成 JSON', async (t) => {
// 指向一個沒有人在聽的 port,讓 fetch 直接拋連線錯誤
const { code, json, stderr } = await runScript('labels-list.js', ['--repo', REPO], {
env: { TEA_SDLC_API_BASE: 'http://127.0.0.1:1/api/v1', TEA_SDLC_TOKEN: 'x' },
});
assert.equal(code, 1);
assert.equal(json.ok, false);
assert.equal(json.error.code, 'NETWORK_ERROR');
assert.equal(stderr, '', 'stderr 必須乾淨,訊息一律走 stdout 的 JSON');
});
// ── --dry-run ─────────────────────────────────────────────────────
test('--dry-run 印出將發出的請求,且完全不碰 Gitea', async (t) => {
const stub = await withStub(t);
const { code, json } = await runScript('labels-list.js', ['--repo', REPO, '--dry-run'], {
env: envFor(stub),
});
assert.equal(code, 0);
assert.equal(json.ok, true);
assert.equal(json.data.dryRun, true);
assert.deepEqual(json.data.requests, [
{ method: 'GET', path: `/repos/${REPO}/labels` },
]);
assert.equal(stub.requests.length, 0, '--dry-run 不得發出任何請求,連前置檢查也不發');
});
// ── 路徑定位 ───────────────────────────────────────────────────────
test('在任意 cwd 下執行結果相同:腳本從自身位置回推 plugin 根', async (t) => {
const stub = await withStub(t);
const fromRoot = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) });
const fromSlash = await runScript('labels-list.js', ['--repo', REPO], {
env: envFor(stub),
cwd: '/',
});
const fromTmp = await runScript('labels-list.js', ['--repo', REPO], {
env: envFor(stub),
cwd: '/tmp',
});
assert.equal(fromRoot.code, 0);
assert.deepEqual(fromSlash.json, fromRoot.json);
assert.deepEqual(fromTmp.json, fromRoot.json);
});
test('--dry-run 回報的 templates/references 路徑為絕對路徑,且不隨 cwd 改變', async (t) => {
const stub = await withStub(t);
const a = await runScript('labels-list.js', ['--repo', REPO, '--dry-run'], { env: envFor(stub) });
const b = await runScript('labels-list.js', ['--repo', REPO, '--dry-run'], {
env: envFor(stub),
cwd: '/tmp',
});
assert.match(a.json.data.paths.templates, /^\/.*\/templates$/);
assert.match(a.json.data.paths.references, /^\/.*\/references$/);
assert.deepEqual(b.json.data.paths, a.json.data.paths);
});