fix(t91+t92): 萃取狀態可見+Finder 啟動 PATH 修正——背景執行不可見的兩題同根一起解

leo 07-28 實測根因:Finder 起的 GUI app 只有最小 PATH(無 /opt/homebrew/bin)
→ claude 靜默找不到→整輪萃取失敗,托盤卻顯示「看守中」。leo:「我怎麼知道它有萃?」
- FindClaudeBin:LookPath 失敗後掃 4 個常見絕對路徑,找到回寫 config claude_bin
- CheckExtractor 預檢+每輪寫 ~/.arcrun-rag/status.json(ok/fail 計數+失敗清單)
- 托盤:引擎未就緒→「⚠ 萃取引擎未就緒:<白話原因>」;失敗>0→可點開明細;正常→「已萃 N 檔」
測試:collector+supervisor+tray 三模組 go test 全綠(總管親跑)。
(實作=子 CC;驗證+commit=總管)
This commit is contained in:
2026-07-28 12:41:41 +08:00
parent 5e122b47ec
commit 6c9d74d588
7 changed files with 595 additions and 17 deletions
+226
View File
@@ -0,0 +1,226 @@
// sync_status_test.go — t91/t92 狀態檔與 fallback 路徑邏輯單元測試。
package main
import (
"os"
"path/filepath"
"runtime"
"testing"
)
// ── FindClaudeBin fallbackt92)──────────────────────────────────────────────
// TestFindClaudeBinPathHithint 在 PATH 能找到時,直接回傳,不進 fallback。
func TestFindClaudeBinPathHit(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("此測試僅在 Unix 執行")
}
dir := t.TempDir()
bin := filepath.Join(dir, "claude")
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho fake"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+":"+os.Getenv("PATH"))
orig := claudeFallbackPaths
claudeFallbackPaths = nil
defer func() { claudeFallbackPaths = orig }()
found, err := FindClaudeBin("")
if err != nil {
t.Fatalf("PATH 有 claude 卻找不到:%v", err)
}
if found != bin {
t.Errorf("found=%q want=%q", found, bin)
}
}
// TestFindClaudeBinFallbackPATH 找不到時,依序掃 claudeFallbackPaths 並命中。
// 模擬 Finder 啟動的 GUI app PATH 不含 /opt/homebrew/bin 的情境(t92 根因)。
func TestFindClaudeBinFallback(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("此測試僅在 Unix 執行")
}
dir := t.TempDir()
bin := filepath.Join(dir, "claude")
if err := os.WriteFile(bin, []byte("#!/bin/sh\necho fake"), 0o755); err != nil {
t.Fatal(err)
}
orig := claudeFallbackPaths
// 清單:第一個不存在(跳過)、第二個是 bin(命中)
claudeFallbackPaths = []string{filepath.Join(dir, "no-such"), bin}
defer func() { claudeFallbackPaths = orig }()
found, err := FindClaudeBin("/definitely/not/there")
if err != nil {
t.Fatalf("fallback 應找到 %sgot err=%v", bin, err)
}
if found != bin {
t.Errorf("found=%q want=%q", found, bin)
}
}
// TestFindClaudeBinAllMissPATH 和所有 fallback 都找不到 → 回誠實錯誤。
func TestFindClaudeBinAllMiss(t *testing.T) {
orig := claudeFallbackPaths
claudeFallbackPaths = []string{"/does/not/exist/claude"}
defer func() { claudeFallbackPaths = orig }()
_, err := FindClaudeBin("/also/does/not/exist")
if err == nil {
t.Fatal("全部找不到應回錯誤")
}
}
// TestFindClaudeBinNotExecutablefallback 路徑存在但不可執行,不應採用。
func TestFindClaudeBinNotExecutable(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("此測試僅在 Unix 執行")
}
dir := t.TempDir()
notExec := filepath.Join(dir, "claude")
if err := os.WriteFile(notExec, []byte("not exec"), 0o644); err != nil { // 0644 無執行位
t.Fatal(err)
}
orig := claudeFallbackPaths
claudeFallbackPaths = []string{notExec}
defer func() { claudeFallbackPaths = orig }()
_, err := FindClaudeBin("/no/such")
if err == nil {
t.Fatal("不可執行的檔不應被當成可用 claude")
}
}
// ── SyncStatus 狀態檔寫入/讀取(t91)────────────────────────────────────────
// TestSyncStatusRoundTrip:寫進去的結構讀出來完全相同。
func TestSyncStatusRoundTrip(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "status.json")
want := SyncStatus{
LastSync: "2026-07-28T10:00:00Z",
ExtractedOK: 3,
ExtractFailed: 1,
Failures: []ExtractFail{
{Path: "重要文件.md", Error: "找不到 Claude 指令"},
},
ExtractorOK: false,
ExtractorError: "找不到 Claude 指令",
}
if err := SaveSyncStatus(path, want); err != nil {
t.Fatalf("SaveSyncStatus 失敗:%v", err)
}
got, err := LoadSyncStatus(path)
if err != nil {
t.Fatalf("LoadSyncStatus 失敗:%v", err)
}
if got.LastSync != want.LastSync {
t.Errorf("LastSync=%q want=%q", got.LastSync, want.LastSync)
}
if got.ExtractedOK != want.ExtractedOK || got.ExtractFailed != want.ExtractFailed {
t.Errorf("counts: ok=%d fail=%d, want ok=%d fail=%d",
got.ExtractedOK, got.ExtractFailed, want.ExtractedOK, want.ExtractFailed)
}
if len(got.Failures) != 1 || got.Failures[0].Path != "重要文件.md" {
t.Errorf("Failures=%+v", got.Failures)
}
if got.ExtractorOK || got.ExtractorError != want.ExtractorError {
t.Errorf("extractor: ok=%v err=%q", got.ExtractorOK, got.ExtractorError)
}
}
// TestLoadSyncStatusMissingFile:檔不存在回零值+error,不 panic。
func TestLoadSyncStatusMissingFile(t *testing.T) {
_, err := LoadSyncStatus(filepath.Join(t.TempDir(), "no-status.json"))
if err == nil {
t.Fatal("不存在的檔應回 error")
}
}
// TestStatusFilePath:依 manifest 路徑回傳同目錄 status.json。
func TestStatusFilePath(t *testing.T) {
got := StatusFilePath("/home/leo/.arcrun-rag/manifest.json")
want := "/home/leo/.arcrun-rag/status.json"
if got != want {
t.Errorf("StatusFilePath=%q want=%q", got, want)
}
}
// ── RunDirectOnce 寫狀態檔(t91 整合)────────────────────────────────────────
// TestRunDirectOnceWritesStatusRunDirectOnce 完成後應在 manifest 同目錄寫出 status.json。
// 使用空資料夾(無事件)確保不觸發 HTTP,只驗 extractor 預檢結果與 LastSync。
func TestRunDirectOnceWritesStatus(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("stub 腳本測試僅跑 Unix")
}
// 建可執行的假 claude stub
stubDir := t.TempDir()
stub := filepath.Join(stubDir, "claude")
if err := os.WriteFile(stub, []byte("#!/bin/sh\necho ok"), 0o755); err != nil {
t.Fatal(err)
}
// 空資料夾 → 零事件 → 無 HTTP 呼叫
root := t.TempDir()
manifestDir := t.TempDir()
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: filepath.Join(manifestDir, "manifest.json"),
CypherURL: "https://unused.example", Namespace: "demo",
Extractor: "claude", ClaudeBin: stub,
MaxRemoved: DefaultMaxRemovedRatio,
}
RunDirectOnce(cfg, false)
statusPath := StatusFilePath(cfg.Manifest)
if _, err := os.Stat(statusPath); err != nil {
t.Fatalf("RunDirectOnce 後應存在 status.json%v", err)
}
st, err := LoadSyncStatus(statusPath)
if err != nil {
t.Fatalf("LoadSyncStatus 失敗:%v", err)
}
if !st.ExtractorOK {
t.Errorf("可用 stub claude 時 ExtractorOK 應為 trueExtractorError=%q", st.ExtractorError)
}
if st.LastSync == "" {
t.Error("LastSync 應非空")
}
// 空資料夾 → 零事件
if st.ExtractedOK != 0 || st.ExtractFailed != 0 {
t.Errorf("空資料夾應零計數,got ok=%d fail=%d", st.ExtractedOK, st.ExtractFailed)
}
}
// TestRunDirectOnceWritesStatusExtractorFail:找不到 claude 時 status 應記 ExtractorOK=false。
func TestRunDirectOnceWritesStatusExtractorFail(t *testing.T) {
orig := claudeFallbackPaths
claudeFallbackPaths = []string{} // 空 fallback,確保找不到
defer func() { claudeFallbackPaths = orig }()
root := t.TempDir()
manifestDir := t.TempDir()
cfg := &DirectConfig{
WatchFolders: []string{root},
Manifest: filepath.Join(manifestDir, "manifest.json"),
CypherURL: "https://unused.example", Namespace: "demo",
Extractor: "claude", ClaudeBin: "/no/such/claude",
MaxRemoved: DefaultMaxRemovedRatio,
}
RunDirectOnce(cfg, false)
st, err := LoadSyncStatus(StatusFilePath(cfg.Manifest))
if err != nil {
t.Fatalf("LoadSyncStatus 失敗:%v", err)
}
if st.ExtractorOK {
t.Error("找不到 claude 時 ExtractorOK 應為 false")
}
if st.ExtractorError == "" {
t.Error("ExtractorError 應有白話說明")
}
}