From b3c9c38d8e1e3fb46278760879c803a081678873 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 12:21:41 +0800 Subject: [PATCH] =?UTF-8?q?test(=E8=85=B3=E6=9C=AC=E5=A5=91=E7=B4=84):=20?= =?UTF-8?q?=E4=BB=A5=E5=AD=90=E8=A1=8C=E7=A8=8B=E8=88=87=20stub=20server?= =?UTF-8?q?=20=E9=A9=97=E8=AD=89=E5=A5=91=E7=B4=84=E8=88=87=E5=9B=9B?= =?UTF-8?q?=E5=B1=A4=E5=89=8D=E7=BD=AE=E6=AA=A2=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 測試一律以子行程執行腳本、比對 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) --- test/helpers/run-script.js | 78 ++++++++++++ test/helpers/stub-gitea.js | 91 ++++++++++++++ test/helpers/temp-repo.js | 31 +++++ test/labels-list.test.js | 101 +++++++++++++++ test/lib-exports.test.js | 113 +++++++++++++++++ test/preflight.test.js | 234 +++++++++++++++++++++++++++++++++++ test/script-contract.test.js | 165 ++++++++++++++++++++++++ test/tea-login.test.js | 124 +++++++++++++++++++ 8 files changed, 937 insertions(+) create mode 100644 test/helpers/run-script.js create mode 100644 test/helpers/stub-gitea.js create mode 100644 test/helpers/temp-repo.js create mode 100644 test/labels-list.test.js create mode 100644 test/lib-exports.test.js create mode 100644 test/preflight.test.js create mode 100644 test/script-contract.test.js create mode 100644 test/tea-login.test.js diff --git a/test/helpers/run-script.js b/test/helpers/run-script.js new file mode 100644 index 0000000..ad1b578 --- /dev/null +++ b/test/helpers/run-script.js @@ -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, 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(); +} diff --git a/test/helpers/stub-gitea.js b/test/helpers/stub-gitea.js new file mode 100644 index 0000000..8b76f23 --- /dev/null +++ b/test/helpers/stub-gitea.js @@ -0,0 +1,91 @@ +/** + * 假的 Gitea:測試時用 TEA_SDLC_API_BASE 指向它,腳本便不會碰到真的伺服器。 + * 路由以 "METHOD /path" 為鍵,值可以是 {status, body} 或一個接 (req) 的函式。 + * 每一筆進來的請求都會被記錄,讓測試可以斷言「腳本到底發了哪些請求」。 + */ +import { createServer } from 'node:http'; + +/** + * 啟動一台假 Gitea。 + * @param {Record} 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} 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; + } +} diff --git a/test/helpers/temp-repo.js b/test/helpers/temp-repo.js new file mode 100644 index 0000000..39ad0b2 --- /dev/null +++ b/test/helpers/temp-repo.js @@ -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 }) }; +} diff --git a/test/labels-list.test.js b/test/labels-list.test.js new file mode 100644 index 0000000..86d1362 --- /dev/null +++ b/test/labels-list.test.js @@ -0,0 +1,101 @@ +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'; + +/** 讓每個測試都拿到一台乾淨的假 Gitea,並在結束後關掉 */ +async function withStub(t, overrides = {}) { + const stub = await startStubGitea(healthyRoutes(REPO, overrides)); + t.after(() => stub.close()); + return stub; +} + +function envFor(stub) { + return { TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' }; +} + +test('列出目標 repo 的既有標籤,輸出單行 JSON 且 exit 0', async (t) => { + const stub = await withStub(t); + + const { code, json, stdout } = await runScript('labels-list.js', ['--repo', REPO], { + env: envFor(stub), + }); + + assert.equal(code, 0); + assert.equal(json.ok, true); + assert.deepEqual( + json.data.labels.map((l) => l.name), + ['ready-for-agent', '進行中'], + ); + assert.equal(stdout.trimEnd().includes('\n'), false, '輸出必須是單行'); +}); + +test('輸出帶上 repo 與標籤數,供下游不必再數一次', async (t) => { + const stub = await withStub(t); + + const { json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) }); + + assert.equal(json.data.repo, REPO); + assert.equal(json.data.count, 2); +}); + +test('帶上 Authorization,且只對 Gitea 發出讀取請求', async (t) => { + const stub = await withStub(t); + + await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) }); + + const labelCalls = stub.requests.filter((r) => r.path.endsWith('/labels')); + assert.equal(labelCalls.length, 1); + assert.equal(labelCalls[0].method, 'GET'); + assert.equal(labelCalls[0].authorization, 'token stub-token'); +}); + +test('不具備建立標籤的能力:沒有任何請求會寫到 labels 端點', async (t) => { + const stub = await withStub(t); + + await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) }); + + const writesToLabels = stub.requests.filter( + (r) => r.path.endsWith('/labels') && r.method !== 'GET', + ); + assert.deepEqual(writesToLabels, []); +}); + +test('不具備建立標籤的能力:看起來像要建標籤的 flag 一律被拒', async (t) => { + const stub = await withStub(t); + + const { code, json } = await runScript( + 'labels-list.js', + ['--repo', REPO, '--create', 'needs-triage'], + { env: envFor(stub) }, + ); + + assert.equal(code, 1); + assert.equal(json.ok, false); + assert.equal(json.error.code, 'UNKNOWN_FLAG'); + assert.equal(stub.requests.length, 0, '參數錯誤時不該碰 Gitea'); +}); + +test('空標籤清單不是錯誤', async (t) => { + const stub = await withStub(t, { [`GET /api/v1/repos/${REPO}/labels`]: { status: 200, body: [] } }); + + const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) }); + + assert.equal(code, 0); + assert.deepEqual(json.data.labels, []); + assert.equal(json.data.count, 0); +}); + +test('Gitea 回非預期狀態碼時,帶著狀態碼失敗而不是硬吞', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/labels`]: { status: 500, body: { message: 'boom' } }, + }); + + const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) }); + + assert.equal(code, 1); + assert.equal(json.error.code, 'HTTP_ERROR'); + assert.match(json.error.message, /500/); +}); diff --git a/test/lib-exports.test.js b/test/lib-exports.test.js new file mode 100644 index 0000000..be643a8 --- /dev/null +++ b/test/lib-exports.test.js @@ -0,0 +1,113 @@ +/** + * 這一支是唯一直接 import lib 的測試,其餘一律走子行程的 CLI 邊界。 + * + * 理由:冪等查重與 git 執行點是 lib 對「其他腳本」公開的契約,但本工作包只交付 + * labels-list(讀取型、不碰 git、不需查重),CLI 邊界上還沒有消費者。等 #4 的 + * issue-create 與 #10 的 branch-prep 落地後,它們的 CLI 測試才是這兩件事的主場, + * 屆時這支可以縮小或移除。在那之前直接測 export,好過讓契約完全沒有測試。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { findIssueByTitle, runGit, pluginRoot, templatesDir, referencesDir } from '../scripts/lib.js'; +import { startStubGitea } from './helpers/stub-gitea.js'; +import { makeTempRepo } from './helpers/temp-repo.js'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +const REPO = 'plugins/tea-sdlc'; + +// ── 冪等查重 ─────────────────────────────────────────────────────── + +test('查重:標題完全相同的既有議題會被找到', async (t) => { + const stub = await startStubGitea({ + [`GET /api/v1/repos/${REPO}/issues`]: { + status: 200, + body: [ + { number: 7, title: '建立工作包的抽取契約' }, + { number: 8, title: '產生圖解版總覽網頁並寫回議題' }, + ], + }, + }); + t.after(() => stub.close()); + + const found = await findIssueByTitle( + { base: stub.base, token: 'stub-token' }, + REPO, + '產生圖解版總覽網頁並寫回議題', + ); + + assert.equal(found.number, 8); +}); + +test('查重:沒有同名議題時回 null,讓呼叫端自行決定要不要建立', async (t) => { + const stub = await startStubGitea({ + [`GET /api/v1/repos/${REPO}/issues`]: { status: 200, body: [{ number: 7, title: '別的議題' }] }, + }); + t.after(() => stub.close()); + + const found = await findIssueByTitle({ base: stub.base, token: 'x' }, REPO, '找不到的標題'); + + assert.equal(found, null); +}); + +test('查重:同時看 open 與 closed,避免把已關閉的議題再建一次', async (t) => { + const stub = await startStubGitea({ + [`GET /api/v1/repos/${REPO}/issues`]: { status: 200, body: [] }, + }); + t.after(() => stub.close()); + + await findIssueByTitle({ base: stub.base, token: 'x' }, REPO, '任何標題'); + + assert.equal(stub.requests[0].query.state, 'all'); +}); + +test('查重:標題只差前後空白仍視為同一顆', async (t) => { + const stub = await startStubGitea({ + [`GET /api/v1/repos/${REPO}/issues`]: { + status: 200, + body: [{ number: 12, title: ' 走通腳本契約 ' }], + }, + }); + t.after(() => stub.close()); + + const found = await findIssueByTitle({ base: stub.base, token: 'x' }, REPO, '走通腳本契約'); + + assert.equal(found.number, 12); +}); + +// ── git 執行點 ───────────────────────────────────────────────────── + +test('git 執行點:在臨時 repo 上跑真實 git,取得目前分支', (t) => { + const repo = makeTempRepo(); + t.after(() => repo.cleanup()); + + const branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repo.dir }); + + assert.equal(branch, 'master'); +}); + +test('git 執行點:輸出前後空白被修掉,呼叫端不必自己 trim', (t) => { + const repo = makeTempRepo(); + t.after(() => repo.cleanup()); + + const log = runGit(['log', '--oneline'], { cwd: repo.dir }); + + assert.equal(log.endsWith('\n'), false); + assert.match(log, /initial$/); +}); + +test('git 執行點:git 失敗時拋出帶有 stderr 的錯誤,不回傳空字串', (t) => { + const repo = makeTempRepo(); + t.after(() => repo.cleanup()); + + assert.throws(() => runGit(['rev-parse', 'no-such-ref'], { cwd: repo.dir }), /no-such-ref/); +}); + +// ── 路徑定位 ─────────────────────────────────────────────────────── + +test('plugin 根由自身檔案位置回推,templates 與 references 都在底下', () => { + assert.equal(templatesDir(), join(pluginRoot(), 'templates')); + assert.equal(referencesDir(), join(pluginRoot(), 'references')); + assert.ok(existsSync(templatesDir())); + assert.ok(existsSync(referencesDir())); +}); diff --git a/test/preflight.test.js b/test/preflight.test.js new file mode 100644 index 0000000..6297b2e --- /dev/null +++ b/test/preflight.test.js @@ -0,0 +1,234 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runScript, pathWithOnly } 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' }); + +// ── 第一層:執行環境 ──────────────────────────────────────────────── + +test('第一層:PATH 上沒有 git 時中止,錯誤訊息指名 git', async (t) => { + const stub = await withStub(t); + + const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { + env: envFor(stub), + path: pathWithOnly(['tea']), + }); + + assert.equal(code, 1); + assert.equal(json.error.code, 'ENV_MISSING'); + assert.match(json.error.message, /git/); + assert.equal(stub.requests.length, 0, '環境不通過就不該發請求'); +}); + +test('第一層:PATH 上沒有 tea 時中止,錯誤訊息指名 tea 並給出安裝指引', async (t) => { + const stub = await withStub(t); + + const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { + env: envFor(stub), + path: pathWithOnly(['git']), + }); + + assert.equal(code, 1); + assert.equal(json.error.code, 'ENV_MISSING'); + assert.match(json.error.message, /tea/); +}); + +// ── 第二層:Gitea 登入 ───────────────────────────────────────────── + +test('第二層:token 無效時中止,訊息指向 tea login', async (t) => { + const stub = await withStub(t, { + 'GET /api/v1/user': { status: 401, body: { message: 'unauthorized' } }, + }); + + const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) }); + + assert.equal(code, 1); + assert.equal(json.error.code, 'LOGIN_INVALID'); + assert.match(json.error.message, /tea login/); +}); + +test('第二層:找不到任何登入資訊時中止', async (t) => { + const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { + env: { TEA_SDLC_API_BASE: '', TEA_SDLC_TOKEN: '', TEA_SDLC_CONFIG: '/nonexistent/tea.yml' }, + }); + + assert.equal(code, 1); + assert.equal(json.error.code, 'LOGIN_INVALID'); +}); + +// ── 第三層:issues unit 寫入權 ───────────────────────────────────── + +test('第三層:repo 不存在或讀不到時,錯誤碼與權限問題分得開', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}`]: { status: 404, body: { message: 'not found' } }, + }); + + const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) }); + + assert.equal(code, 1); + assert.equal(json.error.code, 'REPO_NOT_FOUND'); +}); + +test('第三層:repo 關閉議題功能時中止,訊息指出開啟位置', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}`]: { + status: 200, + body: { + has_issues: false, + permissions: { admin: false, push: true, pull: true }, + internal_tracker: { enable_time_tracker: true }, + }, + }, + }); + + const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) }); + + assert.equal(code, 1); + assert.equal(json.error.code, 'ISSUES_UNIT_OFF'); + assert.match(json.error.message, /Settings/); +}); + +test('第三層:對 issues unit 實測寫入權,被擋下時中止', async (t) => { + const stub = await withStub(t, { + [`PATCH /api/v1/repos/${REPO}/issues/0`]: { status: 403, body: { message: 'forbidden' } }, + }); + + const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) }); + + assert.equal(code, 1); + assert.equal(json.error.code, 'NO_ISSUE_WRITE'); + assert.match(json.error.message, /issues/i); +}); + +test('第三層:不能只看 permissions.push——push 為真但 issues unit 被擋,仍須失敗', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}`]: { + status: 200, + body: { + has_issues: true, + // team 的 unit 權限可獨立於 repo 的 push 權限,所以這裡是真的也不算數 + permissions: { admin: false, push: true, pull: true }, + internal_tracker: { enable_time_tracker: true }, + }, + }, + [`PATCH /api/v1/repos/${REPO}/issues/0`]: { status: 403, body: { message: 'forbidden' } }, + }); + + const { json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) }); + + assert.equal(json.error.code, 'NO_ISSUE_WRITE'); +}); + +test('第三層:探針打在不存在的議題 index 0,確保無副作用', async (t) => { + const stub = await withStub(t); + + await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) }); + + const probes = stub.requests.filter((r) => r.method === 'PATCH'); + assert.equal(probes.length, 1); + assert.equal(probes[0].path, `/api/v1/repos/${REPO}/issues/0`); + assert.deepEqual(probes[0].body, {}, '探針不得挾帶任何要寫入的欄位'); +}); + +// ── 第四層:時間追蹤 ─────────────────────────────────────────────── + +test('第四層:時間追蹤未開啟時中止,訊息指出 Enable Time Tracker 的位置', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}`]: { + status: 200, + body: { + has_issues: true, + permissions: { admin: false, push: true, pull: true }, + internal_tracker: { enable_time_tracker: false }, + }, + }, + }); + + const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) }); + + assert.equal(code, 1); + assert.equal(json.error.code, 'TIME_TRACKER_OFF'); + assert.match(json.error.message, /Settings → Advanced Settings → Enable Time Tracker/); +}); + +// ── 四層之間 ─────────────────────────────────────────────────────── + +test('四層的錯誤碼兩兩相異,呼叫端分得出是哪一層壞了', async (t) => { + const cases = [ + { + code: 'ENV_MISSING', + run: async () => { + const stub = await withStub(t); + return runScript('labels-list.js', ['--repo', REPO], { + env: envFor(stub), + path: pathWithOnly(['git']), + }); + }, + }, + { + code: 'LOGIN_INVALID', + run: async () => { + const stub = await withStub(t, { + 'GET /api/v1/user': { status: 401, body: {} }, + }); + return runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) }); + }, + }, + { + code: 'NO_ISSUE_WRITE', + run: async () => { + const stub = await withStub(t, { + [`PATCH /api/v1/repos/${REPO}/issues/0`]: { status: 403, body: {} }, + }); + return runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) }); + }, + }, + { + code: 'TIME_TRACKER_OFF', + run: async () => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}`]: { + status: 200, + body: { + has_issues: true, + permissions: { admin: true, push: true, pull: true }, + internal_tracker: { enable_time_tracker: false }, + }, + }, + }); + return runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) }); + }, + }, + ]; + + const seen = []; + for (const { code, run } of cases) { + const { json } = await run(); + assert.equal(json.error.code, code); + seen.push(json.error.code); + } + assert.equal(new Set(seen).size, seen.length); +}); + +test('前一層失敗就停手,不會再往下打', async (t) => { + const stub = await withStub(t, { + 'GET /api/v1/user': { status: 401, body: {} }, + }); + + await runScript('labels-list.js', ['--repo', REPO], { env: envFor(stub) }); + + assert.deepEqual( + stub.requests.map((r) => r.path), + ['/api/v1/user'], + '登入檢查沒過就不該再查 repo 或標籤', + ); +}); diff --git a/test/script-contract.test.js b/test/script-contract.test.js new file mode 100644 index 0000000..413d91c --- /dev/null +++ b/test/script-contract.test.js @@ -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); +}); diff --git a/test/tea-login.test.js b/test/tea-login.test.js new file mode 100644 index 0000000..8df75b8 --- /dev/null +++ b/test/tea-login.test.js @@ -0,0 +1,124 @@ +/** + * 認證來源:預設沿用使用者既有的 `tea login`,不另外要求設定一組 token。 + * TEA_SDLC_API_BASE / TEA_SDLC_TOKEN 只是給測試與 CI 的覆寫出口。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { runScript, tmpRoot } from './helpers/run-script.js'; +import { startStubGitea, healthyRoutes } from './helpers/stub-gitea.js'; + +const REPO = 'plugins/tea-sdlc'; + +/** 寫一份 tea 風格的 config.yml 到暫存目錄,回傳路徑 */ +function writeTeaConfig(logins) { + mkdirSync(tmpRoot, { recursive: true }); + const dir = mkdtempSync(join(tmpRoot, 'tea-')); + const path = join(dir, 'config.yml'); + const body = logins + .map((l) => + [ + ` - name: ${l.name}`, + ` url: ${l.url}`, + ` token: ${l.token}`, + ` default: ${l.default ?? false}`, + ` ssh_key: ""`, + ` user: ${l.user ?? 'tester'}`, + ].join('\n'), + ) + .join('\n'); + writeFileSync(path, `logins:\n${body}\npreferences:\n editor: false\n`); + return path; +} + +async function withStub(t) { + const stub = await startStubGitea(healthyRoutes(REPO)); + t.after(() => stub.close()); + return stub; +} + +/** stub 的 base 是 .../api/v1,tea config 記的是站台根網址 */ +const siteUrl = (stub) => stub.base.replace(/\/api\/v1$/, ''); + +test('沒有環境變數覆寫時,採用 tea 設定中 default 為真的登入', async (t) => { + const stub = await withStub(t); + const config = writeTeaConfig([ + { name: 'OTHER', url: 'https://gitea.example.com', token: 'wrong-token' }, + { name: 'JSC', url: siteUrl(stub), token: 'config-token', default: true }, + ]); + + const { code, json } = await runScript('labels-list.js', ['--repo', REPO], { + env: { TEA_SDLC_CONFIG: config }, + }); + + assert.equal(code, 0); + assert.equal(json.ok, true); + assert.equal(stub.requests[0].authorization, 'token config-token'); +}); + +test('--host 指定站台時,挑出對應的那一組登入', async (t) => { + const stub = await withStub(t); + const config = writeTeaConfig([ + { name: 'JSC', url: siteUrl(stub), token: 'jsc-token' }, + { name: 'HP', url: 'https://gitea.example.com', token: 'hp-token', default: true }, + ]); + + const { code, json } = await runScript( + 'labels-list.js', + ['--repo', REPO, '--host', siteUrl(stub)], + { env: { TEA_SDLC_CONFIG: config } }, + ); + + assert.equal(code, 0, JSON.stringify(json)); + assert.equal(stub.requests[0].authorization, 'token jsc-token'); +}); + +test('--host 指定的站台沒有對應登入時,明確說是哪個站台缺登入', async (t) => { + const config = writeTeaConfig([{ name: 'JSC', url: 'https://gitea.example.com', token: 't', default: true }]); + + const { code, json } = await runScript( + 'labels-list.js', + ['--repo', REPO, '--host', 'https://gitea.missing.tw'], + { env: { TEA_SDLC_CONFIG: config } }, + ); + + assert.equal(code, 1); + assert.equal(json.error.code, 'LOGIN_INVALID'); + assert.match(json.error.message, /gitea\.missing\.tw/); +}); + +test('tea 設定檔不存在時,訊息指向 tea login add', async (t) => { + const { json } = await runScript('labels-list.js', ['--repo', REPO], { + env: { TEA_SDLC_CONFIG: join(tmpRoot, 'definitely-not-here', 'config.yml') }, + }); + + assert.equal(json.error.code, 'LOGIN_INVALID'); + assert.match(json.error.message, /tea login add/); +}); + +test('設定檔存在但沒有任何登入時,同樣指向 tea login add', async (t) => { + mkdirSync(tmpRoot, { recursive: true }); + const dir = mkdtempSync(join(tmpRoot, 'tea-')); + const path = join(dir, 'config.yml'); + writeFileSync(path, 'logins: []\npreferences:\n editor: false\n'); + + const { json } = await runScript('labels-list.js', ['--repo', REPO], { + env: { TEA_SDLC_CONFIG: path }, + }); + + assert.equal(json.error.code, 'LOGIN_INVALID'); + assert.match(json.error.message, /tea login add/); +}); + +test('只有一組登入且未標 default 時,就用那一組', async (t) => { + const stub = await withStub(t); + const config = writeTeaConfig([{ name: 'ONLY', url: siteUrl(stub), token: 'only-token' }]); + + const { code } = await runScript('labels-list.js', ['--repo', REPO], { + env: { TEA_SDLC_CONFIG: config }, + }); + + assert.equal(code, 0); + assert.equal(stub.requests[0].authorization, 'token only-token'); +});