Files
boshu2__agentops/cli/internal/paths/paths_test.go
2026-07-15 00:21:02 -04:00

363 lines
10 KiB
Go

package paths
import (
"bufio"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
)
// withEnv sets env vars for the test and restores them on cleanup. It also
// unsets the full AO_* + CLAUDE_PLUGIN_DATA family so leaked values from the
// surrounding shell never reach into the resolver under test.
//
// Uses t.Setenv exclusively (per soc-y1bk) so the test framework's env mutex
// serializes writes — required for clean -race -shuffle=on runs. The first
// t.Setenv per key records the prior value for cleanup; later os.Unsetenv on
// the same key simulates "absent" inside the test scope while letting cleanup
// still restore the original value at the end.
func withEnv(t *testing.T, env map[string]string) {
t.Helper()
keys := []string{
"AO_HOME", "CLAUDE_PLUGIN_DATA",
"AO_AGENTS_DIR", "AO_KNOWLEDGE_ROOT", "AO_HOOKS_DIR",
"AO_RPI_DIR", "AO_FINDINGS_DIR", "AO_PLANS_DIR", "AO_COUNCIL_DIR",
"AO_LEARNINGS_DIR", "AO_PATTERNS_DIR", "AO_DECISIONS_DIR",
"AO_PATHS_DEBUG",
}
// Touch every key with t.Setenv first (records prior value for cleanup),
// then unset it so the resolver under test sees a clean env.
for _, k := range keys {
t.Setenv(k, "")
_ = os.Unsetenv(k)
}
for k, v := range env {
t.Setenv(k, v)
}
}
// chdir changes the working directory for the test and restores it.
func chdir(t *testing.T, dir string) {
t.Helper()
t.Chdir(dir)
}
func TestResolve_EnvPrecedence(t *testing.T) {
// Cannot run table cases in parallel: env mutation is process-global.
tmp := t.TempDir()
// macOS resolves /var → /private/var via os.Getwd; align expected paths
// with what Resolve() will observe after chdir.
if resolved, err := filepath.EvalSymlinks(tmp); err == nil {
tmp = resolved
}
pluginRoot := filepath.Join(tmp, "plugin-root")
homeOverride := filepath.Join(tmp, "explicit-home")
cwdRoot := filepath.Join(tmp, "cwd-root")
for _, d := range []string{pluginRoot, homeOverride, cwdRoot} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatalf("mkdir %s: %v", d, err)
}
}
cases := []struct {
name string
env map[string]string
chdirTo string
wantHome string
wantAgent string // AgentsDir override target ("" means equal to home)
}{
{
name: "no env set, defaults to cwd/.agents",
env: nil,
chdirTo: cwdRoot,
wantHome: filepath.Join(cwdRoot, ".agents"),
},
{
name: "AO_HOME set explicitly",
env: map[string]string{"AO_HOME": homeOverride},
chdirTo: cwdRoot,
wantHome: homeOverride,
},
{
name: "CLAUDE_PLUGIN_DATA set",
env: map[string]string{"CLAUDE_PLUGIN_DATA": pluginRoot},
chdirTo: cwdRoot,
wantHome: filepath.Join(pluginRoot, ".agents"),
},
{
name: "both AO_HOME and CLAUDE_PLUGIN_DATA set — AO_HOME wins",
env: map[string]string{
"AO_HOME": homeOverride,
"CLAUDE_PLUGIN_DATA": pluginRoot,
},
chdirTo: cwdRoot,
wantHome: homeOverride,
},
{
name: "AO_AGENTS_DIR override decouples from home",
env: map[string]string{
"AO_HOME": homeOverride,
"AO_AGENTS_DIR": filepath.Join(tmp, "agents-elsewhere"),
},
chdirTo: cwdRoot,
wantHome: homeOverride,
wantAgent: filepath.Join(tmp, "agents-elsewhere"),
},
{
name: "AO_HOME with relative path is preserved verbatim (no normalization)",
env: map[string]string{"AO_HOME": "rel/agents"},
chdirTo: cwdRoot,
wantHome: "rel/agents",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
withEnv(t, tc.env)
chdir(t, tc.chdirTo)
got := Resolve()
if got == nil {
t.Fatalf("Resolve() returned nil")
}
if got.Home != tc.wantHome {
t.Errorf("Home = %q, want %q", got.Home, tc.wantHome)
}
wantAgent := tc.wantAgent
if wantAgent == "" {
wantAgent = tc.wantHome
}
if got.AgentsDir != wantAgent {
t.Errorf("AgentsDir = %q, want %q", got.AgentsDir, wantAgent)
}
// Default sub-roots must hang off AgentsDir.
wantWiki := filepath.Join(wantAgent, "wiki")
if got.KnowledgeRoot != wantWiki {
t.Errorf("KnowledgeRoot = %q, want %q", got.KnowledgeRoot, wantWiki)
}
})
}
}
func TestResolve_Idempotent(t *testing.T) {
tmp := t.TempDir()
withEnv(t, map[string]string{"AO_HOME": tmp})
chdir(t, tmp)
a := Resolve()
b := Resolve()
if *a != *b {
t.Errorf("Resolve() not idempotent: a=%+v b=%+v", a, b)
}
}
func TestResolveFromRoot_UsesGitRootAndEnvOverrides(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git unavailable")
}
tmp := t.TempDir()
repo := filepath.Join(tmp, "repo")
subdir := filepath.Join(repo, "nested")
if err := os.MkdirAll(subdir, 0o755); err != nil {
t.Fatalf("mkdir subdir: %v", err)
}
cmd := exec.Command("git", "init")
cmd.Dir = repo
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git init: %v\n%s", err, out)
}
withEnv(t, nil)
got := ResolveFromRoot(subdir)
if !samePath(got.AgentsDir, filepath.Join(repo, ".agents")) {
t.Fatalf("AgentsDir = %q, want repo root fallback %q", got.AgentsDir, filepath.Join(repo, ".agents"))
}
agentsOverride := filepath.Join(tmp, "agents-override")
withEnv(t, map[string]string{"AO_AGENTS_DIR": agentsOverride})
got = ResolveFromRoot(subdir)
if got.AgentsDir != agentsOverride {
t.Fatalf("AgentsDir = %q, want AO_AGENTS_DIR override %q", got.AgentsDir, agentsOverride)
}
}
func samePath(a, b string) bool {
return canonicalTestPath(a) == canonicalTestPath(b)
}
func canonicalTestPath(path string) string {
path = filepath.Clean(path)
if resolved, err := filepath.EvalSymlinks(path); err == nil {
return filepath.Clean(resolved)
}
parent := filepath.Dir(path)
if parent == path {
return path
}
if resolved, err := filepath.EvalSymlinks(parent); err == nil {
return filepath.Join(filepath.Clean(resolved), filepath.Base(path))
}
return path
}
func TestValidate_CreatesMissingDirs(t *testing.T) {
tmp := t.TempDir()
home := filepath.Join(tmp, "fresh", ".agents")
withEnv(t, map[string]string{"AO_HOME": home})
chdir(t, tmp)
p := Resolve()
if err := p.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
for _, dir := range []string{p.AgentsDir, p.KnowledgeRoot, p.HooksDir, p.FindingsDir, p.PlansDir} {
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
t.Errorf("Validate did not create %s (err=%v)", dir, err)
}
}
}
func TestValidate_RejectsFileInPlaceOfDir(t *testing.T) {
tmp := t.TempDir()
home := filepath.Join(tmp, ".agents")
if err := os.MkdirAll(home, 0o755); err != nil {
t.Fatalf("mkdir home: %v", err)
}
// Make a file where the wiki dir should go.
wikiPath := filepath.Join(home, "wiki")
if err := os.WriteFile(wikiPath, []byte("not a dir"), 0o644); err != nil {
t.Fatalf("write wiki file: %v", err)
}
withEnv(t, map[string]string{"AO_HOME": home})
chdir(t, tmp)
p := Resolve()
if err := p.Validate(); err == nil {
t.Error("Validate should fail when KnowledgeRoot path is a file")
}
}
// TestShellGoAgreement is the cross-language L2 scenario: under controlled
// env, lib/ao-paths.sh and Resolve() must produce identical AO_* values.
func TestShellGoAgreement(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("shell resolver unavailable on Windows")
}
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not on PATH")
}
// Locate lib/ao-paths.sh by walking up from the test's working dir.
scriptPath := findScript(t)
if scriptPath == "" {
t.Skip("lib/ao-paths.sh not found relative to test cwd")
}
cases := []struct {
name string
env map[string]string
}{
{
name: "explicit AO_HOME",
env: map[string]string{"AO_HOME": filepath.Join(t.TempDir(), "agents")},
},
{
name: "CLAUDE_PLUGIN_DATA",
env: map[string]string{"CLAUDE_PLUGIN_DATA": filepath.Join(t.TempDir(), "plugin")},
},
{
name: "AO_AGENTS_DIR override",
env: map[string]string{
"AO_HOME": filepath.Join(t.TempDir(), "home"),
"AO_AGENTS_DIR": filepath.Join(t.TempDir(), "agents"),
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
withEnv(t, tc.env)
// Use the script's parent dir as cwd so default repo-root resolution
// stays consistent between shell and Go.
chdir(t, filepath.Dir(filepath.Dir(scriptPath)))
// Run the shell resolver.
cmd := exec.Command("bash", "-c", `eval "$(`+scriptPath+`)" && env | grep '^AO_' | sort`)
cmd.Env = os.Environ()
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("shell resolver failed: %v\nout: %s", err, out)
}
shellEnv := parseEnvLines(string(out))
// Run the Go resolver under the same env.
goPaths := Resolve()
goEnv := goPathsToEnv(goPaths)
// Compare every AO_* key — shell may export AO_PATHS_DEBUG (excluded).
for _, key := range []string{
"AO_HOME", "AO_AGENTS_DIR", "AO_KNOWLEDGE_ROOT",
"AO_HOOKS_DIR", "AO_RPI_DIR",
"AO_FINDINGS_DIR", "AO_PLANS_DIR", "AO_COUNCIL_DIR",
"AO_LEARNINGS_DIR", "AO_PATTERNS_DIR", "AO_DECISIONS_DIR",
} {
if shellEnv[key] != goEnv[key] {
t.Errorf("%s: shell=%q go=%q", key, shellEnv[key], goEnv[key])
}
}
})
}
}
// findScript walks up from cwd to find lib/ao-paths.sh.
func findScript(t *testing.T) string {
t.Helper()
cwd, err := os.Getwd()
if err != nil {
return ""
}
dir := cwd
for i := 0; i < 8; i++ {
candidate := filepath.Join(dir, "lib", "ao-paths.sh")
if _, err := os.Stat(candidate); err == nil {
return candidate
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return ""
}
// parseEnvLines parses `KEY=value` lines into a map.
func parseEnvLines(s string) map[string]string {
m := map[string]string{}
scanner := bufio.NewScanner(strings.NewReader(s))
for scanner.Scan() {
line := scanner.Text()
i := strings.Index(line, "=")
if i <= 0 {
continue
}
m[line[:i]] = line[i+1:]
}
return m
}
func goPathsToEnv(p *Paths) map[string]string {
return map[string]string{
"AO_HOME": p.Home,
"AO_AGENTS_DIR": p.AgentsDir,
"AO_KNOWLEDGE_ROOT": p.KnowledgeRoot,
"AO_HOOKS_DIR": p.HooksDir,
"AO_RPI_DIR": p.RPIDir,
"AO_FINDINGS_DIR": p.FindingsDir,
"AO_PLANS_DIR": p.PlansDir,
"AO_COUNCIL_DIR": p.CouncilDir,
"AO_LEARNINGS_DIR": p.LearningsDir,
"AO_PATTERNS_DIR": p.PatternsDir,
"AO_DECISIONS_DIR": p.DecisionsDir,
}
}