Files
tea-sdlc/test/readme-install.test.js
JefferyandClaude Opus 5 73b9cd9f75 feat(install): 安裝完成等於驗過能用
install 寫完轉接檔後,把叫用鏈真的走一遍:轉接檔 → PATH 上的 tea-sdlc → 流程正本。

最脆弱的是中間那一環。套件裝在某個 Node 版本底下,換個版本就找不到了,而轉接檔本身
看起來完全正常——沒有這道驗證,使用者要到第一次打 /sdlc-plan 才發現,那時他已經離開
安裝的心智狀態很久了。所以不是查檔案在不在,而是真的到 PATH 上把 tea-sdlc 找出來執行
一次,再把取回的正本跟套件裡的那一份逐字比對:找不到、叫不動、或叫到的是另一份安裝,
三種都驗得出來。轉接檔則逐一回磁碟讀,比對存在且內容含正確的叫用行。

驗證不碰網路,也與 Gitea 登入、時間追蹤無關,所以無條件執行。

驗不過回 ok:false,但已經寫好的轉接檔一份都不刪。回滾在升級情境下是淨損失:原本有一組
能用的舊轉接檔,覆蓋後驗證失敗再刪掉,使用者就從「有點舊但能用」變成什麼都沒有;何況
最可能的病灶是「PATH 上找不到 tea-sdlc」,那不是轉接檔的問題。

為此 lib 多一個 Failure:有一種失敗是事情做完了、檔案也寫出去了,只是驗不過,那時最該
交出去的正是「已經寫了哪些、哪一段不通」。envelope 形狀不變,只是 {ok:false, error}
旁邊多一個 data,只讀 error.code 的呼叫端照常運作。

--dry-run 不寫入,也就沒有東西可驗,verify 標成 skipped。

Closes #59

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 18:25:32 +08:00

181 lines
7.9 KiB
JavaScript
Raw Permalink 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.
/**
* README 的安裝段。
*
* 這一段是使用者照著打的東西,打錯一個字就裝不起來,所以指令要能逐字複製執行,
* 而且不能留下已經不成立的說法——並列一條「照著做不會出現任何指令」的流程等於在說謊。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { repoRoot, runBin, tmpRoot } from './helpers/run-script.js';
const readme = () => readFileSync(join(repoRoot, 'README.md'), 'utf8');
const agents = () => readFileSync(join(repoRoot, 'AGENTS.md'), 'utf8');
/** 取出所有 bash 圍欄裡的指令行 */
function commands(text) {
return [...text.matchAll(/```bash\n([\s\S]*?)```/g)]
.flatMap((block) => block[1].split('\n'))
.map((line) => line.trim())
.filter((line) => line !== '' && !line.startsWith('#'));
}
test('說明不得宣稱正本沒到齊——prompts/ 裡有幾份,說明就得跟著', () => {
// 「裝了也沒指令可用」這種過時的說法,會讓使用者以為工具還不能用而不去裝;
// 同一句話在 AGENTS.md 裡還會誤導下一個 agent。
const 正本數 = readdirSync(join(repoRoot, 'prompts')).filter((f) => f.endsWith('.md')).length;
for (const [名字, 內容] of [['README.md', readme()], ['AGENTS.md', agents()]]) {
const 說沒到齊 = /正本尚未到齊|仍在實作中/.test(內容);
assert.equal(
說沒到齊,
正本數 < 6,
`${名字} 對正本進度的說法與 prompts/ 的實際份數(${正本數})對不上`,
);
}
});
test('安裝以 npm 為唯一建議路徑,指令含完整可複製的 git URL', () => {
const install = commands(readme()).filter((line) => line.startsWith('npm i -g'));
assert.equal(install.length > 0, true, 'README 沒有 npm 安裝指令');
assert.ok(
install.some((line) => line.includes('gitea.jsc.idv.tw/plugins/tea-sdlc.git')),
`安裝指令少了完整 git URL:${install.join(' / ')}`,
);
});
test('指向 .git 的安裝指令一律帶 git+ 前綴,否則 npm 會把它當壓縮檔去解', () => {
// npm 只有看到 git/git+ssh/git+http/git+https/git+file 才會當成 git repo;
// 純 https://….git 會被歸類成遠端 tarball,下載回來解壓失敗,錯誤訊息是
// TAR_BAD_ARCHIVE: Unrecognized archive format——看起來完全不像「網址寫法錯了」。
for (const line of commands(readme()).filter((l) => l.startsWith('npm i -g') && l.includes('.git'))) {
assert.match(line, /git\+https:\/\//, `這行 npm 跑不起來:${line}`);
}
});
test('更新與移除各有完整指令表,順序講清楚先 uninstall 再 npm rm', () => {
const text = readme();
assert.match(text, /npm i -g git\+https:\/\/gitea\.jsc\.idv\.tw\/plugins\/tea-sdlc\.git/);
assert.match(text, /tea-sdlc uninstall/);
assert.match(text, /npm rm -g tea-sdlc/);
// 先砍套件就再也刪不掉那些孤兒轉接檔,順序本身就是內容
assert.ok(
text.indexOf('tea-sdlc uninstall') < text.indexOf('npm rm -g tea-sdlc'),
'uninstall 必須寫在 npm rm -g 之前',
);
});
test('明寫不要用 npm update -g,並說出為什麼', () => {
const text = readme();
assert.match(text, /npm update -g/);
const at = text.indexOf('npm update -g');
assert.match(text.slice(at - 200, at + 200), /不要|別/);
});
test('四個子指令在 README 上都有用途說明', () => {
const text = readme();
for (const name of ['install', 'uninstall', 'prompt', 'status']) {
assert.match(text, new RegExp(`tea-sdlc ${name}`), `README 沒有交代 ${name}`);
}
});
test('七套 marketplace 流程移進附錄保留,內容沒有被刪掉', () => {
const text = readme();
const appendix = text.slice(text.indexOf('## 附錄'));
assert.ok(text.includes('## 附錄'), 'README 沒有附錄');
for (const line of ['claude plugin marketplace add', 'codex plugin marketplace add', 'agy plugin install']) {
assert.ok(appendix.includes(line), `附錄少了 ${line}`);
}
});
test('prompt 與 status 各自交代了「誰在什麼時候用它」,不是只列出指令', () => {
const text = readme();
const section = text.slice(text.indexOf('## 給 agent 的入口'), text.indexOf('## 附錄'));
// prompt 是 agent 取得流程正本的管道
assert.match(section, /tea-sdlc prompt --name/);
assert.match(section, /agent|模型/);
// status 是使用者查安裝與環境現況的入口
assert.match(section, /tea-sdlc status/);
assert.match(section, /環境/);
});
test('前置需求表分得開「安裝」與「跑流程指令」各自缺什麼會怎樣', () => {
const text = readme();
const section = text.slice(text.indexOf('## 前置需求'), text.indexOf('## 安裝'));
// install 只警告不中止,表上不能再寫成一律中止——那是文件在描述一個不存在的行為
assert.equal(
/缺少時腳本印出指引並中止/.test(section),
false,
'前置需求表還寫著一律中止,但 tea-sdlc install 只警告',
);
assert.match(section, /install/);
});
test('README 裡的 tea-sdlc 指令逐字拿去跑都認得,不會是寫給人看的假指令', async () => {
const invocations = [...readme().matchAll(/```bash\n([\s\S]*?)```/g)]
.flatMap((block) => block[1].split('\n'))
.map((line) => line.trim())
.filter((line) => line.startsWith('tea-sdlc '))
// shell 會把空白後的 # 之後當註解,貼進終端機仍然跑得動,這裡照做
.map((line) => line.replace(/\s+#.*$/, '').trim());
assert.ok(invocations.length >= 4, `README 只找到 ${invocations.length} 個 tea-sdlc 指令`);
for (const line of invocations) {
// 家目錄指到空的暫存:install/uninstall 因此偵測不到任何平台,一個檔都不會寫
const home = mkdtempSync(join(tmpRoot, 'readme-home-'));
const { code, raw } = await runBin(line.split(/\s+/).slice(1), { env: { HOME: home } });
rmSync(home, { recursive: true, force: true });
// 跑成功就沒話說(prompt 印的是原樣 markdown,本來就不是 JSON);
// 失敗的話,理由不可以是「這個指令/參數我不認得」——那代表 README 寫錯了
if (code === 0) continue;
const { error } = JSON.parse(raw.trim().split('\n').at(-1));
for (const wrong of ['UNKNOWN_SUBCOMMAND', 'UNKNOWN_FLAG', 'MISSING_FLAG', 'BAD_PROMPT_NAME']) {
assert.notEqual(error.code, wrong, `README 的「${line}」跑出 ${wrong}:${error.message}`);
}
}
});
test('README 交代了安裝會自動驗證,以及驗不過時轉接檔不會被回滾', () => {
// 「驗不過但檔案還在」如果沒寫出來,使用者看到 ok:false 的第一個念頭會是自己去清乾淨重裝,
// 那正好是這個設計要避免的事
const text = readme();
const section = text.slice(text.indexOf('### 安裝完成等於驗過能用'), text.indexOf('## 更新 / 移除'));
assert.ok(section.length > 0, 'README 沒有交代安裝後的驗證');
assert.match(section, /ok:false/);
assert.match(section, /不.{0,4}回滾|一份都不刪/);
assert.match(section, /dry-run/);
});
test('模組邊界表指得到實際存在的檔案', () => {
const text = agents();
assert.match(text, /`bin\/tea-sdlc\.js`/);
assert.match(text, /`scripts\/install\.js`/);
assert.equal(/\| `install\.js` \|/.test(text), false, '表上還留著已經不存在的根目錄 install.js');
});
test('指令入口那一列把職責與邊界都寫出來了', () => {
const row = agents().split('\n').find((line) => line.includes('`bin/tea-sdlc.js`'));
assert.ok(row, 'AGENTS.md 模組邊界表沒有指令入口那一列');
const [, , duty, boundary] = row.split('|').map((cell) => cell.trim());
// 職責是子指令解析與 dispatch
assert.match(duty, /子指令/);
assert.match(duty, /dispatch|交出去/);
// 邊界是不含任何平台目錄知識與動作實作
assert.match(boundary, /平台目錄/);
assert.match(boundary, /實作|做事/);
});