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:
@@ -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