/** * Arcrun#106 迴歸守衛 —— 「更新完,設定頁還看得到版本號,而且是**這次**的版本號」 * * 2026-08-12 實害:leo 更新完 leo21c,Portal 設定頁的版本欄變成 * 「無法讀取目前版本(知識庫服務可能正在啟動)」。 * 根因:`ARCRUN_BUNDLE_VERSION` 是部署時注入的 plain_text var,**只有安裝器會注入**; * CLI 這條路重部署時 wrangler 整份覆蓋 toml,沒寫的 var 直接消失 ⇒ 標籤被洗掉。 * #97 修好了「櫃子」(KV/D1/Vectorize 沿用既有),**沒修「櫃子上的標籤」**。 * * 這份測試守兩件相反的事(本次的核心判斷): * · 設定類 var(安裝器注入的 PORTAL_MAIL_RELAY_BASE 之類)=使用者實例的事實 → **沿用** * · 版本標籤 ARCRUN_BUNDLE_VERSION =這份成品的屬性 → **每趟重烙,絕不沿用舊值** * (沿用舊值 = 一個永遠停在安裝當天的假標籤,比沒有標籤更糟) * * 全部離線跑:真的 wrangler.toml + 真的 render/inject 程式碼,fetch 用假的,不碰任何實例。 */ 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 { renderWranglerToml, preservedVars, applyVars, resolveBundleStamp, CLI_MANAGED_VARS, VERSION_STAMP_WORKER, type DeployContext, } from '../src/lib/deploy.ts'; import { planResources, type ResourceApi, type ScriptBindings } from '../src/lib/resource-resolver.ts'; const REPO = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..'); const CYPHER_TOML = readFileSync(join(REPO, 'cypher-executor', 'wrangler.toml'), 'utf8'); const CTX: DeployContext = { accountId: 'acc-user-123', apiToken: 'token', workerSubdomain: 'user-sub', selfHosted: true, kbdbEmbed: true, }; /** 一台「安裝器裝出來、已經跑過的」實例上,cypher worker 現在掛著的 plain_text var。 */ const LIVE_VARS: Record = { ARCRUN_BUNDLE_VERSION: '1.4.29', // 安裝當時的舊標籤 PORTAL_MAIL_RELAY_BASE: 'https://mail.example.com', // 安裝器注入、repo toml 沒有 → 洗掉就寄不出信 CONSOLE_TENANT: 'someone-else', // repo toml 寫死 "leo",不能拿官方值蓋掉人家的 WORKER_SUBDOMAIN: 'user-sub', // CLI 自己算 CF_ACCOUNT_ID: 'acc-user-123', // CLI 自己算 MULTI_TENANT: 'false', // CLI 自己算 ENVIRONMENT: 'production', // 與 toml 同值 → 不必重寫 }; /** 從 render 過的 toml 讀 [vars] 區塊(只看未註解的行)。 */ function readVars(toml: string): Record { const out: Record = {}; let inVars = false; for (const raw of toml.split('\n')) { const line = raw.trim(); if (/^\[\[?[A-Za-z0-9_]+\]?\]$/.test(line)) { inVars = line === '[vars]'; continue; } if (!inVars || line.startsWith('#')) continue; const m = line.match(/^([A-Za-z0-9_]+)\s*=\s*"([^"]*)"/); if (m) out[m[1]] = m[2]; } return out; } // ═════════════════════════════════════════════════════════════════════════════ // ① 病灶本身:舊行為會把標籤洗掉 // ═════════════════════════════════════════════════════════════════════════════ test('#106 ①:repo 的 cypher toml 本來就沒有 ARCRUN_BUNDLE_VERSION——不補就是洗掉(病灶重現)', () => { const rendered = renderWranglerToml(CYPHER_TOML, CTX, new Map()); assert.equal( readVars(rendered).ARCRUN_BUNDLE_VERSION, undefined, '若這行開始有值,表示 toml 自己帶了版本標籤,本測試的前提要重寫', ); }); // ═════════════════════════════════════════════════════════════════════════════ // ② 設定類 var:沿用實例上的事實 // ═════════════════════════════════════════════════════════════════════════════ test('#106 ②:安裝器注入、repo toml 沒有的 var 會被沿用(不再被重部署洗掉)', () => { const keep = preservedVars(LIVE_VARS, CYPHER_TOML); assert.equal(keep.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com'); // repo toml 寫死的是官方值,使用者實例上的值才是事實 assert.equal(keep.CONSOLE_TENANT, 'someone-else'); // 與 toml 同值 → 不需要重寫進去(雜訊) assert.equal(keep.ENVIRONMENT, undefined); }); test('#106 ③:CLI 自己算的 var 一律不沿用(沿用等於拿舊值蓋掉這趟的正解)', () => { const keep = preservedVars({ ...LIVE_VARS, WORKER_SUBDOMAIN: 'OLD-sub', CF_ACCOUNT_ID: 'OLD-acc' }, CYPHER_TOML); for (const managed of CLI_MANAGED_VARS) { assert.equal(keep[managed], undefined, `${managed} 不該被沿用`); } // 而且注入完的 toml 裡,這些值仍是這趟算出來的那個 const rendered = renderWranglerToml(CYPHER_TOML, CTX, new Map(), keep); const vars = readVars(rendered); assert.equal(vars.WORKER_SUBDOMAIN, 'user-sub'); assert.equal(vars.CF_ACCOUNT_ID, 'acc-user-123'); assert.equal(vars.MULTI_TENANT, 'false'); assert.equal(vars.KBDB_BASE_URL, 'https://arcrun-kbdb.user-sub.workers.dev'); }); // ═════════════════════════════════════════════════════════════════════════════ // ③ 版本標籤:重烙,不沿用 // ═════════════════════════════════════════════════════════════════════════════ test('#106 ④:版本標籤取「發行頻道公告的 release」+ 實際 commit,不是沿用舊值', async () => { const fakeFetch = (async () => new Response(JSON.stringify({ release: '1.4.41', pin: 'ba81439' }), { status: 200 })) as typeof fetch; const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch); assert.equal(stamp.version, '1.4.41'); assert.notEqual(stamp.version, LIVE_VARS.ARCRUN_BUNDLE_VERSION); // ← 這就是本 issue assert.equal(stamp.commit, 'f87d0e92f49690253e7c89c5badc82a08eb5d21b'); assert.match(stamp.version, /^\d+\.\d+\.\d+$/, 'Portal 拿它跟 /api/latest 比 semver,必須是純 semver'); }); test('#106 ⑤:查不到發行版號時誠實標成 commit 版,**不**沿用舊值、也不掰一個 semver', async () => { const fakeFetch = (async () => { throw new Error('offline'); }) as typeof fetch; const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch); assert.match(stamp.version, /^\d{4}-\d{2}-\d{2}\+f87d0e9$/); assert.notEqual(stamp.version, LIVE_VARS.ARCRUN_BUNDLE_VERSION); assert.doesNotMatch(stamp.version, /^\d+\.\d+\.\d+$/, '掰一個 semver 會讓 Portal 假裝「已是最新版」'); }); test('#106 ⑥:發行頻道回了不是 semver 的東西 → 當成查不到(不把垃圾當版號烙上去)', async () => { const fakeFetch = (async () => new Response(JSON.stringify({ release: 'latest' }), { status: 200 })) as typeof fetch; const stamp = await resolveBundleStamp('main', 'abc1234def', fakeFetch); assert.match(stamp.version, /^\d{4}-\d{2}-\d{2}\+abc1234$/); }); // ═════════════════════════════════════════════════════════════════════════════ // ④ 端到端(離線):一台已安裝的實例跑一次更新,Portal 讀得到的那個欄位長什麼樣 // ═════════════════════════════════════════════════════════════════════════════ test('#106 ⑦:模擬更新——版本標籤變新、設定 var 一個不少、資源沿用不受影響', async () => { const api: ResourceApi = { async getScriptBindings(script: string): Promise { if (script !== VERSION_STAMP_WORKER) return { deployed: false, bindings: [], vars: {} }; return { deployed: true, bindings: [ { kind: 'kv_namespace', binding: 'WEBHOOKS', value: 'kv-webhooks' }, { kind: 'kv_namespace', binding: 'CREDENTIALS_KV', value: 'kv-creds' }, { kind: 'kv_namespace', binding: 'RECIPES', value: 'kv-recipes' }, { kind: 'kv_namespace', binding: 'USERS_KV', value: 'kv-users' }, { kind: 'kv_namespace', binding: 'SESSIONS_KV', value: 'kv-sessions' }, { kind: 'kv_namespace', binding: 'ANALYTICS_KV', value: 'kv-analytics' }, { kind: 'kv_namespace', binding: 'EXEC_CONTEXT', value: 'kv-exec' }, { kind: 'd1', binding: 'CREDENTIALS_DB', value: 'd1-kbdb' }, ], vars: LIVE_VARS, }; }, async listKvNamespaces() { return new Map([ ['a', 'kv-webhooks'], ['b', 'kv-creds'], ['c', 'kv-recipes'], ['d', 'kv-users'], ['e', 'kv-sessions'], ['f', 'kv-analytics'], ['g', 'kv-exec'], ]); }, async listD1Databases() { return new Map([['arcrun-kbdb', 'd1-kbdb']]); }, async listVectorizeIndexes() { return []; }, async createKvNamespace() { throw new Error('這趟不該新建任何 KV'); }, async createD1Database() { throw new Error('這趟不該新建 D1'); }, async createVectorizeIndex() { throw new Error('這趟不該新建 Vectorize'); }, }; const preview = renderWranglerToml(CYPHER_TOML, CTX, new Map()); const { parseWranglerRequirements } = await import('../src/lib/resource-resolver.ts'); const parsed = parseWranglerRequirements(preview); const plan = await planResources( api, parsed.bindings.map((b) => ({ ...b, worker: parsed.script })), 'update', ); assert.deepEqual(plan.blockers, []); // 讀綁定時順手把 var 帶回來——不另外打一次 API assert.equal(plan.liveVars.get(VERSION_STAMP_WORKER)?.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com'); const fakeFetch = (async () => new Response(JSON.stringify({ release: '1.4.41' }), { status: 200 })) as typeof fetch; const stamp = await resolveBundleStamp('main', 'f87d0e92f49690253e7c89c5badc82a08eb5d21b', fakeFetch); const extra = { ...preservedVars(plan.liveVars.get(parsed.script), CYPHER_TOML), ARCRUN_BUNDLE_VERSION: stamp.version, ARCRUN_BUNDLE_COMMIT: stamp.commit!, }; const deployed = readVars(renderWranglerToml(CYPHER_TOML, CTX, new Map(), extra)); // ① Portal 設定頁讀的就是這個欄位——更新完必須有值,且是**這趟**的版本 assert.equal(deployed.ARCRUN_BUNDLE_VERSION, '1.4.41'); assert.equal(deployed.ARCRUN_BUNDLE_COMMIT, 'f87d0e92f49690253e7c89c5badc82a08eb5d21b'); // ② 安裝器注入的設定沒有在更新中消失 assert.equal(deployed.PORTAL_MAIL_RELAY_BASE, 'https://mail.example.com'); assert.equal(deployed.CONSOLE_TENANT, 'someone-else'); // ③ CLI 自己算的仍然是這趟算出來的 assert.equal(deployed.WORKER_SUBDOMAIN, 'user-sub'); assert.equal(deployed.MULTI_TENANT, 'false'); }); // ═════════════════════════════════════════════════════════════════════════════ // ⑤ applyVars 的三種既有狀態 + 不弄壞別的區塊 // ═════════════════════════════════════════════════════════════════════════════ test('#106 ⑧:applyVars——改既有行/取消註解/插進 [vars]/連 [vars] 都沒有時新開一段', () => { assert.match(applyVars('[vars]\nA = "old"\n', { A: 'new' }), /^\[vars\]\nA = "new"\n$/); assert.match(applyVars('[vars]\n# A = "old"\n', { A: 'new' }), /A = "new"/); assert.match(applyVars('[vars]\nB = "b"\n', { A: 'a' }), /\[vars\]\nA = "a"\nB = "b"/); const noVars = applyVars('name = "w"\n', { A: 'a' }); assert.match(noVars, /\[vars\]\nA = "a"/); assert.match(noVars, /^name = "w"/); }); test('#106 ⑨:var 值裡的引號/反斜線會被轉義(不會產生壞掉的 toml)', () => { const out = applyVars('[vars]\n', { A: 'say "hi"\\path' }); assert.match(out, /A = "say \\"hi\\"\\\\path"/); }); test('#106 ⑨b:值裡有 $& / $1 也照原樣寫出(replace 反向參照陷阱)', () => { assert.match(applyVars('[vars]\nA = "old"\n', { A: 'x$&y$1z' }), /A = "x\$&y\$1z"/); assert.match(applyVars('[vars]\n', { A: 'x$&y' }), /A = "x\$&y"/); // 怪名字不寫進去(不拿它組正規式) assert.equal(applyVars('[vars]\n', { 'BAD NAME': 'v' }), '[vars]\n'); }); test('#106 ⑩:注入 var 不影響資源綁定解析(預覽與實際寫入看到的是同一份需求)', async () => { const { parseWranglerRequirements } = await import('../src/lib/resource-resolver.ts'); const withoutVars = parseWranglerRequirements(renderWranglerToml(CYPHER_TOML, CTX, new Map())); const withVars = parseWranglerRequirements( renderWranglerToml(CYPHER_TOML, CTX, new Map(), { ARCRUN_BUNDLE_VERSION: '1.4.41', X: 'y' }), ); assert.equal(withVars.script, withoutVars.script); assert.deepEqual(withVars.bindings, withoutVars.bindings); });