@@ -0,0 +1,243 @@
/**
* 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 < string , string > = {
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 < string , string > {
const out : Record < string , string > = { } ;
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 < ScriptBindings > {
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 ) ;
} ) ;