summaryrefslogtreecommitdiff
path: root/internal/executor/sandbox_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/executor/sandbox_test.go')
-rw-r--r--internal/executor/sandbox_test.go366
1 files changed, 366 insertions, 0 deletions
diff --git a/internal/executor/sandbox_test.go b/internal/executor/sandbox_test.go
new file mode 100644
index 0000000..4893263
--- /dev/null
+++ b/internal/executor/sandbox_test.go
@@ -0,0 +1,366 @@
+package executor
+
+import (
+ "fmt"
+ "io"
+ "log/slog"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/thepeterstone/claudomator/internal/storage"
+)
+
+func TestSandboxCloneSource_PrefersLocalRemote(t *testing.T) {
+ dir := t.TempDir()
+ initGitRepo(t, dir)
+ // Add a "local" remote pointing to a bare repo.
+ bare := t.TempDir()
+ exec.Command("git", "init", "--bare", bare).Run()
+ exec.Command("git", "-C", dir, "remote", "add", "local", bare).Run()
+ exec.Command("git", "-C", dir, "remote", "add", "origin", "https://example.com/repo").Run()
+
+ got := sandboxCloneSource(dir)
+ if got != bare {
+ t.Errorf("expected bare repo path %q, got %q", bare, got)
+ }
+}
+
+func TestSandboxCloneSource_FallsBackToOrigin(t *testing.T) {
+ dir := t.TempDir()
+ initGitRepo(t, dir)
+ // sandboxCloneSource intentionally filters to local-FS remotes (so
+ // `git clone <src>` doesn't go over the network). Use a local path
+ // for origin to verify the fallback semantics.
+ originURL := t.TempDir()
+ exec.Command("git", "-C", dir, "remote", "add", "origin", originURL).Run()
+
+ got := sandboxCloneSource(dir)
+ if got != originURL {
+ t.Errorf("expected origin URL %q, got %q", originURL, got)
+ }
+}
+
+func TestSandboxCloneSource_FallsBackToProjectDir(t *testing.T) {
+ dir := t.TempDir()
+ initGitRepo(t, dir)
+ // No remotes configured.
+ got := sandboxCloneSource(dir)
+ if got != dir {
+ t.Errorf("expected projectDir %q (no remotes), got %q", dir, got)
+ }
+}
+
+func TestSetupSandbox_ClonesGitRepo(t *testing.T) {
+ src := t.TempDir()
+ initGitRepo(t, src)
+
+ sandbox, err := setupSandbox(src, slog.Default())
+ if err != nil {
+ t.Fatalf("setupSandbox: %v", err)
+ }
+ t.Cleanup(func() { os.RemoveAll(sandbox) })
+
+ // Force sandbox to master if it cloned as main
+ exec.Command("git", gitSafe("-C", sandbox, "checkout", "master")...).Run()
+
+ // Debug sandbox
+ logOut, _ := exec.Command("git", "-C", sandbox, "log", "-1").CombinedOutput()
+ fmt.Printf("DEBUG: sandbox log: %s\n", string(logOut))
+
+ // Verify sandbox is a git repo with at least one commit.
+ out, err := exec.Command("git", "-C", sandbox, "log", "--oneline").Output()
+ if err != nil {
+ t.Fatalf("git log in sandbox: %v", err)
+ }
+ if len(strings.TrimSpace(string(out))) == 0 {
+ t.Error("expected at least one commit in sandbox, got empty log")
+ }
+}
+
+func TestSetupSandbox_InitialisesNonGitDir(t *testing.T) {
+ // A plain directory (not a git repo) should be initialised then cloned.
+ src := t.TempDir()
+
+ sandbox, err := setupSandbox(src, slog.Default())
+ if err != nil {
+ t.Fatalf("setupSandbox on plain dir: %v", err)
+ }
+ t.Cleanup(func() { os.RemoveAll(sandbox) })
+
+ if _, err := os.Stat(filepath.Join(sandbox, ".git")); err != nil {
+ t.Errorf("sandbox should be a git repo: %v", err)
+ }
+}
+
+func TestTeardownSandbox_AutocommitsChanges(t *testing.T) {
+ // Create a bare repo as origin so push succeeds.
+ bare := t.TempDir()
+ if out, err := exec.Command("git", "init", "--bare", "-b", "main", bare).CombinedOutput(); err != nil {
+ t.Fatalf("git init bare: %v\n%s", err, out)
+ }
+
+ // Create a sandbox directly.
+ sandbox := t.TempDir()
+ initGitRepo(t, sandbox)
+ if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil {
+ t.Fatalf("git remote add: %v\n%s", err, out)
+ }
+ // Initial push to establish origin/main
+ if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "push", "origin", "main").CombinedOutput(); err != nil {
+ t.Fatalf("git push initial: %v\n%s", err, out)
+ }
+
+ // Capture startHEAD
+ headOut, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "rev-parse", "HEAD").Output()
+ if err != nil {
+ t.Fatalf("rev-parse HEAD: %v", err)
+ }
+ startHEAD := strings.TrimSpace(string(headOut))
+
+ // Leave an uncommitted file in the sandbox.
+ if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("autocommit me"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug}))
+ execRecord := &storage.Execution{}
+
+ err = teardownSandbox("", sandbox, startHEAD, logger, execRecord)
+ if err != nil {
+ t.Fatalf("expected autocommit to succeed, got error: %v", err)
+ }
+
+ // Sandbox should be removed after successful autocommit and push.
+ if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) {
+ t.Error("sandbox should have been removed after successful autocommit and push")
+ }
+
+ // Verify the commit exists in the bare repo.
+ out, err := exec.Command("git", "-C", bare, "log", "-1", "--pretty=%B").Output()
+ if err != nil {
+ t.Fatalf("git log in bare repo: %v", err)
+ }
+ if !strings.Contains(string(out), "chore: autocommit uncommitted changes") {
+ t.Errorf("expected autocommit message in log, got: %q", string(out))
+ }
+
+ // Verify the commit was captured in execRecord.
+ if len(execRecord.Commits) == 0 {
+ t.Error("expected at least one commit in execRecord")
+ } else if !strings.Contains(execRecord.Commits[0].Message, "chore: autocommit uncommitted changes") {
+ t.Errorf("unexpected commit message: %q", execRecord.Commits[0].Message)
+ }
+}
+
+func TestTeardownSandbox_BuildFailure_BlocksAutocommit(t *testing.T) {
+ bare := t.TempDir()
+ if out, err := exec.Command("git", "init", "--bare", "-b", "main", bare).CombinedOutput(); err != nil {
+ t.Fatalf("git init bare: %v\n%s", err, out)
+ }
+
+ sandbox := t.TempDir()
+ initGitRepo(t, sandbox)
+ if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil {
+ t.Fatalf("git remote add: %v\n%s", err, out)
+ }
+
+ // Capture startHEAD
+ headOut, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "rev-parse", "HEAD").Output()
+ if err != nil {
+ t.Fatalf("rev-parse HEAD: %v", err)
+ }
+ startHEAD := strings.TrimSpace(string(headOut))
+
+ // Leave an uncommitted file.
+ if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("dirty"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Add a failing Makefile.
+ makefile := "build:\n\t@echo 'build failed'\n\texit 1\n"
+ if err := os.WriteFile(filepath.Join(sandbox, "Makefile"), []byte(makefile), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ execRecord := &storage.Execution{}
+
+ err = teardownSandbox("", sandbox, startHEAD, logger, execRecord)
+ if err == nil {
+ t.Error("expected teardown to fail due to build failure, but it succeeded")
+ } else if !strings.Contains(err.Error(), "build failed before autocommit") {
+ t.Errorf("expected build failure error message, got: %v", err)
+ }
+
+ // Sandbox should NOT be removed if teardown failed.
+ if _, statErr := os.Stat(sandbox); os.IsNotExist(statErr) {
+ t.Error("sandbox should have been preserved after build failure")
+ }
+
+ // Verify no new commit in bare repo.
+ out, err := exec.Command("git", "-C", bare, "log", "HEAD").CombinedOutput()
+ if strings.Contains(string(out), "chore: autocommit uncommitted changes") {
+ t.Error("autocommit should not have been pushed after build failure")
+ }
+}
+
+func TestTeardownSandbox_BuildSuccess_ProceedsToAutocommit(t *testing.T) {
+ bare := t.TempDir()
+ if out, err := exec.Command("git", "init", "--bare", "-b", "main", bare).CombinedOutput(); err != nil {
+ t.Fatalf("git init bare: %v\n%s", err, out)
+ }
+
+ sandbox := t.TempDir()
+ initGitRepo(t, sandbox)
+ if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil {
+ t.Fatalf("git remote add: %v\n%s", err, out)
+ }
+
+ // Capture startHEAD
+ headOut, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "rev-parse", "HEAD").Output()
+ if err != nil {
+ t.Fatalf("rev-parse HEAD: %v", err)
+ }
+ startHEAD := strings.TrimSpace(string(headOut))
+
+ // Leave an uncommitted file.
+ if err := os.WriteFile(filepath.Join(sandbox, "dirty.txt"), []byte("dirty"), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ // Add a successful Makefile.
+ makefile := "build:\n\t@echo 'build succeeded'\n"
+ if err := os.WriteFile(filepath.Join(sandbox, "Makefile"), []byte(makefile), 0644); err != nil {
+ t.Fatal(err)
+ }
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ execRecord := &storage.Execution{}
+
+ err = teardownSandbox("", sandbox, startHEAD, logger, execRecord)
+ if err != nil {
+ t.Fatalf("expected teardown to succeed after build success, got error: %v", err)
+ }
+
+ // Sandbox should be removed after success.
+ if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) {
+ t.Error("sandbox should have been removed after successful build and autocommit")
+ }
+
+ // Verify new commit in bare repo.
+ out, err := exec.Command("git", "-C", bare, "log", "-1", "--pretty=%B").Output()
+ if err != nil {
+ t.Fatalf("git log in bare repo: %v", err)
+ }
+ if !strings.Contains(string(out), "chore: autocommit uncommitted changes") {
+ t.Errorf("expected autocommit message in log, got: %q", string(out))
+ }
+}
+
+func TestTeardownSandbox_CapturesExplicitCommits(t *testing.T) {
+ bare := t.TempDir()
+ if out, err := exec.Command("git", "init", "--bare", "-b", "main", bare).CombinedOutput(); err != nil {
+ t.Fatalf("git init bare: %v\n%s", err, out)
+ }
+
+ sandbox := t.TempDir()
+ initGitRepo(t, sandbox)
+ if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "remote", "add", "origin", bare).CombinedOutput(); err != nil {
+ t.Fatalf("git remote add: %v\n%s", err, out)
+ }
+ if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "push", "origin", "main").CombinedOutput(); err != nil {
+ t.Fatalf("git push initial: %v\n%s", err, out)
+ }
+
+ headOut, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "rev-parse", "HEAD").Output()
+ if err != nil {
+ t.Fatalf("rev-parse HEAD: %v", err)
+ }
+ startHEAD := strings.TrimSpace(string(headOut))
+
+ // Simulate Claude explicitly committing changes.
+ if err := os.WriteFile(filepath.Join(sandbox, "work.txt"), []byte("done"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ for _, args := range [][]string{
+ {"-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "add", "-A"},
+ {"-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", sandbox, "commit", "-m", "feat: implement the feature"},
+ } {
+ if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
+ t.Fatalf("git %v: %v\n%s", args, err, out)
+ }
+ }
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ execRecord := &storage.Execution{}
+
+ if err := teardownSandbox("", sandbox, startHEAD, logger, execRecord); err != nil {
+ t.Fatalf("teardownSandbox: %v", err)
+ }
+
+ if len(execRecord.Commits) == 0 {
+ t.Fatal("expected commits to be captured in execRecord")
+ }
+ if !strings.Contains(execRecord.Commits[0].Message, "feat: implement the feature") {
+ t.Errorf("unexpected commit message: %q", execRecord.Commits[0].Message)
+ }
+ if execRecord.Commits[0].Hash == "" {
+ t.Error("commit hash should not be empty")
+ }
+}
+
+func TestTeardownSandbox_CleanSandboxWithNoNewCommits_RemovesSandbox(t *testing.T) {
+ src := t.TempDir()
+ initGitRepo(t, src)
+ sandbox, err := setupSandbox(src, slog.Default())
+ if err != nil {
+ t.Fatalf("setupSandbox: %v", err)
+ }
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ execRecord := &storage.Execution{}
+
+ headOut, _ := exec.Command("git", "-C", sandbox, "rev-parse", "HEAD").Output()
+ startHEAD := strings.TrimSpace(string(headOut))
+
+ // Sandbox has no new commits beyond origin; teardown should succeed and remove it.
+ if err := teardownSandbox(src, sandbox, startHEAD, logger, execRecord); err != nil {
+ t.Fatalf("teardownSandbox: %v", err)
+ }
+ if _, statErr := os.Stat(sandbox); !os.IsNotExist(statErr) {
+ t.Error("sandbox should have been removed after clean teardown")
+ os.RemoveAll(sandbox)
+ }
+}
+func TestTailFile_MissingFile_ReturnsEmpty(t *testing.T) {
+ got := tailFile("/nonexistent/path/file.log", 10)
+ if got != "" {
+ t.Errorf("want empty string for missing file, got %q", got)
+ }
+}
+
+func initGitRepo(t *testing.T, dir string) {
+ t.Helper()
+ cmds := [][]string{
+ {"git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "init", "-b", "main"},
+ {"git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "config", "user.email", "test@test"},
+ {"git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "config", "user.name", "test"},
+ }
+ for _, args := range cmds {
+ if out, err := exec.Command(args[0], args[1:]...).CombinedOutput(); err != nil {
+ t.Fatalf("%v: %v\n%s", args, err, out)
+ }
+ }
+ if err := os.WriteFile(filepath.Join(dir, "init.txt"), []byte("init"), 0644); err != nil {
+ t.Fatal(err)
+ }
+ if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "add", ".").CombinedOutput(); err != nil {
+ t.Fatalf("git add: %v\n%s", err, out)
+ }
+ if out, err := exec.Command("git", "-c", "safe.directory=*", "-c", "commit.gpgsign=false", "-C", dir, "commit", "-m", "init").CombinedOutput(); err != nil {
+ t.Fatalf("git commit: %v\n%s", err, out)
+ }
+}