Vectrum
Sign In
415 lines · 12.8 KB · Go
package tui

import (
	"context"
	"os"
	"os/exec"
	"path/filepath"
	"strings"
	"testing"

	tea "github.com/charmbracelet/bubbletea"

	"codey/internal/agent"
	"codey/internal/approval"
	"codey/internal/config"
	"codey/internal/llm"
	"codey/internal/tools"
)

// isolateConfig points the config directory at a temp dir, so a test never
// writes the developer's real ~/.config/codey/worktree.sh or makes a worktree
// in their real tree.
func isolateConfig(t *testing.T) string {
	t.Helper()
	dir := t.TempDir()
	t.Setenv("XDG_CONFIG_HOME", dir)
	return filepath.Join(dir, config.Dir)
}

func TestSlugifyMakesNamesThatCannotEscape(t *testing.T) {
	cases := []struct{ in, want string }{
		{"dark mode toggle", "dark-mode-toggle"},
		{"Add A Dark Mode Toggle", "add-a-dark-mode-toggle"},
		{"  fix   the   login  ", "fix-the-login"},
		{"feat/login", "feat-login"},
		{"../../etc/passwd", "etc-passwd"},
		{"..", ""},
		{"-rf", "rf"},
		{"--force--", "force"},
		{"fix: the login (again!)", "fix-the-login-again"},
		{"réndu naïf", "r-ndu-na-f"},
		{"", ""},
		{"   ", ""},
		{"already-a-slug", "already-a-slug"},
		// A model that answers with a sentence under the slug gets only its
		// first line read.
		{"dark-mode\n\nI chose this because...", "dark-mode"},
	}
	for _, c := range cases {
		if got := slugify(c.in); got != c.want {
			t.Errorf("slugify(%q) = %q, want %q", c.in, got, c.want)
		}
	}
}

func TestSlugifyIsBoundedAndNeverEndsInADash(t *testing.T) {
	got := slugify(strings.Repeat("word ", 40))
	if len(got) > nameMax {
		t.Errorf("slug %q is %d long, over the %d cap", got, len(got), nameMax)
	}
	if strings.HasPrefix(got, "-") || strings.HasSuffix(got, "-") {
		t.Errorf("slug %q has a leading or trailing dash", got)
	}
	if strings.Contains(got, "--") {
		t.Errorf("slug %q has a doubled dash", got)
	}
}

// A name is only used verbatim when it is already exactly what slugify would
// produce. Anything else is a description of work, and goes to the model.
func TestVerbatimNameSeparatesNamesFromTasks(t *testing.T) {
	cases := []struct {
		in       string
		want     string
		verbatim bool
	}{
		{"dark-mode", "dark-mode", true},
		{"fix", "fix", true},
		{"v2", "v2", true},
		// One word is a name, repaired rather than refused.
		{"Dark-Mode", "dark-mode", true},
		{"feat/login", "feat-login", true},
		{"../escape", "escape", true},
		// A phrase is work, and the model names it.
		{"add a dark mode toggle", "", false},
		{"fix the login", "", false},
	}
	for _, c := range cases {
		name, verbatim := verbatimName(c.in)
		if verbatim != c.verbatim {
			t.Errorf("verbatimName(%q) verbatim = %v, want %v", c.in, verbatim, c.verbatim)
			continue
		}
		if verbatim && name != c.want {
			t.Errorf("verbatimName(%q) = %q, want %q", c.in, name, c.want)
		}
	}
}

// The name is never worth failing the command over: an unreachable endpoint
// still has to produce a usable directory name.
func TestWorktreeNameFallsBackWhenTheModelCannotBeReached(t *testing.T) {
	client := llm.New(llm.WithAPIKey("x"), llm.WithBaseURL("http://127.0.0.1:1"))
	got := worktreeName(context.Background(), client, "", "fix the login redirect loop")
	if got != "fix-the-login-redirect-loop" {
		t.Fatalf("name = %q, want the task slugified", got)
	}
}

// A task that slugifies to nothing still has to yield a directory name.
func TestWorktreeNameAlwaysProducesSomething(t *testing.T) {
	if got := worktreeName(context.Background(), nil, "", "!!!"); got == "" {
		t.Fatal("name is empty, which would make an unnameable directory")
	}
}

func TestEnsureScriptWritesTheDefaultOnceAndThenLeavesItAlone(t *testing.T) {
	isolateConfig(t)

	path, err := ensureScript()
	if err != nil {
		t.Fatal(err)
	}
	fi, err := os.Stat(path)
	if err != nil {
		t.Fatal(err)
	}
	if fi.Mode().Perm()&0o100 == 0 {
		t.Errorf("mode = %v, want the owner execute bit set", fi.Mode().Perm())
	}

	// Once it is on disk it belongs to whoever edited it.
	edited := "#!/usr/bin/env bash\necho /somewhere\n"
	if err := os.WriteFile(path, []byte(edited), 0o755); err != nil {
		t.Fatal(err)
	}
	if _, err := ensureScript(); err != nil {
		t.Fatal(err)
	}
	b, err := os.ReadFile(path)
	if err != nil {
		t.Fatal(err)
	}
	if string(b) != edited {
		t.Error("ensureScript overwrote a script that was already there")
	}
}

// The script decides where the tree lands, so its stdout is the answer. A
// script that prints nothing, or names a directory that is not there, is a
// failure rather than a silent move to nowhere.
func TestRunWorktreeScriptReadsThePathBack(t *testing.T) {
	dir := isolateConfig(t)
	repo := t.TempDir()
	target := t.TempDir()

	if err := os.MkdirAll(dir, 0o755); err != nil {
		t.Fatal(err)
	}
	script := filepath.Join(dir, config.ScriptFilename)

	t.Run("takes the last line of stdout", func(t *testing.T) {
		write(t, script, "#!/usr/bin/env bash\nread -r name\necho \"chatter about $name\" >&2\necho "+target+"\n")
		got, err := runWorktreeScript(context.Background(), repo, "tree")
		if err != nil {
			t.Fatal(err)
		}
		if got != target {
			t.Errorf("path = %q, want %q", got, target)
		}
	})

	t.Run("is given the name on stdin", func(t *testing.T) {
		write(t, script, "#!/usr/bin/env bash\nread -r name\nmkdir -p "+target+"/$name\necho "+target+"/$name\n")
		got, err := runWorktreeScript(context.Background(), repo, "from-stdin")
		if err != nil {
			t.Fatal(err)
		}
		if filepath.Base(got) != "from-stdin" {
			t.Errorf("path = %q, want it to end in the name piped in", got)
		}
	})

	t.Run("a path that is not there is an error", func(t *testing.T) {
		write(t, script, "#!/usr/bin/env bash\ncat >/dev/null\necho /no/such/place\n")
		if _, err := runWorktreeScript(context.Background(), repo, "tree"); err == nil {
			t.Fatal("want an error when the script names a directory that does not exist")
		}
	})

	t.Run("no path at all is an error", func(t *testing.T) {
		write(t, script, "#!/usr/bin/env bash\ncat >/dev/null\n")
		if _, err := runWorktreeScript(context.Background(), repo, "tree"); err == nil {
			t.Fatal("want an error when the script prints no path")
		}
	})

	t.Run("the script's own complaint is reported", func(t *testing.T) {
		write(t, script, "#!/usr/bin/env bash\ncat >/dev/null\necho 'branch already checked out' >&2\nexit 1\n")
		_, err := runWorktreeScript(context.Background(), repo, "tree")
		if err == nil || !strings.Contains(err.Error(), "branch already checked out") {
			t.Fatalf("err = %v, want it to carry the script's own message", err)
		}
	})
}

func write(t *testing.T, path, body string) {
	t.Helper()
	if err := os.WriteFile(path, []byte(body), 0o755); err != nil {
		t.Fatal(err)
	}
}

// worktreeModel is a model wired the way main.go wires one: with a factory
// that records which root the tools were last built against.
func worktreeModel(t *testing.T, root string) (*model, *string) {
	t.Helper()
	built := new(string)
	m := newModel(context.Background(), Options{
		Client: llm.New(llm.WithAPIKey("x")),
		Root:   root,
		Policy: approval.New(nil),
		NewTools: func(r string) *tools.Registry {
			*built = r
			return tools.NewRegistry(nil)
		},
	})
	m.Update(tea.WindowSizeMsg{Width: 80, Height: 24})
	return m, built
}

func TestWorktreeRefusesWithoutAnArgument(t *testing.T) {
	m, _ := worktreeModel(t, "/tmp/repo")
	m.submit("/worktree")
	if view := m.tr.render(); !strings.Contains(view, "needs a name or a task") {
		t.Fatalf("transcript = %q, want the usage error", view)
	}
}

func TestWorktreeIsRefusedWhenNothingCanMoveTheTools(t *testing.T) {
	m := newTestModel(t) // no NewTools wired
	m.submit("/worktree dark-mode")
	if view := m.tr.render(); !strings.Contains(view, "no way to move the tools") {
		t.Fatalf("transcript = %q, want the unavailable error", view)
	}
}

// switchRoot moves the work and nothing else: the session keeps its home, so
// the conversation stays filed where Codey was started.
func TestSwitchRootMovesTheWorkButNotTheSession(t *testing.T) {
	m, built := worktreeModel(t, "/tmp/repo")
	m.opts.HomeRoot = "/tmp/repo"
	m.opts.SessionDir = "/tmp/repo/.codey/sessions"

	m.switchRoot("/trees/dark-mode")

	if m.opts.Root != "/trees/dark-mode" {
		t.Errorf("Root = %q, want the worktree", m.opts.Root)
	}
	if m.opts.Worktree != "/trees/dark-mode" {
		t.Errorf("Worktree = %q, want it recorded", m.opts.Worktree)
	}
	if m.opts.HomeRoot != "/tmp/repo" {
		t.Errorf("HomeRoot = %q, want it left alone", m.opts.HomeRoot)
	}
	if m.opts.SessionDir != "/tmp/repo/.codey/sessions" {
		t.Errorf("SessionDir = %q, want it left in the main checkout", m.opts.SessionDir)
	}
	if *built != "/trees/dark-mode" {
		t.Errorf("tools were rebuilt against %q, want the worktree", *built)
	}
}

// A bare name creates the tree and stops. A task creates it and starts work.
func TestFinishWorktreeStartsTheTurnOnlyWhenThereIsATask(t *testing.T) {
	tree := t.TempDir()

	m, _ := worktreeModel(t, "/tmp/repo")
	m.finishWorktree(worktreeReady{name: "dark-mode", path: tree})
	if m.phase == phaseRunning {
		t.Error("a bare /worktree started a turn with nothing to work on")
	}

	m2, _ := worktreeModel(t, "/tmp/repo")
	m2.opts.Agent = agent.New(llm.New(llm.WithAPIKey("x")), tools.NewRegistry(nil),
		agent.NewState(), nil, agent.Options{})
	m2.finishWorktree(worktreeReady{name: "dark-mode", task: "add a toggle", path: tree})
	if m2.phase != phaseRunning {
		t.Error("a /worktree with a task did not start the turn")
	}
	if view := m2.tr.render(); !strings.Contains(view, "add a toggle") {
		t.Errorf("transcript = %q, want the task shown as the prompt", view)
	}
}

// An interrupt is something you did, so it reads as a notice rather than as a
// failure, and the work stays where it was.
func TestInterruptedWorktreeDoesNotMoveTheWork(t *testing.T) {
	m, built := worktreeModel(t, "/tmp/repo")
	m.finishWorktree(worktreeReady{name: "x", err: context.Canceled})

	if m.opts.Root != "/tmp/repo" {
		t.Errorf("Root = %q, want it unchanged", m.opts.Root)
	}
	if *built != "" {
		t.Error("the tools were rebuilt for a worktree that was never made")
	}
	if view := m.tr.render(); !strings.Contains(view, "interrupted") {
		t.Errorf("transcript = %q, want the interruption reported", view)
	}
}

// The whole thing against real git: the default script, a real repository, and
// the session moving into the tree it makes.
func TestWorktreeEndToEnd(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git is not installed")
	}
	isolateConfig(t)
	repo := initRepo(t)

	m, built := worktreeModel(t, repo)
	m.opts.HomeRoot = repo

	// The bare-name path, so no model call is needed to run this.
	name, verbatim := verbatimName("dark-mode")
	if !verbatim {
		t.Fatal("dark-mode should be taken as a name")
	}
	path, err := runWorktreeScript(context.Background(), repo, name)
	if err != nil {
		t.Fatal(err)
	}

	trees, err := config.WorktreesDir()
	if err != nil {
		t.Fatal(err)
	}
	if want := filepath.Join(trees, "dark-mode"); path != want {
		t.Errorf("path = %q, want %q", path, want)
	}
	if _, err := os.Stat(filepath.Join(path, "README")); err != nil {
		t.Errorf("the worktree has no checkout in it: %v", err)
	}
	if got := branchOf(t, path); got != "dark-mode" {
		t.Errorf("branch = %q, want dark-mode", got)
	}

	m.finishWorktree(worktreeReady{name: name, path: path})
	if m.opts.Root != path {
		t.Errorf("Root = %q, want the worktree %q", m.opts.Root, path)
	}
	if *built != path {
		t.Errorf("tools were built against %q, want %q", *built, path)
	}
}

// A second worktree from the same repository must not trip over the first.
func TestWorktreeScriptReusesAnExistingBranch(t *testing.T) {
	if _, err := exec.LookPath("git"); err != nil {
		t.Skip("git is not installed")
	}
	isolateConfig(t)
	repo := initRepo(t)

	path, err := runWorktreeScript(context.Background(), repo, "reused")
	if err != nil {
		t.Fatal(err)
	}
	// Remove the tree but leave the branch, which is what happens when a tree
	// is cleaned up by hand.
	git(t, repo, "worktree", "remove", "--force", path)

	again, err := runWorktreeScript(context.Background(), repo, "reused")
	if err != nil {
		t.Fatalf("a second worktree on an existing branch failed: %v", err)
	}
	if again != path {
		t.Errorf("path = %q, want the same %q", again, path)
	}
}

func initRepo(t *testing.T) string {
	t.Helper()
	repo := t.TempDir()
	git(t, repo, "init", "-b", "main")
	git(t, repo, "config", "user.email", "test@example.com")
	git(t, repo, "config", "user.name", "Test")
	if err := os.WriteFile(filepath.Join(repo, "README"), []byte("hello\n"), 0o644); err != nil {
		t.Fatal(err)
	}
	git(t, repo, "add", "README")
	git(t, repo, "commit", "-m", "first")
	return repo
}

func git(t *testing.T, dir string, args ...string) {
	t.Helper()
	cmd := exec.Command("git", args...)
	cmd.Dir = dir
	if out, err := cmd.CombinedOutput(); err != nil {
		t.Fatalf("git %s: %v\n%s", strings.Join(args, " "), err, out)
	}
}

func branchOf(t *testing.T, dir string) string {
	t.Helper()
	cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
	cmd.Dir = dir
	out, err := cmd.Output()
	if err != nil {
		t.Fatal(err)
	}
	return strings.TrimSpace(string(out))
}