Files
玉澜 c0e579fe6b fix(skills): prove backup ownership before adopting or pruning stamp roots
A stamp-shaped directory name is not ownership proof: pruneSkillBackups
counted and RemoveAll'd any 20260819-120000-shaped entry under
~/.dws/skill-backups, so a user or tool that created such a directory
lost its contents once DWS held five backups, and the Go backup path
(MkdirAll) adopted a same-named foreign root outright. The PowerShell
installers already implemented the correct contract; every other
surface now matches it.

Go stamps a freshly created root with the exact marker bytes the
install scripts write (.dws-skill-backup = "dws skill backup v1")
before any payload moves in, claims the root with mkdir so an existing
unproven root bumps to a collision suffix instead of being adopted,
and prunes only roots whose marker verifies — unmarked or wrongly
worded stamp-shaped directories are foreign data, preserved and never
counted against the keep limit. The shell installers (install.sh,
install-skills.sh, install-event.sh, install-devapp.sh) and the npm
installer apply the same rule in their backup collision loops, with
roots recorded as created by the running process exempt from marker
re-verification so a mid-run marker permission failure still reuses
this run's own root and keeps the sibling payload intact.

Regression tests cover every surface: pruning an unmarked/wrongly
marked stamp-shaped directory alongside marked ones, refusing to adopt
a foreign root (payload moves to a suffixed root, foreign data and its
nonexistent marker untouched), same-stamp reuse of a proven root, and
marker-write failure cleaning the empty fresh root.
2026-08-19 21:29:47 +08:00

1227 lines
49 KiB
Go

// Copyright 2026 Alibaba Group
// Licensed under the Apache License, Version 2.0
package upgrade
import (
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
)
// withFakeHome swaps upgradeUserHomeDir to a temp dir for the test duration.
func withFakeHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home, nil })
// Host-installed macOS apps must not make a fake HOME appear to have extra
// detected Agents. Tests that exercise application-based detection can
// override this seam explicitly.
testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) {
applications := filepath.Join(string(filepath.Separator), "Applications")
if path == filepath.Join(applications, "ZCode.app") || path == filepath.Join(applications, "MiniMax Code.app") {
return nil, os.ErrNotExist
}
return os.Stat(path)
})
return home
}
func useUpgradeManagedNames(t *testing.T, names ...string) {
t.Helper()
records := make([]skillprovenance.Record, 0, len(names))
for _, name := range names {
records = append(records, skillprovenance.Record{Name: name})
}
testseam.Swap(t, &upgradeReadSkillState, func(string) (*skillstate.State, bool, error) {
return &skillstate.State{ManagedSkills: records}, true, nil
})
}
// writeMultiBundle creates a multi-skill bundle root with the given skills.
func writeMultiBundle(t *testing.T, root string, skills ...string) string {
t.Helper()
multi := filepath.Join(root, "multi")
for _, name := range skills {
dir := filepath.Join(multi, name)
if err := os.MkdirAll(filepath.Join(dir, "references"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte("# "+name), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "references", "guide.md"), []byte("guide "+name), 0o644); err != nil {
t.Fatal(err)
}
}
return multi
}
func TestCrossPlatformCoverageLocateSkillsRootPrefersMulti(t *testing.T) {
extract := t.TempDir()
// Legacy mono copy at zip root plus the multi bundle: multi must win.
os.WriteFile(filepath.Join(extract, "SKILL.md"), []byte("# mono"), 0o644)
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat", "dws-shared")
got := LocateSkillsRoot(extract)
if got != multiRoot {
t.Errorf("LocateSkillsRoot() = %q, want multi root %q", got, multiRoot)
}
}
func TestCrossPlatformCoverageLocateSkillsRootFallsBackToMono(t *testing.T) {
extract := t.TempDir()
os.WriteFile(filepath.Join(extract, "SKILL.md"), []byte("# mono"), 0o644)
got := LocateSkillsRoot(extract)
if got != extract {
t.Errorf("LocateSkillsRoot() = %q, want mono flat root %q", got, extract)
}
}
func TestCrossPlatformCoverageSwapUserHomeDirForTest(t *testing.T) {
want := t.TempDir()
SwapUserHomeDirForTest(t, func() (string, error) { return want, nil })
got, err := upgradeUserHomeDir()
if err != nil {
t.Fatalf("upgradeUserHomeDir() error = %v", err)
}
if got != want {
t.Fatalf("upgradeUserHomeDir() = %q, want %q", got, want)
}
}
func TestCrossPlatformCoverageBundleSkillNamesLayouts(t *testing.T) {
// Mono layout (top-level SKILL.md) is not a bundle.
mono := t.TempDir()
os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644)
os.MkdirAll(filepath.Join(mono, "references"), 0o755)
if got := bundleSkillNames(mono); got != nil {
t.Errorf("mono layout: bundleSkillNames() = %v, want nil", got)
}
// Multi bundle: sorted subdir names containing SKILL.md.
extract := t.TempDir()
multi := writeMultiBundle(t, extract, "dingtalk-wiki", "dingtalk-chat", "dws-shared")
want := []string{"dingtalk-chat", "dingtalk-wiki", "dws-shared"}
if got := bundleSkillNames(multi); !reflect.DeepEqual(got, want) {
t.Errorf("multi layout: bundleSkillNames() = %v, want %v", got, want)
}
// Regular files inside the bundle root are ignored.
if err := os.WriteFile(filepath.Join(multi, "README.md"), []byte("file"), 0o644); err != nil {
t.Fatal(err)
}
if got := bundleSkillNames(multi); !reflect.DeepEqual(got, want) {
t.Errorf("bundle with file entry: bundleSkillNames() = %v, want %v", got, want)
}
// Missing directory.
if got := bundleSkillNames(filepath.Join(extract, "nope")); got != nil {
t.Errorf("missing dir: bundleSkillNames() = %v, want nil", got)
}
}
func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) {
home := withFakeHome(t)
// .agents is canonical; detected Claude receives links and Cursor is skipped.
agentsBase := filepath.Join(home, ".agents", "skills")
claudeBase := filepath.Join(home, ".claude", "skills")
for _, base := range []string{agentsBase, claudeBase} {
if err := os.MkdirAll(base, 0o755); err != nil {
t.Fatal(err)
}
}
// Pre-existing state in both homes: mono leftover, a stale dingtalk skill
// no longer in the bundle, and a non-DWS dir that must survive.
for _, base := range []string{agentsBase, claudeBase} {
os.MkdirAll(filepath.Join(base, "dws"), 0o755)
os.WriteFile(filepath.Join(base, "dws", "SKILL.md"), []byte("old mono"), 0o644)
os.MkdirAll(filepath.Join(base, "dingtalk-old"), 0o755)
os.WriteFile(filepath.Join(base, "dingtalk-old", "SKILL.md"), []byte("stale"), 0o644)
os.MkdirAll(filepath.Join(base, "other-skill"), 0o755)
os.WriteFile(filepath.Join(base, "other-skill", "SKILL.md"), []byte("not dws"), 0o644)
}
useUpgradeManagedNames(t, "dingtalk-old")
extract := t.TempDir()
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat", "dws-shared")
// The release zip ships both trees; the mono sibling must refresh the
// mono cache too, so --mode mono fallbacks stay on the upgraded version.
if err := os.MkdirAll(filepath.Join(extract, "mono"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(extract, "mono", "SKILL.md"), []byte("# mono sibling"), 0o644); err != nil {
t.Fatal(err)
}
result, err := UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if failed := result.Failed(); len(failed) != 0 {
t.Fatalf("expected 0 failures, got %v", failed)
}
for _, base := range []string{claudeBase} {
if _, err := os.Stat(filepath.Join(base, "dws")); !os.IsNotExist(err) {
t.Errorf("mono leftover still present: %s", filepath.Join(base, "dws"))
}
if _, err := os.Stat(filepath.Join(base, "dingtalk-old")); !os.IsNotExist(err) {
t.Errorf("stale skill still present: %s", filepath.Join(base, "dingtalk-old"))
}
if _, err := os.Stat(filepath.Join(base, "other-skill", "SKILL.md")); err != nil {
t.Errorf("non-DWS dir should be preserved: %v", err)
}
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
if _, err := os.Stat(filepath.Join(base, name, "SKILL.md")); err != nil {
t.Errorf("installed skill missing: %s/%s: %v", base, name, err)
}
if _, err := os.Stat(filepath.Join(base, name, "references", "guide.md")); err != nil {
t.Errorf("installed skill references missing: %s/%s: %v", base, name, err)
}
}
}
// .cursor has no parent dir: must be skipped and untouched.
cursorBase := filepath.Join(home, ".cursor", "skills")
if _, err := os.Stat(cursorBase); !os.IsNotExist(err) {
t.Errorf(".cursor should not be created, stat err = %v", err)
}
// Succeeded entries report the agent home base in multi mode.
succeeded := result.Succeeded()
if len(succeeded) != 2 {
t.Fatalf("Succeeded() len = %d, want 2 (%v)", len(succeeded), result.Results)
}
wantDirs := map[string]bool{agentsBase: true, claudeBase: true}
for _, d := range succeeded {
if !wantDirs[d.Dir] {
t.Errorf("unexpected succeeded dir %q", d.Dir)
}
}
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
t.Fatalf("canonical Skill missing: %v", err)
}
}
// Multi cache refreshed under the fake home.
if _, err := os.Stat(filepath.Join(home, ".dws", "skills", "multi", "dingtalk-chat", "SKILL.md")); err != nil {
t.Errorf("multi cache not refreshed: %v", err)
}
// Mono sibling tree refreshed the mono cache too.
if _, err := os.Stat(filepath.Join(home, ".dws", "skills", "mono", "SKILL.md")); err != nil {
t.Errorf("mono cache not refreshed from sibling mono tree: %v", err)
}
}
func TestCrossPlatformCoverageUpgradeUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".codex/skills"})
genericBase := filepath.Join(home, ".agents", "skills")
codexBase := filepath.Join(home, ".codex", "skills")
for _, base := range []string{genericBase, codexBase} {
if err := os.MkdirAll(base, 0o755); err != nil {
t.Fatal(err)
}
}
legacyNested := filepath.Join(genericBase, "dws", "multi", "dingtalk-chat")
if err := os.MkdirAll(legacyNested, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(legacyNested, "SKILL.md"), []byte("old nested"), 0o644); err != nil {
t.Fatal(err)
}
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared")
result, err := UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.2.3"})
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("UpgradeSkillLocationsWithOptions() = %#v, %v", result, err)
}
want := filepath.Join(genericBase, "dingtalk-chat", "SKILL.md")
if _, err := os.Stat(want); err != nil {
t.Fatalf("canonical Skill missing at %s: %v", want, err)
}
for _, duplicate := range []string{
filepath.Join(codexBase, "dingtalk-chat"),
filepath.Join(genericBase, "dws", "multi", "dingtalk-chat", "SKILL.md"),
} {
if _, err := os.Stat(duplicate); !os.IsNotExist(err) {
t.Fatalf("generic duplicate still visible at %s: %v", duplicate, err)
}
}
}
func TestCrossPlatformCoverageUpgradeUsesZCodeRootWithoutGenericDuplicate(t *testing.T) {
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".zcode/skills"})
genericBase := filepath.Join(home, ".agents", "skills")
zcodeBase := filepath.Join(home, ".zcode", "skills")
if err := os.MkdirAll(zcodeBase, 0o755); err != nil {
t.Fatal(err)
}
legacyNested := filepath.Join(genericBase, "dws", "multi", "dingtalk-chat")
if err := os.MkdirAll(legacyNested, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(legacyNested, "SKILL.md"), []byte("old nested"), 0o644); err != nil {
t.Fatal(err)
}
multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat")
result, err := UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.2.3"})
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("UpgradeSkillLocationsWithOptions() = %#v, %v", result, err)
}
if _, err := os.Stat(filepath.Join(zcodeBase, "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("canonical ZCode Skill missing: %v", err)
}
if _, err := os.Stat(filepath.Join(genericBase, "dws")); !os.IsNotExist(err) {
t.Fatalf("generic duplicate still visible: %v", err)
}
}
func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackPrimary(t *testing.T) {
home := withFakeHome(t)
// No agent parent dirs at all: only .agents (index 0) is attempted and the
// primary fallback must also land there.
extract := t.TempDir()
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat")
result, err := UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if len(result.Failed()) != 0 {
t.Fatalf("expected 0 failures, got %v", result.Failed())
}
primary := filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")
if _, err := os.Stat(primary); err != nil {
t.Errorf("primary fallback missing: %v", err)
}
}
func TestCrossPlatformCoverageUpgradeSkillLocationsMonoOnlyPackageStillWorks(t *testing.T) {
home := withFakeHome(t)
mono := t.TempDir()
os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644)
// Legacy mono-only package (no multi/): fall back to mono refresh even
// when the disk already has dws/.
agentsBase := filepath.Join(home, ".agents", "skills")
os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755)
os.WriteFile(filepath.Join(agentsBase, "dws", "SKILL.md"), []byte("old mono"), 0o644)
os.MkdirAll(filepath.Join(agentsBase, "other-skill"), 0o755)
os.WriteFile(filepath.Join(agentsBase, "other-skill", "SKILL.md"), []byte("not dws"), 0o644)
result, err := UpgradeSkillLocations(mono)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if len(result.Failed()) != 0 {
t.Fatalf("expected 0 failures, got %v", result.Failed())
}
dest := filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")
data, err := os.ReadFile(dest)
if err != nil {
t.Fatalf("mono install missing: %v", err)
}
if string(data) != "# mono" {
t.Errorf("mono content = %q, want refreshed package", data)
}
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
t.Errorf("non-DWS dir should be preserved: %v", err)
}
// Mono install refreshes the mono cache so --mode mono fallbacks stay on
// the upgraded version.
if _, err := os.Stat(filepath.Join(home, ".dws", "skills", "mono", "SKILL.md")); err != nil {
t.Errorf("mono cache not refreshed: %v", err)
}
}
// TestCrossPlatformCoverageUpgradeSkillLocationsMonoDiskMigratesToMulti pins the 2026-08-05
// decision: upgrade does NOT stick to disk. A mono-only home is one-shot
// migrated to multi when the release zip has multi/ (LocateSkillsRoot input).
func TestCrossPlatformCoverageUpgradeSkillLocationsMonoDiskMigratesToMulti(t *testing.T) {
home := withFakeHome(t)
agentsBase := filepath.Join(home, ".agents", "skills")
os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755)
os.WriteFile(filepath.Join(agentsBase, "dws", "SKILL.md"), []byte("old mono"), 0o644)
os.MkdirAll(filepath.Join(agentsBase, "other-skill"), 0o755)
os.WriteFile(filepath.Join(agentsBase, "other-skill", "SKILL.md"), []byte("not dws"), 0o644)
extract := t.TempDir()
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat", "dws-shared")
if err := os.MkdirAll(filepath.Join(extract, "mono"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(extract, "mono", "SKILL.md"), []byte("# mono sibling"), 0o644); err != nil {
t.Fatal(err)
}
result, err := UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if len(result.Failed()) != 0 {
t.Fatalf("expected 0 failures, got %v", result.Failed())
}
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
t.Fatalf("mono leftover dws/ must be removed after multi upgrade, stat err=%v", err)
}
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
t.Errorf("multi skill missing after mono→multi migration: %s: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
t.Errorf("non-DWS dir should be preserved: %v", err)
}
if _, err := os.Stat(filepath.Join(home, ".dws", "skills", "multi", "dingtalk-chat", "SKILL.md")); err != nil {
t.Errorf("multi cache not refreshed: %v", err)
}
}
// TestCrossPlatformCoverageUpgradeSkillLocationsEmptyDiskInstallsMulti pins fresh/empty homes:
// with a multi package, upgrade installs multi (install default) and never
// writes dws/.
func TestCrossPlatformCoverageUpgradeSkillLocationsEmptyDiskInstallsMulti(t *testing.T) {
home := withFakeHome(t)
extract := t.TempDir()
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat", "dws-shared")
result, err := UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if len(result.Failed()) != 0 {
t.Fatalf("expected 0 failures, got %v", result.Failed())
}
agentsBase := filepath.Join(home, ".agents", "skills")
for _, name := range []string{"dingtalk-chat", "dws-shared"} {
if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil {
t.Errorf("empty-disk multi install missing %s: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
t.Errorf("empty-disk multi upgrade must not create dws/, stat err=%v", err)
}
}
// TestCrossPlatformCoverageUpgradeSkillLocationsMultiDiskRefreshes pins an already-multi home:
// product skills are refreshed, stale dingtalk-* removed, non-DWS kept,
// and dws/ stays absent.
func TestCrossPlatformCoverageUpgradeSkillLocationsMultiDiskRefreshes(t *testing.T) {
home := withFakeHome(t)
agentsBase := filepath.Join(home, ".agents", "skills")
os.MkdirAll(filepath.Join(agentsBase, "dingtalk-chat"), 0o755)
os.WriteFile(filepath.Join(agentsBase, "dingtalk-chat", "SKILL.md"), []byte("OLD chat"), 0o644)
os.MkdirAll(filepath.Join(agentsBase, "dingtalk-stale"), 0o755)
os.WriteFile(filepath.Join(agentsBase, "dingtalk-stale", "SKILL.md"), []byte("stale"), 0o644)
useUpgradeManagedNames(t, "dingtalk-stale")
os.MkdirAll(filepath.Join(agentsBase, "dingtalk-custom"), 0o755)
os.WriteFile(filepath.Join(agentsBase, "dingtalk-custom", "SKILL.md"), []byte("market skill"), 0o644)
os.MkdirAll(filepath.Join(agentsBase, "other-skill"), 0o755)
os.WriteFile(filepath.Join(agentsBase, "other-skill", "SKILL.md"), []byte("not dws"), 0o644)
extract := t.TempDir()
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat", "dws-shared")
result, err := UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
if len(result.Failed()) != 0 {
t.Fatalf("expected 0 failures, got %v", result.Failed())
}
data, err := os.ReadFile(filepath.Join(agentsBase, "dingtalk-chat", "SKILL.md"))
if err != nil {
t.Fatalf("refreshed chat missing: %v", err)
}
if string(data) != "# dingtalk-chat" {
t.Errorf("chat content = %q, want refreshed package", data)
}
if _, err := os.Stat(filepath.Join(agentsBase, "dws-shared", "SKILL.md")); err != nil {
t.Errorf("dws-shared missing after multi refresh: %v", err)
}
if _, err := os.Stat(filepath.Join(agentsBase, "dingtalk-stale")); !os.IsNotExist(err) {
t.Errorf("stale multi skill should be removed, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
t.Errorf("multi refresh must not create dws/, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(agentsBase, "other-skill", "SKILL.md")); err != nil {
t.Errorf("non-DWS dir should be preserved: %v", err)
}
if _, err := os.Stat(filepath.Join(agentsBase, "dingtalk-custom", "SKILL.md")); err != nil {
t.Errorf("unregistered market/user dingtalk-* dir must be preserved: %v", err)
}
}
func TestUpgradeMonoCleansPreStateOfficialAndPreservesCustom(t *testing.T) {
home := withFakeHome(t)
base := filepath.Join(home, ".agents", "skills")
legacyOfficial := filepath.Join(base, "dingtalk-aitable")
custom := filepath.Join(base, "dingtalk-custom")
for _, dir := range []string{legacyOfficial, custom} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(filepath.Base(dir)), 0o644); err != nil {
t.Fatal(err)
}
}
mono := t.TempDir()
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
t.Fatal(err)
}
result, err := UpgradeSkillLocations(mono)
if err != nil || len(result.Failed()) != 0 {
t.Fatalf("mono upgrade = %#v, %v", result, err)
}
if _, err := os.Stat(legacyOfficial); !os.IsNotExist(err) {
t.Fatalf("pre-state official Skill survived mono switch: %v", err)
}
if got, err := os.ReadFile(filepath.Join(custom, "SKILL.md")); err != nil || string(got) != "dingtalk-custom" {
t.Fatalf("custom same-prefix Skill changed: data=%q err=%v", got, err)
}
if _, err := os.Stat(filepath.Join(base, "dws", "SKILL.md")); err != nil {
t.Fatalf("mono Skill missing: %v", err)
}
}
// TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure pins the mono primary
// fallback: when the main-loop copy into ~/.agents/skills/dws fails, the
// fallback retries the primary location and reports success (legacy
// mono-only package path).
func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure(t *testing.T) {
home := withFakeHome(t)
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
agentsBase := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(agentsBase, "dws", "SKILL.md"), []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
origCopy := upgradeCopyDir
copyAttempts := 0
testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error {
copyAttempts++
if copyAttempts == 1 {
return errors.New("injected copy failure")
}
return origCopy(src, dst)
})
mono := t.TempDir()
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
t.Fatal(err)
}
result, err := UpgradeSkillLocations(mono)
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("canonical copy failure = %#v, %v", result, err)
}
data, err := os.ReadFile(filepath.Join(agentsBase, "dws", "SKILL.md"))
if err != nil {
t.Fatalf("mono fallback install missing: %v", err)
}
if string(data) != "old" {
t.Errorf("mono content = %q, want original preserved", data)
}
}
// TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome pins the F4 fix: a base
// directory that exists but cannot be read must mark the home failed instead
// of silently installing mono alongside the multi leftovers.
func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t *testing.T) {
home := withFakeHome(t)
// Inject a read failure for .agents/skills on every platform. .claude
// installs fine so the primary
// fallback never runs and the per-home failure is observable as-is.
agentsBase := filepath.Join(home, ".agents", "skills")
if err := os.MkdirAll(agentsBase, 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
origReadDir := upgradeReadDir
testseam.Swap(t, &upgradeReadDir, func(path string) ([]os.DirEntry, error) {
if path == agentsBase {
return nil, errors.New("injected read failure")
}
return origReadDir(path)
})
mono := t.TempDir()
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
t.Fatal(err)
}
result, err := UpgradeSkillLocations(mono)
if err == nil {
t.Fatal("canonical read failure must fail the upgrade")
}
failed := result.Failed()
if len(failed) != 1 {
t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results)
}
wantDir := filepath.Join(agentsBase, "dws")
if failed[0].Dir != wantDir || failed[0].Err == nil {
t.Fatalf("failed entry = %#v, want dir %q with non-nil err", failed[0], wantDir)
}
if !strings.Contains(failed[0].Err.Error(), "读取技能目录失败") {
t.Fatalf("failed err should mention the read failure, got %v", failed[0].Err)
}
if got := len(result.Succeeded()); got != 0 {
t.Fatalf("Succeeded() len = %d, want 0", got)
}
// Mono must NOT have been laid down next to the unreadable multi state.
if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) {
t.Errorf("mono must not be installed into the unreadable home, stat err=%v", err)
}
}
// TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure pins the fail-loud
// semantics of the multi fallback: when leftover cleanup fails even at the
// primary location, UpgradeSkillLocations returns an error instead of
// installing multi next to the stale skills.
func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t *testing.T) {
home := withFakeHome(t)
if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil {
t.Fatal(err)
}
agentsBase := filepath.Join(home, ".agents", "skills")
staleDir := filepath.Join(agentsBase, "dingtalk-stale")
if err := os.MkdirAll(staleDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(staleDir, "SKILL.md"), []byte("stale"), 0o644); err != nil {
t.Fatal(err)
}
useUpgradeManagedNames(t, filepath.Base(staleDir))
origRename := skillPathRenameNoReplace
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) {
if strings.Contains(src, "dingtalk-stale") {
return "", errors.New("injected backup failure")
}
return origRename(src, dst)
})
extract := t.TempDir()
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat")
result, err := UpgradeSkillLocations(multiRoot)
if err == nil {
t.Fatal("multi fallback cleanup failure must return an error")
}
if !strings.Contains(err.Error(), "canonical Skill 安装失败") {
t.Fatalf("error should mention the canonical cleanup failure, got %v", err)
}
if failed := result.Failed(); len(failed) != 1 {
t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results)
}
// The stale skill was never removed and no partial multi install landed.
if _, err := os.Stat(staleDir); err != nil {
t.Errorf("stale dir should survive the failed cleanup: %v", err)
}
if _, err := os.Stat(filepath.Join(agentsBase, "dingtalk-chat")); !os.IsNotExist(err) {
t.Errorf("multi skill must not be installed when cleanup failed, stat err=%v", err)
}
}
// seedSkillBackupMarker stamps a backup root as DWS-owned, the way every
// install surface does before any payload moves in.
func seedSkillBackupMarker(t *testing.T, root string) {
t.Helper()
if err := os.WriteFile(filepath.Join(root, skillBackupMarkerName), []byte(skillBackupMarkerContent), 0o644); err != nil {
t.Fatal(err)
}
}
// TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges pins the fail-safe
// contract of the backup helper: all lexical paths are preserved, a colliding backup
// target gets a numbered stamp, and any failure (mkdir / rename / unresolvable
// collision) leaves the original directory untouched with an error.
func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) {
home := t.TempDir()
// Stat failure other than not-exist must surface without attempting a
// backup. Inject it through the path seam so this branch is portable to
// Windows, where chmod-based permission failures are not reliable.
statFailurePath := filepath.Join(home, "stat-failure")
origStat := upgradeLstat
testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) {
if path == statFailurePath {
return nil, errors.New("injected stat failure")
}
return origStat(path)
})
if got, err := backupAndRemoveSkillDir(home, statFailurePath); got != "" || err == nil || !strings.Contains(err.Error(), "检查技能目录失败") {
t.Fatalf("stat failure = (%q, %v), want wrapped error", got, err)
}
// Regular file: preserve it instead of allowing a later rename to overwrite it.
filePath := filepath.Join(home, "not-a-dir")
if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if got, err := backupAndRemoveSkillDir(home, filePath); got == "" || err != nil {
t.Fatalf("regular file = (%q, %v), want backup", got, err)
}
if _, err := os.Stat(filePath); !os.IsNotExist(err) {
t.Fatalf("regular file must move into backup: %v", err)
}
// A skill directory outside home falls back to its basename instead of
// allowing a ../ segment into the backup target. Exercise the exported
// wrapper used by internal/app at the same time.
outsideVictim := t.TempDir()
outsideName := filepath.Base(outsideVictim)
got, err := BackupAndRemoveSkillDir(home, outsideVictim)
if err != nil {
t.Fatalf("outside-home backup error = %v", err)
}
if filepath.Base(got) != outsideName {
t.Fatalf("outside-home backup path = %q, want basename %q", got, outsideName)
}
if _, err := os.Stat(outsideVictim); !os.IsNotExist(err) {
t.Fatalf("outside-home victim must be gone after backup, stat err=%v", err)
}
testseam.Swap(t, &upgradeBackupStamp, func() string { return "20260810-000000" })
// Collision: stamp already taken → numbered stamp wins.
victim := filepath.Join(home, ".agents", "skills", "dws")
if err := os.MkdirAll(victim, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(victim, "SKILL.md"), []byte("old"), 0o644); err != nil {
t.Fatal(err)
}
taken := filepath.Join(home, skillBackupSubdir, "20260810-000000", ".agents-skills-dws")
if err := os.MkdirAll(taken, 0o755); err != nil {
t.Fatal(err)
}
got, err = backupAndRemoveSkillDir(home, victim)
if err != nil {
t.Fatalf("backup with collision error = %v", err)
}
want := filepath.Join(home, skillBackupSubdir, "20260810-000000-1", ".agents-skills-dws")
if got != want {
t.Fatalf("backup path = %q, want numbered %q", got, want)
}
if _, err := os.Stat(filepath.Join(want, "SKILL.md")); err != nil {
t.Fatalf("backup content missing: %v", err)
}
if _, err := os.Stat(victim); !os.IsNotExist(err) {
t.Fatalf("victim must be gone after backup, stat err=%v", err)
}
// A dangling link at the first backup target is still an occupied lexical
// path and must select a numbered target rather than being overwritten.
danglingVictim := filepath.Join(home, ".agents", "skills", "dangling-collision")
if err := os.MkdirAll(danglingVictim, 0o755); err != nil {
t.Fatal(err)
}
danglingTaken := filepath.Join(home, skillBackupSubdir, "20260810-000000", ".agents-skills-dangling-collision")
if err := os.Symlink("missing-backup", danglingTaken); err == nil {
got, err = backupAndRemoveSkillDir(home, danglingVictim)
if err != nil {
t.Fatalf("backup with dangling collision error = %v", err)
}
want = filepath.Join(home, skillBackupSubdir, "20260810-000000-1", ".agents-skills-dangling-collision")
if got != want {
t.Fatalf("dangling collision backup path = %q, want %q", got, want)
}
} else if runtime.GOOS != "windows" {
t.Fatal(err)
}
// An unreadable target probe is not equivalent to a free path.
probeVictim := filepath.Join(home, "probe-victim")
if err := os.MkdirAll(probeVictim, 0o755); err != nil {
t.Fatal(err)
}
probeTarget := filepath.Join(home, skillBackupSubdir, "20260810-000000", "probe-victim")
testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) {
if path == probeTarget {
return nil, errors.New("target probe denied")
}
return os.Lstat(path)
})
if _, err := backupAndRemoveSkillDir(home, probeVictim); err == nil || !strings.Contains(err.Error(), "检查备份目标失败") {
t.Fatalf("target probe error = %v", err)
}
if _, err := os.Stat(probeVictim); err != nil {
t.Fatalf("probe victim must survive target check failure: %v", err)
}
testseam.Swap(t, &upgradeLstat, os.Lstat)
// Unresolvable collision (>1000 numbered stamps taken) fails and keeps dir.
victim2 := filepath.Join(home, "victim2")
if err := os.MkdirAll(victim2, 0o755); err != nil {
t.Fatal(err)
}
for i := 0; i <= 1000; i++ {
stamp := "20260810-000000"
if i > 0 {
stamp = fmt.Sprintf("20260810-000000-%d", i)
}
if err := os.MkdirAll(filepath.Join(home, skillBackupSubdir, stamp, "victim2"), 0o755); err != nil {
t.Fatal(err)
}
}
if _, err := backupAndRemoveSkillDir(home, victim2); err == nil || !strings.Contains(err.Error(), "备份目录冲突无法解决") {
t.Fatalf("collision limit error = %v, want unresolvable", err)
}
if _, err := os.Stat(victim2); err != nil {
t.Fatalf("victim2 must survive unresolvable collision: %v", err)
}
// MkdirAll failure: no removal.
victim3 := filepath.Join(home, "victim3")
if err := os.MkdirAll(victim3, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return errors.New("mkdir denied") })
if _, err := backupAndRemoveSkillDir(home, victim3); err == nil || !strings.Contains(err.Error(), "创建备份根目录失败") {
t.Fatalf("mkdir error = %v", err)
}
if _, err := os.Stat(victim3); err != nil {
t.Fatalf("victim3 must survive mkdir failure: %v", err)
}
testseam.Swap(t, &upgradeMkdirAll, os.MkdirAll)
// Rename failure: no removal.
victim4 := filepath.Join(home, "victim4")
if err := os.MkdirAll(victim4, 0o755); err != nil {
t.Fatal(err)
}
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", errors.New("rename denied") })
if _, err := backupAndRemoveSkillDir(home, victim4); err == nil || !strings.Contains(err.Error(), "备份技能目录失败") {
t.Fatalf("rename error = %v", err)
}
if _, err := os.Stat(victim4); err != nil {
t.Fatalf("victim4 must survive rename failure: %v", err)
}
testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace)
}
// TestCrossPlatformCoveragePruneSkillBackupsEdges pins the backup retention:
// oldest stamps beyond skillBackupKeep are pruned and a prune failure is
// reported without aborting callers.
func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) {
home := t.TempDir()
root := filepath.Join(home, skillBackupSubdir)
for i := 0; i < skillBackupKeep+2; i++ {
stampDir := filepath.Join(root, fmt.Sprintf("20260810-00000%d", i))
if err := os.MkdirAll(stampDir, 0o755); err != nil {
t.Fatal(err)
}
seedSkillBackupMarker(t, stampDir)
}
if err := pruneSkillBackups(home); err != nil {
t.Fatalf("pruneSkillBackups() error = %v", err)
}
entries, err := os.ReadDir(root)
if err != nil {
t.Fatal(err)
}
if len(entries) != skillBackupKeep {
t.Fatalf("pruned backups = %d, want %d", len(entries), skillBackupKeep)
}
for _, wantGone := range []string{"20260810-000000", "20260810-000001"} {
if _, err := os.Stat(filepath.Join(root, wantGone)); !os.IsNotExist(err) {
t.Errorf("oldest backup %s must be pruned, stat err=%v", wantGone, err)
}
}
// Read failure (non-ENOENT) surfaces.
testseam.Swap(t, &upgradeReadDir, func(string) ([]os.DirEntry, error) { return nil, errors.New("read denied") })
if err := pruneSkillBackups(home); err == nil {
t.Fatal("read failure must surface")
}
// ENOENT is a no-op.
testseam.Swap(t, &upgradeReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrNotExist })
if err := pruneSkillBackups(home); err != nil {
t.Fatalf("missing backup root must be a no-op, got %v", err)
}
testseam.Swap(t, &upgradeReadDir, os.ReadDir)
// Removal failure is reported as the first error. Seed more than
// skillBackupKeep dirs again so the prune loop actually runs.
for i := 0; i < skillBackupKeep+2; i++ {
stampDir := filepath.Join(root, fmt.Sprintf("20260811-00000%d", i))
if err := os.MkdirAll(stampDir, 0o755); err != nil {
t.Fatal(err)
}
seedSkillBackupMarker(t, stampDir)
}
testseam.Swap(t, &upgradeRemoveAll, func(string) error { return errors.New("remove denied") })
if err := pruneSkillBackups(home); err == nil {
t.Fatal("removal failure must surface")
}
testseam.Swap(t, &upgradeRemoveAll, os.RemoveAll)
}
// TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown pins that
// pruneSkillBackups only retires directories whose names match the DWS backup
// stamp format. Any foreign directory in the backup root must survive even when
// more than skillBackupKeep stamped backups exist.
func TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown(t *testing.T) {
home := t.TempDir()
root := filepath.Join(home, skillBackupSubdir)
for i := 0; i < skillBackupKeep+2; i++ {
stampDir := filepath.Join(root, fmt.Sprintf("20260910-00000%d", i))
if err := os.MkdirAll(stampDir, 0o755); err != nil {
t.Fatal(err)
}
seedSkillBackupMarker(t, stampDir)
}
// A collision-suffixed stamp is still a valid DWS backup and is eligible.
suffixed := filepath.Join(root, "20260910-000000-7")
if err := os.MkdirAll(suffixed, 0o755); err != nil {
t.Fatal(err)
}
seedSkillBackupMarker(t, suffixed)
// Stamp-shaped names without a verifying marker are foreign data too:
// the name format alone proves nothing.
unmarked := filepath.Join(root, "20260901-000000")
if err := os.MkdirAll(unmarked, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(unmarked, "user-data.txt"), []byte("must survive"), 0o644); err != nil {
t.Fatal(err)
}
wrongMarker := filepath.Join(root, "20260902-000000")
if err := os.MkdirAll(wrongMarker, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(wrongMarker, skillBackupMarkerName), []byte("not dws"), 0o644); err != nil {
t.Fatal(err)
}
unknown := []string{
"user-personal-backup",
"20260910-000000-abc", // stamp-shaped but non-numeric suffix
"20260910-00000", // too short
"notes",
"20260901-000000", // stamp-shaped but unmarked
"20260902-000000", // stamp-shaped but wrong marker content
}
for _, name := range unknown {
if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil {
t.Fatal(err)
}
}
if err := pruneSkillBackups(home); err != nil {
t.Fatalf("pruneSkillBackups() error = %v", err)
}
for _, name := range unknown {
if _, err := os.Stat(filepath.Join(root, name)); err != nil {
t.Errorf("unknown backup entry %s must be preserved, stat err=%v", name, err)
}
}
// Only marker-proven backups beyond skillBackupKeep are pruned; foreign
// entries (including unmarked stamp-shaped ones) are excluded from the
// count, so the proven total stays at skillBackupKeep.
entries, err := os.ReadDir(root)
if err != nil {
t.Fatal(err)
}
proven := 0
for _, e := range entries {
if isSkillBackupStamp(e.Name()) && skillBackupMarkerValid(filepath.Join(root, e.Name())) {
proven++
}
}
if proven != skillBackupKeep {
t.Errorf("marker-proven backups after prune = %d, want %d", proven, skillBackupKeep)
}
if data, err := os.ReadFile(filepath.Join(unmarked, "user-data.txt")); err != nil || string(data) != "must survive" {
t.Errorf("unmarked stamp-shaped data must survive, got %q, %v", data, err)
}
}
// TestCrossPlatformCoverageResolveSkillSrcLayouts pins every fallback branch of
// the mono-source resolver and the multi-bundle child lookup.
func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) {
// (a) extractedDir itself is the mono tree.
direct := t.TempDir()
os.WriteFile(filepath.Join(direct, "SKILL.md"), []byte("# m"), 0o644)
if got := resolveMonoSkillSrc(direct); got != direct {
t.Errorf("direct root = %q", got)
}
// (b) sibling mono/ next to a multi/ root.
pack := t.TempDir()
multiSibling := filepath.Join(pack, "multi")
os.MkdirAll(multiSibling, 0o755)
monoSibling := filepath.Join(pack, "mono")
os.MkdirAll(monoSibling, 0o755)
os.WriteFile(filepath.Join(monoSibling, "SKILL.md"), []byte("# m"), 0o644)
if got := resolveMonoSkillSrc(multiSibling); got != monoSibling {
t.Errorf("sibling mono = %q, want %q", got, monoSibling)
}
// (c) extract root SKILL.md fallback.
rootOnly := t.TempDir()
os.WriteFile(filepath.Join(rootOnly, "SKILL.md"), []byte("# m"), 0o644)
child := filepath.Join(rootOnly, "extract-child")
os.MkdirAll(child, 0o755)
if got := resolveMonoSkillSrc(child); got != rootOnly {
t.Errorf("parent root = %q, want %q", got, rootOnly)
}
// (d) child mono/ inside the extracted dir.
childMonoParent := t.TempDir()
childMono := filepath.Join(childMonoParent, "mono")
os.MkdirAll(childMono, 0o755)
os.WriteFile(filepath.Join(childMono, "SKILL.md"), []byte("# m"), 0o644)
if got := resolveMonoSkillSrc(childMonoParent); got != childMono {
t.Errorf("child mono = %q, want %q", got, childMono)
}
// (e) nothing found.
if got := resolveMonoSkillSrc(t.TempDir()); got != "" {
t.Errorf("empty package = %q, want empty", got)
}
// resolveMultiBundle: the extracted dir itself is the bundle.
directBundle := writeMultiBundle(t, pack, "dingtalk-chat")
if root, skills := resolveMultiBundle(directBundle); root != directBundle || len(skills) != 1 {
t.Errorf("resolveMultiBundle(direct) = %q %v", root, skills)
}
// ... or only its multi/ child is.
bundleRoot := t.TempDir()
childBundle := writeMultiBundle(t, bundleRoot, "dingtalk-chat")
if root, skills := resolveMultiBundle(bundleRoot); root != childBundle || len(skills) != 1 {
t.Errorf("resolveMultiBundle(child) = %q %v, want %q with 1 skill", root, skills, childBundle)
}
if root, skills := resolveMultiBundle(t.TempDir()); root != "" || skills != nil {
t.Errorf("resolveMultiBundle(empty) = %q %v", root, skills)
}
}
// TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges pins the mono
// path's fail-loud backup semantics and every primary-fallback outcome.
func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) {
mono := t.TempDir()
if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644); err != nil {
t.Fatal(err)
}
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"})
os.MkdirAll(filepath.Join(home, ".agents", "skills", "dws"), 0o755)
os.WriteFile(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md"), []byte("old"), 0o644)
os.MkdirAll(filepath.Join(home, ".claude"), 0o755)
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) {
if strings.Contains(src, ".agents") {
return "", errors.New("backup denied")
}
return renameSkillPathNoReplace(src, dst)
})
result, err := UpgradeSkillLocations(mono)
if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 {
t.Fatalf("canonical backup failure = %#v, %v", result, err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
t.Fatalf("failed canonical home must keep its original dws/: %v", err)
}
}
// TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges pins the multi
// path's blacklisted branch, per-skill backup/copy failures, and the primary
// fallback outcomes (cleanup OK + backup/copy failure, fresh append).
func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) {
extract := t.TempDir()
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat")
home := withFakeHome(t)
testseam.Swap(t, &knownSkillDirs, []string{".real/skills", ".agents/skills"})
os.MkdirAll(filepath.Join(home, ".agents"), 0o755)
result, err := UpgradeSkillLocations(multiRoot)
if err != nil {
t.Fatalf("UpgradeSkillLocations() error = %v", err)
}
var blacklisted int
for _, r := range result.Results {
if r.Status == SkillDirBlacklisted {
blacklisted++
}
}
if blacklisted != 1 {
t.Fatalf("blacklisted entries = %d, want 1 (%v)", blacklisted, result.Results)
}
if _, err := os.Stat(filepath.Join(home, ".real")); !os.IsNotExist(err) {
t.Fatalf("blacklisted home must never be touched, stat err=%v", err)
}
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil {
t.Fatalf("canonical install missing: %v", err)
}
}
// The production wrappers that used to bundle these two steps were removed as
// dead code; the test keeps exercising the live building blocks they composed.
func cleanupMultiLeftoversForTest(homeDir, baseDir string) error {
victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir))
if err != nil {
return err
}
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("备份并清理 multi 残留失败: %w", err)
}
return nil
}
func cleanupOppositeModeLeftoversForTest(homeDir, destBase string, skillSet map[string]bool) error {
victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir))
if err != nil {
return err
}
if _, err := backupSkillSet(homeDir, victims); err != nil {
return fmt.Errorf("备份并清理对面模式残留失败: %w", err)
}
return nil
}
// TestCrossPlatformCoverageCleanupLeftoversEdges pins the cleanup helpers:
// read failures surface, backup failures abort, and the opposite-mode cleanup
// preserves bundle skills while removing the mono leftover.
func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) {
home := t.TempDir()
base := filepath.Join(home, ".agents", "skills")
// Read failure (non-ENOENT) surfaces from both cleanups.
testseam.Swap(t, &upgradeReadDir, func(string) ([]os.DirEntry, error) { return nil, errors.New("read denied") })
if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") {
t.Fatalf("cleanupMultiLeftoversForTest read error = %v", err)
}
if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") {
t.Fatalf("cleanupOppositeModeLeftoversForTest read error = %v", err)
}
testseam.Swap(t, &upgradeReadDir, os.ReadDir)
// Backup failure of a multi leftover aborts cleanupMultiLeftovers.
os.MkdirAll(filepath.Join(base, "dingtalk-stale"), 0o755)
useUpgradeManagedNames(t, "dingtalk-stale")
testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", errors.New("backup denied") })
if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") {
t.Fatalf("cleanupMultiLeftovers backup error = %v", err)
}
// Backup failure of the mono leftover aborts the opposite-mode cleanup.
os.MkdirAll(filepath.Join(base, "dws"), 0o755)
if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
t.Fatalf("opposite cleanup mono backup error = %v", err)
}
testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace)
// Backup failure of a stale (non-mono) skill aborts with its own message.
testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) {
if strings.Contains(src, "dingtalk-stale") {
return "", errors.New("backup denied")
}
return renameSkillPathNoReplace(src, dst)
})
if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") {
t.Fatalf("opposite cleanup stale backup error = %v", err)
}
testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace)
// Success matrix: mono leftover + stale skill removed into backups, bundle
// skill and regular file preserved.
os.MkdirAll(filepath.Join(base, "dws"), 0o755)
skillSet := map[string]bool{"dingtalk-keep": true}
os.MkdirAll(filepath.Join(base, "dingtalk-keep"), 0o755)
os.WriteFile(filepath.Join(base, "regular-file"), []byte("x"), 0o644)
if err := cleanupOppositeModeLeftoversForTest(home, base, skillSet); err != nil {
t.Fatalf("cleanupOppositeModeLeftovers() error = %v", err)
}
for _, gone := range []string{"dws", "dingtalk-stale"} {
if _, err := os.Stat(filepath.Join(base, gone)); !os.IsNotExist(err) {
t.Errorf("%s must be backed up and removed, stat err=%v", gone, err)
}
}
if _, err := os.Stat(filepath.Join(base, "dingtalk-keep")); err != nil {
t.Errorf("bundle skill must be preserved: %v", err)
}
if _, err := os.Stat(filepath.Join(base, "regular-file")); err != nil {
t.Errorf("regular file must be preserved: %v", err)
}
backupRoot := filepath.Join(home, skillBackupSubdir)
if entries, err := os.ReadDir(backupRoot); err != nil || len(entries) == 0 {
t.Errorf("removed dirs must land under %s: %v", backupRoot, err)
}
// cleanupMultiLeftovers on a missing base is a no-op.
if err := cleanupMultiLeftoversForTest(home, filepath.Join(home, "missing")); err != nil {
t.Fatalf("missing base must be a no-op, got %v", err)
}
}
func TestCrossPlatformCoverageUnifiedSkillOwnershipAndProvenanceFailure(t *testing.T) {
dir := filepath.Join(t.TempDir(), "dingtalk-custom")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if isManagedMultiSkillDir(dir) {
t.Fatal("an unregistered dingtalk-* directory must not be treated as DWS-owned")
}
if !isManagedMultiSkillDir(dir, map[string]bool{"dingtalk-custom": true}) {
t.Fatal("unified metadata must prove ownership")
}
if !isManagedMultiSkillDir(filepath.Join(t.TempDir(), "dws-shared")) {
t.Fatal("the exact legacy dws-shared name must remain managed")
}
if !isManagedMultiSkillDir(filepath.Join(t.TempDir(), "dingtalk-aitable")) {
t.Fatal("an exact pre-state official name must remain managed")
}
home := withFakeHome(t)
extract := t.TempDir()
multiRoot := writeMultiBundle(t, extract, "dingtalk-chat")
testseam.Swap(t, &upgradeBuildProvenance, func(string, string, string, string) (skillprovenance.Record, error) {
return skillprovenance.Record{}, errors.New("digest denied")
})
result, err := UpgradeSkillLocations(multiRoot)
if err == nil || !strings.Contains(err.Error(), "provenance") {
t.Fatalf("provenance failure must fail the upgrade, got %v", err)
}
if result != nil {
t.Fatalf("provenance failure must happen before install: %#v", result)
}
if _, statErr := os.Stat(filepath.Join(home, ".dws", "skills-state.json")); !os.IsNotExist(statErr) {
t.Fatalf("failed managed install must not publish state, stat err=%v", statErr)
}
if _, statErr := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat")); !os.IsNotExist(statErr) {
t.Fatalf("provenance failure published a Skill, stat err=%v", statErr)
}
}