11
This commit is contained in:
commit
e6f1776d4f
264 files changed
+54215
No files matched your search
@@ -0,0 +1,426 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user