Files
tea-sdlc/test/script-contract.test.js
T
jiantw83andClaude Opus 5 28dec83498 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>
2026-09-17 04:26:51 +00:00

166 lines
6.1 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.
/**
* 所有腳本共用的介面契約:具名 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);
});