feat(佈署): 以 npm 裝出 tea-sdlc 指令並產生各平台轉接檔
單一入口 bin/tea-sdlc.js 認四個子指令。第一個位置參數是子指令,其餘 argv 原樣 交出去——既有的 flag 解析拒絕位置參數,所以子指令必須在那之前就被取走。 轉接檔裡沒有路徑,只有一句 tea-sdlc prompt --name <指令名>。正本在哪由 PATH 上 的 tea-sdlc 自己回推:fnm 把 Node 版號寫進全域安裝路徑,寫死路徑的話升一次 Node,七個平台的轉接檔會同時指向不存在的檔案,而且不會有任何錯誤訊息。 prompt 是全專案唯一輸出非 JSON 的路徑,理由只有一個:它的輸出要餵給模型讀。 失敗仍走 envelope——成功是內容,失敗才需要結構。 status 的 ok 不兼差表達環境好壞,健康與否放在 data.healthy:呼叫端要分得出 「status 掛了」與「status 成功查到你環境有問題」。 install 只寫進偵測得到的平台;缺 git/tea 只警告不中止,因為那兩個完全不影響 轉接檔產生,硬擋等於逼使用者為了裝 plugin 先去裝 tea。uninstall 只刪帶產生標記 的檔案,使用者自己寫的同名檔案一律留著並在輸出裡交代。裝哪些指令以 prompts/ 裡 實際存在的正本為準,不是寫死的六個名字——裝出指向不存在正本的轉接檔,使用者只會 看到 PROMPT_NOT_FOUND。 流程正本的 description 前綴在抄進轉接檔之前就檢查:有三個平台關不掉自動觸發, 全靠那句話把 description 窄到不會被誤判,不能等使用者發現誤觸才知道漏了。 議題 #26 #27 #28 #29 #17 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* 指令入口的契約:第一個位置參數是子指令,其餘 argv 原樣交給子指令。
|
||||
*
|
||||
* 入口是唯一收位置參數的地方——既有的 flag 解析明確拒絕位置參數,所以子指令必須
|
||||
* 在交出去之前先被取走。這裡釘住的是「取走」這件事本身,以及取不到、取錯時的說法。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { repoRoot, manifest, runBin } from './helpers/run-script.js';
|
||||
|
||||
/** 入口必須認得的四個名字,缺一個就有轉接檔或文件會指向不存在的子指令 */
|
||||
const SUBCOMMANDS = ['install', 'uninstall', 'prompt', 'status'];
|
||||
|
||||
|
||||
// ── 子指令解析 ─────────────────────────────────────────────────────
|
||||
|
||||
test('未帶子指令時說清楚缺的是什麼,而不是印出用法就當作成功', async () => {
|
||||
const { code, json } = await runBin([]);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.ok, false);
|
||||
assert.equal(json.error.code, 'MISSING_SUBCOMMAND');
|
||||
// 訊息要列得出可用的名字,使用者才不用回頭翻文件
|
||||
for (const name of SUBCOMMANDS) assert.match(json.error.message, new RegExp(name));
|
||||
});
|
||||
|
||||
test('不認得的子指令與「flag 打錯」分得開', async () => {
|
||||
const { code, json } = await runBin(['instal']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'UNKNOWN_SUBCOMMAND');
|
||||
assert.match(json.error.message, /instal/);
|
||||
});
|
||||
|
||||
test('四個子指令的名字都被認得,沒有一個掉進 UNKNOWN_SUBCOMMAND', async () => {
|
||||
for (const name of SUBCOMMANDS) {
|
||||
const { json } = await runBin([name, '--no-such-flag']);
|
||||
|
||||
assert.notEqual(
|
||||
json.error?.code,
|
||||
'UNKNOWN_SUBCOMMAND',
|
||||
`子指令 ${name} 沒被入口認得`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('子指令之後的未知 flag 沿用既有的 UNKNOWN_FLAG,不另立一套', async () => {
|
||||
const { code, json } = await runBin(['status', '--verbose']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'UNKNOWN_FLAG');
|
||||
assert.match(json.error.message, /--verbose/);
|
||||
});
|
||||
|
||||
test('子指令當成位置參數被取走,不會再被 flag 解析當成不認得的參數', async () => {
|
||||
const { json } = await runBin(['status']);
|
||||
|
||||
assert.equal(json.ok, true);
|
||||
});
|
||||
|
||||
|
||||
// ── 打包 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('manifest 可發佈:移除 private、有 bin、有 files 白名單,且沒有 prepare', () => {
|
||||
const pkg = manifest();
|
||||
|
||||
assert.equal(pkg.private, undefined);
|
||||
assert.equal(pkg.bin['tea-sdlc'], 'bin/tea-sdlc.js');
|
||||
assert.ok(Array.isArray(pkg.files) && pkg.files.length > 0);
|
||||
assert.equal(pkg.scripts.prepare, undefined);
|
||||
});
|
||||
|
||||
test('零外部套件這條線沒有因為要發佈而鬆掉', () => {
|
||||
const pkg = manifest();
|
||||
|
||||
assert.equal(pkg.dependencies, undefined);
|
||||
assert.equal(pkg.devDependencies, undefined);
|
||||
});
|
||||
|
||||
test('打包內容以白名單決定:四個正本目錄都在,測試與暫存都不在', () => {
|
||||
const packed = JSON.parse(
|
||||
execFileSync('npm', ['pack', '--dry-run', '--json'], { cwd: repoRoot, encoding: 'utf8' }),
|
||||
);
|
||||
const files = packed[0].files.map((file) => file.path);
|
||||
|
||||
for (const dir of ['prompts/', 'scripts/', 'templates/', 'references/', 'bin/']) {
|
||||
assert.ok(
|
||||
files.some((file) => file.startsWith(dir)),
|
||||
`打包內容少了 ${dir}`,
|
||||
);
|
||||
}
|
||||
for (const dir of ['test/', '.tmp/']) {
|
||||
assert.equal(
|
||||
files.some((file) => file.startsWith(dir)),
|
||||
false,
|
||||
`${dir} 不該被發給使用者`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 在測試暫存裡建一棵完整的假 plugin 根,並從那裡啟動指令入口。
|
||||
*
|
||||
* 為什麼要整棵複製而不是加一個環境變數:入口回推 plugin 根的那段明寫「不依賴 cwd
|
||||
* 也不依賴環境變數」,為測試破它等於把要驗的東西驗掉。把 bin/ 與 scripts/ 原封複製
|
||||
* 過去,回推就自然指向假根,跑的仍是真正的程式碼;順便把「plugin 目錄不完整」
|
||||
* 那條路徑一起測得到——少給哪個目錄由測試自己決定。
|
||||
*/
|
||||
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { repoRoot, runBin, tmpRoot } from './run-script.js';
|
||||
|
||||
/** 一份假的流程正本,格式與真正的正本一致:頭兩行是 name 與 description */
|
||||
export const fakePrompt = (name) =>
|
||||
`name: ${name}\ndescription: 僅由 /${name} 指令叫用。假的正本,只用於測試。\n\n# ${name}\n\n第一段。\n\n## 小節\n\n第二段。\n`;
|
||||
|
||||
/**
|
||||
* @param {object} t node:test 的 TestContext
|
||||
* @param {{prompts?: Record<string,string>, omit?: string[], version?: string}} options
|
||||
* prompts 要放進 prompts/ 的正本,鍵為指令名;
|
||||
* omit 故意不建立的目錄,用來造出「plugin 目錄不完整」;
|
||||
* version 覆寫假根的套件版本
|
||||
* @returns {{root: string, run: (args: string[], opts?: object) => Promise<object>}}
|
||||
*/
|
||||
export function makeFakePlugin(t, { prompts = {}, omit = [], version } = {}) {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const root = mkdtempSync(join(tmpRoot, 'plugin-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
// 真正的程式碼,不是替身:測到的就是使用者會執行的東西
|
||||
for (const dir of ['bin', 'scripts']) {
|
||||
cpSync(join(repoRoot, dir), join(root, dir), { recursive: true });
|
||||
}
|
||||
|
||||
const manifest = JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'));
|
||||
if (version !== undefined) manifest.version = version;
|
||||
writeFileSync(join(root, 'package.json'), `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
|
||||
for (const dir of ['prompts', 'templates', 'references']) {
|
||||
if (omit.includes(dir)) continue;
|
||||
mkdirSync(join(root, dir), { recursive: true });
|
||||
}
|
||||
for (const [name, text] of Object.entries(prompts)) {
|
||||
writeFileSync(join(root, 'prompts', `${name}.md`), text);
|
||||
}
|
||||
|
||||
return { root, run: (args, opts = {}) => runBin(args, { ...opts, root }) };
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* 以子行程執行 scripts/ 底下的腳本,回傳它印出的 JSON 與 exit code。
|
||||
* 以子行程執行 scripts/ 底下的腳本或 bin/ 的指令入口,回傳它印出的東西與 exit code。
|
||||
* 這是本專案唯一的測試接縫:測到的東西就是使用者真正會執行的東西。
|
||||
*/
|
||||
import { execFile, execFileSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, symlinkSync } from 'node:fs';
|
||||
import { mkdtempSync, mkdirSync, readFileSync, symlinkSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, join } from 'node:path';
|
||||
|
||||
@@ -13,14 +13,52 @@ export const repoRoot = join(dirname(fileURLToPath(import.meta.url)), '..', '..'
|
||||
/** 所有測試暫存的落點;已被 .gitignore 忽略,也不會被 node --test 探索到 */
|
||||
export const tmpRoot = join(repoRoot, '.tmp');
|
||||
|
||||
/** 套件 manifest。版本只有這一個來源,測試也從同一個地方讀,不另外寫死一份。 */
|
||||
export function manifest() {
|
||||
return JSON.parse(readFileSync(join(repoRoot, 'package.json'), 'utf8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} name 腳本檔名,例如 "labels-list.js"
|
||||
* @param {string[]} args 具名 flag 陣列
|
||||
* @param {{env?: Record<string,string>, cwd?: string, path?: string}} opts
|
||||
* @param {{env?: Record<string,string>, cwd?: string, path?: string, root?: string}} opts
|
||||
* env 額外環境變數;path 覆寫 PATH(用來模擬缺少 git / tea)
|
||||
* @returns {Promise<{code: number, stdout: string, stderr: string, json: object}>}
|
||||
*/
|
||||
export function runScript(name, args = [], opts = {}) {
|
||||
export async function runScript(name, args = [], opts = {}) {
|
||||
const result = await runNode(join(opts.root ?? repoRoot, 'scripts', name), args, opts);
|
||||
// 腳本的輸出契約是單行 JSON,這裡就地斷言:多印一行或印出非 JSON 都在這裡炸掉
|
||||
return { ...result, json: parseSingleLine(result.stdout) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 以子行程執行 bin/ 的指令入口,第一個參數是子指令。
|
||||
*
|
||||
* 回傳的 `json` 與 `raw` 是兩種並存的斷言,取用哪一個由測試決定:`json` 取用時才
|
||||
* 斷言單行 JSON(原樣輸出的子指令碰不到它),`raw` 永遠是逐字的 stdout。
|
||||
*
|
||||
* @param {string[]} args 子指令與其後的具名 flag
|
||||
* @param {{env?: Record<string,string>, cwd?: string, path?: string, root?: string}} opts
|
||||
* root 覆寫 plugin 根,用來從測試暫存裡的假 plugin 根啟動
|
||||
* @returns {Promise<{code: number, stdout: string, stderr: string, json: object, raw: string}>}
|
||||
*/
|
||||
export async function runBin(args = [], opts = {}) {
|
||||
const result = await runNode(join(opts.root ?? repoRoot, 'bin', 'tea-sdlc.js'), args, opts);
|
||||
|
||||
return {
|
||||
...result,
|
||||
raw: result.stdout,
|
||||
get json() {
|
||||
return parseSingleLine(result.stdout);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 真的開一個 node 子行程跑指定檔案。runScript 與 runBin 的共用地基。
|
||||
* @param {string} file 要執行的檔案絕對路徑
|
||||
*/
|
||||
function runNode(file, args, opts = {}) {
|
||||
const { env = {}, cwd = repoRoot, path } = opts;
|
||||
// 先把繼承來的 TEA_SDLC_* 清乾淨,測試結果才不會隨開發者的 shell 而變
|
||||
const inherited = Object.fromEntries(
|
||||
@@ -32,7 +70,7 @@ export function runScript(name, args = [], opts = {}) {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[join(repoRoot, 'scripts', name), ...args],
|
||||
[file, ...args],
|
||||
// 預設 1MB 會在長輸出時砍掉子行程,那是測試工具的限制而非腳本的問題
|
||||
{ cwd, env: childEnv, maxBuffer: 64 * 1024 * 1024 },
|
||||
(error, stdout, stderr) => {
|
||||
@@ -40,7 +78,6 @@ export function runScript(name, args = [], opts = {}) {
|
||||
code: typeof error?.code === 'number' ? error.code : error ? 1 : 0,
|
||||
stdout,
|
||||
stderr,
|
||||
json: parseSingleLine(stdout),
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -48,7 +85,7 @@ export function runScript(name, args = [], opts = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 腳本的輸出契約是「單行 JSON」。這裡順便把契約本身斷言掉:
|
||||
* 「單行 JSON」的輸出契約。這裡順便把契約本身斷言掉:
|
||||
* 多印一行、印出非 JSON,都會在這裡就炸掉。
|
||||
*/
|
||||
function parseSingleLine(stdout) {
|
||||
|
||||
@@ -0,0 +1,412 @@
|
||||
/**
|
||||
* 轉接檔的產生與移除。
|
||||
*
|
||||
* 全部在臨時家目錄上跑:真的寫檔、真的刪檔,然後斷言家目錄裡剩下什麼。
|
||||
* 只驗「函式有沒有被呼叫」不會發現多建了一層目錄或少刪了一個檔,而那正是這支腳本
|
||||
* 唯一會出的錯。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { pathWithOnly, tmpRoot } from './helpers/run-script.js';
|
||||
import { fakePrompt, makeFakePlugin } from './helpers/fake-plugin.js';
|
||||
|
||||
/** 七個平台的偵測目錄,相對於家目錄;copilot 那個是相對於工作目錄 */
|
||||
const DETECT = {
|
||||
claude: '.claude',
|
||||
codex: '.codex',
|
||||
opencode: '.config/opencode',
|
||||
'oh-my-pi': '.omp',
|
||||
antigravity: '.gemini',
|
||||
kiro: '.kiro',
|
||||
};
|
||||
|
||||
const PROMPTS = { 'sdlc-plan': fakePrompt('sdlc-plan'), 'sdlc-feat': fakePrompt('sdlc-feat') };
|
||||
|
||||
/**
|
||||
* 一個臨時家目錄,只「裝了」指定的平台。
|
||||
* @param {string[]} platforms 要建出偵測目錄的平台名;'copilot' 建在工作目錄底下
|
||||
*/
|
||||
function makeHome(t, platforms = []) {
|
||||
mkdirSync(tmpRoot, { recursive: true });
|
||||
const home = mkdtempSync(join(tmpRoot, 'home-'));
|
||||
t.after(() => rmSync(home, { recursive: true, force: true }));
|
||||
|
||||
for (const name of platforms) {
|
||||
const dir = name === 'copilot' ? join(home, 'work', '.github') : join(home, DETECT[name]);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
mkdirSync(join(home, 'work'), { recursive: true });
|
||||
return home;
|
||||
}
|
||||
|
||||
/** 從臨時家目錄執行入口:HOME 指過去,cwd 指到它底下的 work/(copilot 的 .github 在那) */
|
||||
const inHome = (plugin, home) => (args) =>
|
||||
plugin.run(args, { env: { HOME: home }, cwd: join(home, 'work') });
|
||||
|
||||
/** 列出家目錄底下所有檔案的相對路徑 */
|
||||
function filesUnder(dir, prefix = '') {
|
||||
const out = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
if (entry.isDirectory()) out.push(...filesUnder(join(dir, entry.name), rel));
|
||||
else out.push(rel);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
// ── 偵測 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('只裝到偵測得到的平台,沒裝的平台不留下孤兒目錄', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t, ['claude', 'kiro']);
|
||||
|
||||
const { code, json } = await inHome(plugin, home)(['install']);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.deepEqual(json.data.platforms.map((p) => p.name).sort(), ['claude', 'kiro']);
|
||||
assert.ok(existsSync(join(home, '.claude', 'commands', 'sdlc-plan.md')));
|
||||
assert.equal(existsSync(join(home, '.codex')), false);
|
||||
assert.equal(existsSync(join(home, '.config')), false);
|
||||
});
|
||||
|
||||
test('一個平台都沒偵測到時明講,而不是回報裝了零個當作成功', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t, []);
|
||||
|
||||
const { code, json } = await inHome(plugin, home)(['install']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'NO_PLATFORM_DETECTED');
|
||||
});
|
||||
|
||||
test('status 的 platforms 欄位報得出偵測結果、轉接檔數與過時狀態', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t, ['claude']);
|
||||
await inHome(plugin, home)(['install']);
|
||||
|
||||
const { json } = await inHome(plugin, home)(['status']);
|
||||
const byName = Object.fromEntries(json.data.platforms.map((p) => [p.name, p]));
|
||||
|
||||
assert.equal(byName.claude.detected, true);
|
||||
assert.equal(byName.claude.dir, join(home, '.claude', 'commands'));
|
||||
assert.deepEqual(byName.claude.adapters, { expected: 2, present: 2, stale: false });
|
||||
assert.equal(byName.codex.detected, false);
|
||||
assert.deepEqual(byName.codex.adapters, { expected: 2, present: 0, stale: false });
|
||||
});
|
||||
|
||||
test('少裝了指令時 expected 與 present 對不起來,看得出來還有東西沒佈署', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t, ['claude']);
|
||||
await inHome(plugin, home)(['install']);
|
||||
rmSync(join(home, '.claude', 'commands', 'sdlc-feat.md'));
|
||||
|
||||
const { json } = await inHome(plugin, home)(['status']);
|
||||
const claude = json.data.platforms.find((p) => p.name === 'claude');
|
||||
|
||||
assert.equal(claude.adapters.expected, 2);
|
||||
assert.equal(claude.adapters.present, 1);
|
||||
});
|
||||
|
||||
test('轉接檔版本與套件不符時,status 把該平台標成過時', async (t) => {
|
||||
const old = makeFakePlugin(t, { prompts: PROMPTS, version: '0.0.1' });
|
||||
const home = makeHome(t, ['claude']);
|
||||
await inHome(old, home)(['install']);
|
||||
|
||||
const next = makeFakePlugin(t, { prompts: PROMPTS, version: '0.2.0' });
|
||||
const { json } = await inHome(next, home)(['status']);
|
||||
const claude = json.data.platforms.find((p) => p.name === 'claude');
|
||||
|
||||
assert.equal(claude.adapters.stale, true);
|
||||
assert.equal(claude.adapters.present, 2);
|
||||
});
|
||||
|
||||
|
||||
// ── 指定安裝對象 ───────────────────────────────────────────────────
|
||||
|
||||
test('--platform a,b 可非互動指定安裝對象', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t, ['claude', 'kiro', 'codex']);
|
||||
|
||||
const { json } = await inHome(plugin, home)(['install', '--platform', 'claude,codex']);
|
||||
|
||||
assert.deepEqual(json.data.platforms.map((p) => p.name), ['claude', 'codex']);
|
||||
assert.equal(existsSync(join(home, '.kiro', 'skills')), false);
|
||||
});
|
||||
|
||||
test('--platform 給了不存在的平台名時列出可用的名字', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t, ['claude']);
|
||||
|
||||
const { json } = await inHome(plugin, home)(['install', '--platform', 'vscode']);
|
||||
|
||||
assert.equal(json.error.code, 'UNKNOWN_PLATFORM');
|
||||
assert.match(json.error.message, /vscode/);
|
||||
assert.match(json.error.message, /claude/);
|
||||
});
|
||||
|
||||
test('--platform 指名一個沒裝的平台時擋下,不替它建目錄', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t, ['claude']);
|
||||
|
||||
const { json } = await inHome(plugin, home)(['install', '--platform', 'kiro']);
|
||||
|
||||
assert.equal(json.error.code, 'PLATFORM_NOT_DETECTED');
|
||||
assert.equal(existsSync(join(home, '.kiro')), false);
|
||||
});
|
||||
|
||||
|
||||
// ── 轉接檔內容 ─────────────────────────────────────────────────────
|
||||
|
||||
test('支援 command 的四個平台產生 command 轉接檔,另外三個產生 SKILL.md', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') } });
|
||||
const home = makeHome(t, ['claude', 'codex', 'opencode', 'oh-my-pi', 'antigravity', 'kiro', 'copilot']);
|
||||
|
||||
const { json } = await inHome(plugin, home)(['install']);
|
||||
|
||||
assert.equal(json.data.platforms.length, 7);
|
||||
assert.deepEqual(filesUnder(join(home, '.claude')), ['commands/sdlc-plan.md']);
|
||||
assert.deepEqual(filesUnder(join(home, '.codex')), ['prompts/sdlc-plan.md']);
|
||||
assert.deepEqual(filesUnder(join(home, '.config', 'opencode')), ['command/sdlc-plan.md']);
|
||||
assert.deepEqual(filesUnder(join(home, '.omp')), ['commands/sdlc-plan.md']);
|
||||
assert.deepEqual(filesUnder(join(home, '.gemini')), ['skills/sdlc-plan/SKILL.md']);
|
||||
assert.deepEqual(filesUnder(join(home, '.kiro')), ['skills/sdlc-plan/SKILL.md']);
|
||||
assert.deepEqual(filesUnder(join(home, 'work', '.github')), ['skills/sdlc-plan/SKILL.md']);
|
||||
});
|
||||
|
||||
test('轉接檔內容是一句指向 tea-sdlc 的話,不含任何檔案路徑', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS, version: '1.2.3' });
|
||||
const home = makeHome(t, ['claude']);
|
||||
await inHome(plugin, home)(['install']);
|
||||
|
||||
const text = readFileSync(join(home, '.claude', 'commands', 'sdlc-plan.md'), 'utf8');
|
||||
|
||||
assert.match(text, /tea-sdlc prompt --name sdlc-plan --adapter-version 1\.2\.3/);
|
||||
// 路徑一旦寫進去,升一次 Node 就會同時指向不存在的檔案,而且不會有任何錯誤訊息
|
||||
assert.equal(text.includes(plugin.root), false);
|
||||
assert.equal(text.includes('prompts/sdlc-plan.md'), false);
|
||||
assert.equal(/(^|\s)\/\w/.test(text.replace(/\/sdlc-\S+/g, '')), false, '轉接檔裡不該有絕對路徑');
|
||||
});
|
||||
|
||||
test('description 前綴一律是「僅由 /sdlc-xxx 指令叫用。」,從正本抄過來而不是另寫一份', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t, ['claude', 'kiro']);
|
||||
await inHome(plugin, home)(['install']);
|
||||
|
||||
for (const path of [
|
||||
join(home, '.claude', 'commands', 'sdlc-plan.md'),
|
||||
join(home, '.kiro', 'skills', 'sdlc-plan', 'SKILL.md'),
|
||||
]) {
|
||||
const description = readFileSync(path, 'utf8').match(/^description:\s*(.+)$/m)[1];
|
||||
assert.ok(description.startsWith('僅由 /sdlc-plan 指令叫用。'), `${path}:${description}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('支援關閉自動觸發的 command 平台設上旗標;三個 skill 平台沒有那個旗標可設', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') } });
|
||||
const home = makeHome(t, ['claude', 'codex', 'opencode', 'oh-my-pi', 'antigravity', 'kiro', 'copilot']);
|
||||
await inHome(plugin, home)(['install']);
|
||||
|
||||
const commands = [
|
||||
join(home, '.claude', 'commands', 'sdlc-plan.md'),
|
||||
join(home, '.codex', 'prompts', 'sdlc-plan.md'),
|
||||
join(home, '.config', 'opencode', 'command', 'sdlc-plan.md'),
|
||||
join(home, '.omp', 'commands', 'sdlc-plan.md'),
|
||||
];
|
||||
const skills = [
|
||||
join(home, '.gemini', 'skills', 'sdlc-plan', 'SKILL.md'),
|
||||
join(home, '.kiro', 'skills', 'sdlc-plan', 'SKILL.md'),
|
||||
join(home, 'work', '.github', 'skills', 'sdlc-plan', 'SKILL.md'),
|
||||
];
|
||||
|
||||
for (const path of commands) {
|
||||
assert.match(readFileSync(path, 'utf8'), /^disable-model-invocation: true$/m, path);
|
||||
}
|
||||
for (const path of skills) {
|
||||
assert.equal(readFileSync(path, 'utf8').includes('disable-model-invocation'), false, path);
|
||||
}
|
||||
});
|
||||
|
||||
test('重跑安裝是覆蓋而不是疊加,也不會多留一份舊檔', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t, ['claude']);
|
||||
|
||||
await inHome(plugin, home)(['install']);
|
||||
await inHome(plugin, home)(['install']);
|
||||
|
||||
assert.deepEqual(filesUnder(join(home, '.claude')).sort(), [
|
||||
'commands/sdlc-feat.md',
|
||||
'commands/sdlc-plan.md',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
test('正本的 description 前綴不對時擋下,不讓它變成一份會被誤觸的轉接檔', async (t) => {
|
||||
const plugin = makeFakePlugin(t, {
|
||||
prompts: { 'sdlc-plan': 'name: sdlc-plan\ndescription: 把需求變成議題。\n\n# sdlc-plan\n' },
|
||||
});
|
||||
const home = makeHome(t, ['claude']);
|
||||
|
||||
const { code, json } = await inHome(plugin, home)(['install']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'PROMPT_BAD_DESCRIPTION');
|
||||
assert.match(json.error.message, /僅由 \/sdlc-plan 指令叫用。/);
|
||||
assert.equal(existsSync(join(home, '.claude', 'commands')), false);
|
||||
});
|
||||
|
||||
|
||||
// ── 勾選 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('列出來的每一項預設都是勾起來的,編號從 1 開始', async () => {
|
||||
const { checklist } = await import('../scripts/install.js');
|
||||
|
||||
const lines = checklist([
|
||||
{ name: 'claude', label: 'Claude Code' },
|
||||
{ name: 'kiro', label: 'Kiro' },
|
||||
]);
|
||||
|
||||
assert.deepEqual(lines, [
|
||||
' [x] 1. Claude Code(claude)',
|
||||
' [x] 2. Kiro(kiro)',
|
||||
]);
|
||||
});
|
||||
|
||||
test('直接按 Enter 就是全裝', async () => {
|
||||
const { selectPlatforms } = await import('../scripts/install.js');
|
||||
const detected = [{ name: 'claude' }, { name: 'kiro' }];
|
||||
|
||||
assert.deepEqual(selectPlatforms(detected, ''), detected);
|
||||
assert.deepEqual(selectPlatforms(detected, ' \n'), detected);
|
||||
});
|
||||
|
||||
test('勾選可以打編號也可以打名稱,順序以列出來的為準', async () => {
|
||||
const { selectPlatforms } = await import('../scripts/install.js');
|
||||
const detected = [{ name: 'claude' }, { name: 'kiro' }, { name: 'codex' }];
|
||||
|
||||
assert.deepEqual(selectPlatforms(detected, '2,1').map((p) => p.name), ['claude', 'kiro']);
|
||||
assert.deepEqual(selectPlatforms(detected, 'codex').map((p) => p.name), ['codex']);
|
||||
});
|
||||
|
||||
test('勾了沒列出來的東西時當場說,不要默默少裝一個', async () => {
|
||||
const { selectPlatforms } = await import('../scripts/install.js');
|
||||
|
||||
assert.throws(
|
||||
() => selectPlatforms([{ name: 'claude' }], '9'),
|
||||
(error) => error.code === 'UNKNOWN_PLATFORM',
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
// ── 移除 ───────────────────────────────────────────────────────────
|
||||
|
||||
test('uninstall 之後家目錄裡沒有任何殘留的轉接檔', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t, ['claude', 'kiro']);
|
||||
await inHome(plugin, home)(['install']);
|
||||
|
||||
const { code, json } = await inHome(plugin, home)(['uninstall']);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.data.removed.length, 4);
|
||||
assert.deepEqual(filesUnder(join(home, '.claude')), []);
|
||||
assert.deepEqual(filesUnder(join(home, '.kiro')), []);
|
||||
// 平台自己的目錄不是我們建的東西,留著
|
||||
assert.ok(existsSync(join(home, '.claude')));
|
||||
});
|
||||
|
||||
test('uninstall 不動流程正本', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t, ['claude']);
|
||||
await inHome(plugin, home)(['install']);
|
||||
|
||||
await inHome(plugin, home)(['uninstall']);
|
||||
|
||||
assert.equal(readFileSync(join(plugin.root, 'prompts', 'sdlc-plan.md'), 'utf8'), PROMPTS['sdlc-plan']);
|
||||
assert.deepEqual(readdirSync(join(plugin.root, 'prompts')).sort(), ['sdlc-feat.md', 'sdlc-plan.md']);
|
||||
});
|
||||
|
||||
test('不是 tea-sdlc 產生的同名檔案一律留著,並在輸出裡交代為什麼', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t, ['claude']);
|
||||
await inHome(plugin, home)(['install']);
|
||||
const mine = join(home, '.claude', 'commands', 'sdlc-plan.md');
|
||||
writeFileSync(mine, '# 我自己寫的,不要刪\n');
|
||||
|
||||
const { json } = await inHome(plugin, home)(['uninstall']);
|
||||
|
||||
assert.equal(readFileSync(mine, 'utf8'), '# 我自己寫的,不要刪\n');
|
||||
assert.equal(json.data.kept.length, 1);
|
||||
assert.match(json.data.kept[0].path, /sdlc-plan\.md$/);
|
||||
});
|
||||
|
||||
test('舊版留下、正本已經不存在的轉接檔也一起移除', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t, ['claude']);
|
||||
await inHome(plugin, home)(['install']);
|
||||
// 模擬上一版裝過、這一版已經沒有的指令
|
||||
const orphan = join(home, '.claude', 'commands', 'sdlc-gone.md');
|
||||
writeFileSync(orphan, readFileSync(join(home, '.claude', 'commands', 'sdlc-plan.md'), 'utf8'));
|
||||
|
||||
await inHome(plugin, home)(['uninstall']);
|
||||
|
||||
assert.equal(existsSync(orphan), false);
|
||||
});
|
||||
|
||||
test('--dry-run 列出會動到哪些檔案,但一個字都不寫', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t, ['claude']);
|
||||
|
||||
const { json } = await inHome(plugin, home)(['install', '--dry-run']);
|
||||
|
||||
assert.equal(json.data.dryRun, true);
|
||||
assert.equal(json.data.platforms[0].adapters.length, 2);
|
||||
assert.equal(existsSync(join(home, '.claude', 'commands')), false);
|
||||
});
|
||||
|
||||
|
||||
// ── 執行環境 ───────────────────────────────────────────────────────
|
||||
|
||||
test('缺少 tea 時照樣把轉接檔裝好,但把缺的東西講出來', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t, ['claude']);
|
||||
|
||||
const { code, json } = await plugin.run(['install'], {
|
||||
env: { HOME: home },
|
||||
cwd: join(home, 'work'),
|
||||
path: pathWithOnly(['node', 'git']),
|
||||
});
|
||||
|
||||
// 缺 tea 完全不影響轉接檔產生,硬擋等於逼使用者為了裝 plugin 先去裝 tea
|
||||
assert.equal(code, 0);
|
||||
assert.deepEqual(json.data.missingBinaries, ['tea']);
|
||||
assert.match(json.data.warning, /tea/);
|
||||
assert.match(json.data.warning, /gitea\.com\/gitea\/tea/);
|
||||
assert.ok(existsSync(join(home, '.claude', 'commands', 'sdlc-plan.md')));
|
||||
});
|
||||
|
||||
test('該裝的都在時不留下沒有意義的警告', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t, ['claude']);
|
||||
|
||||
const { json } = await inHome(plugin, home)(['install']);
|
||||
|
||||
assert.deepEqual(json.data.missingBinaries, []);
|
||||
assert.equal(json.data.warning, null);
|
||||
});
|
||||
|
||||
test('安裝不會替使用者裝任何東西:從頭到尾沒有跑過套件管理器', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
|
||||
const home = makeHome(t, ['claude']);
|
||||
|
||||
const { stderr } = await plugin.run(['install'], {
|
||||
env: { HOME: home },
|
||||
cwd: join(home, 'work'),
|
||||
// PATH 上只有 node:真要自動安裝什麼,這裡就會失敗而不是靜靜跳過
|
||||
path: pathWithOnly(['node']),
|
||||
});
|
||||
|
||||
assert.equal(stderr, '');
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* prompt 子指令:agent 讀到轉接檔之後,靠它拿到流程正本的原文。
|
||||
*
|
||||
* 這是全專案唯一輸出非 JSON 的路徑,所以這裡釘的第一件事就是「逐字」——
|
||||
* 多一個換行、少一個空白,都會讓下游模型讀到的東西與正本不同。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { manifest, repoRoot, runBin } from './helpers/run-script.js';
|
||||
import { fakePrompt, makeFakePlugin } from './helpers/fake-plugin.js';
|
||||
|
||||
const version = () => manifest().version;
|
||||
|
||||
|
||||
// ── 成功路徑:原樣輸出 ─────────────────────────────────────────────
|
||||
|
||||
test('把流程正本逐字印出來,不包 JSON、不補字元', async (t) => {
|
||||
const text = fakePrompt('sdlc-plan');
|
||||
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': text } });
|
||||
|
||||
const { code, raw } = await plugin.run(['prompt', '--name', 'sdlc-plan']);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(raw, text);
|
||||
});
|
||||
|
||||
test('輸出允許多行,且不是 JSON', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') } });
|
||||
|
||||
const { raw } = await plugin.run(['prompt', '--name', 'sdlc-plan']);
|
||||
|
||||
assert.ok(raw.split('\n').length > 3);
|
||||
assert.throws(() => JSON.parse(raw.trim()));
|
||||
});
|
||||
|
||||
test('真正的流程正本也走得通,輸出逐字等於檔案內容', async () => {
|
||||
const path = join(repoRoot, 'prompts', 'sdlc-plan.md');
|
||||
|
||||
const { code, raw } = await runBin(['prompt', '--name', 'sdlc-plan']);
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(raw, readFileSync(path, 'utf8'));
|
||||
});
|
||||
|
||||
|
||||
// ── 失敗路徑:仍走單行 JSON ────────────────────────────────────────
|
||||
|
||||
test('指令不存在時回可區分的錯誤碼,而不是印出空內容當成成功', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') } });
|
||||
|
||||
const { code, json } = await plugin.run(['prompt', '--name', 'sdlc-nope']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.ok, false);
|
||||
assert.equal(json.error.code, 'PROMPT_NOT_FOUND');
|
||||
assert.match(json.error.message, /sdlc-nope/);
|
||||
});
|
||||
|
||||
test('未帶 --name 時沿用既有的 MISSING_FLAG', async (t) => {
|
||||
const plugin = makeFakePlugin(t);
|
||||
|
||||
const { code, json } = await plugin.run(['prompt']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'MISSING_FLAG');
|
||||
assert.match(json.error.message, /--name/);
|
||||
});
|
||||
|
||||
test('--name 不能拿來跳出 prompts 目錄', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') } });
|
||||
|
||||
const { code, json } = await plugin.run(['prompt', '--name', '../package.json']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'BAD_PROMPT_NAME');
|
||||
});
|
||||
|
||||
test('plugin 目錄不完整時回既有的 PLUGIN_LAYOUT_BROKEN', async (t) => {
|
||||
const plugin = makeFakePlugin(t, { omit: ['templates'] });
|
||||
|
||||
const { code, json } = await plugin.run(['prompt', '--name', 'sdlc-plan']);
|
||||
|
||||
assert.equal(code, 1);
|
||||
assert.equal(json.error.code, 'PLUGIN_LAYOUT_BROKEN');
|
||||
assert.match(json.error.message, /templates/);
|
||||
});
|
||||
|
||||
|
||||
// ── 轉接檔版本比對 ─────────────────────────────────────────────────
|
||||
|
||||
test('版本相符時的輸出與不帶該 flag 時完全一致', async (t) => {
|
||||
const text = fakePrompt('sdlc-plan');
|
||||
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': text }, version: '9.9.9' });
|
||||
|
||||
const matched = await plugin.run(['prompt', '--name', 'sdlc-plan', '--adapter-version', '9.9.9']);
|
||||
const plain = await plugin.run(['prompt', '--name', 'sdlc-plan']);
|
||||
|
||||
assert.equal(matched.raw, plain.raw);
|
||||
assert.equal(matched.raw, text);
|
||||
});
|
||||
|
||||
test('版本不符時第一行是重跑 install 的警告,其後逐字等於正本原文', async (t) => {
|
||||
const text = fakePrompt('sdlc-plan');
|
||||
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': text }, version: '9.9.9' });
|
||||
|
||||
const { code, raw } = await plugin.run([
|
||||
'prompt', '--name', 'sdlc-plan', '--adapter-version', '0.0.1',
|
||||
]);
|
||||
|
||||
// 正本仍然要給:過時的轉接檔不該讓流程停擺
|
||||
assert.equal(code, 0);
|
||||
const [first, ...rest] = raw.split('\n');
|
||||
assert.match(first, /tea-sdlc install/);
|
||||
assert.match(first, /0\.0\.1/);
|
||||
assert.match(first, /9\.9\.9/);
|
||||
assert.equal(rest.join('\n').trimStart(), text);
|
||||
assert.ok(raw.endsWith(text));
|
||||
});
|
||||
|
||||
test('未帶 --adapter-version 時不產生任何警告', async (t) => {
|
||||
const text = fakePrompt('sdlc-plan');
|
||||
const plugin = makeFakePlugin(t, { prompts: { 'sdlc-plan': text } });
|
||||
|
||||
const { raw } = await plugin.run(['prompt', '--name', 'sdlc-plan']);
|
||||
|
||||
assert.equal(raw.includes('tea-sdlc install'), false);
|
||||
});
|
||||
|
||||
test('比對用的版本只有 manifest 一個來源,沒有另外寫死一份', async (t) => {
|
||||
const plugin = makeFakePlugin(t, {
|
||||
prompts: { 'sdlc-plan': fakePrompt('sdlc-plan') },
|
||||
version: '7.7.7',
|
||||
});
|
||||
|
||||
const { raw } = await plugin.run([
|
||||
'prompt', '--name', 'sdlc-plan', '--adapter-version', version(),
|
||||
]);
|
||||
|
||||
// 假根的版本被改過,帶上真 repo 的版本就該被判為不符
|
||||
assert.match(raw.split('\n')[0], /7\.7\.7/);
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* README 的安裝段。
|
||||
*
|
||||
* 這一段是使用者照著打的東西,打錯一個字就裝不起來,所以指令要能逐字複製執行,
|
||||
* 而且不能留下已經不成立的說法——並列一條「照著做不會出現任何指令」的流程等於在說謊。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { repoRoot } 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('https://gitea.jsc.idv.tw/plugins/tea-sdlc.git')),
|
||||
`安裝指令少了完整 git URL:${install.join(' / ')}`,
|
||||
);
|
||||
});
|
||||
|
||||
test('更新與移除各有完整指令表,順序講清楚先 uninstall 再 npm rm', () => {
|
||||
const text = readme();
|
||||
|
||||
assert.match(text, /npm i -g 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('模組邊界表指得到實際存在的檔案', () => {
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* status 子指令:在出事之前先知道自己是什麼狀態。
|
||||
*
|
||||
* 這裡最要緊的一條是 ok 與健康狀態分離:ok 的意思一直是「這支指令跑成功了」,
|
||||
* 不兼差表達環境好壞。混用的話呼叫端就分不出「status 掛了」與「status 成功查到
|
||||
* 你環境有問題」,而這兩件事的下一步完全不同。
|
||||
*/
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { join } from 'node:path';
|
||||
import { manifest, pathWithOnly, repoRoot, runBin } from './helpers/run-script.js';
|
||||
import { healthyRoutes, stubEnv, withStubGitea } from './helpers/stub-gitea.js';
|
||||
|
||||
const REPO = 'plugins/tea-sdlc';
|
||||
const version = () => manifest().version;
|
||||
|
||||
|
||||
test('回報版本、正本位置、是不是 link 模式、健康旗標與環境四項', async (t) => {
|
||||
const stub = await withStubGitea(t, healthyRoutes(REPO));
|
||||
|
||||
const { code, json } = await runBin(['status'], { env: stubEnv(stub) });
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.data.version, version());
|
||||
assert.equal(json.data.root, repoRoot);
|
||||
assert.equal(json.data.healthy, true);
|
||||
assert.deepEqual(Object.keys(json.data.environment).sort(), ['git', 'login', 'node', 'tea']);
|
||||
});
|
||||
|
||||
test('從 working tree 跑的時候是 link 模式,root 指向 working tree', async (t) => {
|
||||
const stub = await withStubGitea(t, healthyRoutes(REPO));
|
||||
|
||||
const { json } = await runBin(['status'], { env: stubEnv(stub) });
|
||||
|
||||
assert.equal(json.data.linked, true);
|
||||
assert.equal(json.data.root.includes('node_modules'), false);
|
||||
});
|
||||
|
||||
test('缺少 git 或 tea 時對應欄位為 false,健康旗標跟著倒下', async (t) => {
|
||||
const stub = await withStubGitea(t, healthyRoutes(REPO));
|
||||
|
||||
const { code, json } = await runBin(['status'], {
|
||||
env: stubEnv(stub),
|
||||
path: pathWithOnly(['node']),
|
||||
});
|
||||
|
||||
// 查詢本身仍然成功——它成功查到你的環境有問題
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.data.environment.node, true);
|
||||
assert.equal(json.data.environment.git, false);
|
||||
assert.equal(json.data.environment.tea, false);
|
||||
assert.equal(json.data.healthy, false);
|
||||
});
|
||||
|
||||
test('Gitea 登入失效時 ok 仍為 true,healthy 為 false', async (t) => {
|
||||
const stub = await withStubGitea(t, {
|
||||
...healthyRoutes(REPO),
|
||||
'GET /api/v1/user': { status: 401, body: { message: 'token expired' } },
|
||||
});
|
||||
|
||||
const { code, json } = await runBin(['status'], { env: stubEnv(stub) });
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.data.environment.login, false);
|
||||
assert.equal(json.data.healthy, false);
|
||||
});
|
||||
|
||||
test('登入檢查走既有的單一 HTTP 出口,打的是 /user', async (t) => {
|
||||
const stub = await withStubGitea(t, healthyRoutes(REPO));
|
||||
|
||||
await runBin(['status'], { env: stubEnv(stub) });
|
||||
|
||||
assert.deepEqual(
|
||||
stub.requests.map((request) => `${request.method} ${request.path}`),
|
||||
['GET /api/v1/user'],
|
||||
);
|
||||
assert.equal(stub.requests[0].authorization, 'token stub-token');
|
||||
});
|
||||
|
||||
test('根本還沒登入時也只是 login 為 false,不會讓整支指令失敗', async (t) => {
|
||||
const { code, json } = await runBin(['status'], {
|
||||
env: { TEA_SDLC_CONFIG: join(repoRoot, '.tmp', 'no-such-tea-config.yml') },
|
||||
});
|
||||
|
||||
assert.equal(code, 0);
|
||||
assert.equal(json.ok, true);
|
||||
assert.equal(json.data.environment.login, false);
|
||||
assert.equal(json.data.healthy, false);
|
||||
});
|
||||
Reference in New Issue
Block a user