Files
tea-sdlc/test/readme-install.test.js
T
jiantw83andClaude Opus 5 67528dde5f fix(README): 安裝指令補上 git+ 前綴,否則 npm 會把它當壓縮檔
npm 只有看到 git/git+ssh/git+http/git+https/git+file 才會當成 git repo。
寫成 https://….git 會被歸類成遠端 tarball,下載回來解壓失敗,錯誤是
TAR_BAD_ARCHIVE: Unrecognized archive format——看起來完全不像「網址寫法錯了」,
使用者只會以為套件壞了。

先前那條「README 指令逐字執行得動」的測試沒抓到,因為它只跑 tea-sdlc 開頭的
行,npm 那行從來沒被執行過。補一條規則把 .git 網址必須帶 git+ 前綴釘住:
真的去跑一次 npm 全域安裝要開網路、要三秒,不適合放進這套向來密封的測試裡,
但「前綴在不在」這條規則本身就足以擋掉這個錯。

議題 #25

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

154 lines
6.6 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.
/**
* README 的安裝段。
*
* 這一段是使用者照著打的東西,打錯一個字就裝不起來,所以指令要能逐字複製執行,
* 而且不能留下已經不成立的說法——並列一條「照著做不會出現任何指令」的流程等於在說謊。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, 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('安裝以 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('模組邊界表指得到實際存在的檔案', () => {
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, /實作|做事/);
});