Files
ISEP/hooks/tests/mainline-gitea-fallback.test.sh
T
Leo 48befe8906 雲端第一則 prompt 的主線是現在這條:兩份主線檔都不在時 refresh 去 Gitea 讀 InkStoneCo main 那份一次(inkstone/ISEP#140)
薄殼 SessionStart 跑在 bootstrap.sh 之前,InkStoneCo/ 還沒 clone、家目錄空 ⇒ 兩份都不在 ⇒
countdown.py 退到「期限最近的 milestone」⇒ 第一則 prompt 印逾期 341 小時的舊線。機制沒壞,是順序。

- hooks/lib/mainline.py:第三個來源 fetch_gitea()(本檔唯一會打網路的函式,只有 SessionStart 的
  `mainline refresh` 叫它;load()/line() 照舊零網路)。優先序寫進檔頭:家目錄 > repo 那份 > Gitea 那份。
  匿名優先,匿名被拒(401/403/404)且拿得到 token 才帶 token 再問一次;讀不到回 None,逾時有上限。
- scripts/mainline refresh:兩份都不在 ⇒ fetch_gitea 一次、只寫家目錄(不 mirror);token 拿不到不炸、不抱怨。
- hooks/tests/mainline-gitea-fallback.test.sh:31 條,假 Gitea 有 public/private/500/garbage/notitle/hang 六種回法。

假設(票上紅線寫「一律匿名不帶 token」,其前提「Gitea 匿名讀得到」實測為假):
inkstone/InkStoneCo 在 Gitea 是 private——匿名打 raw/contents/repo API 都 404,ISEP 同端點 200。
⇒ 照字面做這張票在真環境永遠讀不到。改成匿名優先、被拒才帶 token(同一條 SessionStart 鏈上
countdown-milestone-refresh.sh 本來就帶 GITEA_TOKEN_CLAUDE_CODE 打 Gitea;D20 管的是 GitHub)。
真跑:帶 token 2.06s 讀到 inkstone/Arcrun#48;拔 token rc 0、零檔案、「現在沒有主線」。

版本:待總管定版。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0178ef1fGw3XeZtpN7LaZrm4
2026-09-07 06:19:39 +00:00

193 lines
14 KiB
Bash
Executable File
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.
#!/usr/bin/env bash
# 主線第三個來源「Gitea main 上那份」的迴歸測試(inkstone/ISEP#140
#
# ── 這支在守什麼 ──────────────────────────────────────────────────────
# 雲端薄殼 `youlinhsieh/inkstoneco` 的 SessionStart 跑在 `bootstrap.sh` 之前:
# `InkStoneCo/` 還沒 clone、家目錄又是空的 ⇒ 兩份主線檔都不存在 ⇒ `countdown.py` 退到
# 「期限最近的 milestone」⇒ 第一則 prompt 印一條兩週前的舊線(票上實測:逾期 341 小時)。
# 解法:`scripts/mainline refresh`SessionStart)在兩份都不在時去 Gitea 讀
# `inkstone/InkStoneCo` main 的 `system-dev/mainline.json` 一次、寫進家目錄。這裡驗:
# A 薄殼形狀(兩份都沒有)+ Gitea 讀得到 ⇒ refresh 之後第一則 prompt 的 ⏱/🎯 就是那條線
# B 匿名優先;InkStoneCo 是 private ⇒ 匿名 404 後有 token 才帶 token 再問一次,沒 token 就退回
# C 家目錄或 repo 那份任一在場 ⇒ 不問 Gitea(優先序:家目錄 > repo Gitea
# D 讀不到(5xx/不是 JSON/沒 title/掛住/連不上/被關掉)⇒ 退回現行行為,不掛、逾時有上限
# E 抓到之後 bootstrap 才出現的 repo 那份,跟 ISEP#130 的 set_at 規則接得起來
# F `load()`UserPromptSubmit 那條路照舊零網路
#
# 用法:bash hooks/tests/mainline-gitea-fallback.test.sh
# 🔴 全程離線:Gitea 是本機一個假伺服器(TICKET_HOST 指過去),它照真 Gitea 回:
# public 時 raw 端點匿名 200private 時匿名 404、帶 token 200(真 InkStoneCo 就是 private)。
# 狀態走 ISEP_COUNTDOWN_STATE_DIR、repo 那份走 TMP 底下的假薄殼。不碰真家目錄、不碰真 Gitea。
set -u
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
MAINLINE="$ROOT/../scripts/mainline"
HOOK="$ROOT/countdown-guard.sh"
TMP=$(mktemp -d)
export ISEP_COUNTDOWN_STATE_DIR="$TMP/home-state"; mkdir -p "$ISEP_COUNTDOWN_STATE_DIR"
export ISEP_COUNTDOWN_NOW=1757203200 # 2026-09-07 00:00 UTC,讓主線那段「剩 X」穩定
export ISEP_MAINLINE_GITEA_TIMEOUT=1
unset ISEP_MAINLINE_REPO_FILE ISEP_MAINLINE_FIXTURE ISEP_MAINLINE_GITEA_URL ISEP_MILESTONE_DUE ISEP_MILESTONE_NAME
unset GITEA_TOKEN_CLAUDE_CODE GITEA_TOKEN
SHELL_ROOT="$TMP/shell"; mkdir -p "$SHELL_ROOT" # 薄殼根:沒有 InkStoneCo/,也不是 git repo
PASS=0; FAIL=0
ok(){ printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
no(){ printf ' ❌ %s —— %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
# ── 假 Gitea:模式從 $TMP/mode 讀,每個請求記一行 `path|auth` 到 $TMP/log ──
cat > "$TMP/stub.py" <<'PY'
import json, os, sys, time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
TMP = sys.argv[1]; PORT = int(sys.argv[2])
RAW = "/api/v1/repos/inkstone/InkStoneCo/raw/system-dev/mainline.json"
DOC = {"ref": "inkstone/Arcrun#48", "owner": "inkstone", "repo": "Arcrun", "id": 48,
"title": "Gitea 上那條現在的主線", "description": "測試用。", "due_on": "2026-09-12T23:59:59Z",
"open_issues": 3, "closed_issues": 0, "members": ["inkstone/ISEP#140"],
"set_at": "2026-09-07 07:30", "refreshed_at": "2026-09-07 08:18"}
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def _send(self, code, body=b"", ctype="application/json"):
self.send_response(code); self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body)
def do_GET(self):
mode = open(os.path.join(TMP, "mode")).read().strip()
auth = "auth" if self.headers.get("Authorization") else "anon"
with open(os.path.join(TMP, "log"), "a") as f: f.write("%s|%s\n" % (self.path.split("?")[0], auth))
if not self.path.startswith(RAW):
return self._send(404, b'{"message":"Not found."}')
if mode == "public": return self._send(200, json.dumps(DOC, ensure_ascii=False).encode())
if mode == "private":
if auth == "auth": return self._send(200, json.dumps(DOC, ensure_ascii=False).encode())
return self._send(404, b"Not found.", "text/plain") # 真 Gitea 對 private repo 就回 404
if mode == "500": return self._send(500, b"boom")
if mode == "garbage": return self._send(200, b"<html>not json</html>", "text/html")
if mode == "notitle": return self._send(200, b'{"ref":"x","set_at":"2026-09-07 07:30"}')
if mode == "hang":
time.sleep(8); return self._send(200, json.dumps(DOC).encode())
return self._send(500, b"unknown mode")
srv = ThreadingHTTPServer(("127.0.0.1", PORT), H); srv.daemon_threads = True; srv.serve_forever() # 掛住的那筆不准堵住後面的請求
PY
PORT=$(python3 -c "import socket;s=socket.socket();s.bind(('127.0.0.1',0));print(s.getsockname()[1])")
echo public > "$TMP/mode"; : > "$TMP/log"
python3 "$TMP/stub.py" "$TMP" "$PORT" & SRV=$!
trap 'kill $SRV 2>/dev/null; rm -rf "$TMP"' EXIT
for _ in $(seq 1 50); do python3 -c "import socket;socket.create_connection(('127.0.0.1',$PORT),1)" 2>/dev/null && break; sleep 0.1; done
export TICKET_HOST="http://127.0.0.1:$PORT"
refresh(){ CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 "$MAINLINE" refresh >"$TMP/refresh.out" 2>"$TMP/refresh.err"; echo $?; }
show(){ CLAUDE_PROJECT_DIR="$SHELL_ROOT" python3 "$MAINLINE" 2>&1 | head -1; }
inject(){ # 第一則 promptUserPromptSubmit 給 countdown-guard.sh,回 additionalContext
printf '{"hook_event_name":"UserPromptSubmit","session_id":"s140","transcript_path":""}' \
| CLAUDE_PROJECT_DIR="$SHELL_ROOT" bash "$HOOK" 2>/dev/null \
| python3 -c 'import json,sys
try: print(json.load(sys.stdin)["hookSpecificOutput"]["additionalContext"])
except Exception: print("")'
}
raw_hits(){ grep -c '/raw/system-dev/mainline.json|' "$TMP/log" 2>/dev/null || true; }
reset(){ rm -f "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "$ISEP_COUNTDOWN_STATE_DIR/milestone-due"; rm -rf "$SHELL_ROOT/InkStoneCo"; : > "$TMP/log"; }
echo "── A. 薄殼形狀(兩份都沒有)+ Gitea 讀得到 ⇒ 第一則 prompt 就是現在的主線 ──"
reset; echo public > "$TMP/mode"
printf '2026-08-25|把管理這條線做對\n' > "$ISEP_COUNTDOWN_STATE_DIR/milestone-due" # 第二基準:那條逾期的舊線
OUT=$(inject)
case "$OUT" in *"現在沒有主線"*) ok "① refresh 之前:兩份都沒有 ⇒ 🎯「現在沒有主線」(票上撞到的那個畫面,先確認測得到)" ;;
*) no "① 前置狀態不對" "$OUT" ;; esac
case "$OUT" in *"把管理這條線做對"*) ok "② refresh 之前:⏱ 退到第二基準,印的是逾期的舊線(同上)" ;;
*) no "② 前置狀態不對(第二基準沒生效)" "$OUT" ;; esac
RC=$(refresh)
[ "$RC" = "0" ] && ok "③ mainline refresh 離開碼 0" || no "③ refresh 離開碼 $RC" "$(cat "$TMP/refresh.err")"
[ -f "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" ] && ok "④ 家目錄那份被建出來(之後每一則訊息讀它,不再打網路)" \
|| no "④ 家目錄沒有檔" "$(ls "$ISEP_COUNTDOWN_STATE_DIR")"
OUT=$(show)
case "$OUT" in *"Gitea 上那條現在的主線"*) ok "⑤ \`mainline\` 答得出 Gitea 上那條" ;; *) no "⑤ show 沒讀到" "$OUT" ;; esac
OUT=$(inject)
case "$OUT" in *"🎯 主線:inkstone/Arcrun#48「Gitea 上那條現在的主線」"*) ok "⑥ 第一則 prompt 的 🎯 那行是 Gitea 上那條(不是「沒有主線」)" ;;
*) no "⑥ 🎯 沒講對" "$OUT" ;; esac
case "$OUT" in *"主線 Gitea 上那條現在的主線 剩"*) ok "⑦ ⏱ 那行掛的是它的名字與期限(被標定的主線蓋過第二基準)" ;;
*) no "⑦ ⏱ 沒掛主線" "$OUT" ;; esac
case "$OUT" in *"把管理這條線做對"*) no "⑧ 舊線還在畫面上" "$OUT" ;; *) ok "⑧ 那條逾期 341 小時的舊線不再出現" ;; esac
FIRST=$(head -1 "$TMP/log")
[ "$FIRST" = "/api/v1/repos/inkstone/InkStoneCo/raw/system-dev/mainline.json|anon" ] && ok "⑨ 第一個請求匿名、打的是 InkStoneCo main 的 raw 檔(讀不帶憑證)" \
|| no "⑨ 第一個請求不對" "$FIRST"
[ "$(raw_hits)" = "1" ] && ok "⑩ 只問了一次" || no "⑩ 問了 $(raw_hits) 次" "$(cat "$TMP/log")"
[ ! -e "$SHELL_ROOT/InkStoneCo" ] && ok "⑪ 沒有建出 InkStoneCo/(不 clone、不弄髒任何工作樹)" || no "⑪ 動了薄殼底下的樹" "$(ls "$SHELL_ROOT")"
grep -q '"refreshed_at"' "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" && ok "⑫ 寫進家目錄的是完整那份(有 set_atrefreshed_at,之後 set_at 規則照用)" \
|| no "⑫ 家目錄那份殘缺" "$(cat "$ISEP_COUNTDOWN_STATE_DIR/mainline.json")"
echo "── F. load()/每一則訊息那條路:零網路 ──"
: > "$TMP/log"; show >/dev/null; show >/dev/null; inject >/dev/null
[ "$(wc -l < "$TMP/log" | tr -d ' ')" = "0" ] && ok "⑬ show 兩次+UserPromptSubmit 一次,假 Gitea 零請求(關鍵路徑上沒有網路)" \
|| no "⑬ 關鍵路徑打了網路" "$(cat "$TMP/log")"
RC=$(refresh)
[ "$(raw_hits)" = "0" ] && ok "⑭ 家目錄那份已在 ⇒ 再跑 refresh 也不問 Gitea 的檔(優先序:家目錄 Gitea)" \
|| no "⑭ 家目錄在場還去問" "$(cat "$TMP/log")"
echo "── B. private:匿名優先,被拒才帶 token;沒 token 就退回 ──"
reset; echo private > "$TMP/mode"
RC=$(refresh); OUT=$(show)
case "$OUT" in *"現在沒有主線"*) ok "⑮ 匿名 404、沒 token ⇒ 退回「現在沒有主線」(不編、不炸;離開碼 $RC" ;;
*) no "⑮ 沒 token 卻讀到了?" "$OUT" ;; esac
[ "$RC" = "0" ] && ok "⑯ 沒 token 時離開碼仍是 0SessionStart 不准炸)" || no "⑯ 離開碼 $RC" "$(cat "$TMP/refresh.err")"
[ "$(raw_hits)" = "1" ] && ok "⑰ 沒 token 只問一次就收手" || no "⑰ 問了 $(raw_hits) 次" "$(cat "$TMP/log")"
reset
RC=$(GITEA_TOKEN_CLAUDE_CODE=fake-token-for-test refresh); OUT=$(show)
case "$OUT" in *"Gitea 上那條現在的主線"*) ok "⑱ 匿名 404、環境有 token ⇒ 帶 token 再問一次 ⇒ 讀到(真 InkStoneCo 就是 private" ;;
*) no "⑱ 有 token 還是讀不到" "$OUT$(cat "$TMP/log")" ;; esac
[ "$(sed -n 1p "$TMP/log")" = "/api/v1/repos/inkstone/InkStoneCo/raw/system-dev/mainline.json|anon" ] \
&& [ "$(sed -n 2p "$TMP/log")" = "/api/v1/repos/inkstone/InkStoneCo/raw/system-dev/mainline.json|auth" ] \
&& ok "⑲ 順序:第一次匿名、第二次才帶 token(不是一上來就實名)" || no "⑲ 順序不對" "$(cat "$TMP/log")"
echo "── C. 家目錄或 repo 那份任一在場 ⇒ 不問 Gitea ──"
reset; echo public > "$TMP/mode"
mkdir -p "$SHELL_ROOT/InkStoneCo/system-dev"
python3 - "$SHELL_ROOT/InkStoneCo/system-dev/mainline.json" <<'PY'
import json, sys
json.dump({"ref": "inkstone/ISEP#1", "owner": "inkstone", "repo": "ISEP", "id": 1, "title": "bootstrap 之後 repo 裡那條",
"due_on": "2026-09-20T23:59:59Z", "open_issues": 1, "closed_issues": 0, "members": [],
"set_at": "2026-09-06 10:00", "refreshed_at": "2026-09-06 10:00"}, open(sys.argv[1], "w"), ensure_ascii=False)
PY
RC=$(refresh); OUT=$(show)
[ "$(raw_hits)" = "0" ] && ok "⑳ repo 那份在 ⇒ 不問 Gitea(優先序:repo Gitea" || no "⑳ repo 在場還去問" "$(cat "$TMP/log")"
case "$OUT" in *"bootstrap 之後 repo 裡那條"*) ok "㉑ 答的是 repo 那份(0.24.0 的行為一字不改)" ;; *) no "㉑ 沒答 repo 那份" "$OUT" ;; esac
[ ! -e "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" ] && ok "㉒ 只是讀 repo 那份,家目錄沒有被順手建出來(A32 ④ 沒被弄壞)" \
|| no "㉒ 家目錄被寫了" "$(ls "$ISEP_COUNTDOWN_STATE_DIR")"
echo "── D. 讀不到 ⇒ 退回現行行為,不掛、逾時有上限 ──"
for mode in 500 garbage notitle; do
reset; echo "$mode" > "$TMP/mode"
RC=$(refresh); OUT=$(show)
if [ "$RC" = "0" ] && [ ! -e "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" ]; then
case "$OUT" in *"現在沒有主線"*) ok "㉓ Gitea 回 $mode ⇒ 什麼都不寫、「現在沒有主線」、離開碼 0" ;; *) no "㉓ $mode 之後答錯" "$OUT" ;; esac
else no "㉓ $mode 之後狀態不對" "rc=$RC $(ls "$ISEP_COUNTDOWN_STATE_DIR")"; fi
done
reset; echo hang > "$TMP/mode"
T0=$(date +%s); RC=$(GITEA_TOKEN_CLAUDE_CODE=fake refresh); T1=$(date +%s); OUT=$(show)
if [ $((T1 - T0)) -le 5 ] && [ "$RC" = "0" ]; then
case "$OUT" in *"現在沒有主線"*) ok "㉔ Gitea 掛住(8 秒不回)⇒ $((T1 - T0)) 秒內放棄、退回、離開碼 0(逾時 1 秒 × 最多兩次)" ;; *) no "㉔ 掛住之後答錯" "$OUT" ;; esac
else no "㉔ 掛住時沒有在時限內放棄" "用了 $((T1 - T0)) 秒,rc=$RC"; fi
reset; echo public > "$TMP/mode"
RC=$(TICKET_HOST="http://127.0.0.1:9" refresh); OUT=$(show)
case "$OUT" in *"現在沒有主線"*) [ "$RC" = "0" ] && ok "㉕ 連不上 GiteaTICKET_HOST 指到死位址)⇒ 退回、離開碼 0" || no "㉕ 離開碼 $RC" "$(cat "$TMP/refresh.err")" ;;
*) no "㉕ 連不上還答出主線" "$OUT" ;; esac
reset
RC=$(ISEP_MAINLINE_GITEA_URL=none refresh); OUT=$(show)
[ "$(raw_hits)" = "0" ] && case "$OUT" in *"現在沒有主線"*) ok "㉖ ISEP_MAINLINE_GITEA_URL=none ⇒ 零請求(既有測試靠它把外界定住)" ;; *) no "㉖ none 之後答錯" "$OUT" ;; esac \
|| no "㉖ none 沒生效" "$(cat "$TMP/log")"
[ -z "$(cat "$TMP/refresh.err")" ] && ok "㉗ 以上每一種讀不到,stderr 都是空的(不報錯嚇人)" || no "㉗ stderr 有東西" "$(cat "$TMP/refresh.err")"
echo "── E. 抓到之後 bootstrap 才出現的 repo 那份:跟 ISEP#130 的 set_at 規則接得起來 ──"
reset; echo public > "$TMP/mode"; RC=$(refresh)
mkdir -p "$SHELL_ROOT/InkStoneCo/system-dev"
cp "$ISEP_COUNTDOWN_STATE_DIR/mainline.json" "$SHELL_ROOT/InkStoneCo/system-dev/mainline.json" # bootstrap clone 下來的同一份
OUT=$(show)
case "$OUT" in *"Gitea 上那條現在的主線"*) ok "㉘ bootstrap 之後 repo 那份出現、set_at 相同 ⇒ 仍是同一條(家目錄那份算數)" ;; *) no "㉘ 答錯" "$OUT" ;; esac
python3 - "$SHELL_ROOT/InkStoneCo/system-dev/mainline.json" <<'PY'
import json, sys
d = json.load(open(sys.argv[1])); d["title"] = "後來在別台機器新標的"; d["set_at"] = "2026-09-08 09:00"
json.dump(d, open(sys.argv[1], "w"), ensure_ascii=False)
PY
OUT=$(show)
case "$OUT" in *"後來在別台機器新標的"*) ok "㉙ repo 那份 set_at 較新 ⇒ 用 repo 那份(A32 ⑤ 的規則沒被本票弄壞)" ;; *) no "㉙ 沒用較新的" "$OUT" ;; esac
echo; echo "通過 $PASS 條,失敗 $FAIL 條"
[ "$FAIL" -eq 0 ]