Files
superwork/tui/internal/tui/implement_test.go
T
2026-06-23 05:02:15 +08:00

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)
}
}