The platform coverage gates execute only TestCrossPlatformCoverage-named tests, so the child-move error and dispatch branches that the full local suite covered incidentally were reported as uncovered changed code on Windows (96.78% vs the 100% target). Adds a seam-driven edge suite for the child-move fallback — source/claim/child stat and read failures, per-child link and symlink collisions and publish failures, rollback rename failure, foreign-entry abort, mode-restore failure, source shell removal failure, nested-directory and simulated-symlink children, and post-rename content drift — plus the retained-destination notice for a dependent uncertain target in skill setup. The POSIX file identity impl now consults the lstat seam so its degradation branches are coverable the same way. Verified against the gate's own changed-line computation: zero uncovered changed statements in internal/upgrade.
161 lines
6.3 KiB
Go
161 lines
6.3 KiB
Go
package app
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
|
)
|
|
|
|
func canonicalFailurePlan(t *testing.T) (string, *skillSetupPlan) {
|
|
t.Helper()
|
|
home := t.TempDir()
|
|
src := t.TempDir()
|
|
skill := filepath.Join(src, "dingtalk-chat")
|
|
if err := os.MkdirAll(skill, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(skill, "SKILL.md"), []byte("chat"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
canonical := filepath.Join(home, ".agents", "skills")
|
|
dependent := filepath.Join(home, ".claude", "skills")
|
|
plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, dependent}, []string{"dingtalk-chat"}, true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return home, plan
|
|
}
|
|
|
|
func TestCrossPlatformCoverageSkillSetupCanonicalHomeBackupAndPublishFailures(t *testing.T) {
|
|
t.Run("home", func(t *testing.T) {
|
|
_, plan := canonicalFailurePlan(t)
|
|
canonical := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat")
|
|
if err := os.MkdirAll(canonical, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plan.Targets[0].Backups = []skillSetupBackup{{Path: canonical, Reason: skillSetupBackupReplace}}
|
|
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home denied") })
|
|
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 刷新中止") {
|
|
t.Fatalf("home failure = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("backup", func(t *testing.T) {
|
|
home, plan := canonicalFailurePlan(t)
|
|
victim := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat")
|
|
if err := os.MkdirAll(victim, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
plan.Targets[0].Backups = []skillSetupBackup{{Path: victim, 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") })
|
|
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 备份失败") {
|
|
t.Fatalf("backup failure = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("publish", func(t *testing.T) {
|
|
home, plan := canonicalFailurePlan(t)
|
|
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
|
testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) {
|
|
return upgrade.SkillPathPublication{}, errors.New("publish denied")
|
|
})
|
|
if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 发布失败") {
|
|
t.Fatalf("publish failure = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("uncertain-publish", func(t *testing.T) {
|
|
home, plan := canonicalFailurePlan(t)
|
|
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
|
testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) {
|
|
return upgrade.SkillPathPublication{}, fmt.Errorf("并发写入: %w", upgrade.ErrSkillPathPublicationUncertain)
|
|
})
|
|
errOut := &bytes.Buffer{}
|
|
_, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, errOut)
|
|
if err == nil || !strings.Contains(err.Error(), "canonical Skill 发布状态不确定") {
|
|
t.Fatalf("uncertain publish = %v", err)
|
|
}
|
|
// The destination was deliberately retained, so the canonical error
|
|
// must not claim a rollback happened.
|
|
if strings.Contains(err.Error(), "回滚") {
|
|
t.Fatalf("uncertain error must not claim a rollback: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("uncertain dependent target is retained and reported", func(t *testing.T) {
|
|
home, plan := canonicalFailurePlan(t)
|
|
testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil })
|
|
originalPublish := skillSetupPublishPath
|
|
testseam.Swap(t, &skillSetupPublishPath, func(staged, destination string) (upgrade.SkillPathPublication, error) {
|
|
if strings.HasPrefix(destination, filepath.Join(home, ".claude")) {
|
|
return upgrade.SkillPathPublication{}, fmt.Errorf("并发写入: %w", upgrade.ErrSkillPathPublicationUncertain)
|
|
}
|
|
return originalPublish(staged, destination)
|
|
})
|
|
errOut := &bytes.Buffer{}
|
|
_, skipped, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, errOut)
|
|
if err != nil {
|
|
t.Fatalf("dependent uncertain publish = %v", err)
|
|
}
|
|
if skipped != 1 {
|
|
t.Fatalf("skipped = %d, want 1", skipped)
|
|
}
|
|
if !strings.Contains(errOut.String(), "发布状态不确定") || strings.Contains(errOut.String(), "已执行回滚") {
|
|
t.Fatalf("errOut = %q, want retained-destination notice", errOut.String())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestCrossPlatformCoverageSkillSetupLinkResolutionFailures(t *testing.T) {
|
|
home, plan := canonicalFailurePlan(t)
|
|
canonicalTarget := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat")
|
|
if err := os.MkdirAll(canonicalTarget, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(canonicalTarget, "SKILL.md"), []byte("chat"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
linked := plan.Targets[1]
|
|
|
|
t.Run("physical-parent", func(t *testing.T) {
|
|
testseam.Swap(t, &skillSetupEvalSymlinks, func(path string) (string, error) {
|
|
if path == linked.Destination {
|
|
return "", errors.New("parent denied")
|
|
}
|
|
return filepath.EvalSymlinks(path)
|
|
})
|
|
if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "物理目录") {
|
|
t.Fatalf("physical parent error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("canonical-target", func(t *testing.T) {
|
|
testseam.Swap(t, &skillSetupEvalSymlinks, func(path string) (string, error) {
|
|
if path == canonicalTarget {
|
|
return "", errors.New("canonical denied")
|
|
}
|
|
return filepath.EvalSymlinks(path)
|
|
})
|
|
if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "解析 canonical") {
|
|
t.Fatalf("canonical target error = %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("relative-path", func(t *testing.T) {
|
|
testseam.Swap(t, &skillSetupRel, func(string, string) (string, error) { return "", errors.New("relative denied") })
|
|
if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "相对链接") {
|
|
t.Fatalf("relative path error = %v", err)
|
|
}
|
|
})
|
|
|
|
_ = home
|
|
}
|