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

326 lines
14 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.
/**
* 安裝完成等於驗過能用:install 寫完轉接檔之後,真的把那條叫用鏈走一遍。
*
* 為什麼要驗這條鏈,見 scripts/install-verify.js 開頭。這裡只交代測法:一律在臨時家目錄上
* 真的寫檔、真的把 tea-sdlc 放上 PATH、真的執行它,再斷言結果。只驗「有沒有呼叫某個函式」
* 的話,正好驗不到唯一會壞的那一環。
*/
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { manifest, pathWithOnly, tmpRoot } from './helpers/run-script.js';
import { fakePrompt, makeFakePlugin } from './helpers/fake-plugin.js';
import { startStubGitea } from './helpers/stub-gitea.js';
const PROMPTS = { 'sdlc-plan': fakePrompt('sdlc-plan'), 'sdlc-feat': fakePrompt('sdlc-feat') };
/** 一個只「裝了」claude 與 kiro 的臨時家目錄 */
function makeHome(t) {
mkdirSync(tmpRoot, { recursive: true });
const home = mkdtempSync(join(tmpRoot, 'home-'));
t.after(() => rmSync(home, { recursive: true, force: true }));
for (const dir of ['.claude', '.kiro', 'work']) mkdirSync(join(home, dir), { recursive: true });
return home;
}
const inHome = (plugin, home) => (args, opts = {}) =>
plugin.run(args, { env: { HOME: home }, cwd: join(home, 'work'), ...opts });
/** 這次安裝實際寫出去的每一份轉接檔 */
const adaptersOf = (json) => json.data.platforms.flatMap((platform) => platform.adapters);
const byName = (verify) => Object.fromEntries(verify.platforms.map((p) => [p.name, p]));
/**
* 把這份假 plugin 的 tea-sdlc 放上本行程的 PATH,測試結束後還原。
*
* 直接叫 verifyInstall 的測試才需要這個:它跟 install 不一樣,走的是本行程的 PATH。
* 叫用鏈那一環要是通的,那些測試的 fail 才只可能來自轉接檔。
*/
function 把tea_sdlc放上PATH(plugin, t) {
const 原本的 = process.env.PATH;
process.env.PATH = [plugin.shim, 原本的].join(':');
t.after(() => { process.env.PATH = 原本的; });
}
/**
* 組出 install 剛寫完轉接檔、正要交給驗證的那個樣子。純粹組資料,不碰環境。
* @param {string} home 臨時家目錄
* @param {object} plugin 假 plugin,取它的版本
*/
function 裝好的樣子(home, plugin) {
const version = JSON.parse(readFileSync(join(plugin.root, 'package.json'), 'utf8')).version;
const names = Object.keys(PROMPTS).sort();
return {
version,
prompt: { name: names[0], text: PROMPTS[names[0]] },
platforms: [
{
name: 'claude',
adapters: names.map((name) => ({ name, path: join(home, '.claude', 'commands', `${name}.md`) })),
},
{
name: 'kiro',
adapters: names.map((name) => ({ name, path: join(home, '.kiro', 'skills', name, 'SKILL.md') })),
},
],
};
}
// ── 全部通過 ───────────────────────────────────────────────────────
test('轉接檔寫完就驗一次真實的叫用鏈,逐平台回報 pass', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t);
const { code, json } = await inHome(plugin, home)(['install']);
assert.equal(code, 0);
assert.equal(json.ok, true);
assert.equal(json.data.verify.ok, true);
const claude = byName(json.data.verify).claude;
assert.equal(claude.name, 'claude');
assert.equal(claude.ok, true);
assert.equal(claude.status, 'pass');
assert.deepEqual(claude.runtime.commands, ['sdlc-feat', 'sdlc-plan']);
const kiro = byName(json.data.verify).kiro;
assert.equal(kiro.name, 'kiro');
assert.equal(kiro.ok, true);
assert.equal(kiro.status, 'not-supported');
assert.deepEqual(kiro.failures, []);
});
test('驗證是真的把 PATH 上的 tea-sdlc 找出來執行,不是查有沒有這個檔', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t);
const { json } = await inHome(plugin, home)(['install']);
const { chain } = json.data.verify;
assert.equal(chain.ok, true);
assert.equal(chain.command, 'tea-sdlc');
assert.equal(chain.resolved, join(plugin.shim, 'tea-sdlc'));
assert.ok(json.data.commands.includes(chain.prompt), `取回的是 ${chain.prompt}`);
});
// ── 中間那一環斷掉 ─────────────────────────────────────────────────
test('PATH 上找不到 tea-sdlc 時整體 ok:false,並指出病灶在 PATH 而不是轉接檔', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t);
const { code, json } = await inHome(plugin, home)(['install'], { shim: false, path: pathWithOnly(['node']) });
assert.equal(code, 1);
assert.equal(json.ok, false);
assert.equal(json.data.verify.chain.ok, false);
assert.equal(json.data.verify.chain.resolved, null);
assert.match(json.data.verify.chain.病灶, /PATH/);
assert.match(json.data.verify.chain.修復, /npm/);
// 轉接檔本身沒有問題,刪掉它一點幫助也沒有——這裡要分得開
assert.equal(byName(json.data.verify).claude.ok, true);
});
test('PATH 上的 tea-sdlc 是另一份安裝時驗得出來——取回的正本跟這一份不一樣', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const 另一份 = makeFakePlugin(t, {
prompts: Object.fromEntries(
Object.entries(PROMPTS).map(([name, text]) => [name, `${text}\n舊版多出來的一段。\n`]),
),
});
const home = makeHome(t);
const { code, json } = await inHome(plugin, home)(['install'], {
shim: false,
path: [另一份.shim, process.env.PATH].join(':'),
});
assert.equal(code, 1);
assert.equal(json.data.verify.chain.ok, false);
assert.equal(json.data.verify.chain.resolved, join(另一份.shim, 'tea-sdlc'));
assert.match(json.data.verify.chain.病灶, /正本/);
});
test('PATH 上的 tea-sdlc 叫得到卻跑不完時,把它的 stderr 當成病灶講出來,自己的 stderr 仍然乾淨', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t);
// 裝壞了的 tea-sdlc:叫得到、跑不完。輸出契約是「stderr 永遠乾淨,呼叫端只讀 stdout」,
// 所以子行程罵的話要被收進病灶裡,不能直接漏到我們的 stderr 上。
const 壞殼 = join(home, 'bin');
mkdirSync(壞殼, { recursive: true });
writeFileSync(join(壞殼, 'tea-sdlc'), '#!/bin/sh\necho "Cannot find module node_modules/x" >&2\nexit 1\n');
chmodSync(join(壞殼, 'tea-sdlc'), 0o755);
const { code, stderr, json } = await inHome(plugin, home)(['install'], {
shim: false,
path: [壞殼, process.env.PATH].join(':'),
});
assert.equal(code, 1);
assert.equal(stderr, '', '子行程的 stderr 漏出來了');
assert.equal(json.data.verify.chain.ok, false);
assert.match(json.data.verify.chain.病灶, /Cannot find module/);
});
test('PATH 上的 tea-sdlc 自己裝壞了時,病灶講的是它自己報的錯,不是空泛的 Command failed', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
// tea-sdlc 的失敗一律是 stdout 上的一行 JSON(stderr 永遠乾淨),病灶要從那裡撈
const 裝壞的 = makeFakePlugin(t, { prompts: PROMPTS, omit: ['templates'] });
const home = makeHome(t);
const { code, json } = await inHome(plugin, home)(['install'], {
shim: false,
path: [裝壞的.shim, process.env.PATH].join(':'),
});
assert.equal(code, 1);
assert.equal(json.data.verify.chain.ok, false);
assert.match(json.data.verify.chain.病灶, /PLUGIN_LAYOUT_BROKEN/);
assert.equal(/Command failed/.test(json.data.verify.chain.病灶), false, '子行程自己說的話被丟掉了');
// 使用者一定會看到的地方也要講得出來
assert.match(json.error.message, /PLUGIN_LAYOUT_BROKEN/);
});
// ── 轉接檔那一環壞掉 ───────────────────────────────────────────────
//
// 這兩支直接叫 verifyInstall,因為 install 會先把轉接檔寫過一遍才驗——從 CLI 進去
// 沒有辦法讓它看到一份壞掉的轉接檔。驗的仍然是真的檔案與真的家目錄,只是少了寫入那一步。
test('轉接檔的叫用行不對時該平台 fail,其他平台照常 pass,整體 ok:false', async (t) => {
const { verifyInstall } = await import('../scripts/install-verify.js');
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t);
await inHome(plugin, home)(['install']);
const 壞掉的 = join(home, '.claude', 'commands', 'sdlc-plan.md');
writeFileSync(壞掉的, '執行 `tea-sdlc prompt --name sdlc-plna`,並完全遵照它印出的內容執行。\n');
把tea_sdlc放上PATH(plugin, t);
const verify = verifyInstall(裝好的樣子(home, plugin));
assert.equal(verify.ok, false);
assert.equal(byName(verify).claude.ok, false);
assert.equal(byName(verify).kiro.ok, true);
assert.deepEqual(byName(verify).claude.failures.map((f) => f.path), [壞掉的]);
assert.match(byName(verify).claude.failures[0].病灶, /叫用行/);
assert.match(byName(verify).claude.failures[0].修復, /install/);
});
test('轉接檔不見了時該平台 fail,病灶講的是檔案不在', async (t) => {
const { verifyInstall } = await import('../scripts/install-verify.js');
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t);
await inHome(plugin, home)(['install']);
const 不見的 = join(home, '.kiro', 'skills', 'sdlc-feat', 'SKILL.md');
rmSync(不見的);
把tea_sdlc放上PATH(plugin, t);
const verify = verifyInstall(裝好的樣子(home, plugin));
assert.equal(verify.ok, false);
assert.equal(byName(verify).kiro.ok, false);
assert.deepEqual(byName(verify).kiro.failures.map((f) => f.path), [不見的]);
assert.match(byName(verify).kiro.failures[0].病灶, /找不到/);
});
test('某個平台的轉接檔驗不過時,install 整體回 ok:false,data 照樣交出去', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t);
// 把其中一份轉接檔接到 /dev/null:install 照常寫得進去(不會中途炸掉),
// 但讀回來是空的——「寫出去了」與「檔案真的長那樣」不是同一件事,正是這道驗證的理由。
const 寫不進去的 = join(home, '.claude', 'commands', 'sdlc-plan.md');
mkdirSync(dirname(寫不進去的), { recursive: true });
symlinkSync('/dev/null', 寫不進去的);
const { code, json } = await inHome(plugin, home)(['install']);
assert.equal(code, 1);
assert.equal(json.ok, false);
assert.equal(json.error.code, 'INSTALL_VERIFY_FAILED');
assert.equal(json.data.verify.chain.ok, true, '叫用鏈是通的,壞的只有這一份轉接檔');
assert.equal(byName(json.data.verify).claude.ok, false);
assert.equal(byName(json.data.verify).kiro.ok, true);
// 病灶與修復方式要出現在使用者一定會看到的地方
assert.match(json.error.message, /叫用行/);
assert.match(json.error.message, /重跑 tea-sdlc install/);
});
// ── 失敗不回滾 ─────────────────────────────────────────────────────
test('驗證失敗時已經寫好的轉接檔一份都不刪', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t);
// 病灶在 PATH,不在轉接檔:刪掉轉接檔只會讓使用者從「有點舊但能用」變成什麼都沒有
const { json } = await inHome(plugin, home)(['install'], { shim: false, path: pathWithOnly(['node']) });
assert.equal(json.ok, false);
for (const path of adaptersOf(json)) {
assert.ok(existsSync(path), `${path} 被回滾掉了`);
}
});
test('升級情境:驗證失敗也不會把使用者原本能用的舊轉接檔弄不見', async (t) => {
const 舊版 = makeFakePlugin(t, { prompts: PROMPTS, version: '0.0.1' });
const home = makeHome(t);
await inHome(舊版, home)(['install']);
const 新版 = makeFakePlugin(t, { prompts: PROMPTS, version: '9.9.9' });
const { json } = await inHome(新版, home)(['install'], { shim: false });
assert.equal(json.ok, false);
const 轉接檔 = join(home, '.claude', 'commands', 'sdlc-plan.md');
assert.ok(existsSync(轉接檔));
assert.match(readFileSync(轉接檔, 'utf8'), /--adapter-version 9\.9\.9/);
});
// ── 不需要網路、不需要登入 ─────────────────────────────────────────
test('整個驗證過程一個網路請求都不發', async (t) => {
// 取正本是讀套件內的檔案,比對轉接檔是讀本機目錄,兩件事都不該碰到 Gitea
const stub = await startStubGitea({});
t.after(() => stub.close());
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t);
const { code } = await inHome(plugin, home)(['install'], {
env: { HOME: home, TEA_SDLC_API_BASE: stub.base, TEA_SDLC_TOKEN: 'stub-token' },
});
assert.equal(code, 0);
assert.deepEqual(stub.requests, []);
});
// ── 試跑 ───────────────────────────────────────────────────────────
test('--dry-run 一個字都不寫,也不因為沒東西可驗就報失敗', async (t) => {
const plugin = makeFakePlugin(t, { prompts: PROMPTS });
const home = makeHome(t);
const { code, json } = await inHome(plugin, home)(['install', '--dry-run']);
assert.equal(code, 0);
assert.equal(json.ok, true);
assert.equal(json.data.verify.skipped, true);
assert.match(json.data.verify.reason, /dry-run/);
assert.equal(existsSync(join(home, '.claude', 'commands')), false);
});
// ── 打包範圍 ───────────────────────────────────────────────────────
test('test/ 不在套件白名單裡:驗的是安裝結果,不是開發期的契約', () => {
assert.equal(manifest().files.some((entry) => entry.replace(/\/$/, '') === 'test'), false);
});