Files
tea-sdlc/test/install.test.js
T
jiantw83 43e91ceb70 test(workflow-assets): 整合流程正本與委派規則
以能力描述補齊可委派步驟,讓流程正本、委派規則、AGENTS、README 與 ADR 保持同一份繁體中文與 UTF-8 契約。
2026-09-22 15:31:36 +08:00

434 lines
18 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.
/**
* 轉接檔的產生與移除。
*
* 全部在臨時家目錄上跑:真的寫檔、真的刪檔,然後斷言家目錄裡剩下什麼。
* 只驗「函式有沒有被呼叫」不會發現多建了一層目錄或少刪了一個檔,而那正是這支腳本
* 唯一會出的錯。
*/
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')), ['agent/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('七個平台的轉接檔都保留繁體中文且沒有 UTF-8 亂碼', 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 files = [
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', 'agent', 'commands', 'sdlc-plan.md'),
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 files) {
const text = new TextDecoder('utf-8', { fatal: true }).decode(readFileSync(path));
assert.match(text, /僅由 \/sdlc-plan 指令叫用。/);
}
});
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', 'agent', '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, '');
});