/** * 兩條路必須得出同一個答案 —— 本票的核心驗收。 * * leo 2026-08-12:「根本就不應該在 CLI,我要的是一個大家都可以用到的規則。」 * * 後果已經真的發生過:`acr` 那條有 Arcrun#97 的修法、安裝器那條沒有, * 於是安裝器照名字找、找不到就建一顆空的綁上去 ⇒ 使用者的工作流與登入狀態整片消失。 * * 這份測試把**同一個帳號狀態**餵給兩條路: * A. `acr` 那條:`CfAccountClient` + `resource-resolver`(CLI 真正跑的 import 鏈) * B. 安裝器那條:只 import `shared/resource-rule/`(安裝器唯一該碰的入口) * 然後比對它們選出的 **resource id 必須相同**。 * * 假的是 `fetch`,不是 `ResourceApi`——所以兩條路都真的走完 HTTP → 解析 → 判斷整條鏈。 * 只測判斷會漏掉「怎麼把 CF 回應讀成事實」,而 #97 的重演只要眼睛不一樣就夠了。 */ import { test } from 'node:test'; import assert from 'node:assert/strict'; // ── A:acr 那條(CLI 真正用的東西) import { CfAccountClient } from '../src/lib/cf-api.ts'; import { planResources, applyResourcePlan, bindingKey } from '../src/lib/resource-resolver.ts'; import type { BindingRequirement } from '../src/lib/resource-resolver.ts'; // ── B:安裝器那條(只碰 shared/) import { resolveInstanceResources } from '../../shared/resource-rule/installer-entry.mjs'; // ── 共用 fixture import { makeAccount, requirements, SCENARIOS, WORKER_NEEDS, type Scenario, } from '../../shared/resource-rule/tests/fixture-account.mjs'; const ACCOUNT = 'acct-fixture'; const TOKEN = 'tok-fixture'; /** 把 fixture 的需求組成安裝器吃的 wrangler.toml 文字(它的入口是從 toml 讀需求的)。 */ function tomlsFor(): string[] { return Object.entries(WORKER_NEEDS).map(([script, need]) => { let t = `name = "${script}"\ncompatibility_date = "2025-02-19"\n`; for (const b of need.kv) t += `\n[[kv_namespaces]]\nbinding = "${b}"\nid = "PLACEHOLDER"\n`; for (const d of need.d1) { t += `\n[[d1_databases]]\nbinding = "${d.binding}"\ndatabase_name = "${d.database_name}"\ndatabase_id = "PLACEHOLDER"\n`; } return t; }); } /** A:跑 acr 那條。CfAccountClient 走 global fetch,所以這裡把它換成 fixture。 */ async function runAcrPath(scenario: Scenario, mode: 'update' | 'init') { const account = makeAccount(scenario); const realFetch = globalThis.fetch; globalThis.fetch = account.fetch; try { const api = new CfAccountClient(ACCOUNT, TOKEN); const plan = await planResources(api, requirements() as BindingRequirement[], mode); if (plan.blockers.length > 0) { return { blocked: true, blockers: plan.blockers, bindings: {} as Record, account }; } const resolved = await applyResourcePlan(api, plan); const bindings: Record = {}; for (const [k, r] of resolved) bindings[k] = r.value; return { blocked: false, blockers: [] as string[], bindings, account }; } finally { globalThis.fetch = realFetch; } } /** B:跑安裝器那條。只用 shared/ 的入口,fetch 直接注入。 */ async function runInstallerPath(scenario: Scenario, mode: 'update' | 'init') { const account = makeAccount(scenario); const r = await resolveInstanceResources({ accountId: ACCOUNT, apiToken: TOKEN, wranglerTomls: tomlsFor(), mode, fetch: account.fetch, }); return { blocked: r.blocked, blockers: r.blockers, bindings: r.bindings, account }; } /** 把兩邊的決定印出來——PR 要貼的就是這張對照表。 */ function report(scenario: Scenario, a: Record, b: Record): void { const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort(); console.log(`\n ── ${scenario}:${SCENARIOS[scenario].label}`); console.log(` ${'binding'.padEnd(34)} ${'acr 選的'.padEnd(24)} 安裝器選的 一致?`); for (const k of keys) { const same = a[k] === b[k] ? '✓' : '✗'; console.log(` ${k.padEnd(34)} ${(a[k] ?? '—').padEnd(24)} ${(b[k] ?? '—').padEnd(18)} ${same}`); } } // ───────────────────────────────────────────────────────────────────────────── for (const scenario of ['fresh', 'installed', 'renamed'] as const) { const mode = scenario === 'fresh' ? 'init' : 'update'; test(`兩條路一致 — ${scenario}:${SCENARIOS[scenario].label}`, async () => { const a = await runAcrPath(scenario, mode); const b = await runInstallerPath(scenario, mode); assert.equal(a.blocked, b.blocked, '一邊停手、一邊照做 = 最危險的分歧'); assert.deepEqual(a.blockers, b.blockers, '停手的理由也要一樣'); report(scenario, a.bindings, b.bindings); assert.deepEqual( a.bindings, b.bindings, `${scenario}:兩條路選出的 resource id 不同——這就是 Arcrun#97 的形狀`, ); // 建立行為也要一致(一邊沿用、一邊新建 = 使用者的東西在其中一條路上會消失) assert.deepEqual(a.account.created, b.account.created, '兩條路「建了什麼」必須一樣'); }); } // ── 三種情境各自該有的行為(不只是「兩邊一樣」,還要「一樣地對」)───────────── test('情境① 沒裝過 → 正常建新的(不能為了沿用而變成永遠不建)', async () => { const { blocked, bindings, account } = await runInstallerPath('fresh', 'init'); assert.equal(blocked, false, '全新帳號要裝得起來'); assert.equal(account.created.kv.length, 9, `應新建 9 顆 KV,實際 ${account.created.kv.length}`); assert.equal(account.created.d1.length, 1, `應新建 1 顆 D1,實際 ${account.created.d1.length}`); // cypher 的 CREDENTIALS_DB 與 kbdb 的 DB 宣告同一個 database_name → 只該建一顆,兩邊共用 assert.equal(bindings['d1:CREDENTIALS_DB'], bindings['d1:DB'], '同一顆 D1 不該被建成兩顆'); console.log(`\n ① 新建:KV ${account.created.kv.length} 顆、D1 ${account.created.d1.length} 顆` + `(D1 共用:CREDENTIALS_DB = DB = ${bindings['d1:DB']})`); }); test('情境② 裝過了 → 沿用原本那幾顆,工作流與登入 session 都還在', async () => { const { blocked, bindings, account } = await runInstallerPath('installed', 'update'); assert.equal(blocked, false); assert.deepEqual(account.created, { kv: [], d1: [], vectorize: [] }, '更新不該建出任何新資源'); // 使用者的東西掛在資源 id 上:綁定還指向原本那顆 = 東西還在 assert.equal(bindings['kv_namespace:WEBHOOKS'], account.kvIdFor('WEBHOOKS')); assert.equal(bindings['kv_namespace:SESSIONS_KV'], account.kvIdFor('SESSIONS_KV')); assert.equal(bindings['d1:DB'], account.d1Id); console.log(`\n ② 沿用:WEBHOOKS → ${bindings['kv_namespace:WEBHOOKS']}` + `(工作流 ${account.userData.workflows.length} 支還在)|` + `SESSIONS_KV → ${bindings['kv_namespace:SESSIONS_KV']}(登入 session 還在)|` + `DB → ${bindings['d1:DB']}(子庫 ${account.userData.libraries.length} 個還在)|新建 0 顆`); }); test('情境③ 資源在但名字與預期完全不同 → 仍然沿用(#97 的病根,專門驗)', async () => { const { blocked, bindings, account } = await runInstallerPath('renamed', 'update'); assert.equal(blocked, false); assert.deepEqual(account.created, { kv: [], d1: [], vectorize: [] }, '名字對不上就新建 = 正是 #97:一次更新生出 9 顆空 KV,使用者的東西從畫面上消失'); for (const b of ['WEBHOOKS', 'SESSIONS_KV', 'RECIPES', 'USERS_KV']) { assert.equal(bindings[bindingKey('kv_namespace', b)], account.kvIdFor(b), `${b} 沒有沿用到原本那顆`); } console.log(`\n ③ 名字全不同(例:WEBHOOKS 那顆實際叫 "${SCENARIOS.renamed.titleFor('WEBHOOKS')}")` + ` → 仍沿用 ${bindings['kv_namespace:WEBHOOKS']},新建 0 顆`); });