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).
522 lines
20 KiB
Go
522 lines
20 KiB
Go
package upgrade
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
|
)
|
|
|
|
func TestCrossPlatformCoverageCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testing.T) {
|
|
home := withFakeHome(t)
|
|
testseam.Swap(t, &knownSkillDirs, []string{
|
|
".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills",
|
|
})
|
|
|
|
for _, parent := range []string{".codex", ".claude", ".openclaw"} {
|
|
if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
// beta.6 wrote a physical copy into Codex. The canonical migration must
|
|
// remove it without treating a matching user/market name as DWS-owned.
|
|
oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat")
|
|
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared")
|
|
result, err := UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"})
|
|
if err != nil || len(result.Failed()) != 0 {
|
|
t.Fatalf("upgrade = %#v, %v", result, err)
|
|
}
|
|
|
|
canonical := filepath.Join(home, ".agents", "skills")
|
|
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
|
|
if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil {
|
|
t.Fatalf("canonical %s missing: %v", name, err)
|
|
}
|
|
if _, err := os.Lstat(filepath.Join(home, ".codex", "skills", name)); !os.IsNotExist(err) {
|
|
t.Fatalf("universal Codex duplicate remains for %s: %v", name, err)
|
|
}
|
|
for _, agent := range []string{".claude", ".openclaw"} {
|
|
link := filepath.Join(home, agent, "skills", name)
|
|
info, err := os.Lstat(link)
|
|
if err != nil || info.Mode()&os.ModeSymlink == 0 {
|
|
t.Fatalf("%s link = %#v, %v", link, info, err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(link, "SKILL.md")); err != nil {
|
|
t.Fatalf("%s does not resolve canonical content: %v", link, err)
|
|
}
|
|
}
|
|
}
|
|
result, err = UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"})
|
|
if err != nil || len(result.Failed()) != 0 {
|
|
t.Fatalf("idempotent upgrade = %#v, %v", result, err)
|
|
}
|
|
for _, agent := range []string{".claude", ".openclaw"} {
|
|
info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat"))
|
|
if err != nil || info.Mode()&os.ModeSymlink == 0 {
|
|
t.Fatalf("idempotent upgrade replaced %s link: %#v, %v", agent, info, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageCanonicalCustomRootsSymlinkedParentsAndLexicalVictims(t *testing.T) {
|
|
home := withFakeHome(t)
|
|
customCodex := filepath.Join(t.TempDir(), "codex")
|
|
customHermes := filepath.Join(t.TempDir(), "hermes")
|
|
externalClaude := filepath.Join(t.TempDir(), "claude")
|
|
for _, root := range []string{customCodex, customHermes, externalClaude, filepath.Join(home, ".clawdbot"), filepath.Join(home, ".codeium", "windsurf")} {
|
|
if err := os.MkdirAll(root, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := os.Symlink(externalClaude, filepath.Join(home, ".claude")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
testseam.Swap(t, &upgradeGetenv, func(name string) string {
|
|
switch name {
|
|
case "CODEX_HOME":
|
|
return customCodex
|
|
case "HERMES_HOME":
|
|
return customHermes
|
|
default:
|
|
return ""
|
|
}
|
|
})
|
|
|
|
oldCodex := filepath.Join(customCodex, "skills", "dingtalk-chat")
|
|
if err := os.MkdirAll(oldCodex, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
claudeBase := filepath.Join(home, ".claude", "skills")
|
|
if err := os.MkdirAll(claudeBase, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(claudeBase, "dingtalk-chat"), []byte("unexpected file"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink("missing-target", filepath.Join(claudeBase, "dingtalk-shared")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared")
|
|
result, err := UpgradeSkillLocations(multiRoot)
|
|
if err != nil || len(result.Failed()) != 0 {
|
|
t.Fatalf("upgrade = %#v, %v", result, err)
|
|
}
|
|
if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) {
|
|
t.Fatalf("custom CODEX_HOME duplicate remains: %v", err)
|
|
}
|
|
for _, base := range []string{
|
|
claudeBase,
|
|
filepath.Join(customHermes, "skills"),
|
|
filepath.Join(home, ".clawdbot", "skills"),
|
|
filepath.Join(home, ".codeium", "windsurf", "skills"),
|
|
} {
|
|
for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} {
|
|
path := filepath.Join(base, name)
|
|
info, statErr := os.Lstat(path)
|
|
if statErr != nil || info.Mode()&os.ModeSymlink == 0 {
|
|
t.Fatalf("expected canonical link %s: %#v, %v", path, info, statErr)
|
|
}
|
|
body, readErr := os.ReadFile(filepath.Join(path, "SKILL.md"))
|
|
if readErr != nil || !strings.Contains(string(body), name) {
|
|
t.Fatalf("link %s does not resolve: %q, %v", path, body, readErr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageCanonicalSkillLinksFallBackToCopies(t *testing.T) {
|
|
home := withFakeHome(t)
|
|
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
|
|
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("links unavailable") })
|
|
|
|
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat")
|
|
result, err := UpgradeSkillLocations(multiRoot)
|
|
if err != nil || len(result.Failed()) != 0 {
|
|
t.Fatalf("upgrade = %#v, %v", result, err)
|
|
}
|
|
dest := filepath.Join(home, ".claude", "skills", "dingtalk-chat")
|
|
info, err := os.Lstat(dest)
|
|
if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
t.Fatalf("copy fallback = %#v, %v", info, err)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(dest, "SKILL.md")); err != nil {
|
|
t.Fatalf("copy fallback content missing: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageConfiguredRootsDetectShallowAndApplicationAgents(t *testing.T) {
|
|
// Keep the destination HOME synthetic: app-bundle detection is deliberately
|
|
// machine-scoped and must not depend on the selected installation HOME.
|
|
home := t.TempDir()
|
|
testseam.Swap(t, &upgradeGetenv, func(string) string { return "" })
|
|
for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} {
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
sentinel := filepath.Join(home, "app-sentinel")
|
|
if err := os.MkdirAll(sentinel, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
appInfo, err := os.Stat(sentinel)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
zcodeApp := filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")
|
|
minimaxApp := filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")
|
|
originalStat := upgradeStat
|
|
testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) {
|
|
if path == zcodeApp || path == minimaxApp {
|
|
return appInfo, nil
|
|
}
|
|
return originalStat(path)
|
|
})
|
|
want := map[string]bool{
|
|
".config/kimchi/harness/skills": false,
|
|
".tabnine/agent/skills": false,
|
|
".zcode/skills": false,
|
|
".minimax/skills": false,
|
|
}
|
|
for _, root := range configuredSkillRoots(home) {
|
|
label := filepath.ToSlash(root.label)
|
|
if _, ok := want[label]; ok {
|
|
want[label] = skillRootDetectedBase(root)
|
|
}
|
|
}
|
|
for label, detected := range want {
|
|
if !detected {
|
|
t.Errorf("root %s was not detected", label)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageConfiguredAgentRootsMatchUpstreamAndCustomHomes(t *testing.T) {
|
|
home := t.TempDir()
|
|
custom := map[string]string{
|
|
"AUTOHAND_HOME": filepath.Join(home, "custom-autohand"),
|
|
"CLAUDE_CONFIG_DIR": filepath.Join(home, "custom-claude"),
|
|
"CODEX_HOME": filepath.Join(home, "custom-codex"),
|
|
"GROK_HOME": filepath.Join(home, "custom-grok"),
|
|
"HERMES_HOME": filepath.Join(home, "custom-hermes"),
|
|
"VIBE_HOME": filepath.Join(home, "custom-vibe"),
|
|
"XDG_CONFIG_HOME": filepath.Join(home, "custom-xdg"),
|
|
}
|
|
testseam.Swap(t, &upgradeGetenv, func(name string) string { return custom[name] })
|
|
roots := configuredSkillRoots(home)
|
|
if len(roots) != len(knownSkillDirs) {
|
|
t.Fatalf("configured unique roots = %d, want %d", len(roots), len(knownSkillDirs))
|
|
}
|
|
byLabel := make(map[string]resolvedSkillRoot, len(roots))
|
|
for _, root := range roots {
|
|
if _, duplicate := byLabel[root.label]; duplicate {
|
|
t.Fatalf("duplicate configured label %s", root.label)
|
|
}
|
|
byLabel[root.label] = root
|
|
}
|
|
wants := map[string]string{
|
|
".autohand/skills": filepath.Join(custom["AUTOHAND_HOME"], "skills"),
|
|
".claude/skills": filepath.Join(custom["CLAUDE_CONFIG_DIR"], "skills"),
|
|
".codex/skills": filepath.Join(custom["CODEX_HOME"], "skills"),
|
|
".grok/skills": filepath.Join(custom["GROK_HOME"], "skills"),
|
|
".hermes/skills": filepath.Join(custom["HERMES_HOME"], "skills"),
|
|
".vibe/skills": filepath.Join(custom["VIBE_HOME"], "skills"),
|
|
".config/agents/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"),
|
|
".config/crush/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "crush", "skills"),
|
|
".config/devin/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "devin", "skills"),
|
|
".config/goose/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "goose", "skills"),
|
|
".config/kimchi/harness/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "kimchi", "harness", "skills"),
|
|
".config/opencode/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "opencode", "skills"),
|
|
}
|
|
for label, want := range wants {
|
|
if got := byLabel[label].base; filepath.Clean(got) != filepath.Clean(want) {
|
|
t.Errorf("configured %s = %q, want %q", label, got, want)
|
|
}
|
|
}
|
|
for _, label := range []string{
|
|
".config/agents/skills", ".gemini/antigravity/skills", ".gemini/antigravity-cli/skills",
|
|
".codex/skills", ".cursor/skills", ".deepagents/agent/skills", ".firebender/skills",
|
|
".gemini/skills", ".copilot/skills", ".config/opencode/skills",
|
|
} {
|
|
if !byLabel[label].universal {
|
|
t.Errorf("%s not classified universal", label)
|
|
}
|
|
}
|
|
if !byLabel[".agents/skills"].canonical || byLabel[".claude/skills"].universal {
|
|
t.Fatal("canonical/non-universal classification drift")
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageUpgradeCanonicalFailureFailsFastUnconditionally(t *testing.T) {
|
|
for _, mode := range []string{"mono", "multi"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
withFakeHome(t)
|
|
// Canonical-only configuration (no non-universal dependents): a failed
|
|
// canonical publish must still fail the whole upgrade. Universal agents
|
|
// read canonical directly, so silently succeeding here would leave the
|
|
// machine without any usable Skill while reporting success.
|
|
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills"})
|
|
testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("canonical denied") })
|
|
|
|
src := t.TempDir()
|
|
if mode == "mono" {
|
|
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("mono"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
} else {
|
|
src = writeMultiBundle(t, src, "dingtalk-chat")
|
|
}
|
|
result, err := UpgradeSkillLocations(src)
|
|
if err == nil || !strings.Contains(err.Error(), "canonical Skill 安装失败") {
|
|
t.Fatalf("canonical publish failure must propagate an error, got (%v)", err)
|
|
}
|
|
if result == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
|
|
t.Fatalf("result = %#v", result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageUpgradeOpenClawAliasPriority(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
dirs []string
|
|
want string
|
|
}{
|
|
{name: "default", want: ".openclaw"},
|
|
{name: "moltbot", dirs: []string{".moltbot"}, want: ".moltbot"},
|
|
{name: "clawdbot-before-moltbot", dirs: []string{".moltbot", ".clawdbot"}, want: ".clawdbot"},
|
|
{name: "openclaw-first", dirs: []string{".moltbot", ".clawdbot", ".openclaw"}, want: ".openclaw"},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
home := t.TempDir()
|
|
for _, dir := range tc.dirs {
|
|
if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if got := resolveOpenClawSkillBase(home); got != filepath.Join(home, tc.want, "skills") {
|
|
t.Fatalf("OpenClaw root = %q", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageUpgradeLinkStagingFailureEdges(t *testing.T) {
|
|
home := t.TempDir()
|
|
canonical := filepath.Join(home, ".agents", "skills")
|
|
dest := filepath.Join(home, ".claude", "skills")
|
|
if err := os.MkdirAll(filepath.Join(canonical, "dingtalk-chat"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
t.Run("mkdir", func(t *testing.T) {
|
|
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return errors.New("mkdir denied") })
|
|
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "创建 Agent Skill 目录") {
|
|
t.Fatalf("mkdir failure = %v", err)
|
|
}
|
|
})
|
|
t.Run("temp", func(t *testing.T) {
|
|
testseam.Swap(t, &upgradeMkdirTemp, func(string, string) (string, error) { return "", errors.New("temp denied") })
|
|
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "链接 staging") {
|
|
t.Fatalf("temp failure = %v", err)
|
|
}
|
|
})
|
|
t.Run("physical-parent", func(t *testing.T) {
|
|
testseam.Swap(t, &upgradeEvalSymlinks, func(path string) (string, error) {
|
|
if path == dest {
|
|
return "", errors.New("physical denied")
|
|
}
|
|
return filepath.EvalSymlinks(path)
|
|
})
|
|
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "物理目录") {
|
|
t.Fatalf("physical failure = %v", err)
|
|
}
|
|
})
|
|
t.Run("canonical-target", func(t *testing.T) {
|
|
target := filepath.Join(canonical, "dingtalk-chat")
|
|
testseam.Swap(t, &upgradeEvalSymlinks, func(path string) (string, error) {
|
|
if path == target {
|
|
return "", errors.New("canonical denied")
|
|
}
|
|
return filepath.EvalSymlinks(path)
|
|
})
|
|
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "解析 canonical") {
|
|
t.Fatalf("canonical failure = %v", err)
|
|
}
|
|
})
|
|
t.Run("relative-path", func(t *testing.T) {
|
|
testseam.Swap(t, &upgradeRel, func(string, string) (string, error) { return "", errors.New("relative denied") })
|
|
if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "相对链接") {
|
|
t.Fatalf("relative failure = %v", err)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCrossPlatformCoverageUpgradeAgentBranchFailures(t *testing.T) {
|
|
for _, mode := range []string{"mono", "multi"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
makeSource := func(t *testing.T) string {
|
|
t.Helper()
|
|
src := t.TempDir()
|
|
if mode == "mono" {
|
|
if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("mono"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return src
|
|
}
|
|
return writeMultiBundle(t, src, "dingtalk-chat")
|
|
}
|
|
|
|
t.Run("undetected-and-physical-alias", func(t *testing.T) {
|
|
home := withFakeHome(t)
|
|
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills", ".qoder/skills"})
|
|
if err := os.MkdirAll(filepath.Join(home, ".qoder"), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Symlink(filepath.Join(home, ".agents", "skills"), filepath.Join(home, ".qoder", "skills")); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := UpgradeSkillLocations(makeSource(t))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(result.Results) < 3 || result.Results[1].Status != SkillDirSkipped || result.Results[2].Status != SkillDirSkipped {
|
|
t.Fatalf("skip branches = %#v", result.Results)
|
|
}
|
|
})
|
|
|
|
t.Run("universal-retire-failure", func(t *testing.T) {
|
|
home := withFakeHome(t)
|
|
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".cursor/skills"})
|
|
victimBase := filepath.Join(home, ".cursor", "skills")
|
|
victim := filepath.Join(victimBase, "dws")
|
|
if mode == "multi" {
|
|
victim = filepath.Join(victimBase, "dingtalk-chat")
|
|
}
|
|
if err := os.MkdirAll(victim, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if mode == "multi" {
|
|
useUpgradeManagedNames(t, "dingtalk-chat")
|
|
}
|
|
origRename := skillPathRenameNoReplace
|
|
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) {
|
|
if src == victim {
|
|
return "", errors.New("retire denied")
|
|
}
|
|
return origRename(src, dst)
|
|
})
|
|
result, err := UpgradeSkillLocations(makeSource(t))
|
|
if err != nil {
|
|
t.Fatalf("retire failure must not fail the upgrade: %v", err)
|
|
}
|
|
if len(result.Failed()) != 0 {
|
|
t.Fatalf("retire failure must not count as an install failure: %#v", result.Failed())
|
|
}
|
|
warnings := result.RetireWarnings()
|
|
if len(warnings) != 1 || warnings[0].Err == nil {
|
|
t.Fatalf("retire warnings = %#v", warnings)
|
|
}
|
|
if len(result.Succeeded()) == 0 {
|
|
t.Fatalf("canonical must still be published: %#v", result)
|
|
}
|
|
if _, statErr := os.Lstat(victim); statErr != nil {
|
|
t.Fatalf("unretired copy must be preserved: %v", statErr)
|
|
}
|
|
})
|
|
|
|
t.Run("universal-retire-success", func(t *testing.T) {
|
|
home := withFakeHome(t)
|
|
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".cursor/skills"})
|
|
victimBase := filepath.Join(home, ".cursor", "skills")
|
|
victim := filepath.Join(victimBase, "dws")
|
|
if mode == "multi" {
|
|
victim = filepath.Join(victimBase, "dingtalk-chat")
|
|
useUpgradeManagedNames(t, "dingtalk-chat")
|
|
}
|
|
if err := os.MkdirAll(victim, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err := UpgradeSkillLocations(makeSource(t))
|
|
if err != nil || len(result.Failed()) != 0 {
|
|
t.Fatalf("retire success = %#v, %v", result, err)
|
|
}
|
|
if _, err := os.Lstat(victim); !os.IsNotExist(err) {
|
|
t.Fatalf("universal victim remains: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("victim-scan-and-fallback-copy-failures", func(t *testing.T) {
|
|
t.Run("victim-scan", func(t *testing.T) {
|
|
home := withFakeHome(t)
|
|
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
|
|
base := filepath.Join(home, ".claude", "skills")
|
|
if err := os.MkdirAll(base, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
origReadDir := upgradeReadDir
|
|
testseam.Swap(t, &upgradeReadDir, func(path string) ([]os.DirEntry, error) {
|
|
if path == base {
|
|
return nil, errors.New("scan denied")
|
|
}
|
|
return origReadDir(path)
|
|
})
|
|
result, err := UpgradeSkillLocations(makeSource(t))
|
|
if err != nil || len(result.Failed()) != 1 {
|
|
t.Fatalf("victim scan = %#v, %v", result, err)
|
|
}
|
|
})
|
|
|
|
t.Run("fallback-copy", func(t *testing.T) {
|
|
home := withFakeHome(t)
|
|
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
|
|
base := filepath.Join(home, ".claude", "skills")
|
|
if err := os.MkdirAll(base, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("link denied") })
|
|
origCopy := upgradeCopyDir
|
|
testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
|
|
if strings.HasPrefix(filepath.Clean(dst), filepath.Clean(base)+string(filepath.Separator)) {
|
|
return errors.New("copy denied")
|
|
}
|
|
return origCopy(src, dst)
|
|
})
|
|
result, err := UpgradeSkillLocations(makeSource(t))
|
|
if err != nil || len(result.Failed()) != 1 {
|
|
t.Fatalf("fallback copy = %#v, %v", result, err)
|
|
}
|
|
})
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCrossPlatformCoverageUpgradeWindowsPathNormalizationAndRootDedup(t *testing.T) {
|
|
testseam.Swap(t, &upgradeFoldPathCase, true)
|
|
if got := skillRootPathKey(filepath.Join("Root", "Skills")); got != strings.ToLower(got) {
|
|
t.Fatalf("case-folded path key = %q", got)
|
|
}
|
|
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills", ".claude/skills"})
|
|
if roots := configuredSkillRoots(t.TempDir()); len(roots) != 2 {
|
|
t.Fatalf("deduplicated roots = %#v", roots)
|
|
}
|
|
}
|