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