visionA/local-tool/visiona-local/wheel_dedup_test.go
jim800121chen 4f316bb3b8 fix: wheels 目錄累積多版本導致 pip 解析失敗,app 啟動失敗
Windows 啟動卡在階段 2,pip 在 2 秒內失敗:

    ERROR: Cannot install certifi 2026.2.25 and certifi 2026.6.17
           because these package versions have conflicting dependencies.
    ERROR: ResolutionImpossible

根因:Makefile 的 vendor-wheels* 用 pip download --dest 但從不清空目錄,
payload-* 複製時也只疊上去不清。upstream 每發一次新版就多留一顆,於是
`pip install *.whl` 同時收到同一套件的多個版本,相依解析器立刻放棄。

vendor/wheels/darwin 目前 16 顆 = 9 個套件 + 7 顆重複舊版,與 log 的
「正在安裝 16 個 Python 套件」完全吻合。

三層修正:

1. ensurePythonRuntime 的 Auto 分支不再丟棄 bundled/system 的失敗原因。
   這是使用者連續三輪拿不到線索的原因 —— 它不是根因,是放大器。
   pip 的完整輸出本來就在 error 裡,只是被這裡擋掉。

2. selectLatestWheelPerPackage:送 pip 前每個套件只留最新版。這層才救得了
   已經出貨的安裝包,只修 Makefile 對使用者手上那包無效。套件名依 PEP 503
   正規化,版本用整數逐段比(避免 2.9 > 2.10 的字典序錯誤)。

3. Makefile 新增 clean_wheels_dir(沿用 copy_bundled_data 的路徑防護),
   vendor-wheels* 先清再下載、payload-* 複製前先清,從源頭杜絕。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 00:55:42 +08:00

366 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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/<os>`
// 該目錄從不清空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/darwin16 顆)。
// 這組資料就是會讓 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("乾淨目錄不應有 droppedgot=%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.0got=%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 的真實原因
// ---------------------------------------------------------------------------
// 這是使用者「連續三輪看不到真因」的直接迴歸測試。
//
// 測試必須是確定性的,所以兩條路徑都強制失敗:
// - bundledcwd 切到空目錄 + dataDir 空 → locateBundledPythonAssets 找不到資產
// - systemPATH 清空 → 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/<os> 的開發模式 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")
}
}