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) if err := os.RemoveAll(path); err != nil { return "", err } 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 }) 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. if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil { t.Fatal(err) } 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") canonical := filepath.Join(home, ".agents", "skills") oldCodex := filepath.Join(dest, "dingtalk-chat") if err := os.MkdirAll(oldCodex, 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{canonical, dest}, []string{"dingtalk-chat"}, true) if err != nil { t.Fatal(err) } if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != dest { t.Fatalf("universal cleanup target = %#v", plan.Targets) } if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != oldCodex { t.Fatalf("universal cleanup backups = %#v", plan.Targets[1].Backups) } var preview bytes.Buffer renderSkillSetupPlan(&preview, plan) if !strings.Contains(preview.String(), "改用统一安装位置") { t.Fatalf("universal cleanup preview missing: %s", preview.String()) } } func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.T) { failure := errors.New("cleanup failure") cleanup := skillSetupTargetPlan{Destination: "generic", CleanupOnly: true, Backups: []skillSetupBackup{{Path: "old"}}} t.Run("cleanup runs even after an install skip", 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) } }) // A cleanup-only target installs nothing, so its failure is a warning and // must never increment skipped — runSkillSetup turns any skipped count into // a hard error and would fail an otherwise complete installation. 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 != 0 || !strings.Contains(stderr.String(), "保留 universal Agent 旧副本") { 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 != 0 || !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 }) testseam.Swap(t, &skillSetupLstat, 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, &skillSetupLstat, 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()) } }