Files
agent-lib/internal/e2e/sync_test.go
T

280 lines
8.3 KiB
Go
Raw Normal View History

package e2e
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func agentLibCmd(args ...string) *exec.Cmd {
return exec.Command(binPath, args...)
}
type syncEnv struct {
home string
stateDir string
configDir string
config string
workRepo string
}
func newSyncEnv(t *testing.T, h *harness) *syncEnv {
t.Helper()
base := t.TempDir()
env := &syncEnv{
home: filepath.Join(base, "home"),
stateDir: filepath.Join(base, "state"),
configDir: filepath.Join(base, "config"),
workRepo: filepath.Join(base, "work-repo"),
}
for _, d := range []string{env.home, env.stateDir, env.configDir, env.workRepo} {
if err := os.MkdirAll(d, 0o755); err != nil {
t.Fatal(err)
}
}
return env
}
func (e *syncEnv) writeConfig(t *testing.T, repoURL, token string) {
t.Helper()
cfg := map[string]string{"repo_url": repoURL, "ref": "main"}
if token != "" {
cfg["token"] = token
}
data, err := json.Marshal(cfg)
if err != nil {
t.Fatal(err)
}
e.config = filepath.Join(e.configDir, "config.json")
os.WriteFile(e.config, data, 0o600)
}
func (e *syncEnv) run(t *testing.T, args ...string) (string, error) {
t.Helper()
cmd := agentLibCmd(append([]string{"sync", "--config", e.config}, args...)...)
cmd.Env = append(os.Environ(),
"HOME="+e.home, "USERPROFILE="+e.home,
"AGENT_LIB_STATE_DIR="+e.stateDir,
)
out, err := cmd.CombinedOutput()
return string(out), err
}
func buildWorkRepo(t *testing.T, h *harness, dir string) {
t.Helper()
h.git(t, dir, "init", "-b", "main")
write := func(rel, content string) {
p := filepath.Join(dir, rel)
os.MkdirAll(filepath.Dir(p), 0o755)
os.WriteFile(p, []byte(content), 0o644)
}
write("skills/own-skill/SKILL.md", "---\nname: Own Skill\n---\n# Own\n")
write("skills/own-skill/run.sh", "#!/bin/sh\necho hi\n")
os.Chmod(filepath.Join(dir, "skills/own-skill/run.sh"), 0o755)
write("commands/deploy.md", "---\nname: Deploy\n---\nbody\n")
write("agents/helper.md", "---\nname: Helper\n---\nbody\n")
write("mcp/internal.yaml", "servers: {}\n")
h.git(t, dir, "add", "-A")
h.git(t, dir, "commit", "-m", "work repo")
}
func TestSyncHappyPathAndManifest(t *testing.T) {
h := newHarness(t)
env := newSyncEnv(t, h)
buildWorkRepo(t, h, env.workRepo)
env.writeConfig(t, env.workRepo, "")
out, err := env.run(t)
if err != nil {
t.Fatalf("sync failed: %v\n%s", err, out)
}
skillMd := filepath.Join(env.home, ".agents/skills/own-skill/SKILL.md")
if _, err := os.Stat(skillMd); err != nil {
t.Error("skill must deploy to ~/.agents/skills/<id>/")
}
runSh := filepath.Join(env.home, ".agents/skills/own-skill/run.sh")
info, err := os.Stat(runSh)
if err != nil {
t.Fatal("skill folder artifacts must be preserved")
}
if info.Mode()&0o111 == 0 {
t.Error("executable bit must survive deployment")
}
if _, err := os.Stat(filepath.Join(env.home, ".config/opencode/commands/deploy.md")); err != nil {
t.Error("command must deploy to ~/.config/opencode/commands/<id>.md")
}
if _, err := os.Stat(filepath.Join(env.home, ".config/opencode/agents/helper.md")); err != nil {
t.Error("agent must deploy to ~/.config/opencode/agents/<id>.md")
}
if _, err := os.Stat(filepath.Join(env.home, ".config/opencode/mcp")); err == nil {
t.Error("MCP must never be deployed")
}
mcpEverywhere := false
filepath.WalkDir(env.home, func(p string, d os.DirEntry, err error) error {
if d != nil && !d.IsDir() && strings.HasSuffix(p, ".yaml") {
mcpEverywhere = true
}
return nil
})
if mcpEverywhere {
t.Error("no yaml fragment may land anywhere in HOME")
}
manifestData, err := os.ReadFile(filepath.Join(env.stateDir, "manifest.json"))
if err != nil {
t.Fatal("manifest must exist in the state dir")
}
var manifest struct {
Version int `json:"version"`
Items map[string]struct {
Name string `json:"name"`
Type string `json:"type"`
Origin string `json:"origin"`
Revision string `json:"revision"`
Hash string `json:"hash"`
} `json:"items"`
}
if err := json.Unmarshal(manifestData, &manifest); err != nil {
t.Fatalf("manifest not parseable: %v", err)
}
skill := manifest.Items["skills/own-skill"]
if skill.Origin != "own" || skill.Hash == "" || skill.Revision == "" {
t.Errorf("manifest record incomplete: %+v", skill)
}
}
func TestSyncIdempotentSecondRun(t *testing.T) {
h := newHarness(t)
env := newSyncEnv(t, h)
buildWorkRepo(t, h, env.workRepo)
env.writeConfig(t, env.workRepo, "")
if _, err := env.run(t); err != nil {
t.Fatal(err)
}
skillMd := filepath.Join(env.home, ".agents/skills/own-skill/SKILL.md")
firstInfo, _ := os.Stat(skillMd)
manifestBefore := readFile(t, filepath.Join(env.stateDir, "manifest.json"))
out, err := env.run(t)
if err != nil {
t.Fatalf("second sync failed: %v\n%s", err, out)
}
if !contains(out, "kept: 3") {
t.Errorf("second run must keep everything:\n%s", out)
}
secondInfo, _ := os.Stat(skillMd)
if !firstInfo.ModTime().Equal(secondInfo.ModTime()) {
t.Error("unchanged items must not be rewritten (byte-level no-op)")
}
if got := readFile(t, filepath.Join(env.stateDir, "manifest.json")); got != manifestBefore {
t.Error("manifest bytes must not change on a no-op sync")
}
}
func TestSyncUnreachableRepoKeepsState(t *testing.T) {
h := newHarness(t)
env := newSyncEnv(t, h)
buildWorkRepo(t, h, env.workRepo)
env.writeConfig(t, env.workRepo, "")
if _, err := env.run(t); err != nil {
t.Fatal(err)
}
skillBefore := readFile(t, filepath.Join(env.home, ".agents/skills/own-skill/SKILL.md"))
env.writeConfig(t, filepath.Join(t.TempDir(), "does-not-exist"), "")
out, err := env.run(t)
if err == nil {
t.Fatalf("unreachable repo must fail:\n%s", out)
}
if !contains(out, "error") {
t.Errorf("failure must be a clean error:\n%s", out)
}
if got := readFile(t, filepath.Join(env.home, ".agents/skills/own-skill/SKILL.md")); got != skillBefore {
t.Error("previous state must survive a failed sync")
}
}
func TestSyncPicksUpChanges(t *testing.T) {
h := newHarness(t)
env := newSyncEnv(t, h)
buildWorkRepo(t, h, env.workRepo)
env.writeConfig(t, env.workRepo, "")
env.run(t)
p := filepath.Join(env.workRepo, "commands/deploy.md")
os.WriteFile(p, []byte("---\nname: Deploy\ndescription: updated\n---\nnew body\n"), 0o644)
h.git(t, env.workRepo, "add", "-A")
h.git(t, env.workRepo, "commit", "-m", "update command")
out, err := env.run(t)
if err != nil {
t.Fatalf("sync after upstream change failed: %v\n%s", err, out)
}
if !contains(out, "updated: 1") {
t.Errorf("changed item must be updated:\n%s", out)
}
deployed := readFile(t, filepath.Join(env.home, ".config/opencode/commands/deploy.md"))
if !contains(deployed, "new body") {
t.Error("updated content must land on disk")
}
}
func TestSyncVendoredContentDeploys(t *testing.T) {
h := newHarness(t)
standardFixture(h, t)
env := newSyncEnv(t, h)
buildWorkRepo(t, h, env.workRepo)
upstreamWork := env.workRepo
cmd := agentLibCmd("vendor", "add", "superpowers", h.upstreamDir)
cmd.Dir = upstreamWork
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("vendor add into work repo: %v\n%s", err, out)
}
h.git(t, upstreamWork, "add", "-A")
h.git(t, upstreamWork, "commit", "-m", "vendor superpowers")
env.writeConfig(t, upstreamWork, "")
out, err := env.run(t)
if err != nil {
t.Fatalf("sync with vendored content failed: %v\n%s", err, out)
}
if _, err := os.Stat(filepath.Join(env.home, ".agents/skills/good-skill/SKILL.md")); err != nil {
t.Error("vendored skill must deploy under its deployed name")
}
manifestData := []byte(readFile(t, filepath.Join(env.stateDir, "manifest.json")))
if !contains(string(manifestData), `"origin": "superpowers"`) {
t.Errorf("manifest must record the source origin:\n%s", manifestData)
}
}
func TestSyncTokenNeverLeaked(t *testing.T) {
h := newHarness(t)
env := newSyncEnv(t, h)
buildWorkRepo(t, h, env.workRepo)
env.writeConfig(t, env.workRepo, "super-secret-token")
out, err := env.run(t)
if err != nil {
t.Fatalf("sync with token failed: %v\n%s", err, out)
}
if contains(out, "super-secret-token") {
t.Errorf("token must never appear in output:\n%s", out)
}
for _, path := range []string{
filepath.Join(env.stateDir, "manifest.json"),
filepath.Join(env.workRepo, "agent-lib.lock.json"),
} {
if data, err := os.ReadFile(path); err == nil && strings.Contains(string(data), "super-secret-token") {
t.Errorf("token leaked into %s", path)
}
}
}