package main // venv_health_test.go — bundled Python venv 健康檢查單元測試 // // 背景:舊版 ensureBundledPython 只 os.Stat(python.exe) 就視為就緒。實機踩到的 // 情境是 python.exe 在、但 `import kp` 失敗(pip 中途失敗留下半套 venv), // 於是每次啟動都跳過安裝、永遠卡住且無任何提示,使用者只能手動刪整個 runtime。 // // 這裡驗證的三件事: // 1. 指紋(wheels 清單)能偵測到 wheels 換版 // 2. 標記檔的快路徑不會啟動任何 process(成本) // 3. 壞掉的 venv 不會被誤判成健康 import ( "os" "path/filepath" "runtime" "strings" "testing" ) func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) } } func wheelsDirWith(t *testing.T, names ...string) string { t.Helper() dir := t.TempDir() for _, n := range names { writeFile(t, filepath.Join(dir, n), "x") } return dir } func TestBundledWheelsFingerprint_StableRegardlessOfReadOrder(t *testing.T) { a := wheelsDirWith(t, "numpy-2.4.4.whl", "KneronPLUS-3.1.2.whl", "opencv.whl") b := wheelsDirWith(t, "opencv.whl", "KneronPLUS-3.1.2.whl", "numpy-2.4.4.whl") if bundledWheelsFingerprint(a) != bundledWheelsFingerprint(b) { t.Fatalf("fingerprint 應與檔案列舉順序無關\na=%q\nb=%q", bundledWheelsFingerprint(a), bundledWheelsFingerprint(b)) } } func TestBundledWheelsFingerprint_ChangesWhenWheelVersionChanges(t *testing.T) { // 這是升級 KneronPLUS 時觸發重裝的機制:wheel 檔名帶版本號。 old := wheelsDirWith(t, "KneronPLUS-2.0.0-py3-none-any.whl", "numpy-2.4.4.whl") upgraded := wheelsDirWith(t, "KneronPLUS-3.1.2-py3-none-any.whl", "numpy-2.4.4.whl") if bundledWheelsFingerprint(old) == bundledWheelsFingerprint(upgraded) { t.Fatal("wheels 換版後 fingerprint 必須改變,否則升級不會觸發重裝") } } func TestBundledWheelsFingerprint_IgnoresNonWheelFiles(t *testing.T) { dir := wheelsDirWith(t, "numpy-2.4.4.whl") writeFile(t, filepath.Join(dir, "README.txt"), "not a wheel") writeFile(t, filepath.Join(dir, ".DS_Store"), "junk") if got := bundledWheelsFingerprint(dir); got != "numpy-2.4.4.whl" { t.Fatalf("fingerprint=%q, 只應包含 .whl", got) } } func TestBundledWheelsFingerprint_MissingDirIsEmpty(t *testing.T) { if got := bundledWheelsFingerprint(filepath.Join(t.TempDir(), "nope")); got != "" { t.Fatalf("不存在的目錄 fingerprint=%q, want empty", got) } } // 快路徑:標記檔內容與 wheels 相符 → 直接放行,**不執行 pythonBin**。 // 用一個不存在的 pythonBin 路徑證明它真的沒被執行(真跑會失敗)。 func TestBundledPythonDepsHealthy_MarkerFastPathSkipsProcess(t *testing.T) { runtimeDir := t.TempDir() wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl", "numpy-2.4.4.whl") writeFile(t, filepath.Join(runtimeDir, venvReadyMarkerName), bundledWheelsFingerprint(wheels)) a := &App{} nonExistentPython := filepath.Join(runtimeDir, "definitely-not-a-python") if !a.bundledPythonDepsHealthy(runtimeDir, nonExistentPython, wheels) { t.Fatal("標記相符時應走快路徑回 true,且不得執行 python") } } // 標記內容與當前 wheels 不符(升級情境)→ 必須離開快路徑去實跑驗證。 // pythonBin 不存在 → 驗證失敗 → 回 false(觸發重裝)。 func TestBundledPythonDepsHealthy_StaleMarkerTriggersRevalidation(t *testing.T) { runtimeDir := t.TempDir() wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl") writeFile(t, filepath.Join(runtimeDir, venvReadyMarkerName), "KneronPLUS-2.0.0.whl") // 舊版留下的標記 a := &App{} if a.bundledPythonDepsHealthy(runtimeDir, filepath.Join(runtimeDir, "no-python"), wheels) { t.Fatal("標記過期且無法實跑驗證時,不可回報健康") } } // 沒有標記檔(舊版 venv / 首次升級到本版)→ 走慢路徑實跑驗證。 func TestBundledPythonDepsHealthy_NoMarkerAndBrokenPythonIsUnhealthy(t *testing.T) { runtimeDir := t.TempDir() wheels := wheelsDirWith(t, "numpy-2.4.4.whl") a := &App{} if a.bundledPythonDepsHealthy(runtimeDir, filepath.Join(runtimeDir, "no-python"), wheels) { t.Fatal("無標記且 python 不可執行時,不可回報健康") } } // 這是使用者實機踩到的核心情境:python 執行檔在、但 import kp 失敗。 // 舊版只 Stat 檔案存在就放行;新版必須判定為不健康。 func TestBundledPythonDepsHealthy_PythonExistsButImportFails(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell-script 假 python 在 Windows 上不適用") } runtimeDir := t.TempDir() wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl") // 假 python:任何呼叫都以非 0 結束,模擬 import kp 失敗 fakePython := filepath.Join(runtimeDir, "python3") writeFile(t, fakePython, "#!/bin/sh\necho \"ModuleNotFoundError: No module named 'kp'\" >&2\nexit 1\n") if err := os.Chmod(fakePython, 0o755); err != nil { t.Fatalf("chmod: %v", err) } a := &App{} if a.bundledPythonDepsHealthy(runtimeDir, fakePython, wheels) { t.Fatal("import 失敗的 venv 必須判定為不健康(這正是使用者卡住的情境)") } // 且不可留下就緒標記,否則下次啟動又被快路徑放行 if _, err := os.Stat(filepath.Join(runtimeDir, venvReadyMarkerName)); err == nil { t.Fatal("驗證失敗時不可寫入就緒標記") } } // import 成功 → 回 true 並補寫標記,讓下次啟動走快路徑(成本設計的關鍵)。 func TestBundledPythonDepsHealthy_HealthyPythonWritesMarker(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell-script 假 python 在 Windows 上不適用") } runtimeDir := t.TempDir() wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl", "numpy-2.4.4.whl") fakePython := filepath.Join(runtimeDir, "python3") writeFile(t, fakePython, "#!/bin/sh\nexit 0\n") if err := os.Chmod(fakePython, 0o755); err != nil { t.Fatalf("chmod: %v", err) } a := &App{} if !a.bundledPythonDepsHealthy(runtimeDir, fakePython, wheels) { t.Fatal("import 成功時應回報健康") } got, err := os.ReadFile(filepath.Join(runtimeDir, venvReadyMarkerName)) if err != nil { t.Fatalf("應補寫就緒標記讓下次走快路徑: %v", err) } if strings.TrimSpace(string(got)) != bundledWheelsFingerprint(wheels) { t.Fatalf("標記內容=%q, want=%q", got, bundledWheelsFingerprint(wheels)) } } func TestProbePythonModules_EmptyModuleListIsNoop(t *testing.T) { // 空清單不該啟動 process(傳不存在的路徑也不能失敗) if err := probePythonModules("/definitely/not/a/python", nil); err != nil { t.Fatalf("空模組清單應為 no-op, got %v", err) } } func TestProbePythonModules_ReportsStderrOnFailure(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell-script 假 python 在 Windows 上不適用") } dir := t.TempDir() fakePython := filepath.Join(dir, "python3") writeFile(t, fakePython, "#!/bin/sh\necho \"No module named 'kp'\" >&2\nexit 1\n") if err := os.Chmod(fakePython, 0o755); err != nil { t.Fatalf("chmod: %v", err) } err := probePythonModules(fakePython, []string{"kp"}) if err == nil { t.Fatal("非 0 結束碼應回報錯誤") } // 錯誤訊息要帶上 python 的 stderr,否則使用者看不到真正原因 if !strings.Contains(err.Error(), "No module named") { t.Fatalf("錯誤訊息應包含 python stderr, got: %v", err) } } func TestListBundledWheelNames_MissingDirReturnsNil(t *testing.T) { if got := listBundledWheelNames(filepath.Join(t.TempDir(), "nope")); len(got) != 0 { t.Fatalf("不存在的目錄應回空, got %v", got) } }