package agent import ( "context" "os" "os/exec" "path/filepath" "strings" "testing" "time" ) // gitAvailable lets the whole suite no-op on machines without git // (the goreleaser cross-build is one). All these tests genuinely need // the git binary; the agent runtime itself never requires it. func gitAvailable(t *testing.T) { t.Helper() if _, err := exec.LookPath("git"); err != nil { t.Skip("git not on PATH; skipping extension-update tests") } } // initBareRepo creates a bare repo at path. Used as the "remote" for // the clone-and-pull tests. func initBareRepo(t *testing.T, path string) { t.Helper() mustRun(t, "", "git", "init", "--bare", "--initial-branch=main", path) } // initWorkRepo creates a working repo at path with one commit so it // has a real default branch. Returns the path. We commit a single // README so the worktree isn't empty. func initWorkRepo(t *testing.T, path string) { t.Helper() mustRun(t, "", "git", "init", "--initial-branch=main", path) configRepo(t, path) writeFile(t, filepath.Join(path, "README.md"), "v1\n") mustRun(t, path, "git", "add", ".") mustRun(t, path, "git", "commit", "-q", "-m", "init") } func configRepo(t *testing.T, dir string) { t.Helper() mustRun(t, dir, "git", "config", "user.email", "test@example.com") mustRun(t, dir, "git", "config", "user.name", "Test") mustRun(t, dir, "git", "config", "commit.gpgsign", "false") } func writeFile(t *testing.T, path, content string) { t.Helper() if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("write %s: %v", path, err) } } func mustRun(t *testing.T, dir, name string, args ...string) { t.Helper() cmd := exec.Command(name, args...) if dir != "" { cmd.Dir = dir } cmd.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0") out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("%s %v: %v\n%s", name, args, err, out) } } // makeFakeZotHome scaffolds $ZOT_HOME/extensions/ and returns the path. func makeFakeZotHome(t *testing.T) string { t.Helper() home := t.TempDir() if err := os.MkdirAll(filepath.Join(home, "extensions"), 0o755); err != nil { t.Fatalf("mkdir extensions: %v", err) } return home } // extWithManifest creates a directory with an extension.json so // updateOneExtension treats it as a real extension. content is the // JSON to write; pass "" for the default minimal manifest. func extWithManifest(t *testing.T, root, name, content string) string { t.Helper() dir := filepath.Join(root, "extensions", name) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatalf("mkdir %s: %v", dir, err) } if content == "" { content = `{"name":"` + name + `","version":"1.0.0","exec":"./x","enabled":true}` } writeFile(t, filepath.Join(dir, "extension.json"), content) return dir } // ----- tests ----- func TestUpdateAllExtensions_NoExtensionsDirectory(t *testing.T) { // Brand new $ZOT_HOME with no extensions/ directory at all. // Must not panic and must print nothing alarming. home := t.TempDir() updateAllExtensions(home) // no-op } func TestUpdateAllExtensions_EmptyDirectory(t *testing.T) { home := makeFakeZotHome(t) updateAllExtensions(home) // no-op, no panic } func TestUpdateOneExtension_NoManifest(t *testing.T) { home := makeFakeZotHome(t) dir := filepath.Join(home, "extensions", "noman") _ = os.MkdirAll(dir, 0o755) got := updateOneExtension(dir, "noman") if got != "skipped" { t.Fatalf("want skipped, got %s", got) } } func TestUpdateOneExtension_Disabled(t *testing.T) { home := makeFakeZotHome(t) dir := extWithManifest(t, home, "off", `{"name":"off","version":"1.0.0","exec":"./x","enabled":false}`) got := updateOneExtension(dir, "off") if got != "skipped" { t.Fatalf("want skipped, got %s", got) } } func TestUpdateOneExtension_NotAGitCheckout(t *testing.T) { home := makeFakeZotHome(t) dir := extWithManifest(t, home, "plain", "") got := updateOneExtension(dir, "plain") if got != "skipped" { t.Fatalf("want skipped, got %s", got) } } func TestUpdateOneExtension_UpToDate(t *testing.T) { gitAvailable(t) tmp := t.TempDir() home := makeFakeZotHome(t) // Set up a remote and clone it as the "installed" extension. remote := filepath.Join(tmp, "remote.git") initBareRepo(t, remote) source := filepath.Join(tmp, "source") initWorkRepo(t, source) mustRun(t, source, "git", "remote", "add", "origin", remote) mustRun(t, source, "git", "push", "-u", "origin", "main") extDir := filepath.Join(home, "extensions", "up2date") mustRun(t, "", "git", "clone", "-q", remote, extDir) configRepo(t, extDir) // Overwrite the cloned README with a manifest in addition. writeFile(t, filepath.Join(extDir, "extension.json"), `{"name":"up2date","version":"1.0.0","exec":"./x","enabled":true}`) got := updateOneExtension(extDir, "up2date") if got != "up-to-date" { t.Fatalf("want up-to-date, got %s", got) } } func TestUpdateOneExtension_PullsNewCommit(t *testing.T) { gitAvailable(t) tmp := t.TempDir() home := makeFakeZotHome(t) remote := filepath.Join(tmp, "remote.git") initBareRepo(t, remote) source := filepath.Join(tmp, "source") initWorkRepo(t, source) writeFile(t, filepath.Join(source, "extension.json"), `{"name":"pulls","version":"1.0.0","exec":"./x","enabled":true}`) mustRun(t, source, "git", "add", "extension.json") mustRun(t, source, "git", "commit", "-q", "-m", "add manifest") mustRun(t, source, "git", "remote", "add", "origin", remote) mustRun(t, source, "git", "push", "-u", "origin", "main") extDir := filepath.Join(home, "extensions", "pulls") mustRun(t, "", "git", "clone", "-q", remote, extDir) configRepo(t, extDir) // Push a new commit to the remote so pulling sees something new. writeFile(t, filepath.Join(source, "NEW.md"), "added later\n") mustRun(t, source, "git", "add", "NEW.md") mustRun(t, source, "git", "commit", "-q", "-m", "add NEW") mustRun(t, source, "git", "push", "origin", "main") got := updateOneExtension(extDir, "pulls") if got != "updated" { t.Fatalf("want updated, got %s", got) } if _, err := os.Stat(filepath.Join(extDir, "NEW.md")); err != nil { t.Fatalf("expected NEW.md to be pulled in: %v", err) } } func TestUpdateOneExtension_StashesDirtyWorktree(t *testing.T) { gitAvailable(t) tmp := t.TempDir() home := makeFakeZotHome(t) remote := filepath.Join(tmp, "remote.git") initBareRepo(t, remote) source := filepath.Join(tmp, "source") initWorkRepo(t, source) writeFile(t, filepath.Join(source, "extension.json"), `{"name":"dirty","version":"1.0.0","exec":"./x","enabled":true}`) mustRun(t, source, "git", "add", "extension.json") mustRun(t, source, "git", "commit", "-q", "-m", "add manifest") mustRun(t, source, "git", "remote", "add", "origin", remote) mustRun(t, source, "git", "push", "-u", "origin", "main") extDir := filepath.Join(home, "extensions", "dirty") mustRun(t, "", "git", "clone", "-q", remote, extDir) configRepo(t, extDir) // Simulate runtime state: an untracked file the extension wrote. writeFile(t, filepath.Join(extDir, "runtime.json"), `{"state":"hi"}`) // Advance the remote. writeFile(t, filepath.Join(source, "NEW.md"), "added later\n") mustRun(t, source, "git", "add", "NEW.md") mustRun(t, source, "git", "commit", "-q", "-m", "add NEW") mustRun(t, source, "git", "push", "origin", "main") got := updateOneExtension(extDir, "dirty") if got != "updated" { t.Fatalf("want updated, got %s", got) } // Runtime file must still be there after the pull+stash cycle. if _, err := os.Stat(filepath.Join(extDir, "runtime.json")); err != nil { t.Fatalf("runtime.json was clobbered by the update: %v", err) } // And the pulled commit must be present too. if _, err := os.Stat(filepath.Join(extDir, "NEW.md")); err != nil { t.Fatalf("expected NEW.md to be pulled in: %v", err) } } func TestUpdateOneExtension_DivergedFails(t *testing.T) { gitAvailable(t) tmp := t.TempDir() home := makeFakeZotHome(t) remote := filepath.Join(tmp, "remote.git") initBareRepo(t, remote) source := filepath.Join(tmp, "source") initWorkRepo(t, source) writeFile(t, filepath.Join(source, "extension.json"), `{"name":"div","version":"1.0.0","exec":"./x","enabled":true}`) mustRun(t, source, "git", "add", "extension.json") mustRun(t, source, "git", "commit", "-q", "-m", "add manifest") mustRun(t, source, "git", "remote", "add", "origin", remote) mustRun(t, source, "git", "push", "-u", "origin", "main") extDir := filepath.Join(home, "extensions", "div") mustRun(t, "", "git", "clone", "-q", remote, extDir) configRepo(t, extDir) // Local diverging commit (committed, not just dirty). writeFile(t, filepath.Join(extDir, "LOCAL.md"), "local commit\n") mustRun(t, extDir, "git", "add", "LOCAL.md") mustRun(t, extDir, "git", "commit", "-q", "-m", "local") // Conflicting remote commit on the same path. writeFile(t, filepath.Join(source, "LOCAL.md"), "remote commit\n") mustRun(t, source, "git", "add", "LOCAL.md") mustRun(t, source, "git", "commit", "-q", "-m", "remote") mustRun(t, source, "git", "push", "origin", "main") got := updateOneExtension(extDir, "div") if got != "failed" { t.Fatalf("want failed (diverged), got %s", got) } } func TestUpdateOneExtension_BadRemoteFails(t *testing.T) { gitAvailable(t) tmp := t.TempDir() home := makeFakeZotHome(t) // Init a repo with an "origin" that points at a non-existent path. extDir := filepath.Join(home, "extensions", "bad") if err := os.MkdirAll(extDir, 0o755); err != nil { t.Fatal(err) } mustRun(t, extDir, "git", "init", "--initial-branch=main") configRepo(t, extDir) writeFile(t, filepath.Join(extDir, "README.md"), "hi\n") mustRun(t, extDir, "git", "add", ".") mustRun(t, extDir, "git", "commit", "-q", "-m", "init") writeFile(t, filepath.Join(extDir, "extension.json"), `{"name":"bad","version":"1.0.0","exec":"./x","enabled":true}`) mustRun(t, extDir, "git", "add", "extension.json") mustRun(t, extDir, "git", "commit", "-q", "-m", "manifest") mustRun(t, extDir, "git", "remote", "add", "origin", filepath.Join(tmp, "does-not-exist.git")) // Tracking branch is needed for `git pull` to even attempt fetch. // Without one, `git pull` errors out with "no tracking info" which // is also a perfectly valid failure for our purposes. got := updateOneExtension(extDir, "bad") if got != "failed" { t.Fatalf("want failed, got %s", got) } } func TestUpdateAllExtensions_MixedSet(t *testing.T) { gitAvailable(t) tmp := t.TempDir() home := makeFakeZotHome(t) // 1 plain non-git extension (skipped) extWithManifest(t, home, "plain", "") // 1 disabled extWithManifest(t, home, "off", `{"name":"off","version":"1.0.0","exec":"./x","enabled":false}`) // 1 healthy git clone remote := filepath.Join(tmp, "remote.git") initBareRepo(t, remote) source := filepath.Join(tmp, "source") initWorkRepo(t, source) writeFile(t, filepath.Join(source, "extension.json"), `{"name":"ok","version":"1.0.0","exec":"./x","enabled":true}`) mustRun(t, source, "git", "add", "extension.json") mustRun(t, source, "git", "commit", "-q", "-m", "manifest") mustRun(t, source, "git", "remote", "add", "origin", remote) mustRun(t, source, "git", "push", "-u", "origin", "main") extDir := filepath.Join(home, "extensions", "ok") mustRun(t, "", "git", "clone", "-q", remote, extDir) configRepo(t, extDir) // Must not panic, must not propagate any error to the caller. updateAllExtensions(home) } func TestSummariseGitError_PrefersErrorOverHint(t *testing.T) { out := "hint: try git pull\nfatal: refusing to merge unrelated histories\nhint: see git-merge" got := summariseGitError(out, exec.Command("false").Run()) if !strings.Contains(got, "fatal:") { t.Fatalf("want fatal line, got %q", got) } } func TestRunGit_HonoursContextTimeout(t *testing.T) { gitAvailable(t) ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) defer cancel() // `git rev-parse HEAD` in a non-repo errors quickly; we just want // to confirm we don't hang. _, _ = runGit(ctx, t.TempDir(), "rev-parse", "HEAD") }