Files
Arcrun/cli/tests/resource-adoption.test.ts
T
uncle6me-web 45a546a686 fix(cli): 更新不再照名字找資源——已部署 worker 綁著什麼就是什麼(Arcrun#97)
病根:更新會「確保」它需要的資源存在,而它是**照名字找**的。
使用者的資源是安裝器建的(`arcrun-rag-<x>-kv-webhooks`),更新找的是 `WEBHOOKS`
⇒ 找不到 ⇒ 新建一顆空的並綁上去。

2026-08-12 實撞(leo21c):一次例行更新後
  KV 9 顆 → 18 顆、D1 1 顆 → 2 顆,worker 全綁到新建的空的
  ⇒ 工作流一支都看不到、portal 登出、總圖空的、80 把 recipe 解不出來
  leo 原話:「leo21c 是掛掉的」。資料沒掉,但從他的角度就是東西全不見了。

修法方向:**已經部署上去的 worker 綁著什麼,那就是事實**——
名字是使用者那側的事,不是更新指令可以決定的。

📍 repo:matrix/arcrun(cli/)|📍 票:Leo/Arcrun#97
2026-08-12 13:30:17 +08:00

533 lines
27 KiB
TypeScript
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.
/**
* Arcrun#97 迴歸守衛 —— 「跑完更新,使用者的東西還在原地」
*
* 2026-08-12 實害:leo 跑了一次例行更新,跑完工作流一支都沒有、portal 把他登出、
* 總圖是空的、子庫全不見。資料沒被刪,但 worker 被重新綁到 9 顆新建的空 KV + 1 顆空 D1 上。
*
* 根因:更新「照名字」確保資源存在——拿 binding 名(WEBHOOKS)當 CF 上的資源標題去找,
* 安裝器建的資源叫 `arcrun-rag-<instance>-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<string, string>();
/** name → uuid */
d1 = new Map<string, string>();
vectorize: string[] = [];
/** script → bindings */
scripts = new Map<string, LiveBinding[]>();
/** 使用者的東西:kvId → (key → value) */
kvData = new Map<string, Map<string, string>>();
/** d1Id → 子庫名單 */
d1Libraries = new Map<string, string[]>();
/** 這趟總共建立了什麼(驗「顆數不增加」用)。 */
createdKv: string[] = [];
createdD1: string[] = [];
createdVectorize: string[] = [];
constructor(private opts: FakeOpts = {}) {
// 安裝器的命名慣例:arcrun-rag-<instance>-kv-<binding 小寫>
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<ScriptBindings> {
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<Map<string, string>> { return new Map(this.kv); }
async listD1Databases(): Promise<Map<string, string>> { return new Map(this.d1); }
async listVectorizeIndexes(): Promise<string[]> { return [...this.vectorize]; }
async createKvNamespace(title: string): Promise<string> {
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<string> {
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<string> {
this.vectorize.push(name);
this.createdVectorize.push(name);
return name;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// 共用:從真的 wrangler.toml 解析需求(走與 downloadAndDeploy 相同的路徑)
// ─────────────────────────────────────────────────────────────────────────────
function collectRequirements(): { requirements: BindingRequirement[]; tomls: Map<string, string> } {
const requirements: BindingRequirement[] = [];
const tomls = new Map<string, string>();
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<string, string>,
resolved: Map<string, ResolvedResource>,
): Map<string, Map<string, string>> {
const out = new Map<string, Map<string, string>>();
for (const [rel, raw] of tomls) {
const rendered = renderWranglerToml(raw, CTX, resolved);
const script = parseWranglerRequirements(rendered).script;
const bound = new Map<string, string>();
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 ③-aworker 綁著的 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 ③-cupdate 卻一顆 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<string, ResolvedResource>([
[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<string, ResolvedResource>(
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.getScriptBindings404 = 還沒部署;其他錯誤要 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;
}
});