Files
kandev/apps/backend/internal/agent/lifecycle/default_scripts_test.go
T
José Almeida d43405609a test(scripts): execute postlude under bash -e against a real git repo
Pair the static-content assertion with a behaviour test that runs the
postlude as a real shell script. Three scenarios exercise the three
branches inside the snippet: remote tip exists (track origin), no
remote (fresh local branch), and already on the target branch
(idempotency). This catches regressions where the snippet still parses
as bash but no longer does the right thing — wrong refspec, missing
-B, swallowed exit code, etc.
2026-05-07 00:53:46 +01:00

193 lines
7.0 KiB
Go

package lifecycle
import (
"os/exec"
"path/filepath"
"strings"
"testing"
)
// TestKandevBranchCheckoutPostlude_HasInvariantSteps asserts the kandev-
// managed postlude contains the steps needed to land on the session's
// feature branch. The postlude is appended to every user prepare script so
// stale stored scripts (created before the worktree-branch checkout was
// part of the default) still get the checkout.
func TestKandevBranchCheckoutPostlude_HasInvariantSteps(t *testing.T) {
postlude := KandevBranchCheckoutPostlude()
want := []string{
`if [ -d "{{workspace.path}}/.git" ]`,
`[ -n "{{worktree.branch}}" ]`,
`[ "{{worktree.branch}}" != "{{repository.branch}}" ]`,
`cd "{{workspace.path}}"`,
`git fetch --depth=1 origin "{{worktree.branch}}"`,
`git checkout -B "{{worktree.branch}}" "origin/{{worktree.branch}}"`,
`git checkout -b "{{worktree.branch}}"`,
`|| true`,
}
for _, w := range want {
if !strings.Contains(postlude, w) {
t.Errorf("postlude missing %q", w)
}
}
}
// TestKandevBranchCheckoutPostlude_LandsOnFeatureBranch executes the
// postlude as a real shell script against a temp git repo. It is the
// behaviour test that pairs with the static-content test above:
// it catches regressions where the snippet still parses as bash but
// no longer does the right thing (wrong refspec, missing -B, swallowed
// exit code, etc.).
//
// The three cases mirror the three branches inside the postlude:
// - remote feature branch exists → tracks origin/<branch>
// - no remote, no local → creates local branch off HEAD
// - local branch already checked out, no remote → idempotent (no error, same branch)
func TestKandevBranchCheckoutPostlude_LandsOnFeatureBranch(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not available")
}
if _, err := exec.LookPath("bash"); err != nil {
t.Skip("bash not available")
}
cases := []struct {
name string
seedRemote bool // create a "feature" branch on origin before running
seedLocal bool // pre-create the local branch (idempotency check)
featureName string // branch name passed to the postlude
baseName string // base branch name (must match repository.branch)
want string // expected current branch after running
}{
{
name: "remote tip exists",
seedRemote: true,
featureName: "feature/from-remote",
baseName: "main",
want: "feature/from-remote",
},
{
name: "no remote, no local",
seedRemote: false,
featureName: "feature/from-scratch",
baseName: "main",
want: "feature/from-scratch",
},
{
name: "local already checked out",
seedLocal: true,
featureName: "feature/already-here",
baseName: "main",
want: "feature/already-here",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
workspace, originDir := setupPostludeRepo(t, tc.baseName)
if tc.seedRemote {
seedOriginBranch(t, originDir, tc.featureName)
}
if tc.seedLocal {
runIn(t, workspace, "git", "checkout", "-b", tc.featureName)
}
script := strings.NewReplacer(
"{{workspace.path}}", workspace,
"{{worktree.branch}}", tc.featureName,
"{{repository.branch}}", tc.baseName,
).Replace(KandevBranchCheckoutPostlude())
cmd := exec.Command("bash", "-e", "-c", script)
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("bash -e postlude failed: %v\n%s", err, out)
}
gotBranch := strings.TrimSpace(string(runIn(t, workspace, "git", "branch", "--show-current")))
if gotBranch != tc.want {
t.Fatalf("after postlude branch = %q, want %q\nscript output:\n%s", gotBranch, tc.want, out)
}
})
}
}
// setupPostludeRepo creates a fake origin (bare repo with one commit on
// baseBranch) and a workspace cloned from it, then leaves the workspace on
// baseBranch. Returns workspace path + origin (bare) path.
func setupPostludeRepo(t *testing.T, baseBranch string) (workspace, origin string) {
t.Helper()
root := t.TempDir()
origin = filepath.Join(root, "origin.git")
seed := filepath.Join(root, "seed")
workspace = filepath.Join(root, "workspace")
runIn(t, root, "git", "init", "--quiet", "--bare", "--initial-branch="+baseBranch, origin)
runIn(t, root, "git", "init", "--quiet", "--initial-branch="+baseBranch, seed)
runIn(t, seed, "git", "config", "user.email", "test@example.com")
runIn(t, seed, "git", "config", "user.name", "Test")
runIn(t, seed, "git", "commit", "--allow-empty", "-m", "init")
runIn(t, seed, "git", "remote", "add", "origin", origin)
runIn(t, seed, "git", "push", "--quiet", "origin", baseBranch)
runIn(t, root, "git", "clone", "--quiet", "--branch", baseBranch, origin, workspace)
runIn(t, workspace, "git", "config", "user.email", "test@example.com")
runIn(t, workspace, "git", "config", "user.name", "Test")
return workspace, origin
}
// seedOriginBranch creates `branch` on `origin` (the bare repo) so the
// postlude's `git fetch origin <branch>` succeeds. Uses a temporary clone of
// origin to push the new branch in.
func seedOriginBranch(t *testing.T, origin, branch string) {
t.Helper()
tmp := filepath.Join(t.TempDir(), "seed-origin-branch")
runIn(t, "", "git", "clone", "--quiet", origin, tmp)
runIn(t, tmp, "git", "config", "user.email", "test@example.com")
runIn(t, tmp, "git", "config", "user.name", "Test")
runIn(t, tmp, "git", "checkout", "-b", branch)
runIn(t, tmp, "git", "commit", "--allow-empty", "-m", "feature")
runIn(t, tmp, "git", "push", "--quiet", "origin", branch)
}
// runIn runs cmd with args in dir (or current dir when dir == "") and fails
// the test on non-zero exit. Returns stdout for callers that need it.
func runIn(t *testing.T, dir string, cmd string, args ...string) []byte {
t.Helper()
c := exec.Command(cmd, args...)
if dir != "" {
c.Dir = dir
}
out, err := c.CombinedOutput()
if err != nil {
t.Fatalf("%s %v: %v\n%s", cmd, args, err, out)
}
return out
}
// TestDefaultPrepareScripts_NoInlineFeatureBranchCheckout asserts that the
// clone-based remote default scripts no longer carry an inline worktree-
// branch checkout. The checkout is owned exclusively by the postlude
// (KandevBranchCheckoutPostlude) so old stored profiles and the current
// default can never disagree about how the feature branch is materialised.
func TestDefaultPrepareScripts_NoInlineFeatureBranchCheckout(t *testing.T) {
executors := []string{"local_docker", "remote_docker", "sprites"}
forbidden := []string{
`if [ -n "{{worktree.branch}}" ] && [ "{{worktree.branch}}" != "{{repository.branch}}" ]; then`,
`git checkout -B "{{worktree.branch}}" "origin/{{worktree.branch}}"`,
}
for _, executorType := range executors {
t.Run(executorType, func(t *testing.T) {
script := DefaultPrepareScript(executorType)
if script == "" {
t.Fatalf("DefaultPrepareScript(%q) returned empty", executorType)
}
for _, bad := range forbidden {
if strings.Contains(script, bad) {
t.Errorf("script for %q must not contain inline checkout %q (postlude owns it)", executorType, bad)
}
}
})
}
}