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:
@@ -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 不變的情況下,alive/last_sync/seconds_since_last_sync 三者都反映了子行程真的停了")
|
||||
}
|
||||
@@ -37,10 +37,11 @@ import (
|
||||
var diagnosticsHTTP = &http.Client{Timeout: 15 * time.Second}
|
||||
|
||||
// localDiagnostics=地端這半:daemon 版本/自我更新狀態+t210 已算好的總量進度/
|
||||
// 失敗分類+讀不了的檔案樣本(basename)。
|
||||
// 失敗分類+讀不了的檔案樣本(basename)+t213 phase 3 補的 Engine(Q2 缺口)。
|
||||
type localDiagnostics struct {
|
||||
DaemonVersion string `json:"daemon_version"`
|
||||
UpdateCheck UpdateInfo `json:"update_check"` // 現查現答(Q4:Mac 卡在舊版)
|
||||
Engine engineDiagnostics `json:"engine"` // t213 phase 3(Q2:還在不在跑)
|
||||
Progress collector.SyncProgress `json:"progress"` // 同首頁(Q2 的分母:Total)
|
||||
FailureBreakdown collector.FailureBreakdown `json:"failure_breakdown"` // 同首頁(Q3:分類統計)
|
||||
// SkippedSample/SkippedMore=格式讀不了、根本沒進 manifest 的檔案樣本,
|
||||
@@ -50,6 +51,37 @@ type localDiagnostics struct {
|
||||
SkippedMore int `json:"skipped_more,omitempty"`
|
||||
}
|
||||
|
||||
// engineDiagnostics 回答「這台機器現在到底還有沒有在動」
|
||||
// (t213 phase 3,2026-08-08 補考卷 Q2 缺口:封測者 Evan 原話「是壞了嗎?還是繼續在跑?」——
|
||||
// 舊版診斷檔看得到 pending=0,卻分不出「真的做完了」還是「daemon 早就掛了沒人知道」,
|
||||
// 因為完全沒有任何時間資訊。)
|
||||
//
|
||||
// 🔴 不發明第二套判斷法:Alive/Syncing/CrashLooping/Restarts/LastError/Headline/Detail
|
||||
// 直接借用 supervise.go 既有的 collectorAlive()/collectorSyncing()/collectorFailure(),
|
||||
// 以及 app.go 首頁狀態列用的 describeStatus()——同一套憑據,這裡只是把它們也寫進 JSON,
|
||||
// 確保首頁與診斷檔永遠是同一組真相(leo 08-08 紅線①),不是另外重新猜一套「活著嗎」。
|
||||
type engineDiagnostics struct {
|
||||
Alive bool `json:"alive"` // 同步引擎子行程現在活著嗎(supervisor 狀態機,非 pgrep,見 supervise.go collectorAlive() 註解)
|
||||
Syncing bool `json:"syncing"` // 這一刻正在跑一輪嗎
|
||||
CrashLooping bool `json:"crash_looping"` // 一直啟動失敗(≥3 次重試都失敗,見 crashLoopThreshold)
|
||||
Restarts int `json:"restarts"` // 累計重試次數
|
||||
LastError string `json:"last_error,omitempty"` // 最近一次啟動失敗的原因
|
||||
Headline string `json:"headline"` // 同首頁大字,例如「同步引擎沒有在跑」
|
||||
Detail string `json:"detail,omitempty"` // 同首頁小字
|
||||
|
||||
// LastSync=最近一輪「心跳」完成時間(RFC3339)。這是每一輪(預設每 5 秒)**不論那輪
|
||||
// 有沒有事做都會寫**的欄位(見 collector/direct.go RunDirectOnce 尾端),所以它是心跳,
|
||||
// 不是「上次同步到東西」的時間。
|
||||
// LastActivityAt=最近一輪「真的有產出」(成功或失敗都算)的時間,同首頁「已整理 N 份」
|
||||
// 那個時間戳(見 collector/sync_status.go CarryForwardActivity)。
|
||||
//
|
||||
// ⇒ pending=0 但 last_sync 是很久以前 ⇒ 不是「做完了」,是「心跳停了」;
|
||||
// last_sync 很新但 alive=false ⇒ daemon 這一刻已死,last_sync 是死前最後一口氣。
|
||||
LastSync string `json:"last_sync,omitempty"`
|
||||
LastActivityAt string `json:"last_activity_at,omitempty"`
|
||||
SecondsSinceLastSync *int64 `json:"seconds_since_last_sync,omitempty"` // generated_at 減 last_sync 的秒數;從沒同步過時省略(不假裝有數字)
|
||||
}
|
||||
|
||||
// accountDiagnostics=一個雲端帳號(知識庫實例)的雲端那半。
|
||||
// Cloud 拿不到時填 CloudError(誠實回報,不假裝有數字)——常見原因:舊版雲端沒有
|
||||
// /portal/daemon/diagnostics(部署還沒到)、網路不通、api key 尚未設定。
|
||||
@@ -119,6 +151,21 @@ func (a *App) buildDiagnosticsPayload() exportedDiagnostics {
|
||||
skipped := buildSkipped(sync) // 首頁既有邏輯:Files 已是 basename+白話標籤,直接借用
|
||||
update := a.CheckUpdate() // 現查現答,不用可能過期的背景檢查快取(Q4)
|
||||
|
||||
// t213 phase 3(Q2):現查現答同步引擎「現在還在不在跑」,直接讀 supervise.go
|
||||
// 已經在維護的同一組憑據(供首頁 describeStatus() 用的那套),不重新發明判斷法。
|
||||
_, headline, detail := describeStatus(sync)
|
||||
alive := collectorAlive()
|
||||
lastErr, restarts, looping := collectorFailure()
|
||||
engine := engineDiagnostics{
|
||||
Alive: alive,
|
||||
Syncing: collectorSyncing(),
|
||||
CrashLooping: looping,
|
||||
Restarts: restarts,
|
||||
Headline: headline,
|
||||
Detail: detail,
|
||||
}
|
||||
engine.LastError = engineLastErrorFor(alive, looping, lastErr)
|
||||
|
||||
cfg, _ := loadCfg()
|
||||
accounts := make([]accountDiagnostics, 0, len(cfg.Accounts))
|
||||
for _, acc := range cfg.Accounts {
|
||||
@@ -136,7 +183,22 @@ func (a *App) buildDiagnosticsPayload() exportedDiagnostics {
|
||||
accounts = append(accounts, ad)
|
||||
}
|
||||
|
||||
return mergeDiagnostics(sync, skipped, version, update, accounts)
|
||||
return mergeDiagnostics(sync, skipped, version, update, engine, accounts)
|
||||
}
|
||||
|
||||
// engineLastErrorFor 決定 LastError 要不要出現在診斷檔——只在「真的代表出事」時才附上
|
||||
// (子行程不活著、或一直啟動失敗)。
|
||||
//
|
||||
// 🔴 supervisor 的 LastError 是「最近一行 stderr」,即使子行程健康,開機橫幅那類純資訊
|
||||
// 也會被記進去(見 supervisor.go runOnce 的 stderr scanner,本身不判斷這行是不是真錯誤——
|
||||
// 這是既有共用機制的既有行為,不在本次改動範圍);若不加這道判斷,alive=true 的健康快照裡
|
||||
// 也會混進一句看起來像錯誤、實際只是啟動訊息的文字,誤導看診斷檔的人。
|
||||
// 同首頁 describeStatus():只在「一直失敗」或「沒有在跑」兩種狀態才把這句話端上檯面。
|
||||
func engineLastErrorFor(alive, crashLooping bool, lastErr string) string {
|
||||
if !alive || crashLooping {
|
||||
return lastErr
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// mergeDiagnostics 純函式:把已經各自拿到的本機/雲端資料組成最終輸出形狀,不做任何 IO。
|
||||
@@ -147,10 +209,25 @@ func (a *App) buildDiagnosticsPayload() exportedDiagnostics {
|
||||
// sync.FailureBreakdown,本函式不比對/不認得任何一個分類字串。
|
||||
// ③ 失敗檔名只出 basename——skipped.Files 沿用 buildSkipped() 既有輸出(本來就是
|
||||
// filepath.Base()+白話標籤),本函式不重新處理路徑。
|
||||
func mergeDiagnostics(sync syncStatus, skipped *UISkipped, daemonVersion string, update UpdateInfo, accounts []accountDiagnostics) exportedDiagnostics {
|
||||
func mergeDiagnostics(sync syncStatus, skipped *UISkipped, daemonVersion string, update UpdateInfo, engine engineDiagnostics, accounts []accountDiagnostics) exportedDiagnostics {
|
||||
now := time.Now().UTC()
|
||||
|
||||
// engine.Alive/Syncing/CrashLooping/Restarts/LastError/Headline/Detail 是呼叫端
|
||||
// 現查現答傳進來的(IO/即時行程狀態,見 buildDiagnosticsPayload);這裡只補上
|
||||
// 從 status.json 本來就有、原樣接住的兩個時間戳,以及純算術算出的落後秒數。
|
||||
engine.LastSync = sync.LastSync
|
||||
engine.LastActivityAt = sync.LastActivityAt
|
||||
if sync.LastSync != "" {
|
||||
if t, err := time.Parse(time.RFC3339, sync.LastSync); err == nil {
|
||||
secs := int64(now.Sub(t).Seconds())
|
||||
engine.SecondsSinceLastSync = &secs
|
||||
}
|
||||
}
|
||||
|
||||
local := localDiagnostics{
|
||||
DaemonVersion: daemonVersion,
|
||||
UpdateCheck: update,
|
||||
Engine: engine,
|
||||
Progress: sync.Progress,
|
||||
FailureBreakdown: sync.FailureBreakdown,
|
||||
}
|
||||
@@ -159,7 +236,7 @@ func mergeDiagnostics(sync syncStatus, skipped *UISkipped, daemonVersion string,
|
||||
local.SkippedMore = skipped.More
|
||||
}
|
||||
return exportedDiagnostics{
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
GeneratedAt: now.Format(time.RFC3339),
|
||||
Local: local,
|
||||
Accounts: accounts,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
collector "arcrun-rag/collector"
|
||||
)
|
||||
@@ -26,7 +27,7 @@ func TestMergeDiagnostics_SameNumbersAsHomeScreen(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
out := mergeDiagnostics(sync, nil, "0.18.23", UpdateInfo{Current: "0.18.23"}, nil)
|
||||
out := mergeDiagnostics(sync, nil, "0.18.23", UpdateInfo{Current: "0.18.23"}, engineDiagnostics{}, nil)
|
||||
|
||||
if out.Local.Progress != sync.Progress {
|
||||
t.Fatalf("progress 沒有原樣接住:got %+v want %+v", out.Local.Progress, sync.Progress)
|
||||
@@ -53,7 +54,7 @@ func TestMergeDiagnostics_CategoryNamesPassThroughVerbatim(t *testing.T) {
|
||||
Groups: []collector.FailureGroup{{Category: "未來才會新增的假分類", Count: 1}},
|
||||
},
|
||||
}
|
||||
out := mergeDiagnostics(sync, nil, "dev", UpdateInfo{}, nil)
|
||||
out := mergeDiagnostics(sync, nil, "dev", UpdateInfo{}, engineDiagnostics{}, nil)
|
||||
if len(out.Local.FailureBreakdown.Groups) != 1 || out.Local.FailureBreakdown.Groups[0].Category != "未來才會新增的假分類" {
|
||||
t.Fatalf("分類名稱沒有原樣照抄:%+v", out.Local.FailureBreakdown.Groups)
|
||||
}
|
||||
@@ -67,7 +68,7 @@ func TestMergeDiagnostics_SkippedNamesAreBasenameOnly(t *testing.T) {
|
||||
Files: []string{"教材授權書-Leov2.pages(Pages)", "舊版報告.doc(舊版 Word)"},
|
||||
More: 2,
|
||||
}
|
||||
out := mergeDiagnostics(syncStatus{}, skipped, "dev", UpdateInfo{}, nil)
|
||||
out := mergeDiagnostics(syncStatus{}, skipped, "dev", UpdateInfo{}, engineDiagnostics{}, nil)
|
||||
|
||||
if len(out.Local.SkippedSample) != 2 {
|
||||
t.Fatalf("skipped_sample 數量跑掉:%v", out.Local.SkippedSample)
|
||||
@@ -93,7 +94,7 @@ func TestMergeDiagnostics_SkippedNamesAreBasenameOnly(t *testing.T) {
|
||||
|
||||
func TestMergeDiagnostics_NoSkipped_OmitsSampleFields(t *testing.T) {
|
||||
// 沒有任何檔案被略過(skipped == nil,同 buildSkipped() 的既有語意)→ 不該生出空陣列佔畫面。
|
||||
out := mergeDiagnostics(syncStatus{}, nil, "dev", UpdateInfo{}, nil)
|
||||
out := mergeDiagnostics(syncStatus{}, nil, "dev", UpdateInfo{}, engineDiagnostics{}, nil)
|
||||
if out.Local.SkippedSample != nil {
|
||||
t.Fatalf("沒有略過任何檔案時 SkippedSample 應為 nil,得到 %v", out.Local.SkippedSample)
|
||||
}
|
||||
@@ -112,7 +113,7 @@ func TestMergeDiagnostics_AccountsCarryCloudOrError(t *testing.T) {
|
||||
{InstanceName: "geek6688", Host: "arcrun-cypher-executor.arcrun-fc9490d5.workers.dev",
|
||||
CloudError: "你的知識庫還是舊版(沒有雲端診斷功能)⇒ 請到 portal 按「立即更新」重裝一次"},
|
||||
}
|
||||
out := mergeDiagnostics(syncStatus{}, nil, "dev", UpdateInfo{}, accounts)
|
||||
out := mergeDiagnostics(syncStatus{}, nil, "dev", UpdateInfo{}, engineDiagnostics{}, accounts)
|
||||
if len(out.Accounts) != 2 {
|
||||
t.Fatalf("accounts 數量跑掉:%d", len(out.Accounts))
|
||||
}
|
||||
@@ -123,3 +124,96 @@ func TestMergeDiagnostics_AccountsCarryCloudOrError(t *testing.T) {
|
||||
t.Fatalf("第二個帳號應該只有 cloud_error、沒有 cloud:%+v", out.Accounts[1])
|
||||
}
|
||||
}
|
||||
|
||||
// ── t213 phase 3:engine(Q2 缺口——「還在不在跑」)─────────────────────────
|
||||
|
||||
func TestMergeDiagnostics_EngineCarriesLiveStateVerbatim(t *testing.T) {
|
||||
// engine 的 Alive/Syncing/CrashLooping/Restarts/LastError/Headline/Detail 是呼叫端
|
||||
// 現查現答傳進來的(見 buildDiagnosticsPayload),mergeDiagnostics 不准對它們做任何
|
||||
// 判斷或改寫——同 leo 規則②「本函式不認得任何一個分類字串」的精神,換到 engine 身上。
|
||||
engine := engineDiagnostics{
|
||||
Alive: false, Syncing: false, CrashLooping: true, Restarts: 5,
|
||||
LastError: "exit status 1", Headline: "同步引擎一直啟動失敗", Detail: "已自動重試 5 次都失敗",
|
||||
}
|
||||
out := mergeDiagnostics(syncStatus{}, nil, "dev", UpdateInfo{}, engine, nil)
|
||||
got := out.Local.Engine
|
||||
if got.Alive != false || got.CrashLooping != true || got.Restarts != 5 ||
|
||||
got.LastError != "exit status 1" || got.Headline != "同步引擎一直啟動失敗" || got.Detail != "已自動重試 5 次都失敗" {
|
||||
t.Fatalf("engine 現查現答欄位沒有原樣接住:%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineLastErrorFor_OnlyShownWhenItMeansSomething(t *testing.T) {
|
||||
// supervisor 的 LastError 是「最近一行 stderr」,健康行程的開機橫幅也會被記進去
|
||||
// (見 diagnostics_engine_e2e_test.go 情境一實測:alive=true 卻帶著一句啟動訊息)。
|
||||
// 這支測試釘住閘門本身:alive 且沒有一直失敗 ⇒ 就算 lastErr 非空也要被吞掉,
|
||||
// 不然診斷檔會讓人誤以為健康的引擎在報錯。
|
||||
cases := []struct {
|
||||
name string
|
||||
alive, looping bool
|
||||
lastErr, wantOut string
|
||||
}{
|
||||
{"健康行程的雜訊 stderr 被吞掉", true, false, "collector direct daemon 啟動:…", ""},
|
||||
{"沒有在跑時原樣帶出死因", false, false, "exit status 2", "exit status 2"},
|
||||
{"一直啟動失敗時原樣帶出死因", true /* 短暫 Starting 也算 alive */, true, "config JSON 解析失敗", "config JSON 解析失敗"},
|
||||
{"健康且沒有任何 stderr", true, false, "", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := engineLastErrorFor(c.alive, c.looping, c.lastErr)
|
||||
if got != c.wantOut {
|
||||
t.Fatalf("engineLastErrorFor(%v,%v,%q) = %q,want %q", c.alive, c.looping, c.lastErr, got, c.wantOut)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDiagnostics_EngineTimestampsFromSyncStatus(t *testing.T) {
|
||||
// LastSync/LastActivityAt 要原樣接住 status.json 裡的值(同 Progress 的規矩),
|
||||
// 不是本函式另外現查——這兩個欄位是 t91/t205 既有機制,不重新發明。
|
||||
sync := syncStatus{LastSync: "2026-08-08T05:00:00Z", LastActivityAt: "2026-08-08T04:30:00Z"}
|
||||
out := mergeDiagnostics(sync, nil, "dev", UpdateInfo{}, engineDiagnostics{}, nil)
|
||||
if out.Local.Engine.LastSync != sync.LastSync {
|
||||
t.Fatalf("last_sync 沒有原樣接住:got %q want %q", out.Local.Engine.LastSync, sync.LastSync)
|
||||
}
|
||||
if out.Local.Engine.LastActivityAt != sync.LastActivityAt {
|
||||
t.Fatalf("last_activity_at 沒有原樣接住:got %q want %q", out.Local.Engine.LastActivityAt, sync.LastActivityAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDiagnostics_SecondsSinceLastSync_StaleHeartbeatIsVisible(t *testing.T) {
|
||||
// 核心情境(考卷 Q2):pending=0,但 last_sync 停在很久以前——這份 JSON 必須讓人
|
||||
// 看得出來「不是做完了,是心跳停了」,不必額外問任何人。
|
||||
stale := time.Now().UTC().Add(-45 * time.Minute).Format(time.RFC3339)
|
||||
sync := syncStatus{
|
||||
LastSync: stale,
|
||||
Progress: collector.SyncProgress{Total: 9000, Done: 8980, Pending: 0, Stuck: 0, Unreadable: 20},
|
||||
}
|
||||
out := mergeDiagnostics(sync, nil, "dev", UpdateInfo{}, engineDiagnostics{Alive: false, Headline: "同步引擎沒有在跑"}, nil)
|
||||
|
||||
if out.Local.Progress.Pending != 0 {
|
||||
t.Fatalf("測試前提跑掉:pending 應為 0,得到 %d", out.Local.Progress.Pending)
|
||||
}
|
||||
if out.Local.Engine.SecondsSinceLastSync == nil {
|
||||
t.Fatal("seconds_since_last_sync 不該是空的——有 last_sync 就該算得出落後秒數")
|
||||
}
|
||||
got := *out.Local.Engine.SecondsSinceLastSync
|
||||
if got < 44*60 || got > 46*60 {
|
||||
t.Fatalf("seconds_since_last_sync 算錯:got %ds,應接近 2700s(45 分鐘)", got)
|
||||
}
|
||||
if out.Local.Engine.Alive {
|
||||
t.Fatal("測試前提跑掉:這個情境應該是 alive=false(同步引擎沒有在跑)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeDiagnostics_NoLastSync_OmitsSecondsSinceLastSync(t *testing.T) {
|
||||
// 從沒同步過(全新安裝、還沒連知識庫):last_sync 是空字串,不該假裝算得出落後秒數。
|
||||
out := mergeDiagnostics(syncStatus{}, nil, "dev", UpdateInfo{}, engineDiagnostics{}, nil)
|
||||
if out.Local.Engine.SecondsSinceLastSync != nil {
|
||||
t.Fatalf("沒有 last_sync 時 seconds_since_last_sync 應為 nil,得到 %v", *out.Local.Engine.SecondsSinceLastSync)
|
||||
}
|
||||
raw, _ := json.Marshal(out)
|
||||
if strings.Contains(string(raw), "seconds_since_last_sync") {
|
||||
t.Fatalf("omitempty 沒生效:%s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user