Files
dingtalk-workspace-cli/internal/app/skill_setup_plan_test.go
T

426 lines
16 KiB
Go

package app
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
"github.com/charmbracelet/huh"
)
func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".claude", "skills")
source := writeMultiSkillSource(t, []string{"dingtalk-a", "dingtalk-shared"})
for _, name := range []string{"dws", "dingtalk-a", "dingtalk-stale"} {
if err := os.MkdirAll(filepath.Join(dest, name), 0o755); err != nil {
t.Fatal(err)
}
}
useManagedSkillNames(t, "dingtalk-stale")
testseam.Swap(t, &skillSetupResolveMode, func(mode string, _ bool, _ io.Writer) (string, error) { return mode, nil })
testseam.Swap(t, &skillSetupResolveSource, func(string, string) (string, func(), error) { return source, func() {}, nil })
testseam.Swap(t, &skillSetupResolveTargets, func(string, string) ([]string, error) { return []string{dest}, nil })
testseam.Swap(t, &skillSetupListMulti, func(string) ([]string, error) {
return []string{"dingtalk-a", "dingtalk-shared"}, nil
})
testseam.Swap(t, &skillSetupFilterMulti, filterMultiSkillNames)
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupInteractive, func() bool { return true })
testseam.Swap(t, &skillSetupRunForm, func(*huh.Form) error { return nil })
testseam.Swap(t, &skillSetupWriteState, func(string, skillstate.State) error { return nil })
wantBackups := []string{
filepath.Join(dest, "dingtalk-a"),
filepath.Join(dest, "dingtalk-stale"),
filepath.Join(dest, "dws"),
}
// Dry-run must disclose every exact path and perform no backup or copy.
backupCalls, copyCalls := []string{}, 0
testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, path string) (string, error) {
backupCalls = append(backupCalls, path)
return "backup", nil
})
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil })
testseam.Swap(t, &skillSetupWriteFile, func(string, []byte, os.FileMode) error { return nil })
testseam.Swap(t, &skillSetupPublishRename, func(src, dest string) error {
if err := os.RemoveAll(dest); err != nil {
return err
}
return os.Rename(src, dest)
})
dryRunCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
var dryRunOut bytes.Buffer
dryRunCmd.SetOut(&dryRunOut)
if err := dryRunCmd.Root().PersistentFlags().Set("dry-run", "true"); err != nil {
t.Fatal(err)
}
if err := dryRunCmd.RunE(dryRunCmd, nil); err != nil {
t.Fatal(err)
}
for _, path := range wantBackups {
if strings.Count(dryRunOut.String(), path) != 1 {
t.Fatalf("dry-run path %s count != 1:\n%s", path, dryRunOut.String())
}
}
if len(backupCalls) != 0 || copyCalls != 0 {
t.Fatalf("dry-run mutated backup=%v copy=%d", backupCalls, copyCalls)
}
// The real confirmation renderer discloses the same paths. Its default
// negative answer must leave backup and copy at zero calls.
declineCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
var declineOut bytes.Buffer
declineCmd.SetOut(&declineOut)
if err := declineCmd.RunE(declineCmd, nil); err != nil {
t.Fatal(err)
}
for _, path := range wantBackups {
if strings.Count(declineOut.String(), path) != 1 {
t.Fatalf("confirmation path %s count != 1:\n%s", path, declineOut.String())
}
}
if len(backupCalls) != 0 || copyCalls != 0 {
t.Fatalf("declined confirmation mutated backup=%v copy=%d", backupCalls, copyCalls)
}
// Explicit confirmation executes exactly the paths rendered from the plan.
var confirmedPlan *skillSetupPlan
testseam.Swap(t, &skillSetupConfirmPlan, func(out io.Writer, plan *skillSetupPlan) (bool, error) {
confirmedPlan = plan
renderSkillSetupPlan(out, plan)
return true, nil
})
confirmCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false)
if err := confirmCmd.RunE(confirmCmd, nil); err != nil {
t.Fatal(err)
}
var planned []string
for _, target := range confirmedPlan.Targets {
for _, backup := range target.Backups {
planned = append(planned, backup.Path)
}
}
if !reflect.DeepEqual(planned, wantBackups) || !reflect.DeepEqual(backupCalls, wantBackups) {
t.Fatalf("planned=%v executed=%v want=%v", planned, backupCalls, wantBackups)
}
if copyCalls != 2 {
t.Fatalf("copy calls = %d, want 2", copyCalls)
}
// A filtered multi plan replaces only selected same-name skills and leaves
// unselected siblings out of the backup set.
filtered, err := buildSkillSetupPlan(skillSetupModeMulti, source, []string{dest}, []string{"dingtalk-a"}, true)
if err != nil {
t.Fatal(err)
}
var filteredPaths []string
for _, backup := range filtered.Targets[0].Backups {
filteredPaths = append(filteredPaths, backup.Path)
}
if !reflect.DeepEqual(filteredPaths, []string{filepath.Join(dest, "dingtalk-a"), filepath.Join(dest, "dws")}) {
t.Fatalf("filtered backups = %v", filteredPaths)
}
}
func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testing.T) {
dest := filepath.Join(t.TempDir(), ".agents", "skills", "dws")
if err := os.MkdirAll(dest, 0o755); err != nil {
t.Fatal(err)
}
plan, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{dest}, nil, false)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets) != 1 || len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != dest || plan.Targets[0].Backups[0].Reason != skillSetupBackupReplace {
t.Fatalf("mono plan = %#v", plan)
}
}
func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTarget(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".codex", "skills")
genericMono := filepath.Join(home, ".agents", "skills", "dws")
if err := os.MkdirAll(genericMono, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) {
return "", errors.New("transient HOME failure")
})
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != filepath.Dir(genericMono) {
t.Fatalf("generic cleanup target = %#v", plan.Targets)
}
if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != genericMono {
t.Fatalf("generic cleanup backups = %#v", plan.Targets[1].Backups)
}
var preview bytes.Buffer
renderSkillSetupPlan(&preview, plan)
if !strings.Contains(preview.String(), "仅迁移旧的通用 DWS 副本") {
t.Fatalf("generic cleanup preview missing: %s", preview.String())
}
t.Run("managed multi and scan failure", func(t *testing.T) {
managedDir := filepath.Join(home, ".agents", "skills", "dingtalk-chat")
if err := os.MkdirAll(managedDir, 0o755); err != nil {
t.Fatal(err)
}
target, targetErr := genericSkillCleanupTarget([]string{dest}, map[string]bool{"dingtalk-chat": true})
if targetErr != nil || target == nil || len(target.Backups) != 2 {
t.Fatalf("managed generic cleanup = %#v, %v", target, targetErr)
}
failure := errors.New("generic scan failure")
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, targetErr := genericSkillCleanupTarget([]string{dest}, nil); !errors.Is(targetErr, failure) {
t.Fatalf("generic scan error = %v", targetErr)
}
if _, planErr := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true); !errors.Is(planErr, failure) {
t.Fatalf("generic cleanup plan error = %v", planErr)
}
})
}
func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.T) {
failure := errors.New("cleanup failure")
cleanup := skillSetupTargetPlan{Destination: "generic", CleanupOnly: true, Backups: []skillSetupBackup{{Path: "old"}}}
t.Run("prior skip suppresses cleanup", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
plan := &skillSetupPlan{Mode: skillSetupModeMono, Source: "missing", Targets: []skillSetupTargetPlan{{Destination: "install"}, cleanup}}
installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, io.Discard)
if err != nil || installed != 0 || skipped != 1 {
t.Fatalf("cleanup after skip = (%d, %d, %v)", installed, skipped, err)
}
})
t.Run("home failure keeps generic copy", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留通用 Skill 副本") {
t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String())
}
})
t.Run("backup failure is reported", func(t *testing.T) {
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure })
var stderr bytes.Buffer
_, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr)
if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "迁移失败") {
t.Fatalf("cleanup backup failure = (%d, %v, %q)", skipped, err, stderr.String())
}
})
}
func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testing.T) {
dest := filepath.Join(t.TempDir(), "skills")
if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil {
t.Fatal(err)
}
// "dws" is synthetic but makes the mutual-exclusion target and selected
// same-name target overlap, pinning path deduplication in the plan itself.
plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dws"}, true)
if err != nil {
t.Fatal(err)
}
if len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != filepath.Join(dest, "dws") {
t.Fatalf("deduplicated plan = %#v", plan)
}
failure := errors.New("scan denied")
monoDest := filepath.Join(t.TempDir(), "agent", "dws")
if err := os.MkdirAll(filepath.Dir(monoDest), 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure })
if _, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{monoDest}, nil, false); err == nil || !strings.Contains(err.Error(), "\u68c0\u67e5\u5c06\u88ab\u66ff\u6362") {
t.Fatalf("replacement stat error = %v", err)
}
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist })
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure })
if _, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-a"}, false); err == nil || !strings.Contains(err.Error(), "\u626b\u63cf\u8fc7\u671f") {
t.Fatalf("stale scan error = %v", err)
}
}
func TestCrossPlatformCoverageSkillSetupInstallWrappersFailOnPlanErrors(t *testing.T) {
t.Run("multi mono-leftover stat failure", func(t *testing.T) {
failure := errors.New("stat denied")
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) {
return nil, failure
})
installed, skipped, err := installMultiSkillToHomes(
"source",
[]string{"dingtalk-a"},
[]string{filepath.Join(t.TempDir(), "skills")},
io.Discard,
io.Discard,
true,
)
if installed != 0 || skipped != 1 || !errors.Is(err, failure) {
t.Fatalf("installMultiSkillToHomes = (%d, %d, %v), want (0, 1, %v)", installed, skipped, err, failure)
}
})
t.Run("mono multi-leftover scan failure", func(t *testing.T) {
failure := errors.New("scan denied")
testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) {
return nil, failure
})
installed, skipped, err := installSkillToHomes(
"source",
[]string{filepath.Join(t.TempDir(), "skills", "dws")},
io.Discard,
io.Discard,
)
if installed != 0 || skipped != 1 || !errors.Is(err, failure) {
t.Fatalf("installSkillToHomes = (%d, %d, %v), want (0, 1, %v)", installed, skipped, err, failure)
}
})
}
func TestCrossPlatformCoverageSkillSetupMergedEventPlanEdges(t *testing.T) {
t.Run("legacy shared stat failure", func(t *testing.T) {
dest := filepath.Join(t.TempDir(), "skills")
failure := errors.New("legacy stat denied")
testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) {
if path == filepath.Join(dest, legacySharedSkill) {
return nil, failure
}
return nil, os.ErrNotExist
})
_, err := buildSkillSetupPlan(
skillSetupModeMulti,
"source",
[]string{dest},
[]string{multiSharedSkill},
true,
)
if !errors.Is(err, failure) {
t.Fatalf("legacy shared stat error = %v, want %v", err, failure)
}
})
t.Run("migration plan retains unrelated backups", func(t *testing.T) {
dest := filepath.Join(t.TempDir(), "skills")
unrelated := filepath.Join(dest, "dws")
plan := &skillSetupPlan{Targets: []skillSetupTargetPlan{
{
Destination: dest,
Backups: []skillSetupBackup{
{Path: filepath.Join(dest, multiEventSkill)},
{Path: filepath.Join(dest, multiMiscSkill)},
{Path: unrelated},
},
},
}}
configureEventMiscMigrationPlan(plan, []string{dest}, true)
if len(plan.Targets[0].Backups) != 1 || plan.Targets[0].Backups[0].Path != unrelated {
t.Fatalf("migration backups = %#v, want only %s", plan.Targets[0].Backups, unrelated)
}
})
t.Run("no migration targets delegates filtered install", func(t *testing.T) {
called := false
testseam.Swap(t, &skillSetupInstallMulti, func(_ string, _ []string, _ []string, _ io.Writer, _ io.Writer, filtered bool) (int, int, error) {
called = true
if !filtered {
t.Fatal("filtered flag was not forwarded")
}
return 1, 2, nil
})
installed, skipped, err := installMultiSkillsWithEventMigration(
"source", []string{"dingtalk-a"}, []string{"dest"}, nil, true, io.Discard, io.Discard,
)
if err != nil || installed != 1 || skipped != 2 || !called {
t.Fatalf("delegated install = (%d, %d, %v), called=%v", installed, skipped, err, called)
}
})
t.Run("migration cleanup scan failure", func(t *testing.T) {
failure := errors.New("cleanup stat denied")
testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) {
return nil, failure
})
dest := filepath.Join(t.TempDir(), "skills")
installed, skipped, err := installMultiSkillsWithEventMigration(
"source",
[]string{multiEventSkill, multiSharedSkill},
[]string{dest},
[]string{dest},
true,
io.Discard,
io.Discard,
)
if installed != 0 || skipped != 2 || !errors.Is(err, failure) {
t.Fatalf("cleanup scan failure = (%d, %d, %v), want (0, 2, %v)", installed, skipped, err, failure)
}
})
}
func TestCrossPlatformCoverageSkillSetupEmptyCleanupPlansAreNoOps(t *testing.T) {
dest := t.TempDir()
var out, errOut bytes.Buffer
if err := cleanupMutualExclusion(dest, skillSetupModeMulti, &out, &errOut); err != nil {
t.Fatal(err)
}
if err := removeStaleMultiSkills(dest, []string{"dingtalk-a"}, &out, &errOut); err != nil {
t.Fatal(err)
}
if out.Len() != 0 || errOut.Len() != 0 {
t.Fatalf("empty cleanup output = %q / %q", out.String(), errOut.String())
}
}
func TestCrossPlatformCoverageSkillSetupSameNameBackupFailureSkipsTarget(t *testing.T) {
home := t.TempDir()
dest := filepath.Join(home, ".agents", "skills")
plan := &skillSetupPlan{
Mode: skillSetupModeMulti,
Source: "source",
MultiSkillNames: []string{"dingtalk-a"},
Targets: []skillSetupTargetPlan{{
Destination: dest,
Backups: []skillSetupBackup{{
Path: filepath.Join(dest, "dingtalk-a"),
Reason: skillSetupBackupReplace,
}},
}},
}
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) {
return "", errors.New("backup denied")
})
copyCalls := 0
testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil })
var out, errOut bytes.Buffer
installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut)
if err != nil || installed != 0 || skipped != 1 || copyCalls != 1 {
t.Fatalf("same-name failure = (%d, %d, %v), copy=%d", installed, skipped, err, copyCalls)
}
if !strings.Contains(errOut.String(), "Skill 备份失败,已执行回滚,跳过整个 Agent 目标") {
t.Fatalf("same-name warning = %q", errOut.String())
}
}