/** * Arcrun#97 迴歸守衛 —— 「跑完更新,使用者的東西還在原地」 * * 2026-08-12 實害:leo 跑了一次例行更新,跑完工作流一支都沒有、portal 把他登出、 * 總圖是空的、子庫全不見。資料沒被刪,但 worker 被重新綁到 9 顆新建的空 KV + 1 顆空 D1 上。 * * 根因:更新「照名字」確保資源存在——拿 binding 名(WEBHOOKS)當 CF 上的資源標題去找, * 安裝器建的資源叫 `arcrun-rag--kv-webhooks`,永遠對不上 ⇒ 每次更新都新建一顆綁上去。 * * 這份測試用一台**照安裝器命名慣例**的假實例(不是 leo 的實例,不碰 leo21c)跑真正的 * 解析程式碼(planResources / applyResourcePlan / renderWranglerToml),對照更新前後: * ① 工作流數、登入狀態、子庫數 ② 帳號上的資源顆數 ③ 找不到既有資源時要停手 */ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { planResources, applyResourcePlan, parseWranglerRequirements, bindingKey, ResourcePlanBlocked, type BindingRequirement, type ResourceApi, type ScriptBindings, type LiveBinding, type ResolvedResource, } from '../src/lib/resource-resolver.ts'; import { renderWranglerToml, REQUIRED_KV_NAMESPACES, type DeployContext, } from '../src/lib/deploy.ts'; import { CfAccountClient } from '../src/lib/cf-api.ts'; const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..'); /** 這台假實例上跑著的四顆 worker(有資源綁定的那幾顆)。 */ const WORKER_TOMLS = [ 'cypher-executor/wrangler.toml', 'registry/wrangler.toml', 'mcp/wrangler.toml', 'kbdb/wrangler.toml', ]; const CTX: DeployContext = { accountId: 'acct-test', apiToken: 'tok-test', workerSubdomain: 'yuga3bse', selfHosted: true, kbdbEmbed: true, }; // ───────────────────────────────────────────────────────────────────────────── // 假的 Cloudflare 帳號:完全照「安裝器裝出來」的樣子命名 // ───────────────────────────────────────────────────────────────────────────── const INSTANCE = 'yuga3bse'; interface FakeOpts { /** 讓某顆 worker 的綁定讀取失敗(模擬 API 掛掉 / 權限不足)。 */ failBindingsFor?: string; /** 從帳號上「弄不見」某顆 KV,但 worker 上還綁著它(模擬資源被刪)。 */ deleteKvTitle?: string; /** 完全沒有任何已部署的 worker(模擬名字對不上 / token 看錯帳號)。 */ nothingDeployed?: boolean; } class FakeCloudflare implements ResourceApi { /** title → id */ kv = new Map(); /** name → uuid */ d1 = new Map(); vectorize: string[] = []; /** script → bindings */ scripts = new Map(); /** 使用者的東西:kvId → (key → value) */ kvData = new Map>(); /** d1Id → 子庫名單 */ d1Libraries = new Map(); /** 這趟總共建立了什麼(驗「顆數不增加」用)。 */ createdKv: string[] = []; createdD1: string[] = []; createdVectorize: string[] = []; constructor(private opts: FakeOpts = {}) { // 安裝器的命名慣例:arcrun-rag--kv- for (const binding of REQUIRED_KV_NAMESPACES) { const title = `arcrun-rag-${INSTANCE}-kv-${binding.toLowerCase()}`; const id = `kvid-${binding.toLowerCase()}`; this.kv.set(title, id); this.kvData.set(id, new Map()); } this.d1.set(`arcrun-rag-${INSTANCE}-kbdb`, 'd1id-kbdb'); this.vectorize.push(`arcrun-rag-${INSTANCE}-embed`); // 使用者的東西 this.kvData.get('kvid-webhooks')!.set('webhook:leo:daily-digest', '{}'); this.kvData.get('kvid-webhooks')!.set('webhook:leo:inbox-sync', '{}'); this.kvData.get('kvid-webhooks')!.set('webhook:leo:rag-ingest', '{}'); this.kvData.get('kvid-sessions_kv')!.set('session:leo-abc123', '{"user":"leo"}'); this.d1Libraries.set('d1id-kbdb', ['general', '課程', '客戶', '研究']); if (!opts.nothingDeployed) { const kvB = (b: string): LiveBinding => ({ kind: 'kv_namespace', binding: b, value: `kvid-${b.toLowerCase()}` }); this.scripts.set('arcrun-cypher-executor', [ kvB('EXEC_CONTEXT'), kvB('WEBHOOKS'), kvB('CREDENTIALS_KV'), kvB('ANALYTICS_KV'), kvB('RECIPES'), kvB('USERS_KV'), kvB('SESSIONS_KV'), { kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1id-kbdb' }, ]); this.scripts.set('arcrun-registry', [kvB('SUBMISSIONS_KV'), kvB('ANALYTICS_KV')]); this.scripts.set('arcrun-mcp', [kvB('OAUTH_KV')]); this.scripts.set('arcrun-kbdb', [ { kind: 'd1', binding: 'DB', value: 'd1id-kbdb' }, { kind: 'vectorize', binding: 'VECTORIZE', value: `arcrun-rag-${INSTANCE}-embed` }, ]); } if (opts.deleteKvTitle) this.kv.delete(opts.deleteKvTitle); } async getScriptBindings(script: string): Promise { if (this.opts.failBindingsFor === script) throw new Error('HTTP 500 (CF API 暫時掛掉)'); const b = this.scripts.get(script); return b ? { deployed: true, bindings: b } : { deployed: false, bindings: [] }; } async listKvNamespaces(): Promise> { return new Map(this.kv); } async listD1Databases(): Promise> { return new Map(this.d1); } async listVectorizeIndexes(): Promise { return [...this.vectorize]; } async createKvNamespace(title: string): Promise { const id = `NEW-kvid-${this.createdKv.length}`; this.kv.set(title, id); this.kvData.set(id, new Map()); // 新建的是**空的**——災情就是綁到這種東西上 this.createdKv.push(title); return id; } async createD1Database(name: string): Promise { const id = `NEW-d1id-${this.createdD1.length}`; this.d1.set(name, id); this.d1Libraries.set(id, []); this.createdD1.push(name); return id; } async createVectorizeIndex(name: string): Promise { this.vectorize.push(name); this.createdVectorize.push(name); return name; } } // ───────────────────────────────────────────────────────────────────────────── // 共用:從真的 wrangler.toml 解析需求(走與 downloadAndDeploy 相同的路徑) // ───────────────────────────────────────────────────────────────────────────── function collectRequirements(): { requirements: BindingRequirement[]; tomls: Map } { const requirements: BindingRequirement[] = []; const tomls = new Map(); for (const rel of WORKER_TOMLS) { const raw = readFileSync(join(REPO, rel), 'utf8'); tomls.set(rel, raw); const parsed = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map())); for (const b of parsed.bindings) requirements.push({ ...b, worker: parsed.script }); } return { requirements, tomls }; } /** 模擬「部署」:把解析結果注入 toml,再從注入後的 toml 讀回 worker 實際會綁到的資源。 */ function deployAndReadBindings( tomls: Map, resolved: Map, ): Map> { const out = new Map>(); for (const [rel, raw] of tomls) { const rendered = renderWranglerToml(raw, CTX, resolved); const script = parseWranglerRequirements(rendered).script; const bound = new Map(); let kind: string | null = null; let binding = ''; for (const line of rendered.split('\n')) { const t = line.trim(); if (t.startsWith('#')) continue; const table = t.match(/^\[\[?([A-Za-z0-9_]+)\]?\]$/); if (table) { kind = table[1]; binding = ''; continue; } const m = t.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/); if (!m) continue; if (m[1] === 'binding') binding = m[2]; else if (binding && ( (kind === 'kv_namespaces' && m[1] === 'id') || (kind === 'd1_databases' && m[1] === 'database_id') || (kind === 'vectorize' && m[1] === 'index_name') )) bound.set(binding, m[2]); } out.set(script, bound); } return out; } // ═════════════════════════════════════════════════════════════════════════════ // ① 更新前後:工作流數、登入狀態、子庫數 —— 三個都不能少 // ═════════════════════════════════════════════════════════════════════════════ test('#97 ①:安裝器裝出來的實例跑更新——工作流/登入/子庫更新前後完全一致', async () => { const cf = new FakeCloudflare(); const { requirements, tomls } = collectRequirements(); const before = { workflows: cf.kvData.get('kvid-webhooks')!.size, sessions: cf.kvData.get('kvid-sessions_kv')!.size, libraries: cf.d1Libraries.get('d1id-kbdb')!.length, }; assert.deepEqual(before, { workflows: 3, sessions: 1, libraries: 4 }, '前置資料要先擺好'); const plan = await planResources(cf, requirements, 'update'); assert.deepEqual(plan.blockers, [], '一台健康的實例不該有任何 blocker'); const resolved = await applyResourcePlan(cf, plan); const bound = deployAndReadBindings(tomls, resolved); // 更新後,worker 綁到的還是使用者原本那幾顆(名字完全沒對上,但那不重要) const cypher = bound.get('arcrun-cypher-executor')!; assert.equal(cypher.get('WEBHOOKS'), 'kvid-webhooks'); assert.equal(cypher.get('SESSIONS_KV'), 'kvid-sessions_kv'); assert.equal(cypher.get('CREDENTIALS_DB'), 'd1id-kbdb'); assert.equal(bound.get('arcrun-kbdb')!.get('DB'), 'd1id-kbdb'); assert.equal(bound.get('arcrun-mcp')!.get('OAUTH_KV'), 'kvid-oauth_kv'); assert.equal(bound.get('arcrun-registry')!.get('SUBMISSIONS_KV'), 'kvid-submissions_kv'); assert.equal(bound.get('arcrun-kbdb')!.get('VECTORIZE'), `arcrun-rag-${INSTANCE}-embed`); const after = { workflows: cf.kvData.get(cypher.get('WEBHOOKS')!)!.size, sessions: cf.kvData.get(cypher.get('SESSIONS_KV')!)!.size, libraries: cf.d1Libraries.get(bound.get('arcrun-kbdb')!.get('DB')!)!.length, }; assert.deepEqual(after, before, '更新後使用者看到的東西必須跟更新前一模一樣'); }); // ═════════════════════════════════════════════════════════════════════════════ // ② 帳號上的資源顆數不增加(災情當天:9 顆 KV → 18 顆、1 顆 D1 → 2 顆) // ═════════════════════════════════════════════════════════════════════════════ test('#97 ②:更新不會在帳號上多生任何資源', async () => { const cf = new FakeCloudflare(); const kvBefore = cf.kv.size; const d1Before = cf.d1.size; const vecBefore = cf.vectorize.length; assert.deepEqual([kvBefore, d1Before, vecBefore], [9, 1, 1]); const { requirements } = collectRequirements(); const plan = await planResources(cf, requirements, 'update'); await applyResourcePlan(cf, plan); assert.deepEqual(cf.createdKv, [], '不該新建任何 KV'); assert.deepEqual(cf.createdD1, [], '不該新建任何 D1'); assert.deepEqual(cf.createdVectorize, [], '不該新建任何 Vectorize index'); assert.deepEqual([cf.kv.size, cf.d1.size, cf.vectorize.length], [9, 1, 1]); }); test('#97 ②對照組:舊的「照名字 ensure」在同一台實例上會生 9 顆 KV + 1 顆 D1', async () => { // 這段是**修好之前**的演算法(commit e69d6bb 時的 cli/src/commands/update.ts:52-68 與 // cf-api.ts 的 ensureKvNamespace/ensureD1Database),照原樣重寫在這裡當對照組。 // 目的:把「災情是怎麼發生的」釘成可執行的事實,而不是只留在 issue 的文字裡。 const cf = new FakeCloudflare(); const existing = await cf.listKvNamespaces(); for (const title of REQUIRED_KV_NAMESPACES) { if (!existing.get(title)) await cf.createKvNamespace(title); // ← 名字對不上 ⇒ 每個都新建 } const d1s = await cf.listD1Databases(); if (!d1s.get('arcrun-kbdb')) await cf.createD1Database('arcrun-kbdb'); assert.equal(cf.createdKv.length, 9, '舊做法:9 顆 KV 全部重建(對上災情當天的數字)'); assert.equal(cf.createdD1.length, 1, '舊做法:D1 也重建一顆'); assert.equal(cf.kv.size, 18, '9 → 18'); assert.equal(cf.d1.size, 2, '1 → 2'); // 而且新建的那幾顆是空的 —— 使用者的工作流就是這樣「不見」的 assert.equal(cf.kvData.get(cf.kv.get('WEBHOOKS')!)!.size, 0); }); // ═════════════════════════════════════════════════════════════════════════════ // ③ 反向驗證:找不到既有資源 → 停下來說清楚,不是安靜新建一顆綁上去 // ═════════════════════════════════════════════════════════════════════════════ test('#97 ③-a:worker 綁著的 KV 在帳號上不見了 → 停手,一顆都不建', async () => { const cf = new FakeCloudflare({ deleteKvTitle: `arcrun-rag-${INSTANCE}-kv-webhooks` }); const { requirements } = collectRequirements(); const plan = await planResources(cf, requirements, 'update'); assert.ok(plan.blockers.length > 0, '要有 blocker'); const said = plan.blockers.join('\n'); assert.match(said, /WEBHOOKS/, '要指名是哪個綁定'); assert.match(said, /kvid-webhooks/, '要指名是哪一顆資源'); assert.match(said, /找不到/, '要說清楚發生什麼事'); assert.ok(!plan.create.some((c) => c.binding === 'WEBHOOKS'), '絕不能把它排進「要新建」'); await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked); assert.deepEqual(cf.createdKv, [], '被擋下時一顆資源都不能被建出來'); assert.deepEqual(cf.createdD1, []); }); test('#97 ③-b:讀不到某顆 worker 現在綁什麼 → 當「我不知道」而不是「它沒有」', async () => { const cf = new FakeCloudflare({ failBindingsFor: 'arcrun-cypher-executor' }); const { requirements } = collectRequirements(); const plan = await planResources(cf, requirements, 'update'); assert.match(plan.blockers.join('\n'), /arcrun-cypher-executor/); await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked); assert.deepEqual(cf.createdKv, []); }); test('#97 ③-c:update 卻一顆 worker 都找不到 → 停手,不當成全新安裝重建一整套', async () => { const cf = new FakeCloudflare({ nothingDeployed: true }); const { requirements } = collectRequirements(); const plan = await planResources(cf, requirements, 'update'); assert.match(plan.blockers.join('\n'), /找不到任何一顆要更新的 worker/); await assert.rejects(() => applyResourcePlan(cf, plan), ResourcePlanBlocked); assert.deepEqual(cf.createdKv, []); }); test('#97 ③-d:同一個 binding 在不同 worker 上指向不同資源 → 不猜,停手', async () => { const cf = new FakeCloudflare(); // registry 的 ANALYTICS_KV 被指到別顆(真實情境:有人手動改過其中一邊) cf.scripts.get('arcrun-registry')!.find((b) => b.binding === 'ANALYTICS_KV')!.value = 'kvid-other'; cf.kv.set('some-other-kv', 'kvid-other'); const { requirements } = collectRequirements(); const plan = await planResources(cf, requirements, 'update'); assert.match(plan.blockers.join('\n'), /ANALYTICS_KV/); assert.deepEqual(cf.createdKv, []); }); // ═════════════════════════════════════════════════════════════════════════════ // 合法的新建:只有「確定沒人綁過」時才准 // ═════════════════════════════════════════════════════════════════════════════ test('#97:新版本新增的 binding(沒有任何已部署 worker 綁過)才准新建', async () => { const cf = new FakeCloudflare(); cf.scripts.set('arcrun-mcp', []); // mcp 已部署,但還沒有 OAUTH_KV(舊版本裝的) cf.kv.delete(`arcrun-rag-${INSTANCE}-kv-oauth_kv`); const { requirements } = collectRequirements(); const plan = await planResources(cf, requirements, 'update'); assert.deepEqual(plan.blockers, []); assert.deepEqual(plan.create.map((c) => c.binding), ['OAUTH_KV'], '只有這一個該建'); await applyResourcePlan(cf, plan); assert.deepEqual(cf.createdKv, ['OAUTH_KV']); assert.equal(cf.kv.size, 9, '刪掉一顆、補建一顆 → 還是 9 顆'); }); test('#97:全新帳號跑 init → 該建的都建(不會被 update 的停手規則卡住)', async () => { const cf = new FakeCloudflare({ nothingDeployed: true }); cf.kv.clear(); cf.d1.clear(); cf.vectorize.length = 0; const { requirements } = collectRequirements(); const plan = await planResources(cf, requirements, 'init'); assert.deepEqual(plan.blockers, [], 'init 在空帳號上不該停手'); await applyResourcePlan(cf, plan); assert.equal(cf.createdKv.length, REQUIRED_KV_NAMESPACES.length); assert.deepEqual(cf.createdD1, ['arcrun-kbdb']); assert.equal(cf.createdVectorize.length, 1); }); test('#97:一邊已部署一邊沒有 → 跟著沿用同一顆,不要另外建一顆空的', async () => { const cf = new FakeCloudflare(); cf.scripts.delete('arcrun-cypher-executor'); // cypher 還沒部(kbdb 已部,DB → d1id-kbdb) const { requirements, tomls } = collectRequirements(); const plan = await planResources(cf, requirements, 'update'); assert.deepEqual(plan.blockers, []); assert.ok(!plan.create.some((c) => c.kind === 'd1'), 'CREDENTIALS_DB 不該被當成新資源建一顆'); const resolved = await applyResourcePlan(cf, plan); const bound = deployAndReadBindings(tomls, resolved); assert.equal(bound.get('arcrun-cypher-executor')!.get('CREDENTIALS_DB'), 'd1id-kbdb', 'credential 目錄要跟 KBDB 在同一顆庫'); assert.deepEqual(cf.createdD1, []); }); test('#97:部署出去的 toml 不得殘留官方 prod 的資源 id(自架寫進官方庫 = 跨租戶外洩)', async () => { // repo 的 toml 裡 database_id 預設是官方 prod D1。舊版在「D1 解析失敗」時只是把它跳過不注入, // 於是自架用戶的 kbdb worker 就這樣綁著官方那顆庫部署出去。現在不是失敗就跳過,是整趟停手。 const cf = new FakeCloudflare(); const { requirements, tomls } = collectRequirements(); const resolved = await applyResourcePlan(cf, await planResources(cf, requirements, 'update')); const OFFICIAL_D1 = '0c580910-e00b-4f8e-9c57-ac54ea52242f'; for (const [rel, raw] of tomls) { const rendered = renderWranglerToml(raw, CTX, resolved); assert.doesNotMatch(rendered, new RegExp(OFFICIAL_D1), `${rel} 還帶著官方 prod D1 的 id`); assert.doesNotMatch(rendered, /REPLACE_WITH_REAL_KV_ID/, `${rel} 還留著占位 KV id`); } }); // ═════════════════════════════════════════════════════════════════════════════ // 做法本身的看守:不准再出現「照名字 ensure」這種原語 // ═════════════════════════════════════════════════════════════════════════════ test('#97 紅線:cf-api 不得再提供任何「找不到同名就順手建一顆」的 ensure 原語', () => { const src = readFileSync(join(REPO, 'cli/src/lib/cf-api.ts'), 'utf8'); assert.doesNotMatch(src, /\bensureKvNamespace\b|\bensureD1Database\b|\bensureVectorizeIndex\b/, 'ensure* 是 #97 的凶器:把「查不到」當成「不存在」再自作主張新建。' + '要建資源一律先過 resource-resolver 的 planResources。'); }); test('#97 紅線:只有 resource-resolver 能決定「要不要建」,指令層不得自己呼叫 create*', () => { for (const rel of ['cli/src/commands/init.ts', 'cli/src/commands/update.ts']) { const src = readFileSync(join(REPO, rel), 'utf8'); assert.doesNotMatch(src, /\.create(KvNamespace|D1Database|VectorizeIndex)\s*\(/, `${rel} 不該自己建資源——那樣就繞過了「先看已部署的 worker 綁著什麼」這道判斷。`); } }); // ═════════════════════════════════════════════════════════════════════════════ // 底層零件 // ═════════════════════════════════════════════════════════════════════════════ test('parseWranglerRequirements:讀得出 script 名與三種資源綁定,且不把註解掉的區塊當需求', () => { const toml = [ 'name = "arcrun-kbdb" # 註解不影響', '', '[[d1_databases]]', 'binding = "DB"', 'database_name = "arcrun-kbdb"', 'database_id = "placeholder"', '', '[vars]', 'ENVIRONMENT = "production"', '', '# [[vectorize]]', '# binding = "VECTORIZE"', '# index_name = "arcrun-kbdb-embed-m3"', ].join('\n'); const r = parseWranglerRequirements(toml); assert.equal(r.script, 'arcrun-kbdb'); assert.deepEqual(r.bindings, [{ kind: 'd1', binding: 'DB', createName: 'arcrun-kbdb' }]); }); test('KV 沒有 title 欄位 → 真要新建時用 binding 名', () => { const r = parseWranglerRequirements('name = "w"\n[[kv_namespaces]]\nbinding = "WEBHOOKS"\nid = "x"'); assert.deepEqual(r.bindings, [{ kind: 'kv_namespace', binding: 'WEBHOOKS', createName: 'WEBHOOKS' }]); }); test('注入是照 binding 對號,不是盲換「檔案裡第一個 database_id」', () => { const cypher = readFileSync(join(REPO, 'cypher-executor/wrangler.toml'), 'utf8'); const resolved = new Map([ [bindingKey('d1', 'CREDENTIALS_DB'), { kind: 'd1', binding: 'CREDENTIALS_DB', value: 'MINE', origin: 'adopted' }], [bindingKey('kv_namespace', 'WEBHOOKS'), { kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'KV-MINE', origin: 'adopted' }], ]); const out = renderWranglerToml(cypher, CTX, resolved); const bound = parseWranglerRequirements(out); assert.ok(bound.bindings.some((b) => b.binding === 'CREDENTIALS_DB')); assert.match(out, /binding = "CREDENTIALS_DB"\ndatabase_name = "arcrun-kbdb"\ndatabase_id = "MINE"/); assert.match(out, /binding = "WEBHOOKS"\nid = "KV-MINE"/); // 沒被解析到的綁定不能被亂改(EXEC_CONTEXT 這次沒進 resolved) assert.match(out, /binding = "EXEC_CONTEXT"\nid = "616967a852eb450a8c01731f71ac8edd"/); }); test('renderWranglerToml 帶空 map = 預覽:解析看到的 binding 與注入後的完全一致', () => { for (const rel of WORKER_TOMLS) { const raw = readFileSync(join(REPO, rel), 'utf8'); const preview = parseWranglerRequirements(renderWranglerToml(raw, CTX, new Map())); const resolved = new Map( preview.bindings.map((b) => [ bindingKey(b.kind, b.binding), { kind: b.kind, binding: b.binding, value: `v-${b.binding}`, origin: 'adopted' as const }, ]), ); const after = parseWranglerRequirements(renderWranglerToml(raw, CTX, resolved)); assert.deepEqual( after.bindings.map((b) => `${b.kind}:${b.binding}`).sort(), preview.bindings.map((b) => `${b.kind}:${b.binding}`).sort(), `${rel}: 預覽與實際注入看到的綁定必須一致`, ); } }); test('repo 的 toml 綁定總集合 = REQUIRED_KV_NAMESPACES(漏綁會讓某顆 worker 部署失敗)', () => { const { requirements } = collectRequirements(); const kv = [...new Set(requirements.filter((r) => r.kind === 'kv_namespace').map((r) => r.binding))]; assert.deepEqual(kv.sort(), [...REQUIRED_KV_NAMESPACES].sort()); }); test('CfAccountClient.getScriptBindings:404 = 還沒部署;其他錯誤要 throw(不能當成「沒有綁」)', async () => { const orig = globalThis.fetch; try { globalThis.fetch = (async () => new Response(JSON.stringify({ success: false, errors: [{ message: 'not found' }] }), { status: 404 }) ) as typeof fetch; const cf = new CfAccountClient('a', 't'); assert.deepEqual(await cf.getScriptBindings('nope'), { deployed: false, bindings: [] }); globalThis.fetch = (async () => new Response(JSON.stringify({ success: false, errors: [{ message: 'boom' }] }), { status: 500 }) ) as typeof fetch; await assert.rejects(() => new CfAccountClient('a', 't').getScriptBindings('x'), /boom/); } finally { globalThis.fetch = orig; } }); test('CfAccountClient.getScriptBindings:讀得懂 CF 回的 kv/d1/vectorize 三種綁定形狀', async () => { const orig = globalThis.fetch; try { globalThis.fetch = (async () => new Response(JSON.stringify({ success: true, result: { bindings: [ { type: 'kv_namespace', name: 'WEBHOOKS', namespace_id: 'kv1' }, { type: 'd1', name: 'DB', id: 'db1' }, { type: 'vectorize', name: 'VECTORIZE', index_name: 'idx1' }, { type: 'plain_text', name: 'ENVIRONMENT', text: 'production' }, { type: 'service', name: 'SVC_SET', service: 'arcrun-set' }, ], }, }), { status: 200 })) as typeof fetch; const res = await new CfAccountClient('a', 't').getScriptBindings('arcrun-cypher-executor'); assert.equal(res.deployed, true); assert.deepEqual(res.bindings, [ { kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv1' }, { kind: 'd1', binding: 'DB', value: 'db1' }, { kind: 'vectorize', binding: 'VECTORIZE', value: 'idx1' }, ]); } finally { globalThis.fetch = orig; } });