Files
wehub-resource-sync f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:30:36 +08:00

389 lines
12 KiB
Go

package main
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.kenn.io/agentsview/internal/skills"
)
// skillHeaderFormat mirrors the private header format in internal/skills so
// tests here can synthesize stale/modified fixtures without reaching into
// unexported package internals.
const skillHeaderFormat = "# generated-by: agentsview %s hash:%s — do not edit; " +
"re-run `agentsview skills install`"
// sha256Hex returns the hex sha256 digest of body, matching internal/skills'
// own bodyHash so synthesized headers classify the way production ones do.
func sha256Hex(body string) string {
sum := sha256.Sum256([]byte(body))
return hex.EncodeToString(sum[:])
}
// claudeSkillPath returns the SKILL.md path the CLI installs for the Claude
// harness under home.
func claudeSkillPath(home string) string {
return filepath.Join(skills.TargetDir(skills.HarnessClaude, home), skillFileName)
}
// agentsSkillPath returns the SKILL.md path the CLI installs for the Agents
// harness under home.
func agentsSkillPath(home string) string {
return filepath.Join(skills.TargetDir(skills.HarnessAgents, home), skillFileName)
}
func freshClaudeSkill(t *testing.T) skills.Rendered {
t.Helper()
rendered, err := skills.Render(skills.HarnessClaude, version)
require.NoError(t, err)
return rendered
}
// writeSkillFile writes content at path, creating parent directories.
func writeSkillFile(t *testing.T, path, content string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
}
// setTestHome points the process home at dir for both Unix (HOME) and
// Windows (USERPROFILE), since os.UserHomeDir reads a different variable
// per platform.
func setTestHome(t *testing.T, dir string) {
t.Helper()
t.Setenv("HOME", dir)
t.Setenv("USERPROFILE", dir)
}
// staleClaudeContent returns a well-formed generated-by file whose recorded
// hash matches an older body, so Classify reports StateStale.
func staleClaudeContent() string {
oldBody := "---\nname: agentsview-finding-history\n---\n" +
"an earlier revision of the skill body, no longer current\n"
header := fmt.Sprintf(skillHeaderFormat, "0.0.1", sha256Hex(oldBody))
return "---\n" + header + "\n" + strings.TrimPrefix(oldBody, "---\n")
}
// modifiedClaudeContent returns a fresh render whose body was hand-edited
// after the header hash was recorded, so Classify reports StateModified.
func modifiedClaudeContent(t *testing.T) string {
t.Helper()
return freshClaudeSkill(t).Content + "\nan uninvited local edit\n"
}
const foreignClaudeContent = "# Just a hand-written file\n\nNo generated-by header here.\n"
func TestSkillsInstall_StatesAndForce(t *testing.T) {
const refusalMsg = "was modified (or not generated); use --force to overwrite"
tests := []struct {
name string
seed func(t *testing.T, path string) // nil means the file is missing
wantMsgNoForce string
wantMsgForced string // message once --force overrides a refusal; "" when force changes nothing
}{
{
name: "missing",
seed: nil,
wantMsgNoForce: "installed",
},
{
name: "current",
seed: func(t *testing.T, path string) {
writeSkillFile(t, path, freshClaudeSkill(t).Content)
},
wantMsgNoForce: "up to date",
},
{
name: "stale",
seed: func(t *testing.T, path string) {
writeSkillFile(t, path, staleClaudeContent())
},
wantMsgNoForce: "updated",
},
{
name: "modified",
seed: func(t *testing.T, path string) {
writeSkillFile(t, path, modifiedClaudeContent(t))
},
wantMsgNoForce: refusalMsg,
wantMsgForced: "updated",
},
{
name: "foreign",
seed: func(t *testing.T, path string) {
writeSkillFile(t, path, foreignClaudeContent)
},
wantMsgNoForce: refusalMsg,
wantMsgForced: "updated",
},
}
for _, tt := range tests {
for _, force := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/force=%v", tt.name, force), func(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
path := claudeSkillPath(home)
var seedContent string
if tt.seed != nil {
tt.seed(t, path)
seedContent = readFileString(t, path)
}
args := []string{"skills", "install", "--harness", "claude"}
if force {
args = append(args, "--force")
}
out, err := executeCommand(newRootCommand(), args...)
assert.Contains(t, out, path)
refused := tt.wantMsgForced != "" && !force
if refused {
assert.Contains(t, out, tt.wantMsgNoForce)
require.Error(t, err, "expected a refusal error")
assert.Equal(t, seedContent, readFileString(t, path),
"refused install must not touch the file")
return
}
wantMsg := tt.wantMsgNoForce
if force && tt.wantMsgForced != "" {
wantMsg = tt.wantMsgForced
}
assert.Contains(t, out, wantMsg)
require.NoError(t, err, "output: %s", out)
assert.Equal(t, freshClaudeSkill(t).Content, readFileString(t, path))
})
}
}
}
// readFileString reads path, failing the test on error.
func readFileString(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
require.NoError(t, err, "read %s", path)
return string(b)
}
func TestSkillsInstall_DefaultHarnessesInstallBoth(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
out, err := executeCommand(newRootCommand(), "skills", "install")
require.NoError(t, err, "output: %s", out)
assert.Contains(t, out, claudeSkillPath(home))
assert.Contains(t, out, agentsSkillPath(home))
assert.FileExists(t, claudeSkillPath(home))
assert.FileExists(t, agentsSkillPath(home))
}
func TestSkillsInstall_UnknownHarnessErrors(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
_, err := executeCommand(newRootCommand(), "skills", "install", "--harness", "bogus")
require.Error(t, err)
assert.Contains(t, err.Error(), "unknown --harness")
}
func TestSkillsInstall_RefusalStillInstallsOtherTargets(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
writeSkillFile(t, claudeSkillPath(home), foreignClaudeContent)
out, err := executeCommand(newRootCommand(), "skills", "install")
require.Error(t, err, "one refused target must still fail the command")
assert.Contains(t, out, "was modified (or not generated); use --force to overwrite")
assert.Contains(t, out, "installed "+agentsSkillPath(home))
assert.FileExists(t, agentsSkillPath(home))
assert.Equal(t, foreignClaudeContent, readFileString(t, claudeSkillPath(home)),
"the refused claude target must be untouched")
}
func TestSkillsInstall_FilePermissions(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
_, err := executeCommand(newRootCommand(), "skills", "install", "--harness", "claude")
require.NoError(t, err)
info, err := os.Stat(claudeSkillPath(home))
require.NoError(t, err)
// The process umask may strip group/other bits from the 0644 requested by
// os.WriteFile, so only assert the file is a regular, non-executable file
// readable/writable by its owner rather than the exact resulting mode.
assert.True(t, info.Mode().IsRegular())
assert.Zero(t, info.Mode().Perm()&0o111, "installed skill file must not be executable")
assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()&0o600,
"owner must be able to read and write the installed skill file")
}
func TestSkillsList_ReportsEachState(t *testing.T) {
tests := []struct {
name string
seed func(t *testing.T, path string)
want string
}{
{name: "missing", seed: nil, want: "missing"},
{
name: "current",
seed: func(t *testing.T, path string) {
writeSkillFile(t, path, freshClaudeSkill(t).Content)
},
want: "current",
},
{
name: "stale",
seed: func(t *testing.T, path string) {
writeSkillFile(t, path, staleClaudeContent())
},
want: "stale",
},
{
name: "modified",
seed: func(t *testing.T, path string) {
writeSkillFile(t, path, modifiedClaudeContent(t))
},
want: "modified",
},
{
name: "foreign",
seed: func(t *testing.T, path string) {
writeSkillFile(t, path, foreignClaudeContent)
},
want: "foreign",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
path := claudeSkillPath(home)
if tt.seed != nil {
tt.seed(t, path)
}
out, err := executeCommand(newRootCommand(), "skills", "list", "--format", "json")
require.NoError(t, err, "output: %s", out)
var rows []skillListRow
require.NoError(t, json.Unmarshal([]byte(out), &rows), "output: %s", out)
var claudeRow *skillListRow
for i := range rows {
if rows[i].Harness == string(skills.HarnessClaude) {
claudeRow = &rows[i]
}
}
require.NotNil(t, claudeRow, "no claude row in %+v", rows)
assert.Equal(t, tt.want, claudeRow.State)
assert.Equal(t, "user", claudeRow.Level)
assert.Equal(t, path, claudeRow.Path)
})
}
}
func TestSkillsList_HumanTableHasHeaderAndColumns(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
out, err := executeCommand(newRootCommand(), "skills", "list")
require.NoError(t, err, "output: %s", out)
assert.Contains(t, out, "HARNESS")
assert.Contains(t, out, "LEVEL")
assert.Contains(t, out, "STATE")
assert.Contains(t, out, "PATH")
assert.Contains(t, out, "claude")
assert.Contains(t, out, "agents")
assert.Contains(t, out, "missing")
assert.Contains(t, out, claudeSkillPath(home))
}
// initTestGitRepo runs `git init` in a fresh temp directory. No commit is
// required: gitrepo.Root only needs a `.git` directory to resolve a root.
func initTestGitRepo(t *testing.T) string {
t.Helper()
repo := t.TempDir()
cmd := exec.Command("git", "init", "-q", "-b", "main")
cmd.Dir = repo
out, err := cmd.CombinedOutput()
require.NoError(t, err, "git init: %s", out)
resolved, err := filepath.EvalSymlinks(repo)
require.NoError(t, err)
return resolved
}
func TestSkillsInstall_ProjectFlagUsesGitRoot(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
repo := initTestGitRepo(t)
nested := filepath.Join(repo, "a", "b")
require.NoError(t, os.MkdirAll(nested, 0o755))
t.Chdir(nested)
out, err := executeCommand(newRootCommand(), "skills", "install", "--harness", "claude", "--project")
require.NoError(t, err, "output: %s", out)
wantPath := claudeSkillPath(repo)
assert.Contains(t, out, wantPath)
assert.FileExists(t, wantPath)
// Must not have installed under the user home directory instead.
assert.NoFileExists(t, claudeSkillPath(home))
}
func TestSkillsList_ProjectFlagReportsProjectLevel(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
repo := initTestGitRepo(t)
t.Chdir(repo)
out, err := executeCommand(newRootCommand(), "skills", "list", "--project", "--format", "json")
require.NoError(t, err, "output: %s", out)
var rows []skillListRow
require.NoError(t, json.Unmarshal([]byte(out), &rows), "output: %s", out)
require.NotEmpty(t, rows)
for _, r := range rows {
assert.Equal(t, "project", r.Level)
assert.True(t, strings.HasPrefix(r.Path, repo), "path %q must be under repo root %q", r.Path, repo)
}
}
func TestSkillsInstall_ProjectFlagOutsideRepoFallsBackToCWD(t *testing.T) {
home := t.TempDir()
setTestHome(t, home)
outsideRepo := t.TempDir()
resolvedOutside, err := filepath.EvalSymlinks(outsideRepo)
require.NoError(t, err)
t.Chdir(resolvedOutside)
out, err := executeCommand(newRootCommand(), "skills", "install", "--harness", "claude", "--project")
require.NoError(t, err, "output: %s", out)
wantPath := claudeSkillPath(resolvedOutside)
assert.Contains(t, out, wantPath)
assert.FileExists(t, wantPath)
}