The mkdir->rename->remove->rename directory fallback had a TOCTOU window between the second remove and the second rename: a concurrent writer creating an entry at the destination was silently clobbered. The fallback now claims the destination once with mkdir and never unlinks it: the fast-path rename publishes over the claim (Linux), and platforms that refuse directory renames (macOS, Windows) move the staged children into the claim through atomic no-clobber primitives (mkdir/os.Link/os.Symlink), consuming the emptied source shell on success. renameSkillPathNoReplace now returns the mkdir-claim identity captured by the child-move path. PublishSkillPathNoReplace uses it as a three-state ownership witness: the atomic/fast paths keep the staged-inode proof, the child-move path proves dest is still the mkdir claim, and a mismatch reports the new ErrSkillPathPublicationUncertain sentinel with the destination retained. The witness is real on POSIX now: darwin and linux report the dev:ino file identity instead of the empty no-op. Upstream consumers honor the sentinel: the mono/multi upgrade copy fallbacks no longer retry over an uncertain destination (the retry would displace the concurrent writer's object), and skill setup reports the retained destination instead of claiming a rollback. Rewrites the fallback tests that pinned the removed remove-and-retry flow and adds regression coverage: concurrent claim entries abort with the destination retained, wholesale replacement after child-move reports the uncertain sentinel, staged-set transactions pass the sentinel through, and both copy fallbacks short-circuit (ablation-verified).
514 lines
18 KiB
Go
514 lines
18 KiB
Go
package upgrade
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
|
)
|
|
|
|
func TestCrossPlatformCoverageSkillPublicationNoClobberAndOwnedRollback(t *testing.T) {
|
|
t.Run("existing destination is never replaced", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
staged := filepath.Join(root, "staged")
|
|
destination := filepath.Join(root, "destination")
|
|
seedUpgradeSkill(t, staged, "new", false)
|
|
seedUpgradeSkill(t, destination, "concurrent", false)
|
|
|
|
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil {
|
|
t.Fatal("expected no-replace publication to fail")
|
|
}
|
|
assertUpgradeSkillContent(t, destination, "concurrent")
|
|
assertUpgradeSkillContent(t, staged, "new")
|
|
})
|
|
|
|
t.Run("owned publication is removed", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
staged := filepath.Join(root, "staged")
|
|
destination := filepath.Join(root, "destination")
|
|
seedUpgradeSkill(t, staged, "new", false)
|
|
publication, err := PublishSkillPathNoReplace(staged, destination)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := os.Lstat(destination); !os.IsNotExist(err) {
|
|
t.Fatalf("owned publication remains: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("concurrent replacement is quarantined instead of deleted", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
staged := filepath.Join(root, "staged")
|
|
destination := filepath.Join(root, "destination")
|
|
seedUpgradeSkill(t, staged, "new", false)
|
|
publication, err := PublishSkillPathNoReplace(staged, destination)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.RemoveAll(destination); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Even a byte-for-byte identical replacement is not owned by this
|
|
// transaction: inode identity, not content alone, is authoritative.
|
|
seedUpgradeSkill(t, destination, "new", false)
|
|
|
|
err = RollbackSkillPathPublications([]SkillPathPublication{publication})
|
|
if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") {
|
|
t.Fatalf("replacement rollback error = %v", err)
|
|
}
|
|
assertUpgradeSkillContent(t, destination, "new")
|
|
})
|
|
|
|
t.Run("tunneled replacement with same creation time is still refused", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
staged := filepath.Join(root, "staged")
|
|
destination := filepath.Join(root, "destination")
|
|
seedUpgradeSkill(t, staged, "new", false)
|
|
// NTFS file tunneling can restore the original creation time for a
|
|
// same-named recreation, defeating the incarnation check. The file ID
|
|
// (or inode on Unix) must still differ and block the rollback.
|
|
testseam.Swap(t, &skillPathFileIncarnation, func(os.FileInfo) string { return "tunneled" })
|
|
publication, err := PublishSkillPathNoReplace(staged, destination)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Simulate the identity change that a concurrent replacement causes.
|
|
// On Windows, skillPathIdentityProven compares file IDs from
|
|
// GetFileInformationByHandle and ignores this seam. On Unix, where the
|
|
// file ID is unavailable, this swap replaces the os.SameFile check
|
|
// (which can return true for a recreated file on tmpfs due to inode
|
|
// reuse) so the test is deterministic across filesystems.
|
|
testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false })
|
|
if err := os.RemoveAll(destination); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seedUpgradeSkill(t, destination, "new", false)
|
|
|
|
err = RollbackSkillPathPublications([]SkillPathPublication{publication})
|
|
if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") {
|
|
t.Fatalf("tunneled replacement rollback error = %v", err)
|
|
}
|
|
assertUpgradeSkillContent(t, destination, "new")
|
|
})
|
|
}
|
|
|
|
func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) {
|
|
failure := errors.New("injected publication failure")
|
|
|
|
t.Run("platform no-replace path encoding", func(t *testing.T) {
|
|
if _, err := renameSkillPathNoReplace("invalid\x00source", filepath.Join(t.TempDir(), "dest")); err == nil {
|
|
t.Fatal("expected invalid source path failure")
|
|
}
|
|
source := filepath.Join(t.TempDir(), "source")
|
|
if err := os.WriteFile(source, []byte("value"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := renameSkillPathNoReplace(source, "invalid\x00destination"); err == nil {
|
|
t.Fatal("expected invalid destination path failure")
|
|
}
|
|
})
|
|
|
|
t.Run("publish identity", func(t *testing.T) {
|
|
if _, err := PublishSkillPathNoReplace(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dest")); err == nil {
|
|
t.Fatal("expected identity failure")
|
|
}
|
|
})
|
|
|
|
t.Run("publish fingerprint", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
staged := filepath.Join(root, "staged")
|
|
seedUpgradeSkill(t, staged, "value", false)
|
|
originalLstat := skillPathLstat
|
|
calls := 0
|
|
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
|
|
calls++
|
|
if calls == 2 {
|
|
return nil, failure
|
|
}
|
|
return originalLstat(path)
|
|
})
|
|
if _, err := PublishSkillPathNoReplace(staged, filepath.Join(root, "dest")); !errors.Is(err, failure) {
|
|
t.Fatal("expected fingerprint failure")
|
|
}
|
|
})
|
|
|
|
t.Run("publish confirmation missing", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
staged := filepath.Join(root, "staged")
|
|
destination := filepath.Join(root, "destination")
|
|
seedUpgradeSkill(t, staged, "value", false)
|
|
originalRename := skillPathRenameNoReplace
|
|
testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) {
|
|
if _, err := originalRename(source, target); err != nil {
|
|
return "", err
|
|
}
|
|
return "", os.RemoveAll(target)
|
|
})
|
|
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "对象保留") {
|
|
t.Fatalf("publish confirmation error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("publish confirmation identity changed", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
staged := filepath.Join(root, "staged")
|
|
destination := filepath.Join(root, "destination")
|
|
seedUpgradeSkill(t, staged, "value", false)
|
|
originalRename := skillPathRenameNoReplace
|
|
testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) {
|
|
if _, err := originalRename(source, target); err != nil {
|
|
return "", err
|
|
}
|
|
if err := os.RemoveAll(target); err != nil {
|
|
return "", err
|
|
}
|
|
seedUpgradeSkill(t, target, "replacement", false)
|
|
return "", nil
|
|
})
|
|
if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 身份已变化") {
|
|
t.Fatalf("publish identity error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("publish confirmation fingerprint", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
staged := filepath.Join(root, "staged")
|
|
destination := filepath.Join(root, "destination")
|
|
seedUpgradeSkill(t, staged, "value", false)
|
|
originalRename := skillPathRenameNoReplace
|
|
published := false
|
|
testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) {
|
|
if _, err := originalRename(source, target); err != nil {
|
|
return "", err
|
|
}
|
|
published = true
|
|
return "", nil
|
|
})
|
|
originalReadDir := skillPathReadDir
|
|
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
|
|
if published && path == destination {
|
|
return nil, failure
|
|
}
|
|
return originalReadDir(path)
|
|
})
|
|
if _, err := PublishSkillPathNoReplace(staged, destination); !errors.Is(err, failure) || !strings.Contains(err.Error(), "内容失败") {
|
|
t.Fatalf("publish fingerprint confirmation error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("rollback temp", func(t *testing.T) {
|
|
testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", failure })
|
|
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: "dest"}}); !errors.Is(err, failure) {
|
|
t.Fatalf("temp error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("missing destination cleanup", func(t *testing.T) {
|
|
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
|
|
t.Run("missing destination cleanup failure", func(t *testing.T) {
|
|
testseam.Swap(t, &skillPathRemoveAll, func(string) error { return failure })
|
|
if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); !errors.Is(err, failure) {
|
|
t.Fatalf("cleanup error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("quarantine rename", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
staged := filepath.Join(root, "staged")
|
|
destination := filepath.Join(root, "destination")
|
|
seedUpgradeSkill(t, staged, "value", false)
|
|
publication, err := PublishSkillPathNoReplace(staged, destination)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
testseam.Swap(t, &skillPathRename, func(string, string) error { return failure })
|
|
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
|
|
t.Fatalf("quarantine error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("live verification", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
staged := filepath.Join(root, "staged")
|
|
destination := filepath.Join(root, "destination")
|
|
seedUpgradeSkill(t, staged, "value", false)
|
|
publication, err := PublishSkillPathNoReplace(staged, destination)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
originalReadDir := skillPathReadDir
|
|
testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) {
|
|
if path == destination {
|
|
return nil, failure
|
|
}
|
|
return originalReadDir(path)
|
|
})
|
|
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
|
|
t.Fatalf("live verification error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("destination disappears before quarantine", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
staged := filepath.Join(root, "staged")
|
|
destination := filepath.Join(root, "destination")
|
|
seedUpgradeSkill(t, staged, "value", false)
|
|
publication, err := PublishSkillPathNoReplace(staged, destination)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
testseam.Swap(t, &skillPathRename, func(source, target string) error {
|
|
if err := os.RemoveAll(source); err != nil {
|
|
return err
|
|
}
|
|
return &os.PathError{Op: "rename", Path: source, Err: os.ErrNotExist}
|
|
})
|
|
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
|
|
t.Run("owned remove", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
staged := filepath.Join(root, "staged")
|
|
destination := filepath.Join(root, "destination")
|
|
seedUpgradeSkill(t, staged, "value", false)
|
|
publication, err := PublishSkillPathNoReplace(staged, destination)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
originalRemove := skillPathRemoveAll
|
|
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
|
|
if filepath.Base(path) == "payload" {
|
|
return failure
|
|
}
|
|
return originalRemove(path)
|
|
})
|
|
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
|
|
t.Fatalf("remove error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("quarantined fingerprint", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
staged := filepath.Join(root, "staged")
|
|
destination := filepath.Join(root, "destination")
|
|
seedUpgradeSkill(t, staged, "value", false)
|
|
publication, err := PublishSkillPathNoReplace(staged, destination)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
originalLstat := skillPathLstat
|
|
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
|
|
if filepath.Base(path) == "payload" {
|
|
return nil, failure
|
|
}
|
|
return originalLstat(path)
|
|
})
|
|
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
|
|
t.Fatalf("fingerprint error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("mismatch restore and cleanup", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
staged := filepath.Join(root, "staged")
|
|
destination := filepath.Join(root, "destination")
|
|
seedUpgradeSkill(t, staged, "published", false)
|
|
publication, err := PublishSkillPathNoReplace(staged, destination)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
originalRename := skillPathRename
|
|
testseam.Swap(t, &skillPathRename, func(source, target string) error {
|
|
if source == destination {
|
|
if err := os.RemoveAll(source); err != nil {
|
|
return err
|
|
}
|
|
seedUpgradeSkill(t, source, "replacement", false)
|
|
}
|
|
return originalRename(source, target)
|
|
})
|
|
originalRemove := skillPathRemoveAll
|
|
testseam.Swap(t, &skillPathRemoveAll, func(path string) error {
|
|
if strings.Contains(filepath.Base(path), ".destination.rollback-") {
|
|
return failure
|
|
}
|
|
return originalRemove(path)
|
|
})
|
|
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) {
|
|
t.Fatalf("mismatch cleanup error = %v", err)
|
|
}
|
|
assertUpgradeSkillContent(t, destination, "replacement")
|
|
})
|
|
|
|
t.Run("mismatch restore failure", func(t *testing.T) {
|
|
root := t.TempDir()
|
|
staged := filepath.Join(root, "staged")
|
|
destination := filepath.Join(root, "destination")
|
|
seedUpgradeSkill(t, staged, "published", false)
|
|
publication, err := PublishSkillPathNoReplace(staged, destination)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
originalRename := skillPathRename
|
|
testseam.Swap(t, &skillPathRename, func(source, target string) error {
|
|
if source == destination {
|
|
if err := os.RemoveAll(source); err != nil {
|
|
return err
|
|
}
|
|
seedUpgradeSkill(t, source, "replacement", false)
|
|
}
|
|
return originalRename(source, target)
|
|
})
|
|
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", failure })
|
|
if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) || !strings.Contains(err.Error(), "并发对象保留") {
|
|
t.Fatalf("mismatch restore error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("fingerprint read failures and lexical types", func(t *testing.T) {
|
|
t.Run("directory ordering", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
for _, name := range []string{"b", "a"} {
|
|
if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if _, err := fingerprintSkillPath(dir); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
t.Run("directory read", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
|
|
if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) {
|
|
t.Fatalf("read dir error = %v", err)
|
|
}
|
|
})
|
|
t.Run("recursive child", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
child := filepath.Join(dir, "child")
|
|
if err := os.WriteFile(child, []byte("value"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
originalLstat := skillPathLstat
|
|
testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) {
|
|
if path == child {
|
|
return nil, failure
|
|
}
|
|
return originalLstat(path)
|
|
})
|
|
if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) {
|
|
t.Fatalf("child error = %v", err)
|
|
}
|
|
})
|
|
t.Run("symlink read", func(t *testing.T) {
|
|
link := filepath.Join(t.TempDir(), "link")
|
|
if err := os.Symlink("missing", link); err != nil {
|
|
t.Skipf("symlink unavailable: %v", err)
|
|
}
|
|
testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", failure })
|
|
if _, err := fingerprintSkillPath(link); !errors.Is(err, failure) {
|
|
t.Fatalf("readlink error = %v", err)
|
|
}
|
|
})
|
|
t.Run("symlink success", func(t *testing.T) {
|
|
link := filepath.Join(t.TempDir(), "link")
|
|
if err := os.Symlink("missing", link); err != nil {
|
|
t.Skipf("symlink unavailable: %v", err)
|
|
}
|
|
if _, err := fingerprintSkillPath(link); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
})
|
|
t.Run("file digest", func(t *testing.T) {
|
|
file := filepath.Join(t.TempDir(), "file")
|
|
if err := os.WriteFile(file, []byte("value"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
testseam.Swap(t, &skillPathOpen, func(string) (*os.File, error) { return nil, failure })
|
|
if _, err := fingerprintSkillPath(file); !errors.Is(err, failure) {
|
|
t.Fatalf("digest error = %v", err)
|
|
}
|
|
})
|
|
t.Run("special", func(t *testing.T) {
|
|
testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) {
|
|
return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil
|
|
})
|
|
if err := fingerprintSkillPathInto(sha256.New(), "special"); err == nil {
|
|
t.Fatal("expected special path error")
|
|
}
|
|
})
|
|
t.Run("unknown platform identity shape", func(t *testing.T) {
|
|
if got := skillPathFileIncarnation(skillPathFakeInfo{}); got == "" {
|
|
t.Fatal("empty incarnation")
|
|
}
|
|
})
|
|
t.Run("synthetic info does not match same-file identity", func(t *testing.T) {
|
|
fake := skillPathFakeInfo{}
|
|
if skillPathSameFileIdentity(fake, fake) {
|
|
t.Fatal("synthetic info should not match")
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestCrossPlatformCoverageUpgradeRollbackRetainsConcurrentReplacement(t *testing.T) {
|
|
home := t.TempDir()
|
|
base := filepath.Join(home, ".agents", "skills")
|
|
first := filepath.Join(base, "dingtalk-first")
|
|
second := filepath.Join(base, "dingtalk-second")
|
|
seedUpgradeSkill(t, first, "old first", false)
|
|
seedUpgradeSkill(t, second, "old second", false)
|
|
stageRoot := filepath.Join(base, ".stage")
|
|
stagedFirst := filepath.Join(stageRoot, "dingtalk-first")
|
|
stagedSecond := filepath.Join(stageRoot, "dingtalk-second")
|
|
seedUpgradeSkill(t, stagedFirst, "new first", false)
|
|
seedUpgradeSkill(t, stagedSecond, "new second", false)
|
|
|
|
failure := errors.New("injected second publication failure")
|
|
originalPublish := upgradePublishSkillPath
|
|
calls := 0
|
|
testseam.Swap(t, &upgradePublishSkillPath, func(staged, destination string) (SkillPathPublication, error) {
|
|
calls++
|
|
if calls == 2 {
|
|
if err := os.RemoveAll(first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
seedUpgradeSkill(t, first, "concurrent", false)
|
|
return SkillPathPublication{}, failure
|
|
}
|
|
return originalPublish(staged, destination)
|
|
})
|
|
|
|
err := publishStagedSkillSet(home, []stagedSkillDir{
|
|
{staged: stagedFirst, dest: first},
|
|
{staged: stagedSecond, dest: second},
|
|
}, []string{first, second})
|
|
if !errors.Is(err, failure) || !strings.Contains(err.Error(), "拒绝删除非本事务") {
|
|
t.Fatalf("transaction error = %v", err)
|
|
}
|
|
assertUpgradeSkillContent(t, first, "concurrent")
|
|
assertUpgradeSkillContent(t, second, "old second")
|
|
matches, globErr := filepath.Glob(filepath.Join(home, ".dws", "skill-backups", "*", ".agents-skills-dingtalk-first", "SKILL.md"))
|
|
if globErr != nil || len(matches) != 1 {
|
|
t.Fatalf("retained old backup = %v, %v", matches, globErr)
|
|
}
|
|
content, readErr := os.ReadFile(matches[0])
|
|
if readErr != nil || string(content) != "old first" {
|
|
t.Fatalf("retained backup content = %q, %v", content, readErr)
|
|
}
|
|
}
|