Files
dingtalk-workspace-cli/internal/upgrade/canonical_skills_test.go
T

508 lines
19 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 TestCrossPlatformCoverageUpgradeCanonicalFailureOnlyFailsFastWithDependents(t *testing.T) {
for _, mode := range []string{"mono", "multi"} {
t.Run(mode, func(t *testing.T) {
home := withFakeHome(t)
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 {
t.Fatalf("standalone canonical failure should be reported per-target, not fail fast: %v", err)
}
if len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("result = %#v", result)
}
if hasDependentSkillRoot(home, filepath.Join(home, ".agents", "skills")) {
t.Fatal("canonical-only configuration unexpectedly has a dependent root")
}
})
}
}
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 := upgradeRename
testseam.Swap(t, &upgradeRename, func(src, dst string) error {
if src == victim {
return errors.New("retire denied")
}
return origRename(src, dst)
})
result, err := UpgradeSkillLocations(makeSource(t))
if err != nil || len(result.Failed()) != 1 {
t.Fatalf("retire failure = %#v, %v", result, err)
}
})
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)
}
}