Files
tea-sdlc/test/install-verify.test.js
T
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

319 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, 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);
// 逐平台 pass/fail,而不是只有一個總結
assert.deepEqual(byName(json.data.verify).claude, { name: 'claude', ok: true, checked: 2, failures: [] });
assert.deepEqual(byName(json.data.verify).kiro, { name: 'kiro', ok: true, checked: 2, 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 });
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 });
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);
});