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>
319 lines
14 KiB
JavaScript
319 lines
14 KiB
JavaScript
/**
|
||
* 安裝完成等於驗過能用: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);
|
||
});
|