package tui import ( "errors" "os" "path/filepath" "strings" "testing" "os/exec" tea "charm.land/bubbletea/v2" "superwork-tui/internal/issue" ) // ── helpers ────────────────────────────────────────────────────────────────── // captureEditorCall replaces editorExecFn for the duration of the test, // recording the command path and first arg (file path) passed to it. type editorCapture struct { bin string path string } func withEditorCapture(t *testing.T) *editorCapture { t.Helper() cap := &editorCapture{} orig := editorExecFn editorExecFn = func(c *exec.Cmd, fn func(error) tea.Msg) tea.Cmd { cap.bin = c.Path if len(c.Args) > 1 { cap.path = c.Args[1] } // Return a no-op Cmd that immediately delivers success. return func() tea.Msg { return fn(nil) } } t.Cleanup(func() { editorExecFn = orig }) return cap } // captureXdgOpen replaces xdgOpenFn for the duration of the test. type xdgCapture struct { url string err error // if non-nil, xdgOpenFn returns this error } func withXdgCapture(t *testing.T, retErr error) *xdgCapture { t.Helper() cap := &xdgCapture{err: retErr} orig := xdgOpenFn xdgOpenFn = func(url string) error { cap.url = url return cap.err } t.Cleanup(func() { xdgOpenFn = orig }) return cap } // captureWorktreeWindow replaces worktreeWindowFn for the duration of the test. type worktreeCapture struct { winName string path string } func withWorktreeCapture(t *testing.T) *worktreeCapture { t.Helper() cap := &worktreeCapture{} orig := worktreeWindowFn worktreeWindowFn = func(sm *SessionManager, winName, path string) error { cap.winName = winName cap.path = path return nil } t.Cleanup(func() { worktreeWindowFn = orig }) return cap } // ── editor/open-file tests ──────────────────────────────────────────────────── func TestCandidateFiles_NoneSet(t *testing.T) { root := t.TempDir() iss := issue.Issue{Number: 1} got := candidateFiles(iss, root) if len(got) != 0 { t.Errorf("expected 0 candidates, got %v", got) } } func TestCandidateFiles_OnlyExistingReturned(t *testing.T) { root := t.TempDir() spec := filepath.Join(root, "spec.md") if err := os.WriteFile(spec, []byte("spec"), 0o644); err != nil { t.Fatal(err) } // planFile does not exist on disk. iss := issue.Issue{ SpecFile: "spec.md", PlanFile: "missing_plan.md", } got := candidateFiles(iss, root) if len(got) != 1 { t.Fatalf("expected 1 candidate, got %v", got) } if got[0] != spec { t.Errorf("candidate = %q, want %q", got[0], spec) } } func TestCandidateFiles_Order(t *testing.T) { root := t.TempDir() for _, name := range []string{"spec.md", "plan.md", "diff.diff"} { if err := os.WriteFile(filepath.Join(root, name), []byte("x"), 0o644); err != nil { t.Fatal(err) } } iss := issue.Issue{ SpecFile: "spec.md", PlanFile: "plan.md", PrDiffFile: "diff.diff", } got := candidateFiles(iss, root) want := []string{ filepath.Join(root, "spec.md"), filepath.Join(root, "plan.md"), filepath.Join(root, "diff.diff"), } for i, w := range want { if i >= len(got) || got[i] != w { t.Errorf("candidates[%d] = %q, want %q", i, got[i], w) } } } func TestOpenEditorCmd_UsesEditorEnv(t *testing.T) { cap := withEditorCapture(t) t.Setenv("EDITOR", "nano") root := t.TempDir() absFile := filepath.Join(root, "spec.md") if err := os.WriteFile(absFile, []byte("spec"), 0o644); err != nil { t.Fatal(err) } cmd := openEditorCmd(absFile) msg := cmd() if _, ok := msg.(openFileMsg); !ok { t.Fatalf("expected openFileMsg, got %T", msg) } if !strings.HasSuffix(cap.bin, "nano") { t.Errorf("editor bin = %q, want suffix 'nano'", cap.bin) } if cap.path != absFile { t.Errorf("editor path = %q, want %q", cap.path, absFile) } } func TestOpenEditorCmd_FallsBackToVi(t *testing.T) { cap := withEditorCapture(t) t.Setenv("EDITOR", "") // unset absFile := filepath.Join(t.TempDir(), "plan.md") if err := os.WriteFile(absFile, []byte("plan"), 0o644); err != nil { t.Fatal(err) } openEditorCmd(absFile)() if !strings.HasSuffix(cap.bin, "vi") { t.Errorf("editor bin = %q, want suffix 'vi'", cap.bin) } } // TestDetailKey_o_NoFiles verifies that pressing 'o' on an issue with no files // sets a statusMsg and returns no suspend cmd. func TestDetailKey_o_NoFiles(t *testing.T) { cap := withEditorCapture(t) _ = cap // not expected to be called root := t.TempDir() origRoot := overrideWorkspaceRoot(root) defer origRoot() iss := issue.Issue{Number: 5} // no SpecFile/PlanFile/PrDiffFile m := Model{state: stateDetail, detailIss: &iss} m2, cmd := m.Update(buildKeyMsg("o")) if cmd != nil { t.Error("expected nil cmd when no files exist") } m2model := m2.(Model) if m2model.statusMsg == "" { t.Error("expected statusMsg to be set for no-file guard") } } // TestDetailKey_o_SingleFile verifies that pressing 'o' with exactly one // candidate opens the file directly without entering the picker. func TestDetailKey_o_SingleFile(t *testing.T) { cap := withEditorCapture(t) root := t.TempDir() origRoot := overrideWorkspaceRoot(root) defer origRoot() specPath := filepath.Join(root, "spec.md") if err := os.WriteFile(specPath, []byte("spec"), 0o644); err != nil { t.Fatal(err) } iss := issue.Issue{Number: 5, SpecFile: "spec.md"} m := Model{state: stateDetail, detailIss: &iss} m2, cmd := m.Update(buildKeyMsg("o")) if cmd == nil { t.Fatal("expected a cmd for single-file open") } cmd() // execute to populate cap if cap.path != specPath { t.Errorf("editor path = %q, want %q", cap.path, specPath) } m2model := m2.(Model) if m2model.state == stateFilePicker { t.Error("must not enter file picker for single candidate") } } // TestDetailKey_o_MultiFile_EntersPicker verifies that pressing 'o' with >1 // candidates enters stateFilePicker without opening any editor yet. func TestDetailKey_o_MultiFile_EntersPicker(t *testing.T) { cap := withEditorCapture(t) _ = cap root := t.TempDir() origRoot := overrideWorkspaceRoot(root) defer origRoot() for _, name := range []string{"spec.md", "plan.md"} { if err := os.WriteFile(filepath.Join(root, name), []byte("x"), 0o644); err != nil { t.Fatal(err) } } iss := issue.Issue{Number: 5, SpecFile: "spec.md", PlanFile: "plan.md"} m := Model{state: stateDetail, detailIss: &iss} m2, cmd := m.Update(buildKeyMsg("o")) if cmd != nil { t.Error("entering picker must not issue a cmd") } m2model := m2.(Model) if m2model.state != stateFilePicker { t.Errorf("state = %v, want stateFilePicker", m2model.state) } if len(m2model.filePickerFiles) != 2 { t.Errorf("filePickerFiles len = %d, want 2", len(m2model.filePickerFiles)) } } // TestDetailKey_o_MultiFile_SelectPlan verifies that navigating down then // pressing enter in the file picker opens the plan file (index 1), not spec. func TestDetailKey_o_MultiFile_SelectPlan(t *testing.T) { cap := withEditorCapture(t) root := t.TempDir() origRoot := overrideWorkspaceRoot(root) defer origRoot() specPath := filepath.Join(root, "spec.md") planPath := filepath.Join(root, "plan.md") diffPath := filepath.Join(root, "diff.diff") for _, p := range []string{specPath, planPath, diffPath} { if err := os.WriteFile(p, []byte("x"), 0o644); err != nil { t.Fatal(err) } } iss := issue.Issue{Number: 5, SpecFile: "spec.md", PlanFile: "plan.md", PrDiffFile: "diff.diff"} m := Model{state: stateDetail, detailIss: &iss} // Press 'o' → enter picker m2, _ := m.Update(buildKeyMsg("o")) m2model := m2.(Model) if m2model.state != stateFilePicker { t.Fatalf("expected stateFilePicker, got %v", m2model.state) } // Press 'j' → move cursor to plan (index 1) m3, _ := m2model.Update(buildKeyMsg("j")) m3model := m3.(Model) if m3model.filePickerCursor != 1 { t.Errorf("cursor = %d, want 1", m3model.filePickerCursor) } // Press enter → opens plan file m4, cmd := m3model.Update(buildKeyMsg("enter")) if cmd == nil { t.Fatal("expected a cmd from enter in file picker") } cmd() // populate cap if cap.path != planPath { t.Errorf("editor path = %q, want %q (plan)", cap.path, planPath) } m4model := m4.(Model) if m4model.state == stateFilePicker { t.Error("state must leave stateFilePicker after enter") } } // TestDetailKey_o_MultiFile_EscCancels verifies that esc in the file picker // returns to the previous state without opening any file. func TestDetailKey_o_MultiFile_EscCancels(t *testing.T) { cap := withEditorCapture(t) _ = cap root := t.TempDir() origRoot := overrideWorkspaceRoot(root) defer origRoot() for _, name := range []string{"spec.md", "plan.md"} { if err := os.WriteFile(filepath.Join(root, name), []byte("x"), 0o644); err != nil { t.Fatal(err) } } iss := issue.Issue{Number: 5, SpecFile: "spec.md", PlanFile: "plan.md"} m := Model{state: stateDetail, detailIss: &iss} // Enter picker m2, _ := m.Update(buildKeyMsg("o")) m2model := m2.(Model) // Press esc m3, cmd := m2model.Update(buildKeyMsg("esc")) if cmd != nil { t.Error("esc must not issue a cmd") } m3model := m3.(Model) if m3model.state == stateFilePicker { t.Error("esc must leave stateFilePicker") } if cap.path != "" { t.Error("editor must not be called on esc") } } // ── pr/open tests ───────────────────────────────────────────────────────────── func TestPrNumberFor_Empty(t *testing.T) { iss := issue.Issue{Number: 3, PR: ""} _, err := prNumberFor(iss) if err == nil { t.Error("expected error for empty PR") } } func TestPrNumberFor_Valid(t *testing.T) { iss := issue.Issue{Number: 3, PR: "7"} n, err := prNumberFor(iss) if err != nil { t.Fatalf("unexpected error: %v", err) } if n != 7 { t.Errorf("prNumber = %d, want 7", n) } } func TestOpenPRCmd_XdgOpenCalledWithURL(t *testing.T) { cap := withXdgCapture(t, nil) wantURL := "https://gitea.example.com/org/repo/pulls/42" origFn := resolvePRURLFn resolvePRURLFn = func(iss issue.Issue) (string, error) { return wantURL, nil } defer func() { resolvePRURLFn = origFn }() iss := issue.Issue{Number: 5, PR: "42"} cmd := openPRCmd(iss) msg := cmd() result, ok := msg.(openPRMsg) if !ok { t.Fatalf("expected openPRMsg, got %T", msg) } if result.err != nil { t.Fatalf("unexpected error: %v", result.err) } if cap.url != wantURL { t.Errorf("xdg-open url = %q, want %q", cap.url, wantURL) } } func TestOpenPRCmd_NoURL_Guard(t *testing.T) { _ = withXdgCapture(t, nil) origFn := resolvePRURLFn resolvePRURLFn = func(iss issue.Issue) (string, error) { return "", errors.New("issue #5 无关联 PR") } defer func() { resolvePRURLFn = origFn }() iss := issue.Issue{Number: 5, PR: ""} cmd := openPRCmd(iss) msg := cmd() result, ok := msg.(openPRMsg) if !ok { t.Fatalf("expected openPRMsg, got %T", msg) } if result.err == nil { t.Error("expected error for missing PR") } } // TestDetailKey_ShiftO_NoPR verifies that 'O' on an issue without a PR sets // a statusMsg without calling xdg-open. func TestDetailKey_ShiftO_NoPR(t *testing.T) { cap := withXdgCapture(t, nil) _ = cap iss := issue.Issue{Number: 6, PR: ""} m := Model{state: stateDetail, detailIss: &iss} m2, _ := m.Update(buildKeyMsg("O")) m2model := m2.(Model) if m2model.statusMsg == "" { t.Error("expected statusMsg for no-PR guard") } if cap.url != "" { t.Error("xdg-open must not be called when no PR") } } // ── worktree/open tests ─────────────────────────────────────────────────────── func TestOpenWorktreeCmd_MissingPath(t *testing.T) { cap := withWorktreeCapture(t) _ = cap iss := issue.Issue{Number: 10, WorktreePath: ""} sm := NewSessionManager(func(args ...string) ([]byte, error) { return nil, nil }) cmd := openWorktreeCmd(iss, sm) msg := cmd() result, ok := msg.(openWorktreeMsg) if !ok { t.Fatalf("expected openWorktreeMsg, got %T", msg) } if result.err == nil { t.Error("expected error for missing WorktreePath") } } func TestOpenWorktreeCmd_PathNotExist(t *testing.T) { cap := withWorktreeCapture(t) _ = cap root := t.TempDir() origRoot := overrideWorkspaceRoot(root) defer origRoot() iss := issue.Issue{Number: 10, WorktreePath: "nonexistent/worktree"} sm := NewSessionManager(func(args ...string) ([]byte, error) { return nil, nil }) cmd := openWorktreeCmd(iss, sm) msg := cmd() result, ok := msg.(openWorktreeMsg) if !ok { t.Fatalf("expected openWorktreeMsg, got %T", msg) } if result.err == nil { t.Error("expected error for non-existent worktree path") } if cap.winName != "" { t.Error("tmux window must not be opened when path doesn't exist") } } func TestOpenWorktreeCmd_Success(t *testing.T) { cap := withWorktreeCapture(t) root := t.TempDir() origRoot := overrideWorkspaceRoot(root) defer origRoot() wtPath := filepath.Join(root, ".claude/worktrees/abc123") if err := os.MkdirAll(wtPath, 0o755); err != nil { t.Fatal(err) } iss := issue.Issue{Number: 42, WorktreePath: ".claude/worktrees/abc123"} sm := NewSessionManager(func(args ...string) ([]byte, error) { return nil, nil }) cmd := openWorktreeCmd(iss, sm) msg := cmd() result, ok := msg.(openWorktreeMsg) if !ok { t.Fatalf("expected openWorktreeMsg, got %T", msg) } if result.err != nil { t.Fatalf("unexpected error: %v", result.err) } if cap.winName != "42-wt" { t.Errorf("winName = %q, want %q", cap.winName, "42-wt") } if cap.path != wtPath { t.Errorf("path = %q, want %q", cap.path, wtPath) } } // TestDetailKey_W_NoWorktree verifies 'w' on an issue with no WorktreePath sets statusMsg. func TestDetailKey_W_NoWorktree(t *testing.T) { cap := withWorktreeCapture(t) _ = cap iss := issue.Issue{Number: 7, WorktreePath: ""} sm := NewSessionManager(func(args ...string) ([]byte, error) { return nil, nil }) m := Model{state: stateDetail, detailIss: &iss, sessionMgr: sm} m2, _ := m.Update(buildKeyMsg("w")) m2model := m2.(Model) if m2model.statusMsg == "" { t.Error("expected statusMsg for no-worktree guard") } if cap.winName != "" { t.Error("tmux must not be called when no worktree") } } // buildKeyMsg creates a tea.KeyMsg for use in Update tests. func buildKeyMsg(key string) tea.KeyMsg { return keyMsg(key) }