931 lines
33 KiB
Go
931 lines
33 KiB
Go
// Copyright 2026 Alibaba Group
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
package upgrade
|
|
|
|
import (
|
|
"archive/zip"
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"syscall"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type upgradeRoundTripFunc func(*http.Request) (*http.Response, error)
|
|
|
|
func (f upgradeRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
|
return f(req)
|
|
}
|
|
|
|
type upgradeFailWriter struct{}
|
|
|
|
func (upgradeFailWriter) Write([]byte) (int, error) { return 0, errors.New("write failed") }
|
|
|
|
func TestCrossPlatformCoverageDownloaderEdgeCases(t *testing.T) {
|
|
badParent := filepath.Join(t.TempDir(), "file")
|
|
if err := os.WriteFile(badParent, []byte("x"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := DownloadWithConfig(t.Context(), "http://example.test", filepath.Join(badParent, "out"), DefaultDownloadConfig()); err == nil {
|
|
t.Fatal("download mkdir error was ignored")
|
|
}
|
|
if _, err := doDownload(t.Context(), ":", filepath.Join(t.TempDir(), "out"), DefaultDownloadConfig()); err == nil {
|
|
t.Fatal("invalid download URL accepted")
|
|
}
|
|
|
|
okServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Length", "4")
|
|
_, _ = io.WriteString(w, "data")
|
|
}))
|
|
defer okServer.Close()
|
|
cfg := DefaultDownloadConfig()
|
|
cfg.MaxRetries = 0
|
|
if n, err := DownloadWithConfig(t.Context(), okServer.URL, filepath.Join(t.TempDir(), "out"), cfg); err != nil || n != 4 {
|
|
t.Fatalf("download with normalized retries = %d, %v", n, err)
|
|
}
|
|
if _, err := doDownload(t.Context(), okServer.URL, t.TempDir(), DefaultDownloadConfig()); err == nil {
|
|
t.Fatal("download to directory accepted")
|
|
}
|
|
statusServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "bad", http.StatusBadGateway)
|
|
}))
|
|
defer statusServer.Close()
|
|
if _, err := doDownload(t.Context(), statusServer.URL, filepath.Join(t.TempDir(), "out"), DefaultDownloadConfig()); err == nil {
|
|
t.Fatal("bad HTTP status accepted")
|
|
}
|
|
|
|
var retries atomic.Int32
|
|
retryServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
if retries.Add(1) == 1 {
|
|
w.Header().Set("Content-Length", "10")
|
|
_, _ = io.WriteString(w, "x")
|
|
return
|
|
}
|
|
_, _ = io.WriteString(w, "done")
|
|
}))
|
|
defer retryServer.Close()
|
|
cfg = DefaultDownloadConfig()
|
|
cfg.MaxRetries = 2
|
|
if _, err := DownloadWithConfig(t.Context(), retryServer.URL, filepath.Join(t.TempDir(), "out"), cfg); err != nil {
|
|
t.Fatalf("retry download: %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
cancelServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Length", "10")
|
|
_, _ = io.WriteString(w, "x")
|
|
cancel()
|
|
}))
|
|
defer cancelServer.Close()
|
|
cfg = DefaultDownloadConfig()
|
|
cfg.MaxRetries = 2
|
|
if _, err := DownloadWithConfig(ctx, cancelServer.URL, filepath.Join(t.TempDir(), "out"), cfg); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("cancelled retry = %v", err)
|
|
}
|
|
|
|
callbacks := 0
|
|
cfg = DefaultDownloadConfig()
|
|
cfg.ProgressInterval = -time.Second
|
|
cfg.ProgressCallback = func(downloaded, total int64) { callbacks++ }
|
|
if _, err := DownloadWithConfig(t.Context(), okServer.URL, filepath.Join(t.TempDir(), "progress"), cfg); err != nil || callbacks < 2 {
|
|
t.Fatalf("progress callbacks = %d, %v", callbacks, err)
|
|
}
|
|
var shown bool
|
|
if _, err := DownloadWithProgress(t.Context(), okServer.URL, filepath.Join(t.TempDir(), "show"), func(percent float64, downloaded, total int64) {
|
|
shown = percent == 100 && downloaded == total
|
|
}); err != nil || !shown {
|
|
t.Fatalf("progress display = %v, %v", shown, err)
|
|
}
|
|
if _, err := DownloadWithProgress(t.Context(), okServer.URL, filepath.Join(t.TempDir(), "nil-show"), nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
pw := &progressWriter{writer: upgradeFailWriter{}, callback: func(int64, int64) {}, interval: -time.Second}
|
|
if _, err := pw.Write([]byte("x")); err == nil {
|
|
t.Fatal("progress writer ignored underlying error")
|
|
}
|
|
if !isRetriable(&os.PathError{Op: "read", Path: "x", Err: syscall.ETIMEDOUT}) || isRetriable(errors.New("permanent")) {
|
|
t.Fatal("retry classification failed")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageGitHubClientEdgeCases(t *testing.T) {
|
|
t.Setenv("DWS_UPGRADE_URL", "http://mirror.test/")
|
|
t.Setenv("DWS_UPGRADE_REPOSITORY", "owner/repo")
|
|
client := NewClient()
|
|
if client.baseURL != "http://mirror.test" || client.owner != "owner" {
|
|
t.Fatalf("environment client = %#v", client)
|
|
}
|
|
if _, err := client.FetchLatestReleaseForTrack("unknown"); err == nil {
|
|
t.Fatal("unknown release track accepted")
|
|
}
|
|
if _, err := FindBinaryAsset(nil); err == nil {
|
|
t.Fatal("missing host binary asset accepted")
|
|
}
|
|
if got := binaryNameFor("windows"); got != "dws.exe" || binaryNameFor("linux") != "dws" {
|
|
t.Fatalf("binary names = %q / %q", got, binaryNameFor("linux"))
|
|
}
|
|
for tag := range map[string]bool{"1.2": true, "1..3": true, "1.x.3": true, "1.2.3.4": true} {
|
|
if isVersionLikeTag(tag) {
|
|
t.Fatalf("invalid version-like tag %q accepted", tag)
|
|
}
|
|
}
|
|
if truncateBytes([]byte("abcd"), 2) != "ab..." || truncateBytes([]byte("ab"), 2) != "ab" {
|
|
t.Fatal("byte truncation failed")
|
|
}
|
|
|
|
releases := `[
|
|
{"tag_name":"v1.2.3","prerelease":false,"draft":false},
|
|
{"tag_name":"v1.3.0-beta.1","prerelease":true,"draft":false},
|
|
{"tag_name":"nightly","prerelease":true,"draft":false},
|
|
{"tag_name":"v9.0.0","prerelease":false,"draft":true}
|
|
]`
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
switch {
|
|
case strings.HasSuffix(r.URL.Path, "/releases/latest"):
|
|
_, _ = io.WriteString(w, `{"tag_name":"v2.0.0"}`)
|
|
case strings.Contains(r.URL.Path, "/releases/tags/"):
|
|
_, _ = io.WriteString(w, `{"tag_name":"v1.0.0"}`)
|
|
default:
|
|
_, _ = io.WriteString(w, releases)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
client = NewClientWithBaseURL(server.URL)
|
|
if _, err := client.FetchLatestReleaseForTrack(ReleaseTrackRelease); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := client.FetchLatestReleaseForTrack(ReleaseTrackBeta); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := client.FetchLatestReleaseForTrack(""); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := client.FetchReleaseByTag("1.0.0"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if versions, err := client.FetchReleaseVersions("unknown"); err != nil || len(versions) != 0 {
|
|
t.Fatalf("unknown track versions = %#v, %v", versions, err)
|
|
}
|
|
|
|
emptyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
_, _ = io.WriteString(w, `[]`)
|
|
}))
|
|
defer emptyServer.Close()
|
|
client = NewClientWithBaseURL(emptyServer.URL)
|
|
if _, err := client.FetchLatestStableRelease(); err == nil {
|
|
t.Fatal("missing stable release accepted")
|
|
}
|
|
if _, err := client.FetchLatestPrerelease(); err == nil {
|
|
t.Fatal("missing beta release accepted")
|
|
}
|
|
|
|
t.Setenv("DWS_UPGRADE_REPOSITORY", "invalid")
|
|
invalidConfig := NewClient()
|
|
if _, err := invalidConfig.FetchLatestStableRelease(); err == nil {
|
|
t.Fatal("invalid repository config accepted for stable fetch")
|
|
}
|
|
if _, err := invalidConfig.FetchLatestPrerelease(); err == nil {
|
|
t.Fatal("invalid repository config accepted for beta fetch")
|
|
}
|
|
if _, err := invalidConfig.FetchReleaseVersions(ReleaseTrackAll); err == nil {
|
|
t.Fatal("invalid repository config accepted for versions")
|
|
}
|
|
if _, err := invalidConfig.FetchReleaseByTag("v1"); err == nil {
|
|
t.Fatal("invalid repository config accepted for tag")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageGitHubGetJSONMatrix(t *testing.T) {
|
|
client := NewClientWithBaseURL("")
|
|
if err := client.getJSON(":", &map[string]any{}); err == nil {
|
|
t.Fatal("invalid request URL accepted")
|
|
}
|
|
client.httpClient = &http.Client{Transport: upgradeRoundTripFunc(func(*http.Request) (*http.Response, error) {
|
|
return nil, errors.New("dial failed")
|
|
})}
|
|
if err := client.getJSON("http://example.test", &map[string]any{}); err == nil {
|
|
t.Fatal("transport error ignored")
|
|
}
|
|
for name, handler := range map[string]http.HandlerFunc{
|
|
"rate": func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("X-RateLimit-Remaining", "0")
|
|
http.Error(w, "limited", http.StatusForbidden)
|
|
},
|
|
"not-found": func(w http.ResponseWriter, _ *http.Request) { http.Error(w, "missing", http.StatusNotFound) },
|
|
"server": func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, strings.Repeat("x", 250), http.StatusInternalServerError)
|
|
},
|
|
"invalid-json": func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, `{`) },
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
server := httptest.NewServer(handler)
|
|
defer server.Close()
|
|
c := NewClientWithBaseURL(server.URL)
|
|
if err := c.getJSON(server.URL, &map[string]any{}); err == nil {
|
|
t.Fatal("getJSON error response accepted")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageUpgradePathsAndSkillsEdges(t *testing.T) {
|
|
originalHome := upgradeUserHomeDir
|
|
originalDirs := append([]string(nil), knownSkillDirs...)
|
|
t.Cleanup(func() {
|
|
upgradeUserHomeDir = originalHome
|
|
knownSkillDirs = originalDirs
|
|
})
|
|
failure := errors.New("home failed")
|
|
upgradeUserHomeDir = func() (string, error) { return "", failure }
|
|
if _, err := UpgradeSkillLocations(t.TempDir()); !errors.Is(err, failure) {
|
|
t.Fatalf("skill home error = %v", err)
|
|
}
|
|
if err := EnsureUpgradeDirectories(); !errors.Is(err, failure) {
|
|
t.Fatalf("directory home error = %v", err)
|
|
}
|
|
|
|
home := t.TempDir()
|
|
upgradeUserHomeDir = func() (string, error) { return home, nil }
|
|
source := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("skill"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(home, ".present"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
knownSkillDirs = []string{".agents/skills", ".real/skills", ".missing/skills", ".present/skills"}
|
|
result, err := UpgradeSkillLocations(source)
|
|
if err != nil || len(result.Succeeded()) != 2 || len(result.Failed()) != 0 {
|
|
t.Fatalf("skill upgrade = %#v, %v", result, err)
|
|
}
|
|
knownSkillDirs = []string{".real/skills"}
|
|
result, err = UpgradeSkillLocations(source)
|
|
if err != nil || len(result.Succeeded()) != 1 {
|
|
t.Fatalf("skill fallback = %#v, %v", result, err)
|
|
}
|
|
if _, err := UpgradeSkillLocations(filepath.Join(t.TempDir(), "missing")); err == nil {
|
|
t.Fatal("failed skill fallback accepted")
|
|
}
|
|
if err := EnsureUpgradeDirectories(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := DownloadCacheDir(); !strings.HasPrefix(got, home) {
|
|
t.Fatalf("download cache dir = %q", got)
|
|
}
|
|
|
|
modeDir := filepath.Join(t.TempDir(), "mode")
|
|
if err := os.Mkdir(modeDir, 0o777); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureDir(modeDir, 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
blocker := filepath.Join(t.TempDir(), "blocker")
|
|
if err := os.WriteFile(blocker, []byte("x"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ensureDir(filepath.Join(blocker, "child"), 0o700); err == nil {
|
|
t.Fatal("ensureDir stat error ignored")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageExecutablePathEdges(t *testing.T) {
|
|
originalExecutable := upgradeExecutable
|
|
originalEval := upgradeEvalSymlinks
|
|
t.Cleanup(func() {
|
|
upgradeExecutable = originalExecutable
|
|
upgradeEvalSymlinks = originalEval
|
|
})
|
|
failure := errors.New("injected failure")
|
|
upgradeExecutable = func() (string, error) { return "", failure }
|
|
if _, err := CurrentBinaryPath(); !errors.Is(err, failure) {
|
|
t.Fatalf("executable error = %v", err)
|
|
}
|
|
upgradeExecutable = func() (string, error) { return "/tmp/exe", nil }
|
|
upgradeEvalSymlinks = func(string) (string, error) { return "", failure }
|
|
if _, err := CurrentBinaryPath(); !errors.Is(err, failure) {
|
|
t.Fatalf("symlink error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageReplacerAndZipEdges(t *testing.T) {
|
|
originalExecutable := upgradeExecutable
|
|
originalEval := upgradeEvalSymlinks
|
|
originalRename := upgradeRename
|
|
originalChmod := upgradeChmod
|
|
originalCopy := upgradeCopyFile
|
|
originalSync := upgradeSyncParentDir
|
|
t.Cleanup(func() {
|
|
upgradeExecutable = originalExecutable
|
|
upgradeEvalSymlinks = originalEval
|
|
upgradeRename = originalRename
|
|
upgradeChmod = originalChmod
|
|
upgradeCopyFile = originalCopy
|
|
upgradeSyncParentDir = originalSync
|
|
})
|
|
failure := errors.New("injected failure")
|
|
current := filepath.Join(t.TempDir(), "dws")
|
|
if err := os.WriteFile(current, []byte("old"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
upgradeExecutable = func() (string, error) { return current, nil }
|
|
upgradeEvalSymlinks = func(path string) (string, error) { return path, nil }
|
|
newBinary := filepath.Join(t.TempDir(), "new")
|
|
if err := os.WriteFile(newBinary, []byte("new"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ReplaceSelf(newBinary); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if data, _ := os.ReadFile(current); string(data) != "new" {
|
|
t.Fatalf("replaced binary = %q", data)
|
|
}
|
|
upgradeExecutable = func() (string, error) { return "", failure }
|
|
if err := ReplaceSelf("x"); err == nil {
|
|
t.Fatal("executable failure ignored")
|
|
}
|
|
upgradeExecutable = func() (string, error) { return current, nil }
|
|
upgradeEvalSymlinks = func(string) (string, error) { return "", failure }
|
|
if err := ReplaceSelf("x"); err == nil {
|
|
t.Fatal("symlink failure ignored")
|
|
}
|
|
upgradeEvalSymlinks = func(path string) (string, error) { return path, nil }
|
|
upgradeChmod = func(string, os.FileMode) error { return failure }
|
|
if err := ReplaceSelf("x"); err == nil {
|
|
t.Fatal("chmod failure ignored")
|
|
}
|
|
upgradeChmod = originalChmod
|
|
upgradeRename = func(string, string) error { return failure }
|
|
upgradeCopyFile = func(string, string, os.FileMode) error { return failure }
|
|
if err := replaceExeFile("src", "dst"); !errors.Is(err, failure) {
|
|
t.Fatalf("copy fallback error = %v", err)
|
|
}
|
|
|
|
upgradeRename = originalRename
|
|
upgradeCopyFile = originalCopy
|
|
src := filepath.Join(t.TempDir(), "src")
|
|
dst := filepath.Join(t.TempDir(), "dst")
|
|
if err := os.WriteFile(src, []byte("x"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := windowsReplace(src, dst); err != nil {
|
|
t.Fatalf("windows replacement success path: %v", err)
|
|
}
|
|
upgradeRename = func(string, string) error { return failure }
|
|
if err := windowsReplace("src", "dst"); err == nil {
|
|
t.Fatal("windows move failure ignored")
|
|
}
|
|
|
|
upgradeExecutable = func() (string, error) { return "", failure }
|
|
CleanupStaleFiles()
|
|
upgradeExecutable = func() (string, error) { return current, nil }
|
|
CleanupStaleFiles()
|
|
|
|
zipPath := filepath.Join(t.TempDir(), "test.zip")
|
|
var zipData bytes.Buffer
|
|
zw := zip.NewWriter(&zipData)
|
|
_, _ = zw.Create("dir/")
|
|
f, _ := zw.Create("dir/file.txt")
|
|
_, _ = f.Write([]byte("ok"))
|
|
evil, _ := zw.Create("../evil")
|
|
_, _ = evil.Write([]byte("bad"))
|
|
if err := zw.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(zipPath, zipData.Bytes(), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
target := t.TempDir()
|
|
if err := ExtractZip(zipPath, target); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(target, "dir", "file.txt")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := ExtractZip(filepath.Join(t.TempDir(), "missing"), target); err == nil {
|
|
t.Fatal("missing zip accepted")
|
|
}
|
|
if err := copyFile("missing", filepath.Join(t.TempDir(), "x"), 0o600); err == nil {
|
|
t.Fatal("missing copy source accepted")
|
|
}
|
|
file := filepath.Join(t.TempDir(), "file")
|
|
if err := os.WriteFile(file, []byte("x"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := copyFile(file, t.TempDir(), 0o600); err == nil {
|
|
t.Fatal("directory copy destination accepted")
|
|
}
|
|
syncParentDir(filepath.Join(t.TempDir(), "file"))
|
|
syncParentDir("/definitely/missing/file")
|
|
}
|
|
|
|
func TestCrossPlatformCoverageRollbackLifecycleEdges(t *testing.T) {
|
|
originalExecutable := upgradeExecutable
|
|
originalEval := upgradeEvalSymlinks
|
|
originalHome := upgradeUserHomeDir
|
|
originalReadDir := rollbackReadDir
|
|
t.Cleanup(func() {
|
|
upgradeExecutable = originalExecutable
|
|
upgradeEvalSymlinks = originalEval
|
|
upgradeUserHomeDir = originalHome
|
|
rollbackReadDir = originalReadDir
|
|
})
|
|
failure := errors.New("injected failure")
|
|
upgradeUserHomeDir = func() (string, error) { return "", failure }
|
|
if got := NewRollbackManager(); !strings.HasPrefix(got.backupDir, ".dws") {
|
|
t.Fatalf("fallback rollback dir = %q", got.backupDir)
|
|
}
|
|
|
|
current := filepath.Join(t.TempDir(), "dws")
|
|
if err := os.WriteFile(current, []byte("current"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
upgradeExecutable = func() (string, error) { return current, nil }
|
|
upgradeEvalSymlinks = func(path string) (string, error) { return path, nil }
|
|
manager := NewRollbackManagerWithDir(t.TempDir())
|
|
backupPath, err := manager.Backup("1.2.3")
|
|
if err != nil || backupPath == "" {
|
|
t.Fatalf("backup = %q, %v", backupPath, err)
|
|
}
|
|
backups, err := manager.ListBackups()
|
|
if err != nil || len(backups) != 1 {
|
|
t.Fatalf("backups = %#v, %v", backups, err)
|
|
}
|
|
if err := os.WriteFile(current, []byte("changed"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := manager.Rollback(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if data, _ := os.ReadFile(current); string(data) != "current" {
|
|
t.Fatalf("rolled-back binary = %q", data)
|
|
}
|
|
if err := manager.RollbackTo(BackupInfo{Path: t.TempDir()}); err == nil {
|
|
t.Fatal("missing backup binary accepted")
|
|
}
|
|
empty := NewRollbackManagerWithDir(filepath.Join(t.TempDir(), "missing"))
|
|
if err := empty.Rollback(); err == nil {
|
|
t.Fatal("empty rollback accepted")
|
|
}
|
|
|
|
for i := 0; i < 3; i++ {
|
|
name := "v0.0." + string(rune('1'+i)) + "-20260101-00000" + string(rune('1'+i))
|
|
if err := os.MkdirAll(filepath.Join(manager.backupDir, name), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := manager.Cleanup(1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := manager.Cleanup(0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rollbackReadDir = func(string) ([]os.DirEntry, error) { return nil, failure }
|
|
if _, err := manager.ListBackups(); !errors.Is(err, failure) {
|
|
t.Fatalf("backup read error = %v", err)
|
|
}
|
|
rollbackReadDir = originalReadDir
|
|
syncFileData(current)
|
|
syncFileData("/missing/file")
|
|
}
|
|
|
|
func TestCrossPlatformCoverageVersionPrereleaseEdges(t *testing.T) {
|
|
cases := []struct {
|
|
a, b string
|
|
want int
|
|
}{
|
|
{"", "", 0}, {"", "alpha", 1}, {"alpha", "", -1},
|
|
{"alpha", "alpha.1", -1}, {"alpha.1", "alpha", 1},
|
|
{"1", "2", -1}, {"2", "1", 1}, {"1", "1", 0},
|
|
{"1", "alpha", -1}, {"alpha", "1", 1},
|
|
{"alpha", "beta", -1}, {"beta", "alpha", 1}, {"alpha", "alpha", 0},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := comparePrerelease(tc.a, tc.b); got != tc.want {
|
|
t.Fatalf("comparePrerelease(%q,%q) = %d, want %d", tc.a, tc.b, got, tc.want)
|
|
}
|
|
}
|
|
if _, ok := parseNumericIdentifier(""); ok {
|
|
t.Fatal("empty numeric identifier accepted")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageDownloaderRetryControlEdges(t *testing.T) {
|
|
originalAfter := downloadAfter
|
|
t.Cleanup(func() { downloadAfter = originalAfter })
|
|
|
|
truncated := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Length", "10")
|
|
_, _ = io.WriteString(w, "x")
|
|
}))
|
|
defer truncated.Close()
|
|
cfg := DefaultDownloadConfig()
|
|
cfg.MaxRetries = 1
|
|
if _, err := DownloadWithConfig(t.Context(), truncated.URL, filepath.Join(t.TempDir(), "out"), cfg); err == nil || !strings.Contains(err.Error(), "重试 1 次") {
|
|
t.Fatalf("retry exhaustion = %v", err)
|
|
}
|
|
|
|
ready := make(chan time.Time)
|
|
close(ready)
|
|
downloadAfter = func(time.Duration) <-chan time.Time { return ready }
|
|
cfg.MaxRetries = 7
|
|
if _, err := DownloadWithConfig(t.Context(), truncated.URL, filepath.Join(t.TempDir(), "out"), cfg); err == nil {
|
|
t.Fatal("retry exhaustion with capped backoff succeeded")
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
downloadAfter = func(time.Duration) <-chan time.Time {
|
|
cancel()
|
|
return make(chan time.Time)
|
|
}
|
|
cfg.MaxRetries = 2
|
|
if _, err := DownloadWithConfig(ctx, truncated.URL, filepath.Join(t.TempDir(), "out"), cfg); !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("backoff cancellation = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageGitHubFetchReleasesError(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
http.Error(w, "failure", http.StatusInternalServerError)
|
|
}))
|
|
defer server.Close()
|
|
if _, err := NewClientWithBaseURL(server.URL).FetchAllReleases(); err == nil {
|
|
t.Fatal("release list HTTP error ignored")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) {
|
|
originalHome := upgradeUserHomeDir
|
|
originalDirs := append([]string(nil), knownSkillDirs...)
|
|
originalCopy := upgradeCopyDir
|
|
originalEnsure := upgradeEnsureDir
|
|
t.Cleanup(func() {
|
|
upgradeUserHomeDir = originalHome
|
|
knownSkillDirs = originalDirs
|
|
upgradeCopyDir = originalCopy
|
|
upgradeEnsureDir = originalEnsure
|
|
})
|
|
home := t.TempDir()
|
|
upgradeUserHomeDir = func() (string, error) { return home, nil }
|
|
knownSkillDirs = []string{".agents/skills", ".claude/skills"}
|
|
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
calls := 0
|
|
upgradeCopyDir = func(src, dst string) error {
|
|
calls++
|
|
if calls == 1 {
|
|
return errors.New("primary failed")
|
|
}
|
|
return originalCopy(src, dst)
|
|
}
|
|
source := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("x"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := UpgradeSkillLocations(source)
|
|
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
|
|
t.Fatalf("canonical failure = %#v, %v", result, err)
|
|
}
|
|
failure := errors.New("ensure failed")
|
|
upgradeEnsureDir = func(string, os.FileMode) error { return failure }
|
|
if err := EnsureUpgradeDirectories(); !errors.Is(err, failure) {
|
|
t.Fatalf("ensure upgrade directory error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageReplacerInjectedIOEdges(t *testing.T) {
|
|
originalExecutable := upgradeExecutable
|
|
originalEval := upgradeEvalSymlinks
|
|
originalRename := upgradeRename
|
|
originalChmod := upgradeChmod
|
|
originalCopy := upgradeCopyFile
|
|
originalSyncParent := upgradeSyncParentDir
|
|
originalOpenEntry := upgradeOpenZipEntry
|
|
originalOpenFile := upgradeOpenFile
|
|
originalCopyIO := upgradeIOCopy
|
|
originalSync := upgradeFileSync
|
|
t.Cleanup(func() {
|
|
upgradeExecutable = originalExecutable
|
|
upgradeEvalSymlinks = originalEval
|
|
upgradeRename = originalRename
|
|
upgradeChmod = originalChmod
|
|
upgradeCopyFile = originalCopy
|
|
upgradeSyncParentDir = originalSyncParent
|
|
upgradeOpenZipEntry = originalOpenEntry
|
|
upgradeOpenFile = originalOpenFile
|
|
upgradeIOCopy = originalCopyIO
|
|
upgradeFileSync = originalSync
|
|
})
|
|
failure := errors.New("injected failure")
|
|
current := filepath.Join(t.TempDir(), "current")
|
|
newBinary := filepath.Join(t.TempDir(), "new")
|
|
for _, path := range []string{current, newBinary} {
|
|
if err := os.WriteFile(path, []byte("x"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
upgradeExecutable = func() (string, error) { return current, nil }
|
|
upgradeEvalSymlinks = func(path string) (string, error) { return path, nil }
|
|
upgradeRename = func(string, string) error { return failure }
|
|
upgradeCopyFile = func(string, string, os.FileMode) error { return failure }
|
|
if err := ReplaceSelf(newBinary); !errors.Is(err, failure) {
|
|
t.Fatalf("ReplaceSelf replacement error = %v", err)
|
|
}
|
|
if err := replaceExeFileFor("src", "dst", "linux"); !errors.Is(err, failure) {
|
|
t.Fatalf("non-Windows copy fallback error = %v", err)
|
|
}
|
|
|
|
if err := replaceExeFileFor("src", "dst", "windows"); err == nil {
|
|
t.Fatal("windows replace path unexpectedly succeeded")
|
|
}
|
|
renameCalls := 0
|
|
upgradeRename = func(src, dst string) error {
|
|
renameCalls++
|
|
if renameCalls == 1 {
|
|
return originalRename(src, dst)
|
|
}
|
|
return failure
|
|
}
|
|
upgradeCopyFile = originalCopy
|
|
src := filepath.Join(t.TempDir(), "src")
|
|
dst := filepath.Join(t.TempDir(), "dst")
|
|
if err := os.WriteFile(src, []byte("new"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(dst, []byte("old"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := windowsReplace(src, dst); err != nil {
|
|
t.Fatalf("windows copy fallback: %v", err)
|
|
}
|
|
renameCalls = 0
|
|
upgradeCopyFile = func(string, string, os.FileMode) error { return failure }
|
|
src = filepath.Join(t.TempDir(), "src")
|
|
dst = filepath.Join(t.TempDir(), "dst")
|
|
_ = os.WriteFile(src, []byte("new"), 0o600)
|
|
_ = os.WriteFile(dst, []byte("old"), 0o600)
|
|
if err := windowsReplace(src, dst); err == nil {
|
|
t.Fatal("windows copy fallback failure ignored")
|
|
}
|
|
|
|
var zipData bytes.Buffer
|
|
zw := zip.NewWriter(&zipData)
|
|
entryWriter, _ := zw.Create("file")
|
|
_, _ = entryWriter.Write([]byte("x"))
|
|
_ = zw.Close()
|
|
zipPath := filepath.Join(t.TempDir(), "one.zip")
|
|
_ = os.WriteFile(zipPath, zipData.Bytes(), 0o600)
|
|
reader, err := zip.OpenReader(zipPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer reader.Close()
|
|
entry := reader.File[0]
|
|
upgradeOpenZipEntry = func(*zip.File) (io.ReadCloser, error) { return nil, failure }
|
|
if err := extractZipEntry(entry, filepath.Join(t.TempDir(), "out")); !errors.Is(err, failure) {
|
|
t.Fatalf("zip entry open error = %v", err)
|
|
}
|
|
upgradeOpenZipEntry = originalOpenEntry
|
|
upgradeOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, failure }
|
|
if err := extractZipEntry(entry, filepath.Join(t.TempDir(), "out")); !errors.Is(err, failure) {
|
|
t.Fatalf("zip destination open error = %v", err)
|
|
}
|
|
upgradeOpenFile = originalOpenFile
|
|
upgradeIOCopy = func(io.Writer, io.Reader) (int64, error) { return 0, failure }
|
|
if err := extractZipEntry(entry, filepath.Join(t.TempDir(), "out")); !errors.Is(err, failure) {
|
|
t.Fatalf("zip copy error = %v", err)
|
|
}
|
|
file := filepath.Join(t.TempDir(), "file")
|
|
_ = os.WriteFile(file, []byte("x"), 0o600)
|
|
if err := copyFile(file, filepath.Join(t.TempDir(), "out"), 0o600); !errors.Is(err, failure) {
|
|
t.Fatalf("file copy error = %v", err)
|
|
}
|
|
upgradeIOCopy = originalCopyIO
|
|
upgradeFileSync = func(*os.File) error { return failure }
|
|
if err := copyFile(file, filepath.Join(t.TempDir(), "out"), 0o600); !errors.Is(err, failure) {
|
|
t.Fatalf("file sync error = %v", err)
|
|
}
|
|
|
|
target := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(target, "file"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
upgradeFileSync = originalSync
|
|
if err := ExtractZip(zipPath, target); err == nil {
|
|
t.Fatal("zip extraction destination error ignored")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageRollbackInjectedFailures(t *testing.T) {
|
|
originalExecutable := upgradeExecutable
|
|
originalEval := upgradeEvalSymlinks
|
|
originalStat := rollbackStat
|
|
originalMkdir := rollbackMkdirAll
|
|
originalCopy := rollbackCopyFile
|
|
originalReadDir := rollbackReadDir
|
|
originalRemove := rollbackRemoveAll
|
|
originalMarshal := rollbackMarshalIndent
|
|
originalWrite := rollbackWriteFile
|
|
originalRead := rollbackReadFile
|
|
originalReplace := rollbackReplaceFile
|
|
t.Cleanup(func() {
|
|
upgradeExecutable = originalExecutable
|
|
upgradeEvalSymlinks = originalEval
|
|
rollbackStat = originalStat
|
|
rollbackMkdirAll = originalMkdir
|
|
rollbackCopyFile = originalCopy
|
|
rollbackReadDir = originalReadDir
|
|
rollbackRemoveAll = originalRemove
|
|
rollbackMarshalIndent = originalMarshal
|
|
rollbackWriteFile = originalWrite
|
|
rollbackReadFile = originalRead
|
|
rollbackReplaceFile = originalReplace
|
|
})
|
|
failure := errors.New("injected failure")
|
|
manager := NewRollbackManagerWithDir(t.TempDir())
|
|
upgradeExecutable = func() (string, error) { return "", failure }
|
|
if _, err := manager.Backup("1"); err == nil {
|
|
t.Fatal("backup executable error ignored")
|
|
}
|
|
if err := manager.RollbackTo(BackupInfo{}); err == nil {
|
|
t.Fatal("rollback executable error ignored")
|
|
}
|
|
upgradeExecutable = func() (string, error) { return "/current", nil }
|
|
upgradeEvalSymlinks = func(string) (string, error) { return "", failure }
|
|
if _, err := manager.Backup("1"); err == nil {
|
|
t.Fatal("backup symlink error ignored")
|
|
}
|
|
if err := manager.RollbackTo(BackupInfo{}); err == nil {
|
|
t.Fatal("rollback symlink error ignored")
|
|
}
|
|
upgradeEvalSymlinks = func(path string) (string, error) { return path, nil }
|
|
rollbackStat = func(string) (os.FileInfo, error) { return nil, failure }
|
|
if _, err := manager.Backup("1"); err == nil {
|
|
t.Fatal("backup stat error ignored")
|
|
}
|
|
|
|
current := filepath.Join(t.TempDir(), "current")
|
|
backup := filepath.Join(t.TempDir(), "backup")
|
|
_ = os.WriteFile(current, []byte("old"), 0o700)
|
|
_ = os.WriteFile(backup, []byte("new"), 0o700)
|
|
upgradeExecutable = func() (string, error) { return current, nil }
|
|
rollbackStat = originalStat
|
|
for failAt := 1; failAt <= 3; failAt++ {
|
|
calls := 0
|
|
rollbackMkdirAll = func(path string, mode os.FileMode) error {
|
|
calls++
|
|
if calls == failAt {
|
|
return failure
|
|
}
|
|
return originalMkdir(path, mode)
|
|
}
|
|
if _, err := NewRollbackManagerWithDir(filepath.Join(t.TempDir(), "backups")).Backup("1"); err == nil {
|
|
t.Fatalf("backup mkdir stage %d error ignored", failAt)
|
|
}
|
|
}
|
|
rollbackMkdirAll = originalMkdir
|
|
rollbackCopyFile = func(string, string, os.FileMode) error { return failure }
|
|
if _, err := manager.Backup("1"); err == nil {
|
|
t.Fatal("backup copy error ignored")
|
|
}
|
|
if err := manager.RollbackTo(BackupInfo{BinaryPath: backup}); err == nil {
|
|
t.Fatal("rollback preparation copy error ignored")
|
|
}
|
|
rollbackCopyFile = originalCopy
|
|
rollbackReplaceFile = func(string, string) error { return failure }
|
|
if err := manager.RollbackTo(BackupInfo{BinaryPath: backup}); err == nil {
|
|
t.Fatal("rollback replacement error ignored")
|
|
}
|
|
|
|
rollbackReadDir = func(string) ([]os.DirEntry, error) { return nil, failure }
|
|
if err := manager.Rollback(); err == nil {
|
|
t.Fatal("rollback list error ignored")
|
|
}
|
|
if err := manager.Cleanup(1); err == nil {
|
|
t.Fatal("cleanup list error ignored")
|
|
}
|
|
rollbackReadDir = originalReadDir
|
|
rollbackRemoveAll = func(string) error { return failure }
|
|
_ = rollbackRemoveAll
|
|
rollbackMarshalIndent = func(any, string, string) ([]byte, error) { return nil, failure }
|
|
manager.saveBackupInfo(BackupInfo{Path: t.TempDir()})
|
|
rollbackMarshalIndent = originalMarshal
|
|
rollbackWriteFile = func(string, []byte, os.FileMode) error { return failure }
|
|
manager.saveBackupInfo(BackupInfo{Path: t.TempDir()})
|
|
rollbackWriteFile = originalWrite
|
|
rollbackReadFile = func(string) ([]byte, error) { return nil, failure }
|
|
if _, err := manager.loadBackupInfo(t.TempDir()); !errors.Is(err, failure) {
|
|
t.Fatalf("backup info read error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageCopyDirAndVerifyErrors(t *testing.T) {
|
|
if err := copyDir("missing", t.TempDir()); err == nil {
|
|
t.Fatal("missing source directory accepted")
|
|
}
|
|
sourceFile := filepath.Join(t.TempDir(), "source")
|
|
_ = os.WriteFile(sourceFile, []byte("x"), 0o600)
|
|
if err := copyDir(sourceFile, t.TempDir()); err == nil {
|
|
t.Fatal("file source directory accepted")
|
|
}
|
|
blocker := filepath.Join(t.TempDir(), "blocker")
|
|
_ = os.WriteFile(blocker, []byte("x"), 0o600)
|
|
if err := copyDir(t.TempDir(), filepath.Join(blocker, "child")); err == nil {
|
|
t.Fatal("copy directory mkdir error ignored")
|
|
}
|
|
source := t.TempDir()
|
|
if err := os.Symlink(filepath.Join(source, "missing"), filepath.Join(source, "dangling")); err == nil {
|
|
if err := copyDir(source, t.TempDir()); err == nil {
|
|
t.Fatal("dangling source entry accepted")
|
|
}
|
|
}
|
|
if err := VerifySHA256("missing", "deadbeef"); err == nil {
|
|
t.Fatal("missing checksum file accepted")
|
|
}
|
|
if _, err := ComputeSHA256("missing"); err == nil {
|
|
t.Fatal("missing SHA file accepted")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageCopyDirAndBackupEntryInjectedEdges(t *testing.T) {
|
|
originalRollbackInfo := rollbackEntryInfo
|
|
originalStat := upgradeDirStat
|
|
originalMkdir := upgradeDirMkdirAll
|
|
originalReadDir := upgradeDirReadDir
|
|
originalInfo := upgradeDirEntryInfo
|
|
originalOpen := upgradeDirOpen
|
|
originalOpenFile := upgradeDirOpenFile
|
|
originalCopy := upgradeDirCopy
|
|
t.Cleanup(func() {
|
|
rollbackEntryInfo = originalRollbackInfo
|
|
upgradeDirStat = originalStat
|
|
upgradeDirMkdirAll = originalMkdir
|
|
upgradeDirReadDir = originalReadDir
|
|
upgradeDirEntryInfo = originalInfo
|
|
upgradeDirOpen = originalOpen
|
|
upgradeDirOpenFile = originalOpenFile
|
|
upgradeDirCopy = originalCopy
|
|
})
|
|
failure := errors.New("injected failure")
|
|
|
|
backupDir := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(backupDir, "v1-20260101"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rollbackEntryInfo = func(os.DirEntry) (os.FileInfo, error) { return nil, failure }
|
|
backups, err := NewRollbackManagerWithDir(backupDir).ListBackups()
|
|
if err != nil || len(backups) != 0 {
|
|
t.Fatalf("failed backup entry info = %#v, %v", backups, err)
|
|
}
|
|
|
|
source := t.TempDir()
|
|
if err := os.Mkdir(filepath.Join(source, "sub"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(source, "file"), []byte("x"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
upgradeDirReadDir = func(path string) ([]os.DirEntry, error) {
|
|
if strings.HasSuffix(path, "sub") {
|
|
return nil, failure
|
|
}
|
|
return originalReadDir(path)
|
|
}
|
|
if err := copyDir(source, t.TempDir()); !errors.Is(err, failure) {
|
|
t.Fatalf("recursive copy error = %v", err)
|
|
}
|
|
upgradeDirReadDir = originalReadDir
|
|
upgradeDirEntryInfo = func(os.DirEntry) (os.FileInfo, error) { return nil, failure }
|
|
if err := copyDir(source, t.TempDir()); err != nil {
|
|
t.Fatalf("entry info errors should be skipped: %v", err)
|
|
}
|
|
upgradeDirEntryInfo = originalInfo
|
|
upgradeDirOpen = func(string) (*os.File, error) { return nil, failure }
|
|
if err := copyDir(source, t.TempDir()); !errors.Is(err, failure) {
|
|
t.Fatalf("source open error = %v", err)
|
|
}
|
|
upgradeDirOpen = originalOpen
|
|
upgradeDirOpenFile = func(string, int, os.FileMode) (*os.File, error) { return nil, failure }
|
|
if err := copyDir(source, t.TempDir()); !errors.Is(err, failure) {
|
|
t.Fatalf("destination open error = %v", err)
|
|
}
|
|
upgradeDirOpenFile = originalOpenFile
|
|
upgradeDirCopy = func(io.Writer, io.Reader) (int64, error) { return 0, failure }
|
|
if err := copyDir(source, t.TempDir()); !errors.Is(err, failure) {
|
|
t.Fatalf("directory file copy error = %v", err)
|
|
}
|
|
}
|