427 lines
12 KiB
Go
427 lines
12 KiB
Go
package tui
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"superwork-tui/internal/git"
|
|
"superwork-tui/internal/issue"
|
|
)
|
|
|
|
func computeFeature(planFile string) string {
|
|
h := sha256.Sum256([]byte(planFile))
|
|
return fmt.Sprintf("%x", h)[:8]
|
|
}
|
|
|
|
func initGitRepo(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
for _, args := range [][]string{
|
|
{"init", dir},
|
|
{"-C", dir, "config", "user.email", "test@test.com"},
|
|
{"-C", dir, "config", "user.name", "Test"},
|
|
{"-C", dir, "commit", "--allow-empty", "-m", "init"},
|
|
} {
|
|
if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
|
|
t.Fatalf("git %v: %v\n%s", args, err, out)
|
|
}
|
|
}
|
|
return dir
|
|
}
|
|
|
|
func TestComputeImplPaths(t *testing.T) {
|
|
planFile := "/workspace/.spx/plans/42.md"
|
|
feature := computeFeature(planFile)
|
|
if len(feature) != 8 {
|
|
t.Fatalf("feature len = %d, want 8", len(feature))
|
|
}
|
|
branch := "feature/" + feature
|
|
relPath := ".claude/worktrees/" + feature
|
|
if !strings.HasPrefix(branch, "feature/") {
|
|
t.Errorf("branch = %q, want feature/... prefix", branch)
|
|
}
|
|
if !strings.HasPrefix(relPath, ".claude/worktrees/") {
|
|
t.Errorf("relPath = %q", relPath)
|
|
}
|
|
}
|
|
|
|
func TestImplementFlow_MissingPlanFile(t *testing.T) {
|
|
rec := &recorder{responses: map[string][]byte{}}
|
|
sm := NewSessionManager(rec.run)
|
|
iss := issue.Issue{Number: 1, PlanFile: ""}
|
|
cmd := implementCmd(iss, sm)
|
|
msg := cmd()
|
|
result, ok := msg.(implementResultMsg)
|
|
if !ok {
|
|
t.Fatalf("expected implementResultMsg, got %T", msg)
|
|
}
|
|
if result.err == nil {
|
|
t.Error("expected error for missing planFile")
|
|
}
|
|
}
|
|
|
|
func TestImplementFlow_FreshPath(t *testing.T) {
|
|
workspaceRoot := initGitRepo(t)
|
|
planFile := filepath.Join(workspaceRoot, "plan.md")
|
|
if err := os.WriteFile(planFile, []byte("# plan"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rec := &recorder{
|
|
responses: map[string][]byte{
|
|
"has-session": {},
|
|
"list-windows": []byte("other\n"),
|
|
},
|
|
}
|
|
sm := NewSessionManager(rec.run)
|
|
iss := issue.Issue{Number: 42, PlanFile: planFile}
|
|
|
|
// Override WorkspaceRoot so implementCmd uses our temp git repo.
|
|
origCfg := overrideWorkspaceRoot(workspaceRoot)
|
|
defer origCfg()
|
|
|
|
cmd := implementCmd(iss, sm)
|
|
msg := cmd()
|
|
result, ok := msg.(implementResultMsg)
|
|
if !ok {
|
|
t.Fatalf("expected implementResultMsg, got %T", msg)
|
|
}
|
|
|
|
feature := computeFeature(planFile)
|
|
wantBranch := "feature/" + feature
|
|
wantRelPath := ".claude/worktrees/" + feature
|
|
|
|
if result.err != nil {
|
|
t.Fatalf("unexpected error: %v", result.err)
|
|
}
|
|
if result.branch != wantBranch {
|
|
t.Errorf("branch = %q, want %q", result.branch, wantBranch)
|
|
}
|
|
if result.relWorktreePath != wantRelPath {
|
|
t.Errorf("relWorktreePath = %q, want %q", result.relWorktreePath, wantRelPath)
|
|
}
|
|
// Worktree dir was actually created on disk.
|
|
absWorktree := filepath.Join(workspaceRoot, wantRelPath)
|
|
if _, err := os.Stat(absWorktree); err != nil {
|
|
t.Errorf("worktree dir not created: %v", err)
|
|
}
|
|
// OpenOrFocus was invoked (new-window call in recorder).
|
|
cmds := subcommands(rec.calls)
|
|
hasNewWindow := false
|
|
for _, c := range cmds {
|
|
if c == "new-window" {
|
|
hasNewWindow = true
|
|
}
|
|
}
|
|
if !hasNewWindow {
|
|
t.Errorf("expected new-window call, got %v", cmds)
|
|
}
|
|
}
|
|
|
|
func TestImplementFlow_ResumeSkipsCreate(t *testing.T) {
|
|
workspaceRoot := initGitRepo(t)
|
|
planFile := filepath.Join(workspaceRoot, "plan.md")
|
|
if err := os.WriteFile(planFile, []byte("# plan"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
feature := computeFeature(planFile)
|
|
branch := "feature/" + feature
|
|
relWorktreePath := ".claude/worktrees/" + feature
|
|
absWorktreePath := filepath.Join(workspaceRoot, relWorktreePath)
|
|
|
|
// Pre-create the worktree (simulates a previous fresh run).
|
|
if err := os.MkdirAll(filepath.Dir(absWorktreePath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
out, err := exec.Command("git", "-C", workspaceRoot, "worktree", "add", absWorktreePath, "-b", branch).CombinedOutput()
|
|
if err != nil {
|
|
t.Fatalf("git worktree add: %v\n%s", err, out)
|
|
}
|
|
|
|
rec := &recorder{
|
|
responses: map[string][]byte{
|
|
"has-session": {},
|
|
"list-windows": []byte("other\n"),
|
|
},
|
|
}
|
|
sm := NewSessionManager(rec.run)
|
|
iss := issue.Issue{
|
|
Number: 42,
|
|
PlanFile: planFile,
|
|
Branch: branch,
|
|
WorktreePath: relWorktreePath,
|
|
ImplementSessionID: "prev-session-id",
|
|
}
|
|
|
|
origCfg := overrideWorkspaceRoot(workspaceRoot)
|
|
defer origCfg()
|
|
|
|
cmd := implementCmd(iss, sm)
|
|
_ = cmd()
|
|
|
|
// Resume path: OpenOrFocus still happens (has-session call expected).
|
|
cmds := subcommands(rec.calls)
|
|
hasSession := false
|
|
for _, c := range cmds {
|
|
if c == "has-session" {
|
|
hasSession = true
|
|
}
|
|
}
|
|
if !hasSession {
|
|
t.Errorf("expected tmux calls for OpenOrFocus on resume, got %v", cmds)
|
|
}
|
|
}
|
|
|
|
func TestImplementFlow_HookFailureNonFatal(t *testing.T) {
|
|
workspaceRoot := initGitRepo(t)
|
|
planFile := filepath.Join(workspaceRoot, "plan.md")
|
|
if err := os.WriteFile(planFile, []byte("# plan"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
rec := &recorder{
|
|
responses: map[string][]byte{
|
|
"has-session": {},
|
|
"list-windows": []byte("other\n"),
|
|
},
|
|
}
|
|
sm := NewSessionManager(rec.run)
|
|
iss := issue.Issue{Number: 7, PlanFile: planFile}
|
|
|
|
origCfg := overrideWorkspaceRoot(workspaceRoot)
|
|
defer origCfg()
|
|
|
|
cmd := implementCmd(iss, sm)
|
|
msg := cmd()
|
|
result, ok := msg.(implementResultMsg)
|
|
if !ok {
|
|
t.Fatalf("expected implementResultMsg, got %T", msg)
|
|
}
|
|
// No hook scripts exist → hooks skip (non-fatal). Flow must succeed.
|
|
if result.err != nil {
|
|
t.Errorf("hook absence must not cause error, got: %v", result.err)
|
|
}
|
|
}
|
|
|
|
// TestImplementFlow_StateWriteBeforeOpenOrFocus asserts that the state-persist
|
|
// step (persistStateFn) fires before any tmux new-window / select-window call
|
|
// (i.e. before OpenOrFocus).
|
|
func TestImplementFlow_StateWriteBeforeOpenOrFocus(t *testing.T) {
|
|
workspaceRoot := initGitRepo(t)
|
|
planFile := filepath.Join(workspaceRoot, "plan.md")
|
|
if err := os.WriteFile(planFile, []byte("# plan"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
var callOrder []string
|
|
|
|
// Inject a state-write recorder.
|
|
origPersist := persistStateFn
|
|
defer func() { persistStateFn = origPersist }()
|
|
persistStateFn = func(_ context.Context, _, _, _ string, _ int) string {
|
|
callOrder = append(callOrder, "persist")
|
|
return "" // best-effort success
|
|
}
|
|
|
|
rec := &recorder{
|
|
responses: map[string][]byte{
|
|
"has-session": {},
|
|
"list-windows": []byte("other\n"),
|
|
},
|
|
}
|
|
// Wrap the tmux runner so we can observe new-window / select-window.
|
|
origRun := rec.run
|
|
trackingRun := func(args ...string) ([]byte, error) {
|
|
if len(args) > 0 && (args[0] == "new-window" || args[0] == "select-window") {
|
|
callOrder = append(callOrder, args[0])
|
|
}
|
|
return origRun(args...)
|
|
}
|
|
sm := NewSessionManager(trackingRun)
|
|
iss := issue.Issue{Number: 99, PlanFile: planFile}
|
|
|
|
origCfg := overrideWorkspaceRoot(workspaceRoot)
|
|
defer origCfg()
|
|
|
|
msg := implementCmd(iss, sm)()
|
|
result, ok := msg.(implementResultMsg)
|
|
if !ok {
|
|
t.Fatalf("expected implementResultMsg, got %T", msg)
|
|
}
|
|
if result.err != nil {
|
|
t.Fatalf("unexpected error: %v", result.err)
|
|
}
|
|
|
|
// "persist" must appear before any tmux window call.
|
|
persistIdx := -1
|
|
windowIdx := -1
|
|
for i, ev := range callOrder {
|
|
if ev == "persist" && persistIdx == -1 {
|
|
persistIdx = i
|
|
}
|
|
if (ev == "new-window" || ev == "select-window") && windowIdx == -1 {
|
|
windowIdx = i
|
|
}
|
|
}
|
|
if persistIdx == -1 {
|
|
t.Fatal("persistStateFn was never called")
|
|
}
|
|
if windowIdx == -1 {
|
|
t.Fatal("no tmux window call observed (OpenOrFocus never ran)")
|
|
}
|
|
if persistIdx >= windowIdx {
|
|
t.Errorf("state write (idx %d) must happen before tmux window call (idx %d); order: %v",
|
|
persistIdx, windowIdx, callOrder)
|
|
}
|
|
}
|
|
|
|
// TestImplementCmd_ResumeEffortHigh asserts that the implement RESUME path
|
|
// (when ImplementSessionID is set) includes --effort high in the claude command,
|
|
// matching the TypeScript behaviour (sessions.ts line 208).
|
|
func TestImplementCmd_ResumeEffortHigh(t *testing.T) {
|
|
workspaceRoot := initGitRepo(t)
|
|
planFile := filepath.Join(workspaceRoot, "plan.md")
|
|
if err := os.WriteFile(planFile, []byte("# plan"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
feature := computeFeature(planFile)
|
|
branch := "feature/" + feature
|
|
relWorktreePath := ".claude/worktrees/" + feature
|
|
absWorktreePath := filepath.Join(workspaceRoot, relWorktreePath)
|
|
|
|
// Pre-create worktree to trigger the resume path.
|
|
if err := os.MkdirAll(filepath.Dir(absWorktreePath), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out, err := exec.Command("git", "-C", workspaceRoot, "worktree", "add", absWorktreePath, "-b", branch).CombinedOutput(); err != nil {
|
|
t.Fatalf("git worktree add: %v\n%s", err, out)
|
|
}
|
|
|
|
var capturedCmd string
|
|
rec := &recorder{
|
|
responses: map[string][]byte{
|
|
"has-session": {},
|
|
"list-windows": []byte("other\n"),
|
|
},
|
|
}
|
|
origRun := rec.run
|
|
capturingRun := func(args ...string) ([]byte, error) {
|
|
// new-window -d -t ... -n ... shell-command — command is the last arg
|
|
if len(args) > 0 && args[0] == "new-window" {
|
|
capturedCmd = args[len(args)-1]
|
|
}
|
|
return origRun(args...)
|
|
}
|
|
sm := NewSessionManager(capturingRun)
|
|
|
|
// Suppress Gitea writes (best-effort; no real remote here).
|
|
origPersist := persistStateFn
|
|
defer func() { persistStateFn = origPersist }()
|
|
persistStateFn = func(_ context.Context, _, _, _ string, _ int) string {
|
|
return ""
|
|
}
|
|
|
|
iss := issue.Issue{
|
|
Number: 55,
|
|
PlanFile: planFile,
|
|
Branch: branch,
|
|
WorktreePath: relWorktreePath,
|
|
ImplementSessionID: "resume-sess-abc",
|
|
}
|
|
|
|
origCfg := overrideWorkspaceRoot(workspaceRoot)
|
|
defer origCfg()
|
|
|
|
msg := implementCmd(iss, sm)()
|
|
if result, ok := msg.(implementResultMsg); ok && result.err != nil {
|
|
t.Fatalf("unexpected error: %v", result.err)
|
|
}
|
|
|
|
if !strings.Contains(capturedCmd, "--effort high") {
|
|
t.Errorf("resume claude command missing --effort high; got: %q", capturedCmd)
|
|
}
|
|
if !strings.Contains(capturedCmd, "--resume 'resume-sess-abc'") {
|
|
t.Errorf("resume claude command missing --resume flag; got: %q", capturedCmd)
|
|
}
|
|
}
|
|
|
|
// TestDeleteCmd_InvokesImplTabPostCloseHook asserts that deleteCmd fires the
|
|
// impl-tab-post-close hook after closing the impl window, with the correct
|
|
// HookContext fields (I2 fix).
|
|
func TestDeleteCmd_InvokesImplTabPostCloseHook(t *testing.T) {
|
|
workspaceRoot := initGitRepo(t)
|
|
branch := "feature/hook-test"
|
|
worktreeRel := ".claude/worktrees/hook-test"
|
|
worktreeAbs := filepath.Join(workspaceRoot, worktreeRel)
|
|
|
|
// Create a real git worktree so the removal step succeeds.
|
|
if err := os.MkdirAll(filepath.Dir(worktreeAbs), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out, err := exec.Command("git", "-C", workspaceRoot, "worktree", "add", worktreeAbs, "-b", branch).CombinedOutput(); err != nil {
|
|
t.Fatalf("git worktree add: %v\n%s", err, out)
|
|
}
|
|
|
|
// Recorder: impl window is "alive" so Close fires and hook is triggered.
|
|
rec := &recorder{
|
|
responses: map[string][]byte{
|
|
"has-session": {},
|
|
"list-windows": []byte("42-impl\n"),
|
|
},
|
|
}
|
|
sm := NewSessionManager(rec.run)
|
|
|
|
iss := issue.Issue{
|
|
Number: 42,
|
|
Branch: branch,
|
|
WorktreePath: worktreeRel,
|
|
}
|
|
|
|
var hookCalled bool
|
|
var capturedCtx git.HookContext
|
|
orig := runImplTabPostCloseHookFn
|
|
defer func() { runImplTabPostCloseHookFn = orig }()
|
|
runImplTabPostCloseHookFn = func(ctx context.Context, hctx git.HookContext) git.HookResult {
|
|
hookCalled = true
|
|
capturedCtx = hctx
|
|
return git.HookResult{Status: git.HookStatusSkipped}
|
|
}
|
|
|
|
// Stub out Gitea cleanup so the cmd completes without a real remote.
|
|
origDel := deleteGiteaResourcesFn
|
|
defer func() { deleteGiteaResourcesFn = origDel }()
|
|
deleteGiteaResourcesFn = func(_ context.Context, _ string, _ issue.Issue) error { return nil }
|
|
|
|
origRoot := overrideWorkspaceRoot(workspaceRoot)
|
|
defer origRoot()
|
|
|
|
msg := deleteCmd(iss, sm)()
|
|
if result, ok := msg.(deleteResultMsg); ok && result.err != nil {
|
|
t.Fatalf("deleteCmd error: %v", result.err)
|
|
}
|
|
|
|
if !hookCalled {
|
|
t.Fatal("runImplTabPostCloseHookFn was not called after closing impl window")
|
|
}
|
|
if capturedCtx.IssueNumber != 42 {
|
|
t.Errorf("IssueNumber = %d, want 42", capturedCtx.IssueNumber)
|
|
}
|
|
if capturedCtx.Branch != branch {
|
|
t.Errorf("Branch = %q, want %q", capturedCtx.Branch, branch)
|
|
}
|
|
if capturedCtx.WorkspaceRoot != workspaceRoot {
|
|
t.Errorf("WorkspaceRoot = %q, want %q", capturedCtx.WorkspaceRoot, workspaceRoot)
|
|
}
|
|
if capturedCtx.WorktreePath != worktreeAbs {
|
|
t.Errorf("WorktreePath = %q, want %q", capturedCtx.WorktreePath, worktreeAbs)
|
|
}
|
|
}
|