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 ( "errors" "os" "path/filepath" "runtime" "strings" "testing" ) // --------------------------------------------------------------------------- // 迴歸測試:健康檢查不得成為啟動的阻斷點 // // 事故背景(commit f9fbc77):健康檢查失敗時 ensureBundledPython 直接回 error, // Windows 使用者的 app 在啟動階段 2 就掛掉、完全打不開。 // // 而這個健康檢查**本來就會誤判**:probe 跑裸的 `python -c "import kp"`,但 kp // 在 import 時就 ctypes.CDLL 載入 kp/lib 下的 native DLL(libkplus / libusb-1.0 / // libwdi)。Windows 上這些 DLL 不在預設搜尋路徑,要先 add_dll_directory —— 真正 // 跑 bridge 的 kl720_driver.go startPython() 有注入 PATH,probe 沒有。 // // 代價不對稱:放行 → 使用者進 app 後看到具體錯誤(bridge 本來就把 import kp 當 // optional);擋下 → app 完全開不了。所以下面這幾條測試釘住「修不好也要放行」。 // --------------------------------------------------------------------------- // 使用者實機情境:venv 在、python 可執行、但 import kp 失敗(Windows 缺 DLL 路徑), // 且 wheels 重裝也失敗。必須仍然回傳 pythonBin。 func TestReuseExistingVenv_ImportFailsAndRepairFails_StillReturnsPythonBin(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:import 失敗、pip 也失敗(模擬最壞情況) fakePython := filepath.Join(runtimeDir, "python3") writeFile(t, fakePython, "#!/bin/sh\necho \"ImportError: DLL load failed while importing kp\" >&2\nexit 1\n") if err := os.Chmod(fakePython, 0o755); err != nil { t.Fatalf("chmod: %v", err) } a := &App{} got := a.reuseExistingVenv(runtimeDir, fakePython, wheels) if got != fakePython { t.Fatalf("健康檢查失敗時仍必須回傳 pythonBin(不可阻斷啟動)\ngot=%q want=%q", got, fakePython) } // 而且要留下讓使用者看得到的警告,不能靜默 if w := a.GetPythonDepsWarning(); w == "" { t.Fatal("修復失敗時必須留下 warning 讓使用者在 app 內看到自救步驟") } else if !strings.Contains(w, runtimeDir) { t.Fatalf("warning 應告知要刪除哪個目錄,got: %q", w) } } // 健康的 venv 不該被打擾:回傳 pythonBin 且不留 warning。 func TestReuseExistingVenv_HealthyVenvReturnsBinWithoutWarning(t *testing.T) { runtimeDir := t.TempDir() wheels := wheelsDirWith(t, "KneronPLUS-3.1.2.whl", "numpy-2.4.4.whl") // 標記相符 → 快路徑,不執行 python writeFile(t, filepath.Join(runtimeDir, venvReadyMarkerName), bundledWheelsFingerprint(wheels)) a := &App{} pythonBin := filepath.Join(runtimeDir, "python3") if got := a.reuseExistingVenv(runtimeDir, pythonBin, wheels); got != pythonBin { t.Fatalf("健康的 venv 應直接回傳 pythonBin, got=%q", got) } if w := a.GetPythonDepsWarning(); w != "" { t.Fatalf("健康的 venv 不該留 warning, got: %q", w) } } // 舊版留下的 venv(無標記)+ import 驗證通過 → 放行且不留 warning。 func TestReuseExistingVenv_LegacyVenvThatPassesProbeIsSilent(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell-script 假 python 在 Windows 上不適用") } runtimeDir := t.TempDir() wheels := wheelsDirWith(t, "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 got := a.reuseExistingVenv(runtimeDir, fakePython, wheels); got != fakePython { t.Fatalf("probe 通過的舊 venv 應放行, got=%q", got) } if w := a.GetPythonDepsWarning(); w != "" { t.Fatalf("probe 通過時不該留 warning, got: %q", w) } } // installBundledWheels 必須能區分「pip 失敗」與「只是 probe 沒過」, // 否則首次安裝路徑無法決定該不該放行。 func TestInstallBundledWheels_ProbeFailureIsDistinguishableFromPipFailure(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell-script 假 python 在 Windows 上不適用") } runtimeDir := t.TempDir() wheels := wheelsDirWith(t, "numpy-2.4.4.whl") // pip install 成功(有參數時 exit 0)、但 -c 的 import probe 失敗 fakePython := filepath.Join(runtimeDir, "python3") writeFile(t, fakePython, `#!/bin/sh for arg in "$@"; do if [ "$arg" = "-c" ]; then echo "ImportError: DLL load failed while importing kp" >&2 exit 1 fi done exit 0 `) if err := os.Chmod(fakePython, 0o755); err != nil { t.Fatalf("chmod: %v", err) } a := &App{} err := a.installBundledWheels(runtimeDir, fakePython, wheels) if err == nil { t.Fatal("probe 失敗時應回報錯誤") } if !errors.Is(err, errPythonDepsProbeFailed) { t.Fatalf("probe 失敗必須可用 errors.Is 辨識(呼叫端要據此放行), got: %v", err) } // probe 沒過就不可留下就緒標記,否則下次啟動被快路徑放行、真壞掉也發現不了 if _, statErr := os.Stat(filepath.Join(runtimeDir, venvReadyMarkerName)); statErr == nil { t.Fatal("probe 未通過時不可寫入就緒標記") } } // pip 真的失敗(相依根本沒裝上)不該被誤標成 probe 失敗 —— 那是致命錯誤。 func TestInstallBundledWheels_PipFailureIsNotProbeFailure(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("shell-script 假 python 在 Windows 上不適用") } runtimeDir := t.TempDir() wheels := wheelsDirWith(t, "numpy-2.4.4.whl") fakePython := filepath.Join(runtimeDir, "python3") writeFile(t, fakePython, "#!/bin/sh\necho 'pip: disk full' >&2\nexit 1\n") if err := os.Chmod(fakePython, 0o755); err != nil { t.Fatalf("chmod: %v", err) } a := &App{} err := a.installBundledWheels(runtimeDir, fakePython, wheels) if err == nil { t.Fatal("pip 失敗時應回報錯誤") } if errors.Is(err, errPythonDepsProbeFailed) { t.Fatalf("pip 失敗不可被歸類為 probe 失敗(兩者處置不同), got: %v", err) } } // probe script 必須在 import 前處理好 native DLL 搜尋路徑, // 否則 Windows 上健康的環境也會被判成壞掉(本次事故的成因之一)。 func TestPythonProbeScript_SetsUpDLLSearchPathBeforeImport(t *testing.T) { script := pythonProbeScript([]string{"kp", "numpy"}) if !strings.Contains(script, "add_dll_directory") { t.Error("probe script 必須呼叫 add_dll_directory,否則 Windows 載不到 kp 的 native DLL") } if !strings.Contains(script, "PATH") { t.Error("probe script 必須把 lib 目錄加進 PATH") } if !strings.Contains(script, "import kp, numpy") { t.Errorf("probe script 應 import 指定模組, got:\n%s", script) } // DLL 路徑設定必須在 import 之前,順序錯了等於沒做 if strings.Index(script, "add_dll_directory") > strings.Index(script, "import kp, numpy") { t.Error("DLL 搜尋路徑設定必須在 import 目標模組之前") } } 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) } }