feat(t213): 診斷檔補 engine 區塊,補上考卷 Q2「還在不在跑」缺口

pending=0 以前只能證明「現在沒有排隊的」,證明不了「真的做完了」還是
「daemon 早就掛了沒人知道」——因為診斷檔完全沒有時間資訊。

local.engine 不發明第二套判斷法:直接借用首頁狀態列本來就在用的
collectorAlive()/collectorSyncing()/collectorFailure()/describeStatus(),
只是把這些憑據也寫進 JSON(alive/syncing/crash_looping/headline/detail/
last_sync/last_activity_at/seconds_since_last_sync)。last_error 只在
alive=false 或 crash_looping=true 時才附上,避免健康行程的開機橫幅
被誤讀成錯誤訊息。

新增 diagnostics_engine_e2e_test.go:真的建執行檔、真的用 supervisor
拉起子行程、真的 Stop() 停掉它,驗證同一組 progress 數字在兩種情境下
engine.alive 正確反映活/死(永久迴歸測試,零帳號零網路,快且穩定)。

stage(youlin 帳號)實測驗證見 system-dev/wiki/status.md:兩次真實匯出
(活著/停了)JSON 全文、四題考卷重跑 4/4。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-08 14:37:53 +08:00
parent 92fecb48c1
commit 8baa9170da
3 changed files with 293 additions and 9 deletions
@@ -0,0 +1,113 @@
package main
// diagnostics_engine_e2e_test.go — t213 phase 3 端到端驗證(考卷 Q2 缺口)。
//
// 舊版診斷檔看得到 pending=0,但分不出「真的做完了」還是「daemon 早就掛了沒人知道」——
// 因為完全沒有時間資訊。這支測試用**真的執行檔+真的 supervisor 子行程**(同 App 在真機上
// 做的事,見 supervise.go startSupervisor),證明新加的 local.engine 這組欄位不是憑空編出來
// 的假象,而是真的跟著子行程死活變動:
//
// 情境一(存活中):子行程剛拉起、跑過至少一輪 → engine.alive=true、
// seconds_since_last_sync 是幾秒內的新鮮心跳。
// 情境二(考卷 Q2 的核心,刻意製造「同步停擺」):把子行程停掉(模擬同步引擎掛掉/
// 停止——不是「整個 App 被關掉」那種情況,那種情況下 App 根本不會被打開來按
// 「匯出診斷檔」,不在本檔驗證範圍)→ engine.alive=false、headline 講人話說明、
// last_sync 凍結在停止前那一刻不再前進、seconds_since_last_sync 隨等待時間持續變大。
//
// 刻意零帳號、零網路:這支測試只驗「心跳(LastSync)跟著子行程死活走」這條機制本身,
// 不需要任何雲端憑證,任何機器上都能跑、都穩定。
import (
"encoding/json"
"os/exec"
"path/filepath"
"testing"
"time"
"arcrun-rag/collector/supervisor"
)
func TestDiagnosticsEngine_ReflectsRealSupervisorLifecycle(t *testing.T) {
if testing.Short() {
t.Skip("拉真子行程+等真心跳,跑起來要十幾秒,-short 跳過")
}
// 🔴 先編再改 HOME(同 freshinstall_e2e_test.go 的教訓:Go 模組快取會寫進 HOME,
// 先改 HOME 再 build 會把唯讀快取寫進暫存目錄,測試結束清不掉)。
bin := filepath.Join(t.TempDir(), "arcrun-app-test")
if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
t.Fatalf("編不出執行檔:%v\n%s", err, out)
}
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
// 需要至少一個帳號:LoadDirectConfig 會拒絕零帳號的設定(「缺必填欄位:accounts」,
// 連迴圈都進不去),但這個帳號**不必連得上**——cypher_url 刻意指向一個解析不出來的
// 網址,驗證的是「心跳(LastSync)跟著子行程死活走」這條機制,不是雲端連線本身。
cfg := &directConfig{
Manifest: filepath.Join(home, ".arcrun-rag", "manifest.json"),
Accounts: []accountCfg{{CypherURL: "https://example.invalid.test", Namespace: "test", APIKey: "test"}},
}
if err := saveCfg(cfg); err != nil {
t.Fatalf("存設定失敗:%v", err)
}
sup = supervisor.New(bin, configPath())
sup.ArgPrefix = []string{collectorModeFlag}
t.Cleanup(func() { sup.Stop() })
sup.Start()
// ── 情境一:等到子行程真的活著、心跳過至少一輪 ──
deadline := time.Now().Add(15 * time.Second)
for {
s := loadSyncStatus()
if collectorAlive() && s.LastSync != "" {
break
}
if time.Now().After(deadline) {
t.Fatalf("等 15 秒子行程都沒完成第一輪心跳(alive=%v, LastSync=%q", collectorAlive(), s.LastSync)
}
time.Sleep(200 * time.Millisecond)
}
app := &App{}
aliveOut := app.buildDiagnosticsPayload()
aliveJSON, _ := json.MarshalIndent(aliveOut, "", " ")
t.Logf("── 情境一(存活中)完整診斷檔 ──\n%s", aliveJSON)
if !aliveOut.Local.Engine.Alive {
t.Fatalf("情境一:engine.alive 應為 true,得到 %+v", aliveOut.Local.Engine)
}
if aliveOut.Local.Engine.SecondsSinceLastSync == nil || *aliveOut.Local.Engine.SecondsSinceLastSync > 10 {
t.Fatalf("情境一:last_sync 應該是幾秒內的新鮮心跳,得到 seconds_since_last_sync=%v",
aliveOut.Local.Engine.SecondsSinceLastSync)
}
// ── 情境二:停掉子行程,模擬同步引擎掛了/被停止(考卷 Q2)──
frozenLastSync := aliveOut.Local.Engine.LastSync
sup.Stop()
// 等一段「真的有時間流逝」,讓 seconds_since_last_sync 明顯拉開(不是量測誤差)。
time.Sleep(6 * time.Second)
stoppedOut := app.buildDiagnosticsPayload()
stoppedJSON, _ := json.MarshalIndent(stoppedOut, "", " ")
t.Logf("── 情境二(已停擺)完整診斷檔 ──\n%s", stoppedJSON)
if stoppedOut.Local.Engine.Alive {
t.Fatal("情境二:子行程已經 Stop()engine.alive 應為 false")
}
if stoppedOut.Local.Engine.LastSync != frozenLastSync {
t.Fatalf("情境二:子行程停了之後不該再有新心跳,last_sync 卻變了:停前 %q,停後 %q",
frozenLastSync, stoppedOut.Local.Engine.LastSync)
}
if stoppedOut.Local.Engine.SecondsSinceLastSync == nil || *stoppedOut.Local.Engine.SecondsSinceLastSync < 5 {
t.Fatalf("情境二:心跳停了 6 秒以上,seconds_since_last_sync 應該 >=5,得到 %v",
stoppedOut.Local.Engine.SecondsSinceLastSync)
}
if stoppedOut.Local.Engine.Headline == "" {
t.Fatal("情境二:alive=false 時 headline 不該是空的——這是給人看的『還在不在跑』答案")
}
t.Logf("✅ Q2 證據鏈完整:pending 不變的情況下,alivelast_syncseconds_since_last_sync 三者都反映了子行程真的停了")
}