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)") } }