diff --git a/test/branch-prep.test.js b/test/branch-prep.test.js new file mode 100644 index 0000000..6ae8fa0 --- /dev/null +++ b/test/branch-prep.test.js @@ -0,0 +1,353 @@ +/** + * 備妥開工的分支。 + * + * 兩件事各自要驗: + * 1. **分支命名**是純字串規則,表格驅動,成本最低、回歸價值最高。 + * 規則錯了會一路帶到 PR 標題與 CI,事後改名很痛。 + * 2. **不覆蓋他人進度**。來源分支在遠端已存在時要 pull 而不是重建, + * 目標分支已存在時要切過去而不是從來源蓋掉。這兩件事沒有真的遠端就驗不出來, + * 所以測試在臨時 repo 上跑真的 git。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { writeFileSync } from 'node:fs'; +import { runScript, tmpRoot } from './helpers/run-script.js'; +import { makeTempRepoWithRemote } from './helpers/temp-repo.js'; + +/** 開一個有遠端的臨時 repo,並登記在測試結束時清掉 */ +function withRepo(t) { + const repo = makeTempRepoWithRemote(); + t.after(() => repo.cleanup()); + return repo; +} + +const run = (repo, args) => runScript('branch-prep.js', ['--path', repo.dir, ...args]); + +/** 目前 checkout 在哪一支 */ +const currentBranch = (repo) => repo.git('rev-parse', '--abbrev-ref', 'HEAD'); + +// ── 分支命名規則 ─────────────────────────────────────────────────── + +const NAMING = [ + { + name: '從開發分支長出:{類型}/{需求描述}/main', + source: 'master', + args: ['--type', 'feat', '--slug', 'claim-and-branch-prep'], + expected: 'feat/claim-and-branch-prep/main', + }, + { + name: '開發分支叫 main 時同樣成立', + source: 'main', + args: ['--type', 'fix', '--slug', 'crlf-parsing'], + expected: 'fix/crlf-parsing/main', + }, + { + name: '開發分支叫 develop 時同樣成立', + source: 'develop', + args: ['--type', 'chore', '--slug', 'bump-node'], + expected: 'chore/bump-node/main', + }, + { + name: '從功能分支長出:類型與需求描述沿用來源,第三段換成這次的描述', + source: 'feat/wp-extract-contract/main', + args: ['--slug', 'nested-todo-parser'], + expected: 'feat/wp-extract-contract/nested-todo-parser', + }, + { + name: '從功能分支的子分支長出時,仍掛在同一個需求描述底下', + source: 'feat/wp-extract-contract/nested-todo-parser', + args: ['--slug', 'crlf-fix'], + expected: 'feat/wp-extract-contract/crlf-fix', + }, +]; + +for (const { name, source, args, expected } of NAMING) { + test(`命名:${name}`, async (t) => { + const repo = withRepo(t); + if (source !== 'master') repo.git('checkout', '-q', '-B', source); + + const { code, json } = await run(repo, ['--source', source, ...args]); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.branch, expected); + assert.equal(currentBranch(repo), expected); + }); +} + +// ── 命名規則的輸入驗證 ───────────────────────────────────────────── + +test('需求描述含中文時擋下:CI 與 URL 會出問題', async (t) => { + const repo = withRepo(t); + + const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', '領取工作包']); + + assert.equal(code, 1); + assert.equal(json.error.code, 'BAD_SLUG'); + assert.match(json.error.message, /英文/); +}); + +test('需求描述超過 40 字元時擋下', async (t) => { + const repo = withRepo(t); + const long = 'a'.repeat(41); + + const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', long]); + + assert.equal(json.error.code, 'BAD_SLUG'); + assert.match(json.error.message, /40/); +}); + +test('剛好 40 字元是可以的', async (t) => { + const repo = withRepo(t); + const exactly = 'a'.repeat(40); + + const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', exactly]); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.branch, `feat/${exactly}/main`); +}); + +test('大寫、底線、連續連字號都不是 kebab', async (t) => { + const repo = withRepo(t); + + for (const slug of ['Claim-Work', 'claim_work', 'claim--work', '-claim', 'claim-']) { + const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', slug]); + assert.equal(json.error?.code, 'BAD_SLUG', `${slug} 應該被擋下`); + } +}); + +test('從開發分支長出卻沒給 --type 時,指名缺的是哪一個', async (t) => { + const repo = withRepo(t); + + const { json } = await run(repo, ['--source', 'master', '--slug', 'claim-work']); + + assert.equal(json.error.code, 'MISSING_FLAG'); + assert.match(json.error.message, /--type/); +}); + +test('從功能分支長出時給 --type 會被擋,避免子分支跑到別棵樹下', async (t) => { + const repo = withRepo(t); + repo.git('checkout', '-q', '-B', 'feat/wp-extract-contract/main'); + + const { json } = await run(repo, [ + '--source', 'feat/wp-extract-contract/main', '--type', 'fix', '--slug', 'crlf', + ]); + + assert.equal(json.error.code, 'TYPE_FROM_SOURCE'); + assert.match(json.error.message, /feat/); +}); + +// ── 來源分支:pull 而不是重建 ───────────────────────────────────── + +test('來源分支在遠端已存在時執行 pull,帶進別人的進度', async (t) => { + const repo = withRepo(t); + repo.pushFromElsewhere('master', 'theirs.txt', '別人的進度\n'); + + const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(code, 0, json.error?.message); + assert.ok(existsSync(join(repo.dir, 'theirs.txt')), '別人推上去的檔案要被帶進來'); + assert.equal(json.data.來源.動作, 'pull'); +}); + +test('來源分支只在本地時照樣可用,不會因為遠端沒有就報錯', async (t) => { + const repo = withRepo(t); + repo.git('checkout', '-q', '-B', 'feat/local-only/main'); + repo.git('checkout', '-q', 'master'); + + const { code, json } = await run(repo, [ + '--source', 'feat/local-only/main', '--slug', 'sub-feature', + ]); + + assert.equal(code, 0, json.error?.message); + assert.equal(json.data.來源.動作, '用本地既有'); +}); + +test('來源分支本地與遠端都沒有時,回可區分的錯誤碼', async (t) => { + const repo = withRepo(t); + + const { code, json } = await run(repo, [ + '--source', 'feat/不存在/main', '--slug', 'whatever', + ]); + + assert.equal(code, 1); + assert.equal(json.error.code, 'SOURCE_NOT_FOUND'); +}); + +test('遠端分支的比對是全名,不是尾段', async (t) => { + // git ls-remote --heads origin main 會匹配到 refs/heads/feat/x/main —— + // 而本 repo 的命名慣例讓每一支分支都以 /main 結尾,拿 main 當開發分支的專案 + // 會整個誤判成「遠端已經有這一支」。 + const repo = withRepo(t); + repo.pushFromElsewhere('feat/x/main', 'theirs.txt', '別人的分支\n'); + + const { code, json } = await run(repo, ['--source', 'main', '--type', 'feat', '--slug', 'mine']); + + assert.equal(code, 1); + assert.equal(json.error.code, 'SOURCE_NOT_FOUND', '遠端沒有 main 這一支,不該被 feat/x/main 冒名頂替'); +}); + +// ── 開工前的工作區必須乾淨 ───────────────────────────────────────── + +test('工作區有未提交的改動時擋下,不把它們帶進新分支', async (t) => { + const repo = withRepo(t); + writeFileSync(join(repo.dir, 'README.md'), '改到一半的東西\n'); + + const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(code, 1); + assert.equal(json.error.code, 'DIRTY_WORKTREE'); + assert.equal(currentBranch(repo), 'master', '擋下來就不該已經切過分支'); + assert.equal(repo.git('branch', '--list', 'feat/mine/main'), '', '也不該已經建好分支'); +}); + +test('未追蹤的檔案同樣算不乾淨:它會跟著被帶到新分支上', async (t) => { + const repo = withRepo(t); + writeFileSync(join(repo.dir, 'stray.txt'), '不相干的檔案\n'); + + const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(json.error.code, 'DIRTY_WORKTREE'); + assert.match(json.error.message, /stray\.txt/, '要指名是哪些檔案擋住了'); +}); + +test('工作區不乾淨時,--dry-run 也要照樣說出來', async (t) => { + // 試跑印得出漂亮的計畫、實跑卻中途炸掉,是最難查的那種落差 + const repo = withRepo(t); + writeFileSync(join(repo.dir, 'stray.txt'), '不相干的檔案\n'); + + const { json } = await run(repo, [ + '--source', 'master', '--type', 'feat', '--slug', 'mine', '--dry-run', + ]); + + assert.equal(json.error.code, 'DIRTY_WORKTREE'); +}); + +// ── 來源分支與遠端分歧 ───────────────────────────────────────────── + +test('本地來源分支與遠端分歧時,回可區分的錯誤碼而不是 git 的原始訊息', async (t) => { + const repo = withRepo(t); + // 本地有一顆沒推的 commit,遠端也往前走了一顆 + writeFileSync(join(repo.dir, 'mine.txt'), '我的\n'); + repo.git('add', '-A'); + repo.git('commit', '-qm', '本地未推的 commit'); + repo.pushFromElsewhere('master', 'theirs.txt', '別人的\n'); + + const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(code, 1); + assert.equal(json.error.code, 'SOURCE_DIVERGED'); + assert.match(json.error.message, /master/); + assert.equal(currentBranch(repo), 'master', '不該留在半途的狀態'); +}); + +// ── 目標分支:已存在就切過去,不覆蓋 ─────────────────────────────── + +test('目標分支已在遠端時切過去,保留上面已有的進度', async (t) => { + const repo = withRepo(t); + repo.pushFromElsewhere('feat/mine/main', 'progress.txt', '已經做了一半\n'); + + const { code, json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(code, 0, json.error?.message); + assert.equal(currentBranch(repo), 'feat/mine/main'); + assert.ok( + existsSync(join(repo.dir, 'progress.txt')), + '遠端已有的分支要接上去,不是從來源重建一個空的蓋掉', + ); + assert.equal(json.data.分支.動作, '接上遠端既有'); +}); + +test('目標分支只在本地時切過去,不重建', async (t) => { + const repo = withRepo(t); + repo.git('checkout', '-q', '-b', 'feat/mine/main'); + repo.git('checkout', '-q', 'master'); + + const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(json.data.分支.動作, '切換到本地既有'); + assert.equal(currentBranch(repo), 'feat/mine/main'); +}); + +test('目標分支不存在時從來源建立', async (t) => { + const repo = withRepo(t); + + const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'brand-new']); + + assert.equal(json.data.分支.動作, '從來源建立'); + assert.equal(currentBranch(repo), 'feat/brand-new/main'); +}); + +// ── 不留本機狀態檔 ───────────────────────────────────────────────── + +test('跑完不在目標專案裡留下任何狀態檔', async (t) => { + const repo = withRepo(t); + + await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'no-state']); + + const status = repo.git('status', '--porcelain'); + assert.equal(status, '', '工作區要是乾淨的:進度只從 Gitea 推導,不寫本機狀態檔'); +}); + +test('git 自己的進度訊息不漏到 stderr', async (t) => { + // checkout 與 fetch 的訊息 git 一律寫在 stderr。腳本的輸出契約是「stdout 一行 JSON、 + // stderr 乾淨」,漏出去的話呼叫端就得去分辨哪幾行是雜訊。 + const repo = withRepo(t); + + const { stderr } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'quiet']); + + assert.equal(stderr, ''); +}); + +// ── 路徑 ─────────────────────────────────────────────────────────── + +test('--path 指向的不是 git repo 時,回可區分的錯誤碼', async (t) => { + const { json } = await runScript('branch-prep.js', [ + '--path', tmpRoot, '--source', 'master', '--type', 'feat', '--slug', 'whatever', + ]); + + assert.equal(json.error.code, 'NOT_A_GIT_REPO'); +}); + +test('回報實際動到的是哪一個目錄,讓人確認沒搞錯專案', async (t) => { + const repo = withRepo(t); + + const { json } = await run(repo, ['--source', 'master', '--type', 'feat', '--slug', 'mine']); + + assert.equal(json.data.path, repo.dir); +}); + +// ── --dry-run ───────────────────────────────────────────────────── + +test('--dry-run 印出將執行的 git 指令,且完全不動 repo', async (t) => { + const repo = withRepo(t); + const before = repo.git('rev-parse', 'HEAD'); + + const { code, json } = await run(repo, [ + '--source', 'master', '--type', 'feat', '--slug', 'mine', '--dry-run', + ]); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.equal(json.data.branch, 'feat/mine/main'); + assert.ok(json.data.commands.length > 0); + assert.ok( + json.data.commands.every((c) => c.startsWith('git ')), + '預覽的是 git 指令本身,不是自創的描述', + ); + assert.equal(currentBranch(repo), 'master', '試跑不該切分支'); + assert.equal(repo.git('rev-parse', 'HEAD'), before); + assert.equal(repo.git('branch', '--list', 'feat/mine/main'), '', '試跑不該建分支'); +}); + +test('--dry-run 連分支命名都先算出來,看得到才叫預覽', async (t) => { + const repo = withRepo(t); + repo.git('checkout', '-q', '-B', 'feat/wp-extract-contract/main'); + + const { json } = await run(repo, [ + '--source', 'feat/wp-extract-contract/main', '--slug', 'crlf-fix', '--dry-run', + ]); + + assert.equal(json.data.branch, 'feat/wp-extract-contract/crlf-fix'); +}); diff --git a/test/claim.test.js b/test/claim.test.js new file mode 100644 index 0000000..af8cf33 --- /dev/null +++ b/test/claim.test.js @@ -0,0 +1,304 @@ +/** + * 領取工作包的鎖。 + * + * 這一支的價值全在「什麼時候擋下來」:放行的路徑只有一條,擋的理由有四種, + * 而擋錯的代價是兩個人做同一件事、或是工時記到別顆議題上。所以決策表的四種狀態 + * 各有測試,而且每一種都要驗「一個字都沒寫進 Gitea」——擋下來卻已經改了一半, + * 比直接放行更難收拾。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { runScript } from './helpers/run-script.js'; +import { healthyRoutes, stubEnv as envFor, withStubGitea } from './helpers/stub-gitea.js'; + +const REPO = 'plugins/tea-sdlc'; +const INDEX = 11; +const ME = 'tester'; + +/** 議題上跑著的碼錶長什麼樣 */ +const stopwatchOn = (index, repo = REPO) => ({ + issue_index: index, + repo_owner_name: repo.split('/')[0], + repo_name: repo.split('/')[1], +}); + +function routes(overrides = {}, options = {}) { + const { assignees = [], labels = [], stopwatches = [], repoLabels } = options; + + const base = healthyRoutes(REPO, { + 'GET /api/v1/user': { status: 200, body: { login: ME } }, + [`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { + status: 200, + body: { + number: INDEX, + title: '以 sdlc-feat 領取工作包、起錶並備妥分支', + html_url: `https://gitea.jsc.idv.tw/${REPO}/issues/${INDEX}`, + assignees: assignees.map((login) => ({ login })), + labels: labels.map((name, i) => ({ id: 60 + i, name })), + }, + }, + 'GET /api/v1/user/stopwatches': { status: 200, body: stopwatches }, + [`PATCH /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 201, body: {} }, + [`POST /api/v1/repos/${REPO}/issues/${INDEX}/labels`]: { status: 200, body: [] }, + [`POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`]: { status: 201, body: {} }, + }); + if (repoLabels !== undefined) { + base[`GET /api/v1/repos/${REPO}/labels`] = { + status: 200, + body: repoLabels.map((name, i) => ({ id: 55 + i, name })), + }; + } + return { ...base, ...overrides }; +} + +const withStub = (t, overrides = {}, options) => withStubGitea(t, routes(overrides, options)); + +const run = (args, stub) => + runScript('claim.js', ['--repo', REPO, '--index', String(INDEX), ...args], { + env: envFor(stub), + }); + +/** + * 會改動 Gitea 的請求;擋下來的情境裡這些一個都不該出現。 + * 前置檢查對 `issues/0` 的那一發 PATCH 不算數——它是探權限用的,打在一顆不存在的議題上, + * 不會改動任何東西(見 lib.js 的 checkIssueWrite)。 + */ +const writes = (stub) => + stub.requests.filter((r) => r.method !== 'GET').filter((r) => !r.path.endsWith('/issues/0')); + +// ── 決策表:無鎖 ─────────────────────────────────────────────────── + +test('沒有鎖時放行:設 assignee、貼進行中、起錶', async (t) => { + const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent', '進行中'] }); + + const { code, json } = await run([], stub); + + assert.equal(code, 0); + assert.equal(json.data.assignee, ME); + assert.deepEqual(json.data.labels, ['進行中']); + assert.equal(json.data.碼錶中, true); + assert.equal(json.data.已認領過, false); +}); + +test('放行時三個寫入請求都發出,且順序為先上鎖再起錶', async (t) => { + const stub = await withStub(t, {}, { repoLabels: ['進行中'] }); + + await run([], stub); + + assert.deepEqual( + writes(stub).map((r) => `${r.method} ${r.path}`), + [ + `PATCH /api/v1/repos/${REPO}/issues/${INDEX}`, + `POST /api/v1/repos/${REPO}/issues/${INDEX}/labels`, + `POST /api/v1/repos/${REPO}/issues/${INDEX}/stopwatch/start`, + ], + '錶要最後才起:前面任一步失敗時,不該留下一顆還在跑的碼錶', + ); +}); + +test('assignee 送的是自己的帳號,標籤送的是 id 不是名字', async (t) => { + const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent', '進行中'] }); + + await run([], stub); + + const patch = writes(stub).find((r) => r.method === 'PATCH'); + assert.deepEqual(patch.body.assignees, [ME]); + + const label = writes(stub).find((r) => r.path.endsWith('/labels')); + assert.deepEqual(label.body.labels, [56], '進行中在假 repo 上的 id 是 56'); +}); + +// ── 決策表:他人已認領 ───────────────────────────────────────────── + +test('他人已認領時擋下,並指名是誰', async (t) => { + const stub = await withStub(t, {}, { assignees: ['someone-else'], repoLabels: ['進行中'] }); + + const { code, json } = await run([], stub); + + assert.equal(code, 1); + assert.equal(json.error.code, 'CLAIMED_BY_OTHER'); + assert.match(json.error.message, /someone-else/); + assert.deepEqual(writes(stub), [], '擋下來就不該寫進任何東西'); +}); + +test('自己在 assignee 裡但還有別人時,一樣擋', async (t) => { + const stub = await withStub(t, {}, { assignees: [ME, 'someone-else'], repoLabels: ['進行中'] }); + + const { json } = await run([], stub); + + assert.equal(json.error.code, 'CLAIMED_BY_OTHER'); +}); + +// ── 決策表:自己的碼錶在跑 ───────────────────────────────────────── + +test('自己碼錶跑在本議題時擋下,要求先手動停錶', async (t) => { + const stub = await withStub(t, {}, { + stopwatches: [stopwatchOn(INDEX)], + repoLabels: ['進行中'], + }); + + const { code, json } = await run([], stub); + + assert.equal(code, 1); + assert.equal(json.error.code, 'STOPWATCH_ON_THIS_ISSUE'); + assert.match(json.error.message, /停/, '要說清楚下一步是手動停錶'); + assert.deepEqual(writes(stub), []); +}); + +test('自己碼錶跑在別的議題時擋下,並指出是哪一顆', async (t) => { + const stub = await withStub(t, {}, { + stopwatches: [stopwatchOn(7)], + repoLabels: ['進行中'], + }); + + const { code, json } = await run([], stub); + + assert.equal(code, 1); + assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE'); + assert.match(json.error.message, /#7/, '忘了停掉的是哪一顆,要指名'); + assert.deepEqual(writes(stub), []); +}); + +test('別的 repo 上的同號碼錶也算自己有錶在跑', async (t) => { + const stub = await withStub(t, {}, { + stopwatches: [stopwatchOn(INDEX, 'plugins/別的專案')], + repoLabels: ['進行中'], + }); + + const { json } = await run([], stub); + + assert.equal(json.error.code, 'STOPWATCH_ON_OTHER_ISSUE'); + assert.match(json.error.message, /別的專案/); +}); + +// ── 冪等:中斷後重跑 ─────────────────────────────────────────────── + +test('自己已認領但沒有錶時放行,並如實說這顆本來就是自己的', async (t) => { + const stub = await withStub(t, {}, { assignees: [ME], repoLabels: ['進行中'] }); + + const { code, json } = await run([], stub); + + assert.equal(code, 0); + assert.equal(json.data.已認領過, true); + assert.equal(json.data.碼錶中, true, '錶還是要起,中斷重跑就是為了接上這件事'); +}); + +test('進行中標籤已經在議題上時不重複貼', async (t) => { + const stub = await withStub(t, {}, { + assignees: [ME], + labels: ['進行中'], + repoLabels: ['進行中'], + }); + + const { json } = await run([], stub); + + assert.equal(json.data.已認領過, true); + assert.deepEqual(json.data.labels, ['進行中']); + assert.equal( + writes(stub).some((r) => r.path.endsWith('/labels')), + false, + '已經貼著的標籤不必再貼一次', + ); +}); + +// ── 標籤:本 plugin 不自動建立標籤 ───────────────────────────────── + +test('repo 上沒有進行中標籤時擋在寫入之前,並指出該去建哪一個', async (t) => { + const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent'] }); + + const { code, json } = await run([], stub); + + assert.equal(code, 1); + assert.equal(json.error.code, 'LABEL_NOT_FOUND'); + assert.match(json.error.message, /進行中/); + assert.deepEqual(writes(stub), [], '標籤缺了就整件事不做,不要只設一半的鎖'); +}); + +// ── 錯誤 ─────────────────────────────────────────────────────────── + +test('議題不存在時回傳可區分的錯誤碼', async (t) => { + const stub = await withStub(t, { + [`GET /api/v1/repos/${REPO}/issues/${INDEX}`]: { status: 404, body: { message: 'not found' } }, + }, { repoLabels: ['進行中'] }); + + const { json } = await run([], stub); + + assert.equal(json.error.code, 'ISSUE_NOT_FOUND'); +}); + +test('--index 不是正整數時擋在打 Gitea 之前', async (t) => { + const stub = await withStub(t, {}, { repoLabels: ['進行中'] }); + + const { json } = await runScript('claim.js', ['--repo', REPO, '--index', '0'], { + env: envFor(stub), + }); + + assert.equal(json.error.code, 'BAD_INDEX'); + assert.equal(stub.requests.length, 0); +}); + +// ── --dry-run ───────────────────────────────────────────────────── + +test('--dry-run 印出將發出的寫入,但一個字都不寫進去', async (t) => { + const stub = await withStub(t, {}, { repoLabels: ['進行中'] }); + + const { code, json } = await run(['--dry-run'], stub); + + assert.equal(code, 0); + assert.equal(json.data.dryRun, true); + assert.deepEqual( + json.data.requests.map((r) => `${r.method} ${r.path}`), + [ + `PATCH /repos/${REPO}/issues/${INDEX}`, + `POST /repos/${REPO}/issues/${INDEX}/labels`, + `POST /repos/${REPO}/issues/${INDEX}/stopwatch/start`, + ], + ); + assert.deepEqual(writes(stub), [], '預覽不得真的寫入'); +}); + +test('--dry-run 會先讀現況:預覽出來的是這一顆實際的處境', async (t) => { + // 手寫一份固定的清單很容易跟實作走鐘,而且說不出「這顆已經是你的了」這種事 + const stub = await withStub(t, {}, { repoLabels: ['進行中'] }); + + await run(['--dry-run'], stub); + + const reads = stub.requests.filter((r) => r.method === 'GET').map((r) => r.path); + assert.ok(reads.includes('/api/v1/user')); + assert.ok(reads.includes(`/api/v1/repos/${REPO}/issues/${INDEX}`)); + assert.ok(reads.includes('/api/v1/user/stopwatches')); + assert.ok(reads.includes(`/api/v1/repos/${REPO}/labels`)); +}); + +test('--dry-run 略過已經做好的部分,不謊報將發出的請求', async (t) => { + const stub = await withStub(t, {}, { + assignees: [ME], + labels: ['進行中'], + repoLabels: ['進行中'], + }); + + const { json } = await run(['--dry-run'], stub); + + assert.deepEqual( + json.data.requests.map((r) => `${r.method} ${r.path}`), + [`POST /repos/${REPO}/issues/${INDEX}/stopwatch/start`], + 'assignee 與標籤都已經到位,只差起錶', + ); +}); + +test('--dry-run 在鎖擋得住的情況下照樣擋,這才是預覽的用處', async (t) => { + const stub = await withStub(t, {}, { assignees: ['someone-else'], repoLabels: ['進行中'] }); + + const { code, json } = await run(['--dry-run'], stub); + + assert.equal(code, 1); + assert.equal(json.error.code, 'CLAIMED_BY_OTHER'); +}); + +test('--dry-run 遇到缺標籤一樣報錯,不會等到實跑才發現', async (t) => { + const stub = await withStub(t, {}, { repoLabels: ['ready-for-agent'] }); + + const { json } = await run(['--dry-run'], stub); + + assert.equal(json.error.code, 'LABEL_NOT_FOUND'); +}); diff --git a/test/sdlc-feat-assets.test.js b/test/sdlc-feat-assets.test.js new file mode 100644 index 0000000..03b1f66 --- /dev/null +++ b/test/sdlc-feat-assets.test.js @@ -0,0 +1,97 @@ +/** + * 正本第一段「領取與開工準備」的規則。 + * + * 這些檔案是文件不是程式,但它們是指令實際交付的東西:決策表寫錯,被鎖擋下來的人 + * 就會得到錯的下一步;邊界寫漏,第一段就會去做後面幾段的事。靠人記不牢,用測試釘住。 + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { assertNeutralPrompt, readPrompt } from './helpers/prompt-doc.js'; + +const prompt = readPrompt('sdlc-feat'); +/** 第一段的內容,避免把邊界段的字樣誤認成這一段的規則 */ +const phase1 = prompt.slice(prompt.indexOf('## 第一段'), prompt.indexOf('## 邊界')); + +test('正本平台中立,description 前綴正確', () => { + assertNeutralPrompt(prompt, 'sdlc-feat'); +}); + +test('第一段指名三支腳本,順序為先讀再領再備分支', () => { + const order = ['wp-extract.js', 'claim.js', 'branch-prep.js']; + const positions = order.map((name) => phase1.indexOf(name)); + assert.equal(positions.every((p) => p >= 0), true, '三支腳本都要被指名'); + assert.deepEqual([...positions].sort((a, b) => a - b), positions, '領取之前要先讀得懂這顆在做什麼'); +}); + +test('未處理留言不是 0 時要先停下來提示整併', () => { + assert.match(phase1, /未處理留言數/); + assert.match(phase1, /先停下來/); + assert.match(phase1, /sdlc-sync/); +}); + +test('領取鎖的四種狀態各自交代了下一步,含放行那一種', () => { + for (const code of ['CLAIMED_BY_OTHER', 'STOPWATCH_ON_THIS_ISSUE', 'STOPWATCH_ON_OTHER_ISSUE']) { + assert.match(phase1, new RegExp(code), `${code} 要出現在決策表裡`); + } + assert.match(phase1, /沒有鎖/, '第四種狀態(放行)也要在表上,否則只剩擋的那幾種'); + assert.match(phase1, /不要繞過去/, '被擋下來的處置要明講,不能靠 agent 自由發揮'); +}); + +test('缺標籤是前置條件,不混進領取鎖的四種狀態裡', () => { + const table = phase1.slice(phase1.indexOf('| 狀態'), phase1.indexOf('碼錶一律由使用者自己停')); + assert.equal(table.includes('LABEL_NOT_FOUND'), false, '它不是鎖的狀態,別讓四種變五種'); + assert.match(phase1, /LABEL_NOT_FOUND/, '但仍要交代它,否則使用者不知道怎麼辦'); +}); + +test('工作包跨多個 repo 時怎麼開分支,有交代', () => { + assert.match(phase1, /repos/); + assert.match(phase1, /有多顆時逐一確認/); +}); + +test('工作區不乾淨時的處置寫明了,且不替使用者決定', () => { + assert.match(phase1, /DIRTY_WORKTREE/); + assert.match(phase1, /不要自己選/); +}); + +test('碼錶只由使用者自己停,並說明為什麼不代勞', () => { + assert.match(phase1, /由使用者自己停/); + assert.match(phase1, /工時記錯地方/); +}); + +test('來源分支要問過使用者,且一次一題、附理由與手動輸入', () => { + assert.match(phase1, /一次問一題/); + assert.match(phase1, /手動輸入/); + assert.match(phase1, /不要替他決定|不要替使用者決定/); +}); + +test('翻譯規則釘住 kebab 與 40 字元上限,並舉出可照抄的例子', () => { + assert.match(phase1, /kebab/); + assert.match(phase1, /40/); + assert.match(phase1, /wp-extract-contract/, '要有一個真的例子,不要只說規則'); + assert.match(phase1, /不要把長句截斷/); +}); + +test('--type 什麼時候要給、什麼時候不能給,寫清楚了', () => { + assert.match(phase1, /`--type` 只在來源是開發分支時要給/); + assert.match(phase1, /沿用來源/); +}); + +test('兩處「不覆蓋他人進度」的保證都有寫出來', () => { + assert.match(phase1, /pull 而不是重建/); + assert.match(phase1, /接上去而不是蓋掉/); +}); + +test('三支腳本的寫入都要求先試跑', () => { + const dryRuns = phase1.match(/--dry-run/g) ?? []; + assert.ok(dryRuns.length >= 2, `兩支寫入型腳本各要先試跑,只找到 ${dryRuns.length} 處`); +}); + +test('邊界把第一段不做的事分開列,且明講不寫本機狀態檔', () => { + const boundary = prompt.slice(prompt.indexOf('## 邊界')); + assert.match(boundary, /不改任何一行程式碼/); + assert.match(boundary, /不勾待辦/); + assert.match(boundary, /不開 PR/); + assert.match(boundary, /不自行建立標籤/); + assert.match(boundary, /不寫任何本機狀態檔/); + assert.match(boundary, /換一台機器或換一個 agent/, '要說明為什麼不留狀態檔'); +});