diff --git a/local-tool/Makefile b/local-tool/Makefile index 9ea717c..1e1d096 100644 --- a/local-tool/Makefile +++ b/local-tool/Makefile @@ -117,6 +117,54 @@ define copy_bundled_data echo " models.json + $$copied 個 .nef 已就位" endef +# --------------------------------------------------------------------------- +# clean_wheels_dir — 清空並重建 vendor/wheels//,確保 vendor-wheels* 冪等 +# +# $(1) = 目標 wheels 目錄(例:vendor/wheels/windows) +# +# 為什麼必須清空(事故迴歸): +# vendor-wheels* 用 `pip download --dest $(1)`,而 pip download 只「補下載缺的」, +# 不會移除舊版。upstream 每發一次新版,這個目錄就多留一顆 whl —— 實機上 +# vendor/wheels/darwin 累積成 certifi ×3、numpy ×2、idna ×2 … 共 16 顆 +# (正常應為 9 顆)。 +# +# 這些多版本會原封不動被 payload-* 複製進安裝包,最後 app 啟動時 +# `pip install a.whl b.whl ...` 同時收到 certifi 2026.2.25 與 2026.6.17, +# 直接 ResolutionImpossible 並在 ~2 秒內失敗,使用者完全無法啟動。 +# +# (app 端 selectLatestWheelPerPackage 已有防護,能救「已出貨」的舊安裝包; +# 這裡則是從源頭杜絕,讓新產出的安裝包一開始就乾淨。兩層都要有。) +# +# rm -rf 安全防護(與 copy_bundled_data 同一套,$(1) 打錯不能傷到別的目錄): +# 1. 空值檢查 +# 2. 只接受相對路徑、不得含 '..' +# 3. 白名單前綴:必須符合 vendor/wheels/ +# 4. 存在但不是目錄 → 拒絕刪除 +define clean_wheels_dir + @set -e; \ + target='$(strip $(1))'; \ + if [ -z "$$target" ]; then \ + echo "!! ERROR: clean_wheels_dir 的目標目錄為空,拒絕執行 !!"; exit 1; \ + fi; \ + case "$$target" in \ + /*|~*) echo "!! ERROR: 目標目錄必須是相對路徑,收到 '$$target' !!"; exit 1 ;; \ + *..*) echo "!! ERROR: 目標目錄不得含 '..',收到 '$$target' !!"; exit 1 ;; \ + esac; \ + case "$$target" in \ + vendor/wheels/*) : ;; \ + *) echo "!! ERROR: 目標目錄必須符合 vendor/wheels/,收到 '$$target' !!"; exit 1 ;; \ + esac; \ + if [ -e "$$target" ] && [ ! -d "$$target" ]; then \ + echo "!! ERROR: '$$target' 存在但不是目錄,拒絕刪除 !!"; exit 1; \ + fi; \ + if [ -d "$$target" ]; then \ + stale=$$(ls -1 "$$target"/*.whl 2>/dev/null | wc -l | tr -d ' '); \ + echo "==> 清空既有的 $${target}($${stale} 個舊 whl,避免同套件多版本累積)"; \ + rm -rf -- "$$target"; \ + fi; \ + mkdir -p "$$target" +endef + .PHONY: help \ vendor-sync vendor-python vendor-wheels vendor-ffmpeg vendor-ffmpeg-macos-build \ vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows \ @@ -189,7 +237,7 @@ vendor-python: ## 下載 python-build-standalone tarball → vendor/python/darwi fi vendor-wheels: ## 同步 wheels → vendor/wheels/darwin/(內部 wheel 從 visiona-local/wheels 複製,公開相依用 pip download) - @mkdir -p vendor/wheels/darwin + $(call clean_wheels_dir,vendor/wheels/darwin) @echo "==> 同步內部 wheels(KneronPLUS 等)..." @if [ -d visiona-local/wheels/macos ]; then \ cp visiona-local/wheels/macos/*.whl vendor/wheels/darwin/ 2>/dev/null || true; \ @@ -347,6 +395,9 @@ payload-macos: build-server vendor-python vendor-wheels vendor-ffmpeg ## 準備 $(call copy_bundled_data,payload/darwin/data) cp -R server/scripts/* payload/darwin/scripts/ cp vendor/python/darwin/python.tar.gz payload/darwin/python/ + @# payload-macos 雖有 rm -rf payload/darwin,仍顯式清空以與其他平台一致 + @# (日後若移除該 rm -rf,這裡不會默默退化成累積多版本)。 + @rm -f payload/darwin/wheels/*.whl @cp vendor/wheels/darwin/*.whl payload/darwin/wheels/ 2>/dev/null || true @echo "==> macOS payload 完成:" @du -sh payload/darwin @@ -386,7 +437,7 @@ vendor-python-windows: ## 下載 python-build-standalone Windows x86_64 → vend fi vendor-wheels-windows: ## 同步 Windows wheels → vendor/wheels/windows/ - @mkdir -p vendor/wheels/windows + $(call clean_wheels_dir,vendor/wheels/windows) @echo "==> 同步內部 wheels (Windows, KneronPLUS 等)..." @if [ -d visiona-local/wheels/windows ]; then \ cp visiona-local/wheels/windows/*.whl vendor/wheels/windows/ 2>/dev/null || true; \ @@ -462,6 +513,10 @@ payload-windows: build-server-windows vendor-python-windows vendor-wheels-window $(call copy_bundled_data,payload/windows/data) cp -R server/scripts/. payload/windows/scripts/ cp vendor/python/windows/python.tar.gz payload/windows/python/ + @# 先清空再複製:payload-windows 刻意不 rm -rf payload/windows(server.exe 已先放進去), + @# 所以 wheels/ 會殘留前次 build 的舊版本,造成安裝包內同套件多版本 → pip + @# ResolutionImpossible 秒退。這裡確保 staging 精確等於 vendor 的內容。 + @rm -f payload/windows/wheels/*.whl @cp vendor/wheels/windows/*.whl payload/windows/wheels/ 2>/dev/null || true @echo "==> Windows payload 完成:" @du -sh payload/windows @@ -496,7 +551,7 @@ vendor-python-linux: ## 下載 python-build-standalone Linux x86_64 → vendor/p fi vendor-wheels-linux: ## 同步 Linux wheels → vendor/wheels/linux/ - @mkdir -p vendor/wheels/linux + $(call clean_wheels_dir,vendor/wheels/linux) @echo "==> 同步內部 wheels (Linux, KneronPLUS 等)..." @if [ -d visiona-local/wheels/linux ]; then \ cp visiona-local/wheels/linux/*.whl vendor/wheels/linux/ 2>/dev/null || true; \ @@ -550,6 +605,8 @@ payload-linux: build-server-linux vendor-python-linux vendor-wheels-linux vendor exit 1; \ fi @cp vendor/python/linux/python.tar.gz payload/linux/python/ + @# 同 payload-windows:先清空,避免前次 build 的舊版 whl 殘留成多版本。 + @rm -f payload/linux/wheels/*.whl @cp vendor/wheels/linux/*.whl payload/linux/wheels/ 2>/dev/null || true @wheel_count=$$(ls -1 payload/linux/wheels/*.whl 2>/dev/null | wc -l); \ if [ "$$wheel_count" -lt 4 ]; then \ diff --git a/local-tool/visiona-local/app.go b/local-tool/visiona-local/app.go index 1f8ff52..e38d1c4 100644 --- a/local-tool/visiona-local/app.go +++ b/local-tool/visiona-local/app.go @@ -899,13 +899,31 @@ func (a *App) ensurePythonRuntime(mode PythonMode) (string, PythonMode, error) { } switch mode { case PythonModeAuto: - if bin, err := a.ensureBundledPython(); err == nil { - return bin, PythonModeBundled, nil + // ⚠️ 兩條失敗原因都必須保留到最終 error。 + // + // 事故背景:舊版把 bundled / system 的 err 直接丟棄,只回一句通用的 + // "no python runtime available"。實機上 pip 因為 wheels 目錄有同一套件 + // 多版本而 2 秒內 ResolutionImpossible,但使用者(與我們)在 log 裡永遠 + // 只看得到那句通用訊息,連續三輪都無法定位真因。 + // + // 失敗原因是這條路徑上唯一能讓使用者自救的資訊,不可以吞。 + bundledBin, bundledErr := a.ensureBundledPython() + if bundledErr == nil { + return bundledBin, PythonModeBundled, nil } - if bin, err := a.findSystemPython(); err == nil { - return bin, PythonModeSystem, nil + fmt.Fprintf(os.Stderr, "[visiona-local] bundled python 失敗,改試 system python:%v\n", bundledErr) + + systemBin, systemErr := a.findSystemPython() + if systemErr == nil { + return systemBin, PythonModeSystem, nil } - return "", PythonModeAuto, fmt.Errorf("no python runtime available (tried bundled + system)") + fmt.Fprintf(os.Stderr, "[visiona-local] system python 也失敗:%v\n", systemErr) + + // %w 包 bundled(主要路徑、也是幾乎所有實機失敗的來源),system 以文字附上。 + // errors.Is/As 對 bundled 鏈可用,使用者則兩個原因都看得到。 + return "", PythonModeAuto, fmt.Errorf( + "no python runtime available (tried bundled + system)\n bundled 失敗:%w\n system 失敗:%v", + bundledErr, systemErr) case PythonModeSystem: bin, err := a.findSystemPython() @@ -1211,6 +1229,137 @@ func listBundledWheelNames(wheelsDir string) []string { return names } +// droppedWheel 記錄一顆因為同套件有更新版而被略過的 wheel(僅供 log)。 +type droppedWheel struct { + skipped string // 被略過的檔名 + chosen string // 同套件實際採用的檔名 +} + +// parseWheelName 從 wheel 檔名取出 distribution 名稱與版本字串。 +// +// PEP 427 檔名格式:{distribution}-{version}(-{build})?-{python}-{abi}-{platform}.whl +// 前兩段固定是名稱與版本,所以只需切前兩個 "-" 即可,不必完整解析 tag。 +// +// distribution 依 PEP 503 正規化(底線/點/連字號 → 連字號、轉小寫),讓 +// `opencv_python_headless` 與 `opencv-python-headless` 視為同一套件。 +func parseWheelName(filename string) (dist, version string, ok bool) { + base := strings.TrimSuffix(filename, ".whl") + parts := strings.Split(base, "-") + if len(parts) < 2 || parts[0] == "" || parts[1] == "" { + return "", "", false + } + dist = normalizeDistName(parts[0]) + return dist, parts[1], true +} + +// normalizeDistName 依 PEP 503 正規化套件名稱(連續的 -_. 收斂成單一 -、小寫)。 +func normalizeDistName(name string) string { + var b strings.Builder + prevSep := false + for _, r := range strings.ToLower(name) { + if r == '-' || r == '_' || r == '.' { + if !prevSep { + b.WriteRune('-') + prevSep = true + } + continue + } + b.WriteRune(r) + prevSep = false + } + return strings.Trim(b.String(), "-") +} + +// compareWheelVersions 比較兩個版本字串,回傳 -1 / 0 / 1。 +// +// 這裡刻意只做「數字段落逐段比大小」的簡化版 PEP 440,不支援 pre-release +// (1.0rc1 < 1.0)等完整語意。理由:我們只需要在「同一套件的多個 vendored +// wheel」之間挑最新,而那些都是 pip download 抓下來的正式版(2026.2.25 / +// 2026.6.17 這種),不會有 rc。完整實作 PEP 440 需要引入相依,不值得。 +// +// 數字段落用整數比較而非字串比較 —— 字串比較會把 "10" 排在 "9" 前面。 +// 非數字段落(如 "post1"、"dev")退回字串比較,保證結果穩定可預期。 +func compareWheelVersions(a, b string) int { + as := strings.Split(a, ".") + bs := strings.Split(b, ".") + for i := 0; i < len(as) || i < len(bs); i++ { + var av, bv string + if i < len(as) { + av = as[i] + } + if i < len(bs) { + bv = bs[i] + } + an, aErr := strconv.Atoi(av) + bn, bErr := strconv.Atoi(bv) + if aErr == nil && bErr == nil { + if an != bn { + if an < bn { + return -1 + } + return 1 + } + continue + } + if av != bv { + if av < bv { + return -1 + } + return 1 + } + } + return 0 +} + +// selectLatestWheelPerPackage 對每個套件只保留版本最高的一顆 wheel,回傳完整路徑。 +// +// 為什麼需要:見 installBundledWheels 內的說明 —— vendor/wheels/ 會累積同一 +// 套件的多個版本,一起丟給 pip 會 ResolutionImpossible 而秒退。 +// +// 無法解析檔名的 wheel 一律保留(寧可交給 pip 判斷,也不要靜默丟掉使用者的相依)。 +// 回傳順序依檔名排序,確保 pip 參數穩定、可測。 +func selectLatestWheelPerPackage(wheelsDir string, names []string) (paths []string, dropped []droppedWheel) { + best := make(map[string]string, len(names)) // dist → 目前最新的檔名 + var keep []string // 無法解析、直接保留的檔名 + + sorted := append([]string(nil), names...) + sort.Strings(sorted) + + for _, n := range sorted { + dist, ver, ok := parseWheelName(n) + if !ok { + keep = append(keep, n) + continue + } + cur, seen := best[dist] + if !seen { + best[dist] = n + continue + } + _, curVer, _ := parseWheelName(cur) + if compareWheelVersions(ver, curVer) > 0 { + best[dist] = n + dropped = append(dropped, droppedWheel{skipped: cur, chosen: n}) + } else { + dropped = append(dropped, droppedWheel{skipped: n, chosen: cur}) + } + } + + chosen := make([]string, 0, len(best)+len(keep)) + for _, n := range best { + chosen = append(chosen, n) + } + chosen = append(chosen, keep...) + sort.Strings(chosen) + + paths = make([]string, 0, len(chosen)) + for _, n := range chosen { + paths = append(paths, filepath.Join(wheelsDir, n)) + } + sort.Slice(dropped, func(i, j int) bool { return dropped[i].skipped < dropped[j].skipped }) + return paths, dropped +} + // errPythonDepsProbeFailed 標示「pip 安裝成功、但事後 import 驗證沒過」。 // // 與「pip 本身失敗」分開的原因:pip 失敗代表相依真的沒裝上(致命);probe 失敗 @@ -1230,9 +1379,22 @@ func (a *App) installBundledWheels(runtimeDir, pythonBin, wheelsDir string) erro return nil } - wheels := make([]string, 0, len(names)) - for _, n := range names { - wheels = append(wheels, filepath.Join(wheelsDir, n)) + // ⚠️ 同一套件只能挑一個版本送給 pip。 + // + // 事故背景:`make vendor-wheels*` 用 `pip download --dest vendor/wheels/`, + // 而該目錄不會被清空 —— 每次 upstream 出新版就多留一顆 whl,舊的仍在。實機上 + // vendor/wheels/darwin 已累積成 certifi ×3、numpy ×2 …共 16 顆。 + // + // 把重複版本一起丟給 `pip install a.whl b.whl` 會直接 ResolutionImpossible + // ("Cannot install certifi 2026.2.25 and certifi 2026.6.17"),~0.5 秒就退出, + // 而不是慢慢跑完才失敗。這正是使用者看到的「2 秒失敗」。 + // + // 在這裡擋掉而不是只修 Makefile:使用者手上的安裝包已經帶著多版本 wheels, + // 修 build 腳本救不了已出貨的版本;而這層防護對乾淨的 wheels 目錄是 no-op。 + wheels, dropped := selectLatestWheelPerPackage(wheelsDir, names) + for _, d := range dropped { + fmt.Fprintf(os.Stderr, + "[visiona-local] wheels 目錄有同套件多版本,忽略舊版:%s(採用 %s)\n", d.skipped, d.chosen) } a.setBootstrapStatus(fmt.Sprintf("正在安裝 %d 個 Python 套件 (numpy / opencv / KneronPLUS ...) (~30-60 秒)...", len(wheels))) diff --git a/local-tool/visiona-local/wheel_dedup_test.go b/local-tool/visiona-local/wheel_dedup_test.go new file mode 100644 index 0000000..faa3577 --- /dev/null +++ b/local-tool/visiona-local/wheel_dedup_test.go @@ -0,0 +1,365 @@ +package main + +// wheel_dedup_test.go — 「同套件多版本 wheel 導致 pip 秒退」的迴歸測試 +// +// 事故背景(使用者連續三輪 Windows 啟動失敗): +// +// [00:41:59] bootstrap: 正在安裝 16 個 Python 套件 ... +// [00:42:01] startup: ctrl.Start failed: python runtime unavailable: +// no python runtime available (tried bundled + system) +// +// 兩個獨立的 bug 疊在一起: +// +// 1. 根因:`make vendor-wheels*` 用 `pip download --dest vendor/wheels/`, +// 該目錄從不清空,upstream 每出新版就多留一顆 whl。實機 vendor/wheels/darwin +// 已累積成 certifi ×3、numpy ×2、idna ×2 … 共 16 顆(正常應為 9 顆,log 裡 +// 的「16 個套件」就是這麼來的)。把重複版本一起丟給 +// `pip install a.whl b.whl` 會 ResolutionImpossible,~0.5 秒退出 —— 正是 +// 使用者看到的「2 秒失敗」。 +// +// 2. 放大器:ensurePythonRuntime 的 Auto 分支把 bundled / system 的 err 直接 +// 丟棄,只回一句通用的 "no python runtime available",使得上面那個明確的 +// pip 錯誤永遠不會出現在使用者眼前,連續三輪無法定位。 +// +// 下面的測試分別釘死這兩件事。 + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// --------------------------------------------------------------------------- +// 1. 根因:同套件多版本必須只留最新一顆 +// --------------------------------------------------------------------------- + +// 使用者實機的 wheels 目錄內容(vendor/wheels/darwin,16 顆)。 +// 這組資料就是會讓 pip ResolutionImpossible 的那組。 +var realWorldDuplicatedWheels = []string{ + "KneronPLUS-2.0.0-py3-none-any.whl", + "certifi-2026.2.25-py3-none-any.whl", + "certifi-2026.6.17-py3-none-any.whl", + "certifi-2026.7.22-py3-none-any.whl", + "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", + "charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", + "idna-3.11-py3-none-any.whl", + "idna-3.18-py3-none-any.whl", + "numpy-2.4.4-cp312-cp312-macosx_10_13_x86_64.whl", + "numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", + "opencv_python_headless-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl", + "pyusb-1.3.1-py3-none-any.whl", + "requests-2.33.1-py3-none-any.whl", + "requests-2.34.2-py3-none-any.whl", + "urllib3-2.6.3-py3-none-any.whl", + "urllib3-2.7.0-py3-none-any.whl", +} + +// 核心迴歸:16 顆多版本 wheels → 每個套件只剩一顆,且都是最新版。 +func TestSelectLatestWheelPerPackage_RealWorldDuplicates(t *testing.T) { + paths, dropped := selectLatestWheelPerPackage("/w", realWorldDuplicatedWheels) + + // 9 個不同套件:KneronPLUS certifi charset_normalizer idna numpy + // opencv_python_headless pyusb requests urllib3 + if len(paths) != 9 { + t.Fatalf("16 顆多版本 wheels 應收斂成 9 顆(每套件一顆)\ngot=%d\n%v", len(paths), paths) + } + if len(dropped) != 7 { + t.Fatalf("應丟棄 7 顆舊版\ngot=%d %v", len(dropped), dropped) + } + + want := []string{ + "KneronPLUS-2.0.0-py3-none-any.whl", + "certifi-2026.7.22-py3-none-any.whl", + "charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", + "idna-3.18-py3-none-any.whl", + "numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", + "opencv_python_headless-4.10.0.84-cp37-abi3-macosx_12_0_x86_64.whl", + "pyusb-1.3.1-py3-none-any.whl", + "requests-2.34.2-py3-none-any.whl", + "urllib3-2.7.0-py3-none-any.whl", + } + for i, w := range want { + if got := filepath.Base(paths[i]); got != w { + t.Errorf("paths[%d] = %q, want %q", i, got, w) + } + } + + // 最關鍵的性質:pip 參數中不得有任何套件出現兩次(有就會 ResolutionImpossible) + assertNoDuplicatePackages(t, paths) +} + +// 性質測試:不論輸入多少重複版本,輸出永遠每套件至多一顆。 +// 這是「pip 不會再 ResolutionImpossible」的充分條件。 +func TestSelectLatestWheelPerPackage_NeverEmitsDuplicatePackage(t *testing.T) { + cases := [][]string{ + realWorldDuplicatedWheels, + {"numpy-1.0-py3-none-any.whl", "numpy-2.0-py3-none-any.whl", "numpy-3.0-py3-none-any.whl"}, + // 正規化:底線 vs 連字號應視為同一套件 + {"opencv_python_headless-4.10.0-py3-none-any.whl", "opencv-python-headless-4.13.0-py3-none-any.whl"}, + // 大小寫差異 + {"Certifi-2026.1.1-py3-none-any.whl", "certifi-2026.2.2-py3-none-any.whl"}, + } + for i, names := range cases { + paths, _ := selectLatestWheelPerPackage("/w", names) + t.Run(fmt.Sprintf("case%d", i), func(t *testing.T) { + assertNoDuplicatePackages(t, paths) + }) + } +} + +// 乾淨的 wheels 目錄(Windows 的 9 顆)必須是 no-op —— 一顆都不能少。 +// 防止「修重複版本」誤傷正常安裝包。 +func TestSelectLatestWheelPerPackage_CleanDirIsNoOp(t *testing.T) { + clean := []string{ + "KneronPLUS-3.1.2-py3-none-any.whl", + "certifi-2026.2.25-py3-none-any.whl", + "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", + "idna-3.11-py3-none-any.whl", + "numpy-2.4.4-cp312-cp312-win_amd64.whl", + "opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", + "pyusb-1.3.1-py3-none-any.whl", + "requests-2.33.1-py3-none-any.whl", + "urllib3-2.6.3-py3-none-any.whl", + } + paths, dropped := selectLatestWheelPerPackage("/w", clean) + if len(paths) != len(clean) { + t.Fatalf("乾淨目錄不得丟棄任何 wheel\ngot=%d want=%d", len(paths), len(clean)) + } + if len(dropped) != 0 { + t.Fatalf("乾淨目錄不應有 dropped,got=%v", dropped) + } +} + +// 無法解析檔名的 wheel 必須保留(寧可交給 pip 判斷,也不要靜默吞掉相依)。 +func TestSelectLatestWheelPerPackage_KeepsUnparseableWheels(t *testing.T) { + paths, _ := selectLatestWheelPerPackage("/w", []string{"weird.whl", "numpy-2.5.1-py3-none-any.whl"}) + if len(paths) != 2 { + t.Fatalf("無法解析的 wheel 應保留\ngot=%v", paths) + } +} + +// 回傳路徑必須帶上 wheelsDir 前綴(pip 需要完整路徑)。 +func TestSelectLatestWheelPerPackage_ReturnsFullPaths(t *testing.T) { + paths, _ := selectLatestWheelPerPackage("/tmp/wheels", []string{"numpy-2.5.1-py3-none-any.whl"}) + want := filepath.Join("/tmp/wheels", "numpy-2.5.1-py3-none-any.whl") + if len(paths) != 1 || paths[0] != want { + t.Fatalf("got=%v want=[%s]", paths, want) + } +} + +func assertNoDuplicatePackages(t *testing.T, paths []string) { + t.Helper() + seen := map[string]string{} + for _, p := range paths { + base := filepath.Base(p) + dist, _, ok := parseWheelName(base) + if !ok { + continue + } + if prev, dup := seen[dist]; dup { + t.Fatalf("套件 %q 出現兩次(pip 會 ResolutionImpossible 秒退):%s 與 %s", + dist, prev, base) + } + seen[dist] = base + } +} + +// --------------------------------------------------------------------------- +// 1b. 整合層:installBundledWheels 實際交給 pip 的參數必須已去重 +// +// 單獨測 selectLatestWheelPerPackage 不夠 —— 若有人把 installBundledWheels 改回 +// 「把 names 全部丟給 pip」,純函式測試仍會全綠,但使用者又會回到 2 秒失敗。 +// 這條測試攔的就是那個回歸:直接檢查真正送進 pip 的 argv。 +// --------------------------------------------------------------------------- +func TestInstallBundledWheels_PassesDedupedArgsToPip(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("shell-script 假 python 在 Windows 上不適用") + } + runtimeDir := t.TempDir() + wheels := wheelsDirWith(t, realWorldDuplicatedWheels...) + + // 假 python:把收到的 argv 逐行寫檔,pip 與 probe 都回成功。 + argsLog := filepath.Join(runtimeDir, "args.txt") + fakePython := filepath.Join(runtimeDir, "python3") + writeFile(t, fakePython, "#!/bin/sh\nfor arg in \"$@\"; do echo \"$arg\" >> "+argsLog+"; done\nexit 0\n") + if err := os.Chmod(fakePython, 0o755); err != nil { + t.Fatalf("chmod: %v", err) + } + + a := &App{} + if err := a.installBundledWheels(runtimeDir, fakePython, wheels); err != nil { + t.Fatalf("installBundledWheels: %v", err) + } + + raw, err := os.ReadFile(argsLog) + if err != nil { + t.Fatalf("read args log: %v", err) + } + + // 收集 argv 中所有 .whl 參數 —— 這就是 pip 真正要安裝的清單。 + var passed []string + for _, line := range strings.Split(string(raw), "\n") { + if strings.HasSuffix(strings.TrimSpace(line), ".whl") { + passed = append(passed, strings.TrimSpace(line)) + } + } + + if len(passed) != 9 { + t.Fatalf("pip 應只收到 9 顆(每套件一顆),實際 %d 顆\n%v", len(passed), passed) + } + // 核心斷言:argv 內不得有同一套件的兩個版本,否則 pip ResolutionImpossible。 + assertNoDuplicatePackages(t, passed) +} + +// --------------------------------------------------------------------------- +// 2. 版本比較:數字段落必須以整數比大小 +// --------------------------------------------------------------------------- + +func TestCompareWheelVersions(t *testing.T) { + cases := []struct { + a, b string + want int + }{ + {"2026.2.25", "2026.6.17", -1}, + {"2026.6.17", "2026.7.22", -1}, + {"2026.7.22", "2026.2.25", 1}, + {"3.4.7", "3.4.9", -1}, + {"2.4.4", "2.5.1", -1}, + {"1.3.1", "1.3.1", 0}, + // 字串比較會答錯的案例("10" < "9" 字典序)—— 必須用整數比 + {"3.9", "3.10", -1}, + {"2.0", "10.0", -1}, + // 段數不同 + {"4.13.0", "4.13.0.92", -1}, + {"1.0", "1.0.0", -1}, + } + for _, c := range cases { + if got := compareWheelVersions(c.a, c.b); got != c.want { + t.Errorf("compareWheelVersions(%q, %q) = %d, want %d", c.a, c.b, got, c.want) + } + } +} + +// 釘住「用整數而非字串比較」:numpy 2.9 vs 2.10,字串比較會誤選 2.9。 +func TestSelectLatestWheelPerPackage_NumericNotLexicographic(t *testing.T) { + paths, _ := selectLatestWheelPerPackage("/w", []string{ + "numpy-2.9.0-py3-none-any.whl", + "numpy-2.10.0-py3-none-any.whl", + }) + if len(paths) != 1 { + t.Fatalf("應只留一顆,got=%v", paths) + } + if got := filepath.Base(paths[0]); got != "numpy-2.10.0-py3-none-any.whl" { + t.Fatalf("應選數值較大的 2.10.0(字串比較會誤選 2.9.0),got=%q", got) + } +} + +func TestParseWheelName(t *testing.T) { + cases := []struct { + file, dist, ver string + ok bool + }{ + {"numpy-2.4.4-cp312-cp312-win_amd64.whl", "numpy", "2.4.4", true}, + {"KneronPLUS-3.1.2-py3-none-any.whl", "kneronplus", "3.1.2", true}, + // 正規化:底線 → 連字號、轉小寫 + {"opencv_python_headless-4.13.0.92-cp37-abi3-win_amd64.whl", "opencv-python-headless", "4.13.0.92", true}, + {"charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", "charset-normalizer", "3.4.7", true}, + {"weird.whl", "", "", false}, + {"", "", "", false}, + } + for _, c := range cases { + dist, ver, ok := parseWheelName(c.file) + if ok != c.ok || dist != c.dist || ver != c.ver { + t.Errorf("parseWheelName(%q) = (%q, %q, %v), want (%q, %q, %v)", + c.file, dist, ver, ok, c.dist, c.ver, c.ok) + } + } +} + +// PEP 503 正規化:底線 / 點 / 連字號視為等價,連續分隔符收斂。 +func TestNormalizeDistName(t *testing.T) { + cases := []struct{ in, want string }{ + {"opencv_python_headless", "opencv-python-headless"}, + {"opencv-python-headless", "opencv-python-headless"}, + {"opencv.python.headless", "opencv-python-headless"}, + {"KneronPLUS", "kneronplus"}, + {"charset__normalizer", "charset-normalizer"}, + } + for _, c := range cases { + if got := normalizeDistName(c.in); got != c.want { + t.Errorf("normalizeDistName(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// --------------------------------------------------------------------------- +// 3. 放大器:Auto 模式失敗時必須保留 bundled 的真實原因 +// --------------------------------------------------------------------------- + +// 這是使用者「連續三輪看不到真因」的直接迴歸測試。 +// +// 測試必須是確定性的,所以兩條路徑都強制失敗: +// - bundled:cwd 切到空目錄 + dataDir 空 → locateBundledPythonAssets 找不到資產 +// - system:PATH 清空 → exec.LookPath 找不到任何 python +// +// 不這樣控制的話,開發機上 findSystemPython 會成功,Auto 直接回傳,測試被略過; +// 更糟的是 bundled 路徑會真的去解壓 tarball 建 venv(實測 32 秒)。 +func TestEnsurePythonRuntime_AutoPreservesBundledFailureReason(t *testing.T) { + // PATH 清空 → system python 一定找不到。t.Setenv 會自動還原。 + t.Setenv("PATH", "") + // AppImage 的資產提示也要清掉,否則可能指到真實 bundle。 + t.Setenv("VISIONA_BUNDLE_LIB_DIR", "") + + // cwd 切到空目錄 → payload/ 的開發模式 fallback 一定找不到。 + // (不用 t.Chdir:那是 go1.24 才有的 API,本 module 是 go1.22。) + origWD, err := os.Getwd() + if err != nil { + t.Fatalf("getwd: %v", err) + } + if err := os.Chdir(t.TempDir()); err != nil { + t.Fatalf("chdir: %v", err) + } + t.Cleanup(func() { _ = os.Chdir(origWD) }) + + a := &App{dataDir: t.TempDir()} + + _, _, runErr := a.ensurePythonRuntime(PythonModeAuto) + if runErr == nil { + t.Fatal("bundled 與 system 都不可用時,Auto 必須回 error") + } + + msg := runErr.Error() + // 通用訊息仍在(維持既有語意) + if !strings.Contains(msg, "no python runtime available") { + t.Errorf("應保留原本的通用訊息,got=%q", msg) + } + // 關鍵:必須帶出 bundled 的具體原因,而不是只有通用訊息 + if !strings.Contains(msg, "bundled 失敗") { + t.Errorf("最終 error 必須包含 bundled 失敗的原因(否則使用者看不到 pip 真錯)\ngot=%q", msg) + } + if !strings.Contains(msg, "system 失敗") { + t.Errorf("最終 error 必須包含 system 失敗的原因\ngot=%q", msg) + } + // bundled 的原始 error 必須可用 errors.Is/As 追溯(%w 而非 %v) + if !strings.Contains(msg, "bundled python assets not found") { + t.Errorf("bundled 的根本原因(找不到資產)應原樣出現在訊息中\ngot=%q", msg) + } +} + +// 釘住 %w 包裝:bundled 的 error 鏈必須可被 errors.Is 追溯。 +// +// 用一個 sentinel 驗證 fmt.Errorf 的包裝語意沒被改回 %v。 +func TestAutoModeErrorWrapping_IsUnwrappable(t *testing.T) { + sentinel := errors.New("pip install wheels: ResolutionImpossible") + wrapped := fmt.Errorf( + "no python runtime available (tried bundled + system)\n bundled 失敗:%w\n system 失敗:%v", + sentinel, errors.New("no suitable python3")) + + if !errors.Is(wrapped, sentinel) { + t.Fatal("bundled 的原始 error 必須能被 errors.Is 追溯(fmt.Errorf 要用 percent-w 包裝,不可退回 percent-v)") + } +}