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>
This commit is contained in:
jim800121chen 2026-07-23 00:55:42 +08:00
parent 34bc361527
commit 4f316bb3b8
3 changed files with 595 additions and 11 deletions

View File

@ -117,6 +117,54 @@ define copy_bundled_data
echo " models.json + $$copied 個 .nef 已就位" echo " models.json + $$copied 個 .nef 已就位"
endef endef
# ---------------------------------------------------------------------------
# clean_wheels_dir — 清空並重建 vendor/wheels/<os>/,確保 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/<os>
# 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/<os>,收到 '$$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 \ .PHONY: help \
vendor-sync vendor-python vendor-wheels vendor-ffmpeg vendor-ffmpeg-macos-build \ vendor-sync vendor-python vendor-wheels vendor-ffmpeg vendor-ffmpeg-macos-build \
vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows \ vendor-python-windows vendor-wheels-windows vendor-ffmpeg-windows \
@ -189,7 +237,7 @@ vendor-python: ## 下載 python-build-standalone tarball → vendor/python/darwi
fi fi
vendor-wheels: ## 同步 wheels → vendor/wheels/darwin/(內部 wheel 從 visiona-local/wheels 複製,公開相依用 pip download 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 "==> 同步內部 wheelsKneronPLUS 等)..." @echo "==> 同步內部 wheelsKneronPLUS 等)..."
@if [ -d visiona-local/wheels/macos ]; then \ @if [ -d visiona-local/wheels/macos ]; then \
cp visiona-local/wheels/macos/*.whl vendor/wheels/darwin/ 2>/dev/null || true; \ 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) $(call copy_bundled_data,payload/darwin/data)
cp -R server/scripts/* payload/darwin/scripts/ cp -R server/scripts/* payload/darwin/scripts/
cp vendor/python/darwin/python.tar.gz payload/darwin/python/ 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 @cp vendor/wheels/darwin/*.whl payload/darwin/wheels/ 2>/dev/null || true
@echo "==> macOS payload 完成:" @echo "==> macOS payload 完成:"
@du -sh payload/darwin @du -sh payload/darwin
@ -386,7 +437,7 @@ vendor-python-windows: ## 下載 python-build-standalone Windows x86_64 → vend
fi fi
vendor-wheels-windows: ## 同步 Windows wheels → vendor/wheels/windows/ vendor-wheels-windows: ## 同步 Windows wheels → vendor/wheels/windows/
@mkdir -p vendor/wheels/windows $(call clean_wheels_dir,vendor/wheels/windows)
@echo "==> 同步內部 wheels (Windows, KneronPLUS 等)..." @echo "==> 同步內部 wheels (Windows, KneronPLUS 等)..."
@if [ -d visiona-local/wheels/windows ]; then \ @if [ -d visiona-local/wheels/windows ]; then \
cp visiona-local/wheels/windows/*.whl vendor/wheels/windows/ 2>/dev/null || true; \ 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) $(call copy_bundled_data,payload/windows/data)
cp -R server/scripts/. payload/windows/scripts/ cp -R server/scripts/. payload/windows/scripts/
cp vendor/python/windows/python.tar.gz payload/windows/python/ cp vendor/python/windows/python.tar.gz payload/windows/python/
@# 先清空再複製payload-windows 刻意不 rm -rf payload/windowsserver.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 @cp vendor/wheels/windows/*.whl payload/windows/wheels/ 2>/dev/null || true
@echo "==> Windows payload 完成:" @echo "==> Windows payload 完成:"
@du -sh payload/windows @du -sh payload/windows
@ -496,7 +551,7 @@ vendor-python-linux: ## 下載 python-build-standalone Linux x86_64 → vendor/p
fi fi
vendor-wheels-linux: ## 同步 Linux wheels → vendor/wheels/linux/ vendor-wheels-linux: ## 同步 Linux wheels → vendor/wheels/linux/
@mkdir -p vendor/wheels/linux $(call clean_wheels_dir,vendor/wheels/linux)
@echo "==> 同步內部 wheels (Linux, KneronPLUS 等)..." @echo "==> 同步內部 wheels (Linux, KneronPLUS 等)..."
@if [ -d visiona-local/wheels/linux ]; then \ @if [ -d visiona-local/wheels/linux ]; then \
cp visiona-local/wheels/linux/*.whl vendor/wheels/linux/ 2>/dev/null || true; \ 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; \ exit 1; \
fi fi
@cp vendor/python/linux/python.tar.gz payload/linux/python/ @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 @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); \ @wheel_count=$$(ls -1 payload/linux/wheels/*.whl 2>/dev/null | wc -l); \
if [ "$$wheel_count" -lt 4 ]; then \ if [ "$$wheel_count" -lt 4 ]; then \

View File

@ -899,13 +899,31 @@ func (a *App) ensurePythonRuntime(mode PythonMode) (string, PythonMode, error) {
} }
switch mode { switch mode {
case PythonModeAuto: case PythonModeAuto:
if bin, err := a.ensureBundledPython(); err == nil { // ⚠️ 兩條失敗原因都必須保留到最終 error。
return bin, PythonModeBundled, nil //
// 事故背景:舊版把 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 { fmt.Fprintf(os.Stderr, "[visiona-local] bundled python 失敗,改試 system python%v\n", bundledErr)
return bin, PythonModeSystem, nil
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: case PythonModeSystem:
bin, err := a.findSystemPython() bin, err := a.findSystemPython()
@ -1211,6 +1229,137 @@ func listBundledWheelNames(wheelsDir string) []string {
return names 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/<os> 會累積同一
// 套件的多個版本,一起丟給 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 驗證沒過」。 // errPythonDepsProbeFailed 標示「pip 安裝成功、但事後 import 驗證沒過」。
// //
// 與「pip 本身失敗」分開的原因pip 失敗代表相依真的沒裝上致命probe 失敗 // 與「pip 本身失敗」分開的原因pip 失敗代表相依真的沒裝上致命probe 失敗
@ -1230,9 +1379,22 @@ func (a *App) installBundledWheels(runtimeDir, pythonBin, wheelsDir string) erro
return nil return nil
} }
wheels := make([]string, 0, len(names)) // ⚠️ 同一套件只能挑一個版本送給 pip。
for _, n := range names { //
wheels = append(wheels, filepath.Join(wheelsDir, n)) // 事故背景:`make vendor-wheels*` 用 `pip download --dest vendor/wheels/<os>`
// 而該目錄不會被清空 —— 每次 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))) a.setBootstrapStatus(fmt.Sprintf("正在安裝 %d 個 Python 套件 (numpy / opencv / KneronPLUS ...) (~30-60 秒)...", len(wheels)))

View File

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