Files
tea-sdlc/test/pr-create.test.js
T
jiantw83 958b1f85e8 test(pr-create): 開立 PR 並停錶
標題等同分支名:reviewer 在列表上看到的就是分支,兩者對不上會找錯 PR。

描述的段落固定且順序固定,缺一段或順序不對就擋下,不自動補——補出來的段落是編的,
而 reviewer 會把它當成真的。

「測試結果」另外驗一次它不是空話。那一段是 reviewer 唯一能判斷「這東西真的跑過嗎」的
依據,寫「已測試通過」等於沒寫。判斷刻意很窄,只擋「整段只有一行,而那一行是已知的
偷懶寫法」——這一關要擋的是明顯沒跑過就交差,不是去評價別人的測試寫得夠不夠好。
沒有自動化測試時,寫得出可重現的手動驗證步驟就放行。

停錶排在 PR 開出去之後,而且只在 PR 真的建立了才停:工時要記在真的有做事的那段時間上。
錶本來就沒在跑不算失敗(Gitea 對此回 500)——PR 已經開出去了,把整件事報成失敗只會讓人
以為 PR 沒開成而重跑一次。
2026-09-17 08:23:22 +00:00

302 lines
11 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.
/**
* 開立 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');
});