From 958b1f85e8905128ab4ae2410808f30bc4aa492e Mon Sep 17 00:00:00 2001 From: Jeffery Date: Thu, 17 Sep 2026 08:23:22 +0000 Subject: [PATCH] =?UTF-8?q?test(pr-create):=20=E9=96=8B=E7=AB=8B=20PR=20?= =?UTF-8?q?=E4=B8=A6=E5=81=9C=E9=8C=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 標題等同分支名:reviewer 在列表上看到的就是分支,兩者對不上會找錯 PR。 描述的段落固定且順序固定,缺一段或順序不對就擋下,不自動補——補出來的段落是編的, 而 reviewer 會把它當成真的。 「測試結果」另外驗一次它不是空話。那一段是 reviewer 唯一能判斷「這東西真的跑過嗎」的 依據,寫「已測試通過」等於沒寫。判斷刻意很窄,只擋「整段只有一行,而那一行是已知的 偷懶寫法」——這一關要擋的是明顯沒跑過就交差,不是去評價別人的測試寫得夠不夠好。 沒有自動化測試時,寫得出可重現的手動驗證步驟就放行。 停錶排在 PR 開出去之後,而且只在 PR 真的建立了才停:工時要記在真的有做事的那段時間上。 錶本來就沒在跑不算失敗(Gitea 對此回 500)——PR 已經開出去了,把整件事報成失敗只會讓人 以為 PR 沒開成而重跑一次。 --- test/pr-create.test.js | 301 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 test/pr-create.test.js diff --git a/test/pr-create.test.js b/test/pr-create.test.js new file mode 100644 index 0000000..f8421f1 --- /dev/null +++ b/test/pr-create.test.js @@ -0,0 +1,301 @@ +/** + * 開立 PR 並停錶。 + * + * 三件事要驗: + * 1. **標題等同分支名**——reviewer 在列表上看到的就是分支,兩者對不上會找錯 PR。 + * 2. **描述的七段都在**,而且「測試結果」不是空話。這一段是 reviewer 唯一能判斷 + * 「這東西真的跑過嗎」的依據,寫「已測試通過」等於沒寫。 + * 3. **PR 開完才停錶**,而且開失敗時錶不能停——工時要記在真的有做事的那段時間上。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { runScript, tmpRoot } from './helpers/run-script.js'; +import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; + +const REPO = 'plugins/tea-sdlc'; +const HEAD = 'feat/commit-split-and-pr/main'; +const INDEX = 13; + +/** 一份七段俱全的描述 */ +const BODY = `## 摘要 + +工作包做完之後,變更被整理成可讀的歷史,PR 開出來,碼錶停下。 + +## 需求議題 + +#1 + +## 工作包議題 + +#13 + +## 變更內容 + +新增 commit-split 與 pr-create 兩支腳本。 + +## 設計重點 + +分批的界線是類型,一個 commit 只裝一種。 + +## 解決的問題 + +巨大的單一 commit 等於沒有歷史。 + +## 影響的功能 + +sdlc-feat 的第三段。 + +## 測試結果 + +\`\`\` +ℹ tests 527 +ℹ pass 527 +ℹ fail 0 +\`\`\` +`; + +/** 把描述寫成檔案,回傳路徑 */ +function bodyFile(name, content) { + mkdirSync(tmpRoot, { recursive: true }); + const path = join(tmpRoot, `pr-body-${name}-${process.hrtime.bigint()}.md`); + writeFileSync(path, content); + return path; +} + +function routes(overrides = {}) { + return healthyRoutes(REPO, { + [`POST /api/v1/repos/${REPO}/pulls`]: (req) => ({ + status: 201, + body: { number: 99, title: req.body.title, html_url: `https://gitea.jsc.idv.tw/${REPO}/pulls/99` }, + }), + [`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: { status: 201, body: {} }, + ...overrides, + }); +} + +const withStub = (t, overrides = {}) => withStubGitea(t, routes(overrides)); + +const run = (args, stub) => + runScript('pr-create.js', ['--repo', REPO, '--head', HEAD, ...args], { env: envFor(stub) }); + +const posts = (stub) => + stub.requests.filter((r) => r.method === 'POST').map((r) => r.path); + +// ── 標題與描述 ───────────────────────────────────────────────────── + +test('PR 標題等同分支名', async (t) => { + const stub = await withStub(t); + const file = bodyFile('full', BODY); + + const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.equal(code, 0, JSON.stringify(json)); + const pull = stub.requests.find((r) => r.path.endsWith('/pulls')); + assert.equal(pull.body.title, HEAD); + assert.equal(json.data.title, HEAD); +}); + +test('描述原樣送出,一個字都不改寫', async (t) => { + const stub = await withStub(t); + const file = bodyFile('verbatim', BODY); + + await run(['--body-file', file, '--index', String(INDEX)], stub); + + const pull = stub.requests.find((r) => r.path.endsWith('/pulls')); + assert.equal(pull.body.body, BODY); +}); + +test('base 預設為 master,也可以指定', async (t) => { + const stub = await withStub(t); + const file = bodyFile('base', BODY); + + await run(['--body-file', file, '--index', String(INDEX)], stub); + const first = stub.requests.find((r) => r.path.endsWith('/pulls')); + assert.equal(first.body.base, 'master'); + + const stub2 = await withStub(t); + const file2 = bodyFile('base2', BODY); + await run(['--body-file', file2, '--index', String(INDEX), '--base', 'develop'], stub2); + assert.equal(stub2.requests.find((r) => r.path.endsWith('/pulls')).body.base, 'develop'); +}); + +// ── 七段:少一段就擋 ─────────────────────────────────────────────── + +const SECTIONS = [ + '摘要', '需求議題', '工作包議題', '變更內容', + '設計重點', '解決的問題', '影響的功能', '測試結果', +]; + +for (const missing of SECTIONS) { + test(`描述缺少「${missing}」時擋下,並指名缺的是哪一段`, async (t) => { + const stub = await withStub(t); + const body = BODY.split(/^## /m) + .filter((part) => !part.startsWith(missing)) + .join('## '); + const file = bodyFile(`missing-${missing}`, body); + + const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.equal(code, 1); + assert.equal(json.error.code, 'MISSING_SECTION'); + assert.match(json.error.message, new RegExp(missing)); + assert.deepEqual(posts(stub), [], '描述不合格就不該開 PR'); + }); +} + +test('段落順序不對時也擋下:reviewer 每次要在同一個位置找到同一件事', async (t) => { + const stub = await withStub(t); + const swapped = BODY.replace( + /## 設計重點([\s\S]*?)## 解決的問題([\s\S]*?)## 影響的功能/, + '## 解決的問題$2## 設計重點$1## 影響的功能', + ); + const file = bodyFile('order', swapped); + + const { json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.equal(json.error.code, 'SECTION_ORDER'); +}); + +// ── 測試結果不能是空話 ───────────────────────────────────────────── + +const EMPTY_TALK = ['已測試通過', '測試通過', '全部通過', '測試皆已通過', '無']; + +for (const talk of EMPTY_TALK) { + test(`測試結果只寫「${talk}」時擋下`, async (t) => { + const stub = await withStub(t); + const body = BODY.replace(/## 測試結果[\s\S]*$/, `## 測試結果\n\n${talk}\n`); + const file = bodyFile(`talk-${talk}`, body); + + const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.equal(code, 1); + assert.equal(json.error.code, 'EMPTY_TEST_RESULT'); + assert.match(json.error.message, /實際跑過|手動驗證/); + assert.deepEqual(posts(stub), []); + }); +} + +test('測試結果是空的時候擋下', async (t) => { + const stub = await withStub(t); + const file = bodyFile('empty', BODY.replace(/## 測試結果[\s\S]*$/, '## 測試結果\n\n')); + + const { json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.equal(json.error.code, 'EMPTY_TEST_RESULT'); +}); + +test('沒有自動化測試時,寫得出可重現的手動驗證步驟就放行', async (t) => { + const stub = await withStub(t); + const manual = BODY.replace( + /## 測試結果[\s\S]*$/, + '## 測試結果\n\n本工作包無自動化測試,手動驗證步驟:\n\n' + + '1. 執行 `node scripts/pr-create.js --dry-run`\n' + + '2. 確認印出的標題等於分支名\n', + ); + const file = bodyFile('manual', manual); + + const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.equal(code, 0, JSON.stringify(json)); +}); + +// ── 停錶 ─────────────────────────────────────────────────────────── + +test('PR 開完之後才停錶,順序不能反', async (t) => { + const stub = await withStub(t); + const file = bodyFile('stop', BODY); + + await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.deepEqual(posts(stub), [ + `/api/v1/repos/${REPO}/pulls`, + `/api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`, + ]); +}); + +test('PR 開失敗時不停錶:工時要記在真的有做事的那段時間上', async (t) => { + const stub = await withStub(t, { + [`POST /api/v1/repos/${REPO}/pulls`]: { status: 422, body: { message: 'pull request already exists' } }, + }); + const file = bodyFile('fail', BODY); + + const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.equal(code, 1); + assert.equal( + posts(stub).some((path) => path.endsWith('/stopwatch/stop')), + false, + 'PR 沒開成就不該停錶', + ); + assert.match(json.error.message, /422|already exists/); +}); + +test('錶本來就沒在跑時不算失敗:PR 已經開出去了', async (t) => { + // Gitea 對「沒有碼錶在跑」回 500;這時 PR 已經建立,不該把整件事報成失敗 + const stub = await withStub(t, { + [`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/stop`]: { + status: 500, + body: { message: 'cannot stop a non existent stopwatch' }, + }, + }); + const file = bodyFile('nowatch', BODY); + + const { code, json } = await run(['--body-file', file, '--index', String(INDEX)], stub); + + assert.equal(code, 0, JSON.stringify(json)); + assert.equal(json.data.碼錶已停, false); + assert.match(json.data.note ?? '', /碼錶/); +}); + +test('沒給 --index 就不停錶,也不報錯', async (t) => { + const stub = await withStub(t); + const file = bodyFile('noindex', BODY); + + const { code, json } = await run(['--body-file', file], stub); + + assert.equal(code, 0, JSON.stringify(json)); + assert.deepEqual(posts(stub), [`/api/v1/repos/${REPO}/pulls`]); + assert.equal(json.data.碼錶已停, false); +}); + +// ── 輸入 ─────────────────────────────────────────────────────────── + +test('描述檔不存在時回可區分的錯誤碼', async (t) => { + const stub = await withStub(t); + + const { json } = await run(['--body-file', join(tmpRoot, '不存在的檔案.md')], stub); + + assert.equal(json.error.code, 'BODY_FILE_NOT_FOUND'); +}); + +// ── --dry-run ───────────────────────────────────────────────────── + +test('--dry-run 印出將建立的 PR 與將停的錶,但不碰 Gitea', async (t) => { + const stub = await withStub(t); + const file = bodyFile('dry', BODY); + + const { code, json } = await run(['--body-file', file, '--index', String(INDEX), '--dry-run'], stub); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.deepEqual( + json.data.requests.map((r) => `${r.method} ${r.path}`), + [ + `POST /repos/${REPO}/pulls`, + `POST /repos/${REPO}/issues/${INDEX}/stopwatch/stop`, + ], + ); + assert.equal(json.data.requests[0].body.title, HEAD, '試跑要看得到標題長什麼樣'); + assert.equal(stub.requests.length, 0); +}); + +test('--dry-run 照樣驗描述:不合格的描述不該等到實跑才發現', async (t) => { + const stub = await withStub(t); + const file = bodyFile('dry-bad', BODY.replace('## 測試結果', '## 測試結論')); + + const { json } = await run(['--body-file', file, '--dry-run'], stub); + + assert.equal(json.error.code, 'MISSING_SECTION'); +});