Files
arcrun-collector/supervisor/supervisor_test.go
T
Leo b9d880413d feat(daemon-beta t14/t15/t17): supervisor log 檔+removed 清本地卡+托盤建新資料夾
- t14 supervisor:子行程 stdout(tee)/stderr 全落 ~/.arcrun-rag/collector.log,
  >5MB 改名 .old 重開(兩代輪替);落 log 失敗一律吞掉不擋看守。新增 2 測試
  (輸出進檔+輪替觸發)。
- t15 direct removed(extractor 模式):雲端 takedown 2xx 後同步刪本地
  system-dev/wiki/cards/<頁名>.md(存在才刪;刪失敗只記 warning 不擋)。
  新增 2 測試(刪原檔→本地卡被清/卡已不在照常下架)。
- t17 arcrun-tray:tray 選單+設定視窗加「建立新資料夾並看守…」——
  widget.Entry 輸入名稱→ ~/ 底下 mkdir(已存在沿用;擋路徑分隔與 ..)→
  addWatchFolder+saveConfig+重啟看守+rebuildTray。

collector: go vet ✓、go test -count=1 ./... 43/43 綠;arcrun-tray: go build ✓(產物已刪)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 15:06:21 +08:00

189 lines
6.2 KiB
Go
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.
package supervisor
import (
"os"
"path/filepath"
"runtime"
"strings"
"sync/atomic"
"testing"
"time"
)
// fakeCollector 寫一個假的 collector 執行檔(shell 腳本):忽略參數、印 n 輪 JSON。
// stayAlive=true → 印完 sleep(模擬常駐 daemon,狀態維持 watching);false → 印完即退出(觸發重起)。
func fakeCollector(t *testing.T, rounds int, stayAlive bool) string {
t.Helper()
if runtime.GOOS == "windows" {
t.Skip("fake collector script 走 POSIX shellWindows 測略過(supervisor 邏輯與平台無關)")
}
dir := t.TempDir()
p := filepath.Join(dir, "fake-collector.sh")
body := "#!/bin/sh\n"
for i := 0; i < rounds; i++ {
body += "printf '{\"at\":\"2026-07-21T00:00:0" + itoa(i) + "Z\",\"folder\":\"/tmp/kb\",\"results\":[]}\\n'\n"
}
if stayAlive {
body += "sleep 2\n"
}
if err := os.WriteFile(p, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
return p
}
func itoa(i int) string { return string(rune('0' + i)) }
func waitFor(t *testing.T, d time.Duration, cond func() bool, msg string) {
t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timeout waiting for: %s", msg)
}
func TestSupervisorParsesRoundsAndWatches(t *testing.T) {
bin := fakeCollector(t, 2, true)
s := New(bin, "cfg.json")
s.Backoff = 20 * time.Millisecond
s.Start()
defer s.Stop()
waitFor(t, 2*time.Second, func() bool { return s.Status().Rounds >= 2 }, "2 rounds parsed")
st := s.Status()
if st.State != StateWatching {
t.Fatalf("expected watching, got %q", st.State)
}
if st.LastRoundAt.IsZero() {
t.Fatal("LastRoundAt should be parsed from stdout 'at'")
}
if st.LastRoundAt.Year() != 2026 {
t.Fatalf("LastRoundAt parse wrong: %v", st.LastRoundAt)
}
}
func TestSupervisorRestartsOnExit(t *testing.T) {
bin := fakeCollector(t, 1, false)
s := New(bin, "cfg.json")
s.Backoff = 20 * time.Millisecond
s.Start()
defer s.Stop()
// 假 collector 印一輪就退出 → supervisor 視為非預期退出 → 重起
waitFor(t, 2*time.Second, func() bool { return s.Status().Restarts >= 2 }, "restarts accumulate")
}
func TestSupervisorStop(t *testing.T) {
bin := fakeCollector(t, 1, false)
s := New(bin, "cfg.json")
s.Backoff = 20 * time.Millisecond
s.Start()
waitFor(t, 2*time.Second, func() bool { return s.Status().Restarts >= 1 }, "running")
s.Stop()
waitFor(t, 2*time.Second, func() bool { return s.Status().State == StateStopped }, "stopped after Stop()")
// Stop 後不應再累加重起
r := s.Status().Restarts
time.Sleep(120 * time.Millisecond)
if s.Status().Restarts != r {
t.Fatalf("restarts kept growing after Stop: %d → %d", r, s.Status().Restarts)
}
}
// 換資料夾=Stop 緊接 Start;舊 loop 的 setState(stopped) 不可覆寫新 loop 的狀態。
func TestSupervisorStopThenStartNotLeftStopped(t *testing.T) {
bin := fakeCollector(t, 2, true) // stayAlive → 新 loop 進 watching
s := New(bin, "cfg.json")
s.Backoff = 20 * time.Millisecond
s.Start()
waitFor(t, 2*time.Second, func() bool { return s.Status().State == StateWatching }, "first watching")
s.Stop() // 同步等舊 loop 收掉
s.Start() // 立刻重起(模擬換資料夾)
defer s.Stop()
waitFor(t, 2*time.Second, func() bool { return s.Status().State == StateWatching }, "watching again after Stop→Start")
// 稍等,確認不會被舊 loop 尾巴改回 stopped
time.Sleep(100 * time.Millisecond)
if got := s.Status().State; got != StateWatching {
t.Fatalf("Stop→Start left state=%q (舊 loop 覆寫了新狀態)", got)
}
}
// t14:子行程 stdoutJSON 輪)與 stderr 都要落進 log 檔。
func TestSupervisorWritesChildOutputToLog(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake collector script 走 POSIX shell")
}
dir := t.TempDir()
bin := filepath.Join(dir, "fake-collector.sh")
body := "#!/bin/sh\n" +
"printf '{\"at\":\"2026-07-24T00:00:00Z\",\"folder\":\"/tmp/kb\",\"results\":[]}\\n'\n" +
"echo 'boom-stderr-line' 1>&2\n" +
"sleep 2\n"
if err := os.WriteFile(bin, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
logPath := filepath.Join(t.TempDir(), "collector.log")
s := New(bin, "cfg.json")
s.LogPath = logPath
s.Backoff = 20 * time.Millisecond
s.Start()
defer s.Stop()
waitFor(t, 2*time.Second, func() bool {
data, _ := os.ReadFile(logPath)
return strings.Contains(string(data), "2026-07-24T00:00:00Z") &&
strings.Contains(string(data), "[stderr] boom-stderr-line")
}, "stdout JSON 與 stderr 行都寫進 log 檔")
}
// t14log 檔超過上限 → 改名 .old 重開(簡單兩代輪替)。
func TestSupervisorLogRotation(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("fake collector script 走 POSIX shell")
}
dir := t.TempDir()
bin := filepath.Join(dir, "fake-collector.sh")
// 一輪印 ~120 bytes × 20 輪 上限 400 bytes → 必觸發輪替
body := "#!/bin/sh\ni=0\nwhile [ $i -lt 20 ]; do\n" +
"printf '{\"at\":\"2026-07-24T00:00:00Z\",\"folder\":\"/tmp/kb-padding-padding-padding-padding-padding\",\"results\":[]}\\n'\n" +
"i=$((i+1))\ndone\nsleep 2\n"
if err := os.WriteFile(bin, []byte(body), 0o755); err != nil {
t.Fatal(err)
}
logPath := filepath.Join(t.TempDir(), "collector.log")
s := New(bin, "cfg.json")
s.LogPath = logPath
s.LogMaxBytes = 400
s.Backoff = 20 * time.Millisecond
s.Start()
defer s.Stop()
waitFor(t, 2*time.Second, func() bool {
_, err := os.Stat(logPath + ".old")
return err == nil
}, "輪替後 .old 檔存在")
// 現役檔已重開=大小不會無限長(上限 + 單次寫入緩衝的餘裕)
waitFor(t, 2*time.Second, func() bool { return s.Status().Rounds >= 20 }, "20 rounds parsed")
fi, err := os.Stat(logPath)
if err != nil {
t.Fatalf("現役 log 檔應存在:%v", err)
}
if fi.Size() > 2*400+4096 {
t.Fatalf("現役 log 檔未受控:size=%d", fi.Size())
}
}
func TestSupervisorOnChangeFires(t *testing.T) {
bin := fakeCollector(t, 2, true)
s := New(bin, "cfg.json")
s.Backoff = 20 * time.Millisecond
var calls int32
s.SetOnChange(func(Status) { atomic.AddInt32(&calls, 1) })
s.Start()
defer s.Stop()
waitFor(t, 2*time.Second, func() bool { return atomic.LoadInt32(&calls) > 0 }, "onChange fired")
}