Restrict backup pruning to directories whose names match the DWS stamp format (YYYYmmdd-HHMMSS with optional -N suffix) across all 8 installer surfaces (Go, npm, 4 shell, 2 PowerShell). Unknown directories in ~/.dws/skill-backups are now preserved. Also fixes Windows coverage test portability and covers the remaining macOS changed-code gap (retire warning loop in runUpgrade).
449 lines
16 KiB
Go
449 lines
16 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
|
"github.com/spf13/cobra"
|
|
)
|
|
|
|
type fakeUpgradeClient struct {
|
|
latest *upgradepkg.ReleaseInfo
|
|
latestErr error
|
|
tagged *upgradepkg.ReleaseInfo
|
|
taggedErr error
|
|
versions []upgradepkg.VersionEntry
|
|
versionsErr error
|
|
}
|
|
|
|
func (c *fakeUpgradeClient) FetchLatestReleaseForTrack(upgradepkg.ReleaseTrack) (*upgradepkg.ReleaseInfo, error) {
|
|
return c.latest, c.latestErr
|
|
}
|
|
|
|
func (c *fakeUpgradeClient) FetchReleaseByTag(string) (*upgradepkg.ReleaseInfo, error) {
|
|
return c.tagged, c.taggedErr
|
|
}
|
|
|
|
func (c *fakeUpgradeClient) FetchReleaseVersions(upgradepkg.ReleaseTrack) ([]upgradepkg.VersionEntry, error) {
|
|
return c.versions, c.versionsErr
|
|
}
|
|
|
|
type fakeUpgradeRollback struct {
|
|
backups []upgradepkg.BackupInfo
|
|
listErr error
|
|
backupErr error
|
|
rollbackErr error
|
|
cleaned bool
|
|
}
|
|
|
|
func (r *fakeUpgradeRollback) ListBackups() ([]upgradepkg.BackupInfo, error) {
|
|
return r.backups, r.listErr
|
|
}
|
|
func (r *fakeUpgradeRollback) RollbackTo(upgradepkg.BackupInfo) error { return r.rollbackErr }
|
|
func (r *fakeUpgradeRollback) Backup(string) (string, error) { return "backup", r.backupErr }
|
|
func (r *fakeUpgradeRollback) Cleanup(int) error {
|
|
r.cleaned = true
|
|
return nil
|
|
}
|
|
|
|
type upgradeFileInfo struct{ size int64 }
|
|
|
|
func (i upgradeFileInfo) Name() string { return "dws" }
|
|
func (i upgradeFileInfo) Size() int64 { return i.size }
|
|
func (i upgradeFileInfo) Mode() os.FileMode { return 0o755 }
|
|
func (i upgradeFileInfo) ModTime() time.Time { return time.Time{} }
|
|
func (i upgradeFileInfo) IsDir() bool { return false }
|
|
func (i upgradeFileInfo) Sys() any { return nil }
|
|
|
|
func TestCrossPlatformCoverageUpgradeRollbackAndCommandBranchesCoverage(t *testing.T) {
|
|
oldClient, oldRollback := newUpgradeReleaseClient, newUpgradeRollback
|
|
oldEdition := edition.Get()
|
|
oldStdin := os.Stdin
|
|
oldVersion := version
|
|
t.Cleanup(func() {
|
|
newUpgradeReleaseClient, newUpgradeRollback = oldClient, oldRollback
|
|
edition.Override(oldEdition)
|
|
os.Stdin = oldStdin
|
|
version = oldVersion
|
|
})
|
|
fail := errors.New("failure")
|
|
rb := &fakeUpgradeRollback{listErr: fail}
|
|
newUpgradeRollback = func() upgradeRollbackManager { return rb }
|
|
if err := runUpgradeRollback(true); !errors.Is(err, fail) {
|
|
t.Fatalf("rollback list error = %v", err)
|
|
}
|
|
rb.listErr = nil
|
|
if err := runUpgradeRollback(true); err == nil {
|
|
t.Fatal("rollback without backups succeeded")
|
|
}
|
|
rb.backups = []upgradepkg.BackupInfo{{Version: "1.0.0", CreatedAt: time.Now()}}
|
|
read, write, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _ = io.WriteString(write, "n\n")
|
|
_ = write.Close()
|
|
os.Stdin = read
|
|
if err := runUpgradeRollback(false); err != nil {
|
|
t.Fatalf("cancel rollback = %v", err)
|
|
}
|
|
_ = read.Close()
|
|
rb.rollbackErr = fail
|
|
if err := runUpgradeRollback(true); !errors.Is(err, fail) {
|
|
t.Fatalf("rollback apply error = %v", err)
|
|
}
|
|
rb.rollbackErr = nil
|
|
if err := runUpgradeRollback(true); err != nil {
|
|
t.Fatalf("rollback success = %v", err)
|
|
}
|
|
|
|
client := &fakeUpgradeClient{
|
|
latest: &upgradepkg.ReleaseInfo{Version: "9.9.9"},
|
|
tagged: &upgradepkg.ReleaseInfo{Version: "9.9.9"},
|
|
versions: []upgradepkg.VersionEntry{{Version: "9.9.9"}, {Version: "9.9.8"}},
|
|
}
|
|
newUpgradeReleaseClient = func() upgradeReleaseClient { return client }
|
|
client.latestErr = fail
|
|
cmd := &cobra.Command{Use: "upgrade"}
|
|
cmd.SetOut(io.Discard)
|
|
if err := runUpgradeCheck(cmd, "json", upgradepkg.ReleaseTrackRelease); !errors.Is(err, fail) {
|
|
t.Fatalf("upgrade check error = %v", err)
|
|
}
|
|
client.latestErr = nil
|
|
client.versionsErr = fail
|
|
if err := runUpgradeList(cmd, "json", 1, upgradepkg.ReleaseTrackRelease); !errors.Is(err, fail) {
|
|
t.Fatalf("upgrade list error = %v", err)
|
|
}
|
|
client.versionsErr = nil
|
|
version = "9.9.9"
|
|
if err := runUpgradeList(cmd, "json", 1, upgradepkg.ReleaseTrackRelease); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := runUpgradeList(cmd, "table", 1, upgradepkg.ReleaseTrackRelease); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
edition.Override(&edition.Hooks{IsEmbedded: true})
|
|
embedded := newUpgradeCommand()
|
|
if err := embedded.Execute(); err == nil || !strings.Contains(err.Error(), "embedded") {
|
|
t.Fatalf("unnamed embedded upgrade error = %v", err)
|
|
}
|
|
edition.Override(&edition.Hooks{})
|
|
for _, args := range [][]string{{"--list", "--all"}, {"--rollback", "--yes"}, {"--check"}} {
|
|
command := newUpgradeCommand()
|
|
if args[0] == "--rollback" {
|
|
command.Flags().Bool("yes", false, "")
|
|
}
|
|
command.SetOut(io.Discard)
|
|
command.SetArgs(args)
|
|
if err := command.Execute(); err != nil {
|
|
t.Fatalf("upgrade command %v = %v", args, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) {
|
|
oldClient, oldRollback := newUpgradeReleaseClient, newUpgradeRollback
|
|
oldEnsure, oldCleanup := ensureUpgradeDirs, cleanupUpgradeStale
|
|
oldNeeds, oldBinary, oldSkills, oldChecksums := upgradeNeedsUpgrade, findUpgradeBinary, findUpgradeSkills, findUpgradeChecksums
|
|
oldDownload, oldProgress := downloadUpgradeFile, downloadUpgradeProgress
|
|
oldExtract, oldFind, oldLocate := extractUpgradeZip, findExtractedBinary, locateUpgradeSkill
|
|
oldReplace, oldInstall := replaceUpgradeSelf, installUpgradeSkills
|
|
oldTemp, oldRemove, oldRead, oldMkdir := upgradeMkdirTemp, upgradeRemoveAll, upgradeReadFile, upgradeMkdirAll
|
|
oldVerify, oldTar, oldValidate := verifyUpgradeFile, extractUpgradeTarGz, validateUpgradeBinary
|
|
oldStdin := os.Stdin
|
|
t.Cleanup(func() {
|
|
newUpgradeReleaseClient, newUpgradeRollback = oldClient, oldRollback
|
|
ensureUpgradeDirs, cleanupUpgradeStale = oldEnsure, oldCleanup
|
|
upgradeNeedsUpgrade, findUpgradeBinary, findUpgradeSkills, findUpgradeChecksums = oldNeeds, oldBinary, oldSkills, oldChecksums
|
|
downloadUpgradeFile, downloadUpgradeProgress = oldDownload, oldProgress
|
|
extractUpgradeZip, findExtractedBinary, locateUpgradeSkill = oldExtract, oldFind, oldLocate
|
|
replaceUpgradeSelf, installUpgradeSkills = oldReplace, oldInstall
|
|
upgradeMkdirTemp, upgradeRemoveAll, upgradeReadFile, upgradeMkdirAll = oldTemp, oldRemove, oldRead, oldMkdir
|
|
verifyUpgradeFile, extractUpgradeTarGz, validateUpgradeBinary = oldVerify, oldTar, oldValidate
|
|
os.Stdin = oldStdin
|
|
})
|
|
fail := errors.New("stage failure")
|
|
binary := upgradepkg.GitHubAsset{Name: "dws.zip", BrowserDownloadURL: "binary"}
|
|
skills := upgradepkg.GitHubAsset{Name: "dws-skills.zip", BrowserDownloadURL: "skills"}
|
|
checksums := upgradepkg.GitHubAsset{Name: "checksums.txt", BrowserDownloadURL: "checksums"}
|
|
release := &upgradepkg.ReleaseInfo{Version: "9.9.9", Date: "2026-01-01", Prerelease: true, Assets: []upgradepkg.GitHubAsset{binary, skills, checksums}}
|
|
client := &fakeUpgradeClient{latest: release, tagged: release}
|
|
rb := &fakeUpgradeRollback{}
|
|
|
|
configure := func(stage string) {
|
|
client.latestErr, client.taggedErr = nil, nil
|
|
newUpgradeReleaseClient = func() upgradeReleaseClient { return client }
|
|
newUpgradeRollback = func() upgradeRollbackManager { return rb }
|
|
rb.backupErr, rb.cleaned = nil, false
|
|
ensureUpgradeDirs = func() error {
|
|
if stage == "ensure" {
|
|
return fail
|
|
}
|
|
return nil
|
|
}
|
|
cleanupUpgradeStale = func() {}
|
|
upgradeNeedsUpgrade = func(string, string) bool { return stage != "not-needed" }
|
|
findUpgradeBinary = func([]upgradepkg.GitHubAsset) (*upgradepkg.GitHubAsset, error) {
|
|
if stage == "find-binary" {
|
|
return nil, fail
|
|
}
|
|
asset := binary
|
|
if stage == "extract-tar" {
|
|
asset.Name = "dws.tar.gz"
|
|
}
|
|
return &asset, nil
|
|
}
|
|
findUpgradeSkills = func([]upgradepkg.GitHubAsset) *upgradepkg.GitHubAsset { asset := skills; return &asset }
|
|
findUpgradeChecksums = func([]upgradepkg.GitHubAsset) *upgradepkg.GitHubAsset { asset := checksums; return &asset }
|
|
tempCalls := 0
|
|
upgradeMkdirTemp = func(string, string) (string, error) {
|
|
tempCalls++
|
|
if stage == "temp-both" || (stage == "temp-fallback" && tempCalls == 1) {
|
|
return "", fail
|
|
}
|
|
return "/tmp/dws-upgrade-coverage", nil
|
|
}
|
|
upgradeRemoveAll = func(string) error { return nil }
|
|
if stage == "backup" {
|
|
rb.backupErr = fail
|
|
}
|
|
downloadUpgradeFile = func(url, _ string) (int64, error) {
|
|
if stage == "checksum-download" && url == "checksums" {
|
|
return 0, fail
|
|
}
|
|
if stage == "skills-download" && url == "skills" {
|
|
return 0, fail
|
|
}
|
|
return 4, nil
|
|
}
|
|
upgradeReadFile = func(string) ([]byte, error) {
|
|
if stage == "checksum-read" {
|
|
return nil, fail
|
|
}
|
|
return []byte("checksum"), nil
|
|
}
|
|
downloadUpgradeProgress = func(_ context.Context, _, _ string, progress func(float64, int64, int64)) (int64, error) {
|
|
progress(150, 10, 10)
|
|
if stage == "binary-download" || stage == "temp-fallback" {
|
|
return 0, fail
|
|
}
|
|
return 1024, nil
|
|
}
|
|
verifyCalls := 0
|
|
verifyUpgradeFile = func(string, string, string, string, string) error {
|
|
verifyCalls++
|
|
if stage == "verify-binary" && verifyCalls == 1 || stage == "verify-skills" && verifyCalls == 2 {
|
|
return fail
|
|
}
|
|
return nil
|
|
}
|
|
extractCalls := 0
|
|
extractUpgradeZip = func(string, string) error {
|
|
extractCalls++
|
|
if stage == "extract-binary" && extractCalls == 1 || stage == "extract-skills" && extractCalls == 2 {
|
|
return fail
|
|
}
|
|
return nil
|
|
}
|
|
extractUpgradeTarGz = func(string, string) error {
|
|
if stage == "extract-tar" {
|
|
return fail
|
|
}
|
|
return nil
|
|
}
|
|
findExtractedBinary = func(string) string {
|
|
if stage == "binary-missing" {
|
|
return ""
|
|
}
|
|
return "/tmp/new-dws"
|
|
}
|
|
validateUpgradeBinary = func(string, string) error {
|
|
if stage == "validate" {
|
|
return fail
|
|
}
|
|
return nil
|
|
}
|
|
upgradeMkdirAll = func(string, os.FileMode) error { return nil }
|
|
locateUpgradeSkill = func(string) string {
|
|
if stage == "skill-missing" {
|
|
return ""
|
|
}
|
|
return "/tmp/SKILL.md"
|
|
}
|
|
replaceUpgradeSelf = func(string) error {
|
|
if stage == "replace" {
|
|
return fail
|
|
}
|
|
return nil
|
|
}
|
|
installUpgradeSkills = func(string, upgradepkg.SkillUpgradeOptions) (*upgradepkg.SkillUpgradeResult, error) {
|
|
if stage == "install" {
|
|
return nil, fail
|
|
}
|
|
if stage == "install-failed-dir" {
|
|
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/failed", Status: upgradepkg.SkillDirFailed, Err: fail}}}, nil
|
|
}
|
|
if stage == "install-retire-warning" {
|
|
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{
|
|
{Dir: "/ok", Status: upgradepkg.SkillDirOK},
|
|
{Dir: "/stale", Status: upgradepkg.SkillDirRetireWarning, Err: errors.New("retirement refused")},
|
|
}}, nil
|
|
}
|
|
return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/ok", Status: upgradepkg.SkillDirOK}}}, nil
|
|
}
|
|
}
|
|
|
|
for _, stage := range []string{
|
|
"ensure", "tag-error", "latest-error", "not-needed", "cancel", "find-binary", "temp-fallback", "temp-both",
|
|
"backup", "checksum-download", "checksum-read", "binary-download", "skills-download", "verify-binary", "verify-skills",
|
|
"extract-binary", "extract-tar", "binary-missing", "validate", "extract-skills", "skill-missing", "replace", "install", "install-failed-dir",
|
|
"success", "success-no-skills", "install-retire-warning",
|
|
} {
|
|
t.Run(stage, func(t *testing.T) {
|
|
configure(stage)
|
|
opts := upgradeOptions{force: true, yes: true}
|
|
if stage == "tag-error" {
|
|
opts.targetVersion = "v9.9.9"
|
|
client.taggedErr = fail
|
|
}
|
|
if stage == "latest-error" {
|
|
client.latestErr = fail
|
|
}
|
|
if stage == "not-needed" {
|
|
opts.force = false
|
|
}
|
|
if stage == "cancel" {
|
|
opts.yes = false
|
|
read, write, err := os.Pipe()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, _ = io.WriteString(write, "n\n")
|
|
_ = write.Close()
|
|
os.Stdin = read
|
|
defer read.Close()
|
|
}
|
|
if stage == "success-no-skills" {
|
|
opts.skipSkills = true
|
|
}
|
|
err := runUpgrade(context.Background(), opts)
|
|
wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" && stage != "install-retire-warning"
|
|
if wantError && err == nil {
|
|
t.Fatalf("stage %s succeeded", stage)
|
|
}
|
|
if !wantError && err != nil {
|
|
t.Fatalf("stage %s failed: %v", stage, err)
|
|
}
|
|
if (stage == "success" || stage == "success-no-skills" || stage == "install-retire-warning") && !rb.cleaned {
|
|
t.Fatal("successful upgrade did not clean backups")
|
|
}
|
|
if stage == "success" {
|
|
command := newUpgradeCommand()
|
|
command.Flags().Bool("yes", false, "")
|
|
command.Flags().Bool("dry-run", false, "")
|
|
_ = command.Flags().Set("yes", "true")
|
|
_ = command.Flags().Set("force", "true")
|
|
_ = command.Flags().Set("skip-skills", "true")
|
|
if err := command.RunE(command, nil); err != nil {
|
|
t.Fatalf("default upgrade command = %v", err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageUpgradeBinaryHelpersFailureCoverage(t *testing.T) {
|
|
oldStat, oldChmod := upgradeStat, upgradeChmod
|
|
oldTry, oldRepair := upgradeTryExecVersion, upgradeRepairDarwin
|
|
oldGOOS := upgradeRuntimeGOOS
|
|
oldLook, oldCommand, oldMkdir, oldHome := upgradeLookPath, upgradeCommandOutput, upgradeMkdirAll, upgradeUserHomeDir
|
|
t.Cleanup(func() {
|
|
upgradeStat, upgradeChmod = oldStat, oldChmod
|
|
upgradeTryExecVersion, upgradeRepairDarwin = oldTry, oldRepair
|
|
upgradeRuntimeGOOS = oldGOOS
|
|
upgradeLookPath, upgradeCommandOutput, upgradeMkdirAll, upgradeUserHomeDir = oldLook, oldCommand, oldMkdir, oldHome
|
|
})
|
|
fail := errors.New("failure")
|
|
if _, err := oldCommand(filepath.Join(t.TempDir(), "missing-command")); err == nil {
|
|
t.Fatal("default command runner executed a missing command")
|
|
}
|
|
upgradeStat = func(string) (os.FileInfo, error) { return nil, fail }
|
|
if err := validateNewBinary("binary", "1.0"); !errors.Is(err, fail) {
|
|
t.Fatalf("binary stat error = %v", err)
|
|
}
|
|
upgradeStat = func(string) (os.FileInfo, error) { return upgradeFileInfo{}, nil }
|
|
if err := validateNewBinary("binary", "1.0"); err == nil || !strings.Contains(err.Error(), "为空") {
|
|
t.Fatalf("empty binary error = %v", err)
|
|
}
|
|
upgradeStat = func(string) (os.FileInfo, error) { return upgradeFileInfo{size: 1}, nil }
|
|
upgradeChmod = func(string, os.FileMode) error { return fail }
|
|
if err := validateNewBinary("binary", "1.0"); !errors.Is(err, fail) {
|
|
t.Fatalf("binary chmod error = %v", err)
|
|
}
|
|
upgradeChmod = func(string, os.FileMode) error { return nil }
|
|
upgradeTryExecVersion = func(string) ([]byte, error) { return nil, fail }
|
|
upgradeRepairDarwin = func(string) error { return fail }
|
|
if err := validateNewBinary("binary", "1.0"); err == nil {
|
|
t.Fatal("unexecutable binary succeeded")
|
|
}
|
|
upgradeRuntimeGOOS = "darwin"
|
|
calls := 0
|
|
upgradeTryExecVersion = func(string) ([]byte, error) {
|
|
calls++
|
|
if calls == 1 {
|
|
return nil, errors.New("signal: killed")
|
|
}
|
|
return []byte("version 1.0"), nil
|
|
}
|
|
upgradeRepairDarwin = func(string) error { return nil }
|
|
if err := validateNewBinary("binary", "1.0"); err != nil {
|
|
t.Fatalf("repaired binary = %v", err)
|
|
}
|
|
upgradeRuntimeGOOS = oldGOOS
|
|
upgradeTryExecVersion = func(string) ([]byte, error) { return []byte("different"), nil }
|
|
if err := validateNewBinary("binary", "1.0"); err != nil {
|
|
t.Fatalf("version mismatch warning = %v", err)
|
|
}
|
|
|
|
upgradeLookPath = func(string) (string, error) { return "", fail }
|
|
upgradeCommandOutput = func(string, ...string) ([]byte, error) { return nil, nil }
|
|
if err := repairDarwinBinary("binary"); !errors.Is(err, fail) {
|
|
t.Fatalf("codesign lookup error = %v", err)
|
|
}
|
|
upgradeLookPath = func(string) (string, error) { return "/codesign", nil }
|
|
upgradeCommandOutput = func(name string, _ ...string) ([]byte, error) {
|
|
if name == "codesign" {
|
|
return []byte("denied"), fail
|
|
}
|
|
return nil, nil
|
|
}
|
|
if err := repairDarwinBinary("binary"); err == nil || !strings.Contains(err.Error(), "denied") {
|
|
t.Fatalf("codesign execution error = %v", err)
|
|
}
|
|
upgradeCommandOutput = func(string, ...string) ([]byte, error) { return nil, nil }
|
|
if err := repairDarwinBinary("binary"); err != nil {
|
|
t.Fatalf("codesign success = %v", err)
|
|
}
|
|
upgradeMkdirAll = func(string, os.FileMode) error { return nil }
|
|
upgradeCommandOutput = func(string, ...string) ([]byte, error) { return []byte("tar failed"), fail }
|
|
if err := extractTarGz("archive", filepath.Join(t.TempDir(), "dest")); err == nil || !strings.Contains(err.Error(), "tar failed") {
|
|
t.Fatalf("tar error = %v", err)
|
|
}
|
|
upgradeUserHomeDir = func() (string, error) { return "", fail }
|
|
if shortenHome("/path") != "/path" {
|
|
t.Fatal("home lookup failure shortened path")
|
|
}
|
|
if got := parseChangelogEntries("abcdef12", 1); len(got) != 0 {
|
|
t.Fatalf("empty changelog entry = %#v", got)
|
|
}
|
|
}
|