Files
arcrun-collector/cmd/arcrun-app/selfupdate_test.go
T
Leo edc9916b2f 修好:Mac 自我更新斷七代(dmg 餵給解 zip 的 ditto)+殘留 staged 旗標騙人重啟
症狀一:manifest.daemon.mac.file 出貨格式從 zip 換成 dmg(build-dmg.sh),
但 applyStagedAndRestart 只認 zip(ditto -x -k)⇒ ditto: Couldn't read PKZip
signature,Mac「檢查更新」從 v0.18.5 斷到 v0.18.22(leo 真機截圖實測)。
症狀二:手動裝好新版後仍顯示「已下載完成,重新啟動就會套用」——staged.json
判準是「有沒有下載過」而非「目前版本 vs 最新版本」。

- extractAppFrom 依副檔名分流 .dmg(hdiutil attach -mountpoint)/.zip;
  Windows 補上真正的自我更新(rename 正在跑的 exe → 搬新版 → 原路徑重啟,
  併檔後 Windows 只有單一 exe,「開資料夾」是併檔前的殘留假設)。
- CheckUpdate 加 stagedDecision 純函式:已追上或 staged 版本過期就清掉
  殘留(連磁碟檔案一起清,不只清記憶體)。
- 新增 selfupdate_test.go:用真的 hdiutil 造 dmg 驗證解壓(不是 mock)。

Mac 端到端已用真的線上 manifest/真 v0.18.22 dmg 驗證整條鏈路(含用舊寫法
對同一份真檔案重現原始錯誤,證明修法對症)。Windows 只驗到 GOOS=windows
交叉編譯成功,真機行為待 leo 用 VMware Fusion 驗證——詳見 status.md。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-08 02:58:01 +08:00

235 lines
8.2 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
// selfupdate_test.go — 2026-08-08 leo 真機實測撞到「檢查更新」壞掉兩個症狀後補的迴歸測試。
//
// 症狀一:manifest.daemon.mac.file 出貨格式從 zip 換成 dmg,但 applyStagedAndRestart
//
// 只認 zipditto -x -k)⇒ 拿 dmg 餵它 ⇒ ditto: Couldn't read PKZip signature
// Mac 自我更新從 v0.18.5 斷到 v0.18.22(至少七代)。
//
// 症狀二:手動裝好新版後按「檢查更新」,仍顯示「已下載完成,重新啟動就會套用」——
//
// 殘留的 staged.json 沒人清,判斷依據是「有沒有下載過」而不是「目前版本 vs 最新版本」。
//
// 本檔測兩件事的迴歸:
// 1. extractAppFrom 兩種格式(.dmg.zip)都真的解得出 Arcrun.app(用真的 hdiutilditto
// 不是 mock——症狀一就是「編譯期看起來對,跑真檔案才炸」,mock 測不出來)。
// 2. stagedDecision 的四種版本組合,涵蓋症狀二的所有分支。
import (
"os"
"os/exec"
"path/filepath"
"runtime"
"testing"
)
// ── 症狀二:staged 旗標的判準要是「當前 vs 最新」,不是「有沒有下載過」 ──────────
func TestStagedDecision(t *testing.T) {
cases := []struct {
name string
available bool
stagedVersion string
latestVersion string
wantReport bool
wantClear bool
}{
{
name: "已經追上(leo 08-08 實測的那個病):手動裝好新版,殘留 staged 版本剛好等於 latest",
available: false, // newerThanCurrent(latest) == false ⇒ 目前版本已經是最新
stagedVersion: "v0.18.22",
latestVersion: "v0.18.22",
wantReport: false,
wantClear: true,
},
{
name: "真的落後、staged 正是這次查到的最新版 ⇒ 該回報已就緒",
available: true,
stagedVersion: "v0.18.22",
latestVersion: "v0.18.22",
wantReport: true,
wantClear: false,
},
{
name: "落後,但 staged 的是更舊的一版(線上又出新的了)⇒ 舊 staged 作廢",
available: true,
stagedVersion: "v0.18.21",
latestVersion: "v0.18.22",
wantReport: false,
wantClear: true,
},
{
name: "已追上、且 staged 版本本來就對不上 ⇒ 一樣要清",
available: false,
stagedVersion: "v0.18.20",
latestVersion: "v0.18.22",
wantReport: false,
wantClear: true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
report, clear := stagedDecision(c.available, c.stagedVersion, c.latestVersion)
if report != c.wantReport || clear != c.wantClear {
t.Fatalf("stagedDecision(%v, %q, %q) = (%v, %v)want (%v, %v)",
c.available, c.stagedVersion, c.latestVersion, report, clear, c.wantReport, c.wantClear)
}
})
}
}
// TestCheckUpdate_ClearsStaleStagedFile 端到端驗症狀二:staged.json 與暫存檔案
// 在「已追上」時真的從磁碟上消失,不是只清記憶體變數(否則下次啟動 loadStaged() 又撿回來)。
func TestCheckUpdate_ClearsStaleStagedFile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
// 準備一個「已備妥」的殘留檔案+標記,版本跟目前正在跑的一樣(模擬手動裝好新版)。
stalePath := updateStagePath("v0.18.22-fake.dmg")
if err := os.WriteFile(stalePath, []byte("fake"), 0o644); err != nil {
t.Fatal(err)
}
staged := stagedUpdate{Version: "v0.18.22", ZipPath: stalePath, Ready: true}
saveStaged(staged)
currentStaged = staged
t.Cleanup(func() { currentStaged = stagedUpdate{} })
if _, err := os.Stat(stalePath); err != nil {
t.Fatalf("前置條件不成立:暫存檔沒建起來:%v", err)
}
// available=false、stagedVersion==latestVersion ⇒ 直接測 clearStaleStaged 路徑本身
// CheckUpdate 會打真網路,這裡只驗「決定要清」之後磁碟真的乾淨——那才是症狀二的核心)。
report, clear := stagedDecision(false, staged.Version, staged.Version)
if report || !clear {
t.Fatalf("前置判斷不對:report=%v clear=%v,這個案例應該是 (false, true)", report, clear)
}
clearStaleStaged()
if _, err := os.Stat(stalePath); !os.IsNotExist(err) {
t.Fatalf("clearStaleStaged 後暫存檔還在:%v", err)
}
if _, err := os.Stat(stagedMarkerPath()); !os.IsNotExist(err) {
t.Fatalf("clearStaleStaged 後 staged.json 還在:%v", err)
}
if reloaded := loadStaged(); reloaded.Ready {
t.Fatalf("清完之後重新 loadStaged() 還是 Ready=true%+v", reloaded)
}
}
// ── 症狀一:extractAppFrom 兩種格式都要吃得下,且是用真的系統工具驗,不是 mock ──────
// fakeApp 在 dir 底下造一個看起來像 Arcrun.app 的最小骨架,回傳其路徑。
// 只需要「能被辨識出是這個 app」,不需要真的能跑(selfupdate 的解壓/覆蓋邏輯不在乎內容物)。
func fakeApp(t *testing.T, dir string) string {
t.Helper()
app := filepath.Join(dir, "Arcrun.app")
contents := filepath.Join(app, "Contents", "MacOS")
if err := os.MkdirAll(contents, 0o755); err != nil {
t.Fatal(err)
}
marker := filepath.Join(contents, "arcrun-app")
if err := os.WriteFile(marker, []byte("#!/bin/sh\necho fake\n"), 0o755); err != nil {
t.Fatal(err)
}
return app
}
func TestExtractAppFrom_UnknownExtension(t *testing.T) {
work := t.TempDir()
_, err := extractAppFrom(filepath.Join(work, "update.tar.gz"), work)
if err == nil {
t.Fatal("非 .dmg 非 .zip 應該要報錯,卻沒有")
}
}
func TestExtractAppFrom_Zip(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("ditto 是 macOS 專用工具")
}
if _, err := exec.LookPath("ditto"); err != nil {
t.Skip("這台機器沒有 ditto,略過")
}
src := t.TempDir()
app := fakeApp(t, src)
zipPath := filepath.Join(t.TempDir(), "update.zip")
if out, err := exec.Command("ditto", "-c", "-k", "--keepParent", app, zipPath).CombinedOutput(); err != nil {
t.Fatalf("測試前置:打包 zip 失敗:%v\n%s", err, out)
}
work := t.TempDir()
got, err := extractAppFrom(zipPath, work)
if err != nil {
t.Fatalf("extractAppFrom(.zip) 失敗:%v", err)
}
marker := filepath.Join(got, "Contents", "MacOS", "arcrun-app")
if _, err := os.Stat(marker); err != nil {
t.Fatalf("解出來的 .app 裡找不到標記檔:%v", err)
}
}
// TestExtractAppFrom_DMG 是症狀一的核心迴歸:用真的 hdiutil 造一顆 dmg(與出貨的
// build-dmg.sh 同一款工具),餵給 extractAppFrom,驗證解得出 Arcrun.app。
// 這正是 08-08 之前會爆「ditto: Couldn't read PKZip signature」的那條路徑。
func TestExtractAppFrom_DMG(t *testing.T) {
if runtime.GOOS != "darwin" {
t.Skip("hdiutil 是 macOS 專用工具")
}
if _, err := exec.LookPath("hdiutil"); err != nil {
t.Skip("這台機器沒有 hdiutil,略過")
}
stage := t.TempDir()
fakeApp(t, stage)
dmgPath := filepath.Join(t.TempDir(), "update.dmg")
out, err := exec.Command("hdiutil", "create",
"-volname", "ArcrunTest",
"-srcfolder", stage,
"-fs", "HFS+",
"-ov", "-format", "UDZO",
dmgPath).CombinedOutput()
if err != nil {
t.Fatalf("測試前置:造 dmg 失敗:%v\n%s", err, out)
}
work := t.TempDir()
got, err := extractAppFrom(dmgPath, work)
if err != nil {
t.Fatalf("extractAppFrom(.dmg) 失敗(症狀一沒修好就會在這裡炸):%v", err)
}
marker := filepath.Join(got, "Contents", "MacOS", "arcrun-app")
if _, err := os.Stat(marker); err != nil {
t.Fatalf("解出來的 .app 裡找不到標記檔:%v", err)
}
}
// ── moveFileWindows 自我更新覆蓋新 exe 那一步,同磁碟區要用 rename 般的效果 ──────
func TestMoveFile_SameDir(t *testing.T) {
dir := t.TempDir()
src := filepath.Join(dir, "src.bin")
dst := filepath.Join(dir, "dst.bin")
want := []byte("new version content")
if err := os.WriteFile(src, want, 0o644); err != nil {
t.Fatal(err)
}
if err := moveFile(src, dst); err != nil {
t.Fatalf("moveFile 失敗:%v", err)
}
if _, err := os.Stat(src); !os.IsNotExist(err) {
t.Fatalf("moveFile 後來源檔還在:%v", err)
}
got, err := os.ReadFile(dst)
if err != nil {
t.Fatalf("目的檔讀不到:%v", err)
}
if string(got) != string(want) {
t.Fatalf("內容不符:got %q want %q", got, want)
}
}