26f897c1ec
release / release-please (push) Failing after 1m49s
docs / build (push) Failing after 6m34s
release / build-and-upload (arm64, linux) (push) Has been cancelled
release / build-and-upload (arm64, windows) (push) Has been cancelled
release / build-darwin (amd64, darwin) (push) Has been cancelled
release / checksums (push) Has been cancelled
release / finalize (push) Has been cancelled
release / build-darwin (arm64, darwin) (push) Has been cancelled
release / build-and-upload (amd64, linux) (push) Has been cancelled
release / build-and-upload (amd64, windows) (push) Has been cancelled
docs / deploy (push) Has been cancelled
1131 lines
32 KiB
Go
1131 lines
32 KiB
Go
package wizard
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
// recording stubs capture calls for assertions.
|
|
type recorder struct {
|
|
createdBranch string
|
|
commitMsg string
|
|
pushedBranch string
|
|
telemetry []wizardTelemetryEvent
|
|
|
|
createBranchErr error
|
|
commitErr error
|
|
pushErr error
|
|
|
|
suggestBranch string
|
|
suggestCommit string
|
|
suggestBranchErr error
|
|
suggestCommitErr error
|
|
}
|
|
|
|
type wizardTelemetryEvent struct {
|
|
action string
|
|
fields map[string]any
|
|
}
|
|
|
|
func (r *recorder) deps() Config {
|
|
return Config{
|
|
CreateBranch: func(_ context.Context, name string) error {
|
|
r.createdBranch = name
|
|
return r.createBranchErr
|
|
},
|
|
CommitAll: func(_ context.Context, msg string) error {
|
|
r.commitMsg = msg
|
|
return r.commitErr
|
|
},
|
|
Push: func(_ context.Context, branch string) error {
|
|
r.pushedBranch = branch
|
|
return r.pushErr
|
|
},
|
|
SuggestBranch: func(_ context.Context) (string, error) {
|
|
return r.suggestBranch, r.suggestBranchErr
|
|
},
|
|
SuggestCommit: func(_ context.Context) (string, error) {
|
|
return r.suggestCommit, r.suggestCommitErr
|
|
},
|
|
Track: func(action string, fields map[string]any) {
|
|
clone := make(map[string]any, len(fields))
|
|
for k, v := range fields {
|
|
clone[k] = v
|
|
}
|
|
r.telemetry = append(r.telemetry, wizardTelemetryEvent{action: action, fields: clone})
|
|
},
|
|
}
|
|
}
|
|
|
|
func baseConfig(r *recorder) Config {
|
|
cfg := r.deps()
|
|
cfg.RepoDir = "/tmp/repo"
|
|
cfg.CurrentBranch = "main"
|
|
cfg.DefaultBranch = "main"
|
|
cfg.NeedsBranch = true
|
|
cfg.IsDirty = true
|
|
cfg.GateRemote = "no-mistakes"
|
|
return cfg
|
|
}
|
|
|
|
// step the model through a command synchronously by running the returned
|
|
// Cmd and feeding the result back into Update.
|
|
func drain(m Model, cmd tea.Cmd) Model {
|
|
for cmd != nil {
|
|
msg := cmd()
|
|
if msg == nil {
|
|
return m
|
|
}
|
|
// Skip timer ticks - they'd spin forever in tests.
|
|
if _, ok := msg.(spinnerTickMsg); ok {
|
|
return m
|
|
}
|
|
// Skip batched commands from bubbles/textinput (e.g. Blink).
|
|
if batch, ok := msg.(tea.BatchMsg); ok {
|
|
for _, c := range batch {
|
|
m = drain(m, c)
|
|
}
|
|
return m
|
|
}
|
|
next, nextCmd := m.Update(msg)
|
|
m = next.(Model)
|
|
cmd = nextCmd
|
|
}
|
|
return m
|
|
}
|
|
|
|
func advance(m Model, msg tea.Msg) Model {
|
|
next, cmd := m.Update(msg)
|
|
return drain(next.(Model), cmd)
|
|
}
|
|
|
|
func TestNewModel_AllStepsPending(t *testing.T) {
|
|
m := NewModel(baseConfig(&recorder{}))
|
|
if m.steps[0].status != statInput {
|
|
t.Errorf("active branch step should be in input mode, got %v", m.steps[0].status)
|
|
}
|
|
if m.steps[1].status != statPending {
|
|
t.Errorf("commit should be pending when dirty, got %v", m.steps[1].status)
|
|
}
|
|
if m.steps[2].status != statPending {
|
|
t.Errorf("push should be pending initially, got %v", m.steps[2].status)
|
|
}
|
|
if m.active != 0 {
|
|
t.Errorf("first active step should be branch (0), got %d", m.active)
|
|
}
|
|
}
|
|
|
|
func TestNewModel_SkipsBranchOnFeatureBranch(t *testing.T) {
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.CurrentBranch = "feat/existing"
|
|
cfg.NeedsBranch = false
|
|
m := NewModel(cfg)
|
|
if m.steps[0].status != statSkipped {
|
|
t.Fatalf("expected branch skipped, got %v", m.steps[0].status)
|
|
}
|
|
if !strings.Contains(m.steps[0].skipReason, "feat/existing") {
|
|
t.Fatalf("skip reason should mention branch, got %q", m.steps[0].skipReason)
|
|
}
|
|
if m.active != 1 {
|
|
t.Fatalf("first active should be commit, got %d", m.active)
|
|
}
|
|
}
|
|
|
|
func TestNewModel_SkipsCommitWhenClean(t *testing.T) {
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.IsDirty = false
|
|
m := NewModel(cfg)
|
|
if m.steps[1].status != statSkipped {
|
|
t.Fatalf("expected commit skipped, got %v", m.steps[1].status)
|
|
}
|
|
}
|
|
|
|
func TestNewModel_SkipsBothWhenOnFeatureAndClean(t *testing.T) {
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.CurrentBranch = "feat/x"
|
|
cfg.NeedsBranch = false
|
|
cfg.IsDirty = false
|
|
m := NewModel(cfg)
|
|
if m.active != 2 {
|
|
t.Fatalf("expected push to be first active, got %d", m.active)
|
|
}
|
|
}
|
|
|
|
func TestNewModel_DetachedHEADForcesBranchStep(t *testing.T) {
|
|
// Simulates detached HEAD: CurrentBranch literally "HEAD" but caller
|
|
// set NeedsBranch=true to force the branch step to run.
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.CurrentBranch = "HEAD"
|
|
cfg.DefaultBranch = "main"
|
|
cfg.NeedsBranch = true
|
|
cfg.IsDirty = false
|
|
m := NewModel(cfg)
|
|
|
|
if m.steps[0].status == statSkipped {
|
|
t.Fatal("branch step should NOT be skipped in detached HEAD")
|
|
}
|
|
if m.active != 0 {
|
|
t.Fatalf("expected branch to be first active, got %d", m.active)
|
|
}
|
|
}
|
|
|
|
func TestNewModel_UsesConfigContext(t *testing.T) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.Context = ctx
|
|
|
|
m := NewModel(cfg)
|
|
defer m.cancel()
|
|
|
|
cancel()
|
|
|
|
select {
|
|
case <-m.ctx.Done():
|
|
case <-time.After(100 * time.Millisecond):
|
|
t.Fatal("expected model context to be cancelled with config context")
|
|
}
|
|
}
|
|
|
|
func TestBranchStep_UserTyped(t *testing.T) {
|
|
r := &recorder{}
|
|
m := NewModel(baseConfig(r))
|
|
m = drain(m, m.Init())
|
|
|
|
// Type a name.
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("feat/wizard")})
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyEnter})
|
|
|
|
// After running the create-branch cmd, handleAction advances.
|
|
if r.createdBranch != "feat/wizard" {
|
|
t.Fatalf("expected CreateBranch called with feat/wizard, got %q", r.createdBranch)
|
|
}
|
|
if m.steps[0].status != statDone {
|
|
t.Fatalf("expected branch done, got %v", m.steps[0].status)
|
|
}
|
|
if !m.branchCreated {
|
|
t.Fatal("expected branchCreated = true")
|
|
}
|
|
if m.targetBranch != "feat/wizard" {
|
|
t.Fatalf("expected targetBranch feat/wizard, got %q", m.targetBranch)
|
|
}
|
|
if m.active != 1 {
|
|
t.Fatalf("expected active to be commit, got %d", m.active)
|
|
}
|
|
}
|
|
|
|
func TestBranchStep_BlankUsesAgent(t *testing.T) {
|
|
r := &recorder{suggestBranch: "fix/agent-name"}
|
|
m := NewModel(baseConfig(r))
|
|
m = drain(m, m.Init())
|
|
|
|
// Press enter with blank input.
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyEnter})
|
|
|
|
if r.createdBranch != "fix/agent-name" {
|
|
t.Fatalf("expected agent suggestion used, got %q", r.createdBranch)
|
|
}
|
|
if m.targetBranch != "fix/agent-name" {
|
|
t.Fatalf("targetBranch should be agent suggestion, got %q", m.targetBranch)
|
|
}
|
|
}
|
|
|
|
func TestBranchStep_SuggestionFailureFallsBackToInput(t *testing.T) {
|
|
r := &recorder{suggestBranchErr: errors.New("agent down")}
|
|
m := NewModel(baseConfig(r))
|
|
m = drain(m, m.Init())
|
|
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyEnter})
|
|
|
|
if m.steps[0].status != statInput {
|
|
t.Fatalf("expected fallback to input, got %v", m.steps[0].status)
|
|
}
|
|
if !strings.Contains(m.input.Placeholder, "agent unavailable") {
|
|
t.Fatalf("expected placeholder to mention agent failure, got %q", m.input.Placeholder)
|
|
}
|
|
if r.createdBranch != "" {
|
|
t.Fatalf("should not have called CreateBranch, got %q", r.createdBranch)
|
|
}
|
|
}
|
|
|
|
func TestCommitStep_UserTyped(t *testing.T) {
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.CurrentBranch = "feat/x" // skip branch
|
|
cfg.NeedsBranch = false
|
|
r := &recorder{}
|
|
cfg2 := cfg
|
|
cfg2.CreateBranch = r.deps().CreateBranch
|
|
cfg2.CommitAll = r.deps().CommitAll
|
|
cfg2.Push = r.deps().Push
|
|
cfg2.SuggestBranch = r.deps().SuggestBranch
|
|
cfg2.SuggestCommit = r.deps().SuggestCommit
|
|
m := NewModel(cfg2)
|
|
m = drain(m, m.Init())
|
|
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("feat: add x")})
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyEnter})
|
|
|
|
if r.commitMsg != "feat: add x" {
|
|
t.Fatalf("expected commit message, got %q", r.commitMsg)
|
|
}
|
|
if !m.commitMade {
|
|
t.Fatal("expected commitMade = true")
|
|
}
|
|
if m.steps[1].status != statDone {
|
|
t.Fatalf("expected commit done, got %v", m.steps[1].status)
|
|
}
|
|
if m.active != 2 {
|
|
t.Fatalf("expected push active, got %d", m.active)
|
|
}
|
|
}
|
|
|
|
func TestPushStep_Confirm(t *testing.T) {
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.CurrentBranch = "feat/x" // skip branch
|
|
cfg.NeedsBranch = false
|
|
cfg.IsDirty = false // skip commit
|
|
r := &recorder{}
|
|
cfg.CreateBranch = r.deps().CreateBranch
|
|
cfg.CommitAll = r.deps().CommitAll
|
|
cfg.Push = r.deps().Push
|
|
cfg.SuggestBranch = r.deps().SuggestBranch
|
|
cfg.SuggestCommit = r.deps().SuggestCommit
|
|
m := NewModel(cfg)
|
|
m = drain(m, m.Init())
|
|
|
|
if m.active != 2 {
|
|
t.Fatalf("active should be push, got %d", m.active)
|
|
}
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")})
|
|
|
|
if r.pushedBranch != "feat/x" {
|
|
t.Fatalf("expected Push for feat/x, got %q", r.pushedBranch)
|
|
}
|
|
if !m.pushed {
|
|
t.Fatal("expected pushed = true")
|
|
}
|
|
if !m.quitting {
|
|
t.Fatal("wizard should quit after final step")
|
|
}
|
|
if !m.success {
|
|
t.Fatal("wizard should report success")
|
|
}
|
|
}
|
|
|
|
func TestRunAuto_UsesAgentSuggestionsAndPushes(t *testing.T) {
|
|
r := &recorder{suggestBranch: "feat/auto", suggestCommit: "feat: auto commit"}
|
|
|
|
res, err := RunAuto(baseConfig(r))
|
|
if err != nil {
|
|
t.Fatalf("RunAuto() error = %v", err)
|
|
}
|
|
|
|
if r.createdBranch != "feat/auto" {
|
|
t.Fatalf("expected CreateBranch called with agent suggestion, got %q", r.createdBranch)
|
|
}
|
|
if r.commitMsg != "feat: auto commit" {
|
|
t.Fatalf("expected CommitAll called with agent suggestion, got %q", r.commitMsg)
|
|
}
|
|
if r.pushedBranch != "feat/auto" {
|
|
t.Fatalf("expected Push called with created branch, got %q", r.pushedBranch)
|
|
}
|
|
if !res.Success {
|
|
t.Fatal("expected success result")
|
|
}
|
|
if !res.BranchCreated || !res.CommitMade || !res.Pushed {
|
|
t.Fatalf("unexpected result: %+v", res)
|
|
}
|
|
if res.TargetBranch != "feat/auto" {
|
|
t.Fatalf("TargetBranch = %q, want %q", res.TargetBranch, "feat/auto")
|
|
}
|
|
if !containsWizardEvent(r.telemetry, "branch_created", "source", "agent") {
|
|
t.Fatal("expected branch_created telemetry with agent source")
|
|
}
|
|
if !containsWizardEvent(r.telemetry, "committed", "source", "agent") {
|
|
t.Fatal("expected committed telemetry with agent source")
|
|
}
|
|
if !containsWizardEvent(r.telemetry, "pushed", "source", "auto") {
|
|
t.Fatal("expected pushed telemetry with auto source")
|
|
}
|
|
if !containsWizardEvent(r.telemetry, "completed", "pushed", true) {
|
|
t.Fatal("expected completed telemetry")
|
|
}
|
|
}
|
|
|
|
func TestAutoAdvance_ActsLikePressingEnterThroughWizard(t *testing.T) {
|
|
r := &recorder{suggestBranch: "feat/auto", suggestCommit: "feat: auto commit"}
|
|
cfg := baseConfig(r)
|
|
cfg.AutoAdvance = true
|
|
|
|
m := NewModel(cfg)
|
|
m = drain(m, m.Init())
|
|
|
|
if r.createdBranch != "feat/auto" {
|
|
t.Fatalf("expected CreateBranch called with agent suggestion, got %q", r.createdBranch)
|
|
}
|
|
if r.commitMsg != "feat: auto commit" {
|
|
t.Fatalf("expected CommitAll called with agent suggestion, got %q", r.commitMsg)
|
|
}
|
|
if r.pushedBranch != "feat/auto" {
|
|
t.Fatalf("expected Push called with created branch, got %q", r.pushedBranch)
|
|
}
|
|
if !m.success || !m.quitting {
|
|
t.Fatalf("expected auto-advanced wizard to finish successfully, got success=%v quitting=%v", m.success, m.quitting)
|
|
}
|
|
out := m.View()
|
|
if !strings.Contains(out, "feat/auto") {
|
|
t.Fatalf("expected final view to show created branch, got:\n%s", out)
|
|
}
|
|
if !strings.Contains(out, "feat: auto commit") {
|
|
t.Fatalf("expected final view to show commit message, got:\n%s", out)
|
|
}
|
|
if !containsWizardEvent(r.telemetry, "branch_created", "source", "agent") {
|
|
t.Fatal("expected branch_created telemetry with agent source")
|
|
}
|
|
if !containsWizardEvent(r.telemetry, "committed", "source", "agent") {
|
|
t.Fatal("expected committed telemetry with agent source")
|
|
}
|
|
if !containsWizardEvent(r.telemetry, "pushed", "source", "auto") {
|
|
t.Fatal("expected pushed telemetry with auto source")
|
|
}
|
|
}
|
|
|
|
func TestAutoAdvance_SuggestionErrorFailsFast(t *testing.T) {
|
|
r := &recorder{suggestBranchErr: errors.New("agent down")}
|
|
cfg := baseConfig(r)
|
|
cfg.AutoAdvance = true
|
|
|
|
m := NewModel(cfg)
|
|
m = drain(m, m.Init())
|
|
|
|
if m.err == nil {
|
|
t.Fatal("expected auto-advance suggestion failure to be recorded")
|
|
}
|
|
if !strings.Contains(m.err.Error(), "suggest branch") {
|
|
t.Fatalf("error should mention branch suggestion, got %v", m.err)
|
|
}
|
|
if !m.quitting {
|
|
t.Fatal("expected auto-advance suggestion failure to quit")
|
|
}
|
|
if m.steps[0].status != statFailed {
|
|
t.Fatalf("expected branch step to fail, got %v", m.steps[0].status)
|
|
}
|
|
if r.createdBranch != "" || r.commitMsg != "" || r.pushedBranch != "" {
|
|
t.Fatalf("auto-advance should stop before side effects, got branch=%q commit=%q push=%q", r.createdBranch, r.commitMsg, r.pushedBranch)
|
|
}
|
|
res := m.Result()
|
|
if !errors.Is(res.Err, m.err) {
|
|
t.Fatalf("result error = %v, want %v", res.Err, m.err)
|
|
}
|
|
}
|
|
|
|
func TestAutoAdvance_ActionErrorFailsFast(t *testing.T) {
|
|
r := &recorder{createBranchErr: errors.New("branch exists")}
|
|
cfg := baseConfig(r)
|
|
cfg.AutoAdvance = true
|
|
|
|
m := NewModel(cfg)
|
|
m = drain(m, m.Init())
|
|
|
|
if m.err == nil {
|
|
t.Fatal("expected auto-advance action failure to be recorded")
|
|
}
|
|
if !strings.Contains(m.err.Error(), "create branch") {
|
|
t.Fatalf("error should mention branch action, got %v", m.err)
|
|
}
|
|
if !strings.Contains(m.err.Error(), "branch exists") {
|
|
t.Fatalf("error should mention branch failure, got %v", m.err)
|
|
}
|
|
if !m.quitting {
|
|
t.Fatal("expected auto-advance action failure to quit")
|
|
}
|
|
if m.steps[0].status != statFailed {
|
|
t.Fatalf("expected branch step to fail, got %v", m.steps[0].status)
|
|
}
|
|
if r.commitMsg != "" || r.pushedBranch != "" {
|
|
t.Fatalf("auto-advance should stop after branch failure, got commit=%q push=%q", r.commitMsg, r.pushedBranch)
|
|
}
|
|
res := m.Result()
|
|
if !errors.Is(res.Err, m.err) {
|
|
t.Fatalf("result error = %v, want %v", res.Err, m.err)
|
|
}
|
|
}
|
|
|
|
func TestRunAuto_SuggestionErrorReturnsFailure(t *testing.T) {
|
|
r := &recorder{suggestBranchErr: errors.New("agent down")}
|
|
|
|
res, err := RunAuto(baseConfig(r))
|
|
if err == nil {
|
|
t.Fatal("expected RunAuto to fail when branch suggestion fails")
|
|
}
|
|
if !strings.Contains(err.Error(), "suggest branch") {
|
|
t.Fatalf("error should mention branch suggestion, got %v", err)
|
|
}
|
|
if res.Success {
|
|
t.Fatal("RunAuto should not report success on suggestion error")
|
|
}
|
|
if r.createdBranch != "" || r.commitMsg != "" || r.pushedBranch != "" {
|
|
t.Fatalf("RunAuto should stop before side effects, got branch=%q commit=%q push=%q", r.createdBranch, r.commitMsg, r.pushedBranch)
|
|
}
|
|
}
|
|
|
|
func TestRunAuto_UsesCallerContext(t *testing.T) {
|
|
r := &recorder{}
|
|
cfg := baseConfig(r)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
cfg.Context = ctx
|
|
cfg.SuggestBranch = func(ctx context.Context) (string, error) {
|
|
<-ctx.Done()
|
|
return "", ctx.Err()
|
|
}
|
|
|
|
res, err := RunAuto(cfg)
|
|
if err == nil {
|
|
t.Fatal("expected RunAuto to fail when caller context is canceled")
|
|
}
|
|
if !strings.Contains(err.Error(), context.Canceled.Error()) {
|
|
t.Fatalf("error should mention canceled context, got %v", err)
|
|
}
|
|
if res.Success {
|
|
t.Fatal("RunAuto should not report success when caller context is canceled")
|
|
}
|
|
if r.createdBranch != "" || r.commitMsg != "" || r.pushedBranch != "" {
|
|
t.Fatalf("RunAuto should stop before side effects, got branch=%q commit=%q push=%q", r.createdBranch, r.commitMsg, r.pushedBranch)
|
|
}
|
|
}
|
|
|
|
func TestRunAuto_WrapsUnderlyingContextError(t *testing.T) {
|
|
r := &recorder{}
|
|
cfg := baseConfig(r)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
cfg.Context = ctx
|
|
cfg.SuggestBranch = func(ctx context.Context) (string, error) {
|
|
<-ctx.Done()
|
|
return "", ctx.Err()
|
|
}
|
|
|
|
_, err := RunAuto(cfg)
|
|
if err == nil {
|
|
t.Fatal("expected RunAuto to fail when caller context is canceled")
|
|
}
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("errors.Is(err, context.Canceled) = false, err = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRun_UsesCallerContext(t *testing.T) {
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.CurrentBranch = "feat/existing"
|
|
cfg.NeedsBranch = false
|
|
cfg.IsDirty = false
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
cfg.Context = ctx
|
|
|
|
_, err := Run(cfg)
|
|
if err == nil {
|
|
t.Fatal("expected Run to fail when caller context is canceled")
|
|
}
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Run() error = %v, want wrapped context.Canceled", err)
|
|
}
|
|
}
|
|
|
|
func TestRun_ContextCancelResetsTerminalTitle(t *testing.T) {
|
|
var out bytes.Buffer
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.CurrentBranch = "feat/existing"
|
|
cfg.NeedsBranch = false
|
|
cfg.IsDirty = false
|
|
cfg.DisableInput = true
|
|
cfg.Output = &out
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cfg.Context = ctx
|
|
go func() {
|
|
time.Sleep(50 * time.Millisecond)
|
|
cancel()
|
|
}()
|
|
|
|
_, err := Run(cfg)
|
|
if err == nil {
|
|
t.Fatal("expected Run to fail when caller context is canceled")
|
|
}
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("Run() error = %v, want wrapped context.Canceled", err)
|
|
}
|
|
|
|
initialTitle := setTerminalTitle("⏸ Setup Push - feat/existing")
|
|
resetTitle := setTerminalTitle("")
|
|
if !strings.Contains(out.String(), initialTitle) {
|
|
t.Fatalf("expected wizard output to include initial title sequence, got %q", out.String())
|
|
}
|
|
if !strings.Contains(out.String(), resetTitle) {
|
|
t.Fatalf("expected wizard output to include title reset sequence on context cancel, got %q", out.String())
|
|
}
|
|
if strings.LastIndex(out.String(), resetTitle) < strings.Index(out.String(), initialTitle) {
|
|
t.Fatalf("expected title reset to be emitted after initial title, got %q", out.String())
|
|
}
|
|
}
|
|
|
|
func TestWizardTracksCompletedKeyActions(t *testing.T) {
|
|
r := &recorder{}
|
|
m := NewModel(baseConfig(r))
|
|
m = drain(m, m.Init())
|
|
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("feat/wizard")})
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyEnter})
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("feat: add wizard telemetry")})
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyEnter})
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyEnter})
|
|
|
|
if !containsWizardEvent(r.telemetry, "branch_created", "source", "user") {
|
|
t.Fatal("expected branch_created telemetry with user source")
|
|
}
|
|
if !containsWizardEvent(r.telemetry, "committed", "source", "user") {
|
|
t.Fatal("expected committed telemetry with user source")
|
|
}
|
|
if !containsWizardEvent(r.telemetry, "pushed", "step", "push") {
|
|
t.Fatal("expected pushed telemetry")
|
|
}
|
|
if !containsWizardEvent(r.telemetry, "completed", "pushed", true) {
|
|
t.Fatal("expected completed telemetry")
|
|
}
|
|
}
|
|
|
|
func TestWizardTracksAbortOnPushDecline(t *testing.T) {
|
|
r := &recorder{}
|
|
cfg := baseConfig(r)
|
|
cfg.CurrentBranch = "feat/x"
|
|
cfg.NeedsBranch = false
|
|
cfg.IsDirty = false
|
|
m := NewModel(cfg)
|
|
m = drain(m, m.Init())
|
|
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")})
|
|
|
|
if !containsWizardEvent(r.telemetry, "aborted", "reason", "decline_push") {
|
|
t.Fatal("expected aborted telemetry with decline_push reason")
|
|
}
|
|
}
|
|
|
|
func TestWizardTracksAgentSourcedBranchAction(t *testing.T) {
|
|
r := &recorder{suggestBranch: "feat/agent"}
|
|
m := NewModel(baseConfig(r))
|
|
m = drain(m, m.Init())
|
|
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyEnter})
|
|
|
|
if !containsWizardEvent(r.telemetry, "branch_created", "source", "agent") {
|
|
t.Fatal("expected branch_created telemetry with agent source")
|
|
}
|
|
}
|
|
|
|
func containsWizardEvent(events []wizardTelemetryEvent, action, field string, want any) bool {
|
|
for _, event := range events {
|
|
if event.action != action {
|
|
continue
|
|
}
|
|
if field == "" {
|
|
return true
|
|
}
|
|
if got, ok := event.fields[field]; ok && got == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func TestWaitForRun_CalledAfterPushWithTargetBranch(t *testing.T) {
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.CurrentBranch = "feat/x"
|
|
cfg.NeedsBranch = false
|
|
cfg.IsDirty = false
|
|
|
|
var gotBranch string
|
|
var called int
|
|
cfg.WaitForRun = func(_ context.Context, branch string) error {
|
|
called++
|
|
gotBranch = branch
|
|
return nil
|
|
}
|
|
|
|
m := NewModel(cfg)
|
|
m = drain(m, m.Init())
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")})
|
|
|
|
if called != 1 {
|
|
t.Fatalf("WaitForRun called %d times, want 1", called)
|
|
}
|
|
if gotBranch != "feat/x" {
|
|
t.Fatalf("WaitForRun got branch %q, want feat/x", gotBranch)
|
|
}
|
|
if !m.success {
|
|
t.Fatal("expected success after wait completes")
|
|
}
|
|
if !m.quitting {
|
|
t.Fatal("expected wizard to quit after wait completes")
|
|
}
|
|
if m.err != nil {
|
|
t.Fatalf("unexpected err after successful wait: %v", m.err)
|
|
}
|
|
}
|
|
|
|
func TestWaitForRun_NotCalledWhenNil(t *testing.T) {
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.CurrentBranch = "feat/x"
|
|
cfg.NeedsBranch = false
|
|
cfg.IsDirty = false
|
|
cfg.WaitForRun = nil
|
|
|
|
m := NewModel(cfg)
|
|
m = drain(m, m.Init())
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")})
|
|
|
|
if !m.success || !m.quitting {
|
|
t.Fatal("nil WaitForRun should fall through to immediate quit")
|
|
}
|
|
}
|
|
|
|
func TestWaitForRun_NotCalledWhenUserDeclinesPush(t *testing.T) {
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.CurrentBranch = "feat/x"
|
|
cfg.NeedsBranch = false
|
|
cfg.IsDirty = false
|
|
called := false
|
|
cfg.WaitForRun = func(context.Context, string) error {
|
|
called = true
|
|
return nil
|
|
}
|
|
|
|
m := NewModel(cfg)
|
|
m = drain(m, m.Init())
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")})
|
|
|
|
if called {
|
|
t.Fatal("WaitForRun must not run when push is declined")
|
|
}
|
|
if !m.aborted {
|
|
t.Fatal("expected aborted on decline")
|
|
}
|
|
}
|
|
|
|
func TestWaitForRun_ErrorSurfacesInResult(t *testing.T) {
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.CurrentBranch = "feat/x"
|
|
cfg.NeedsBranch = false
|
|
cfg.IsDirty = false
|
|
cfg.WaitForRun = func(context.Context, string) error {
|
|
return errors.New("run never appeared")
|
|
}
|
|
|
|
m := NewModel(cfg)
|
|
m = drain(m, m.Init())
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")})
|
|
|
|
if !m.quitting {
|
|
t.Fatal("wizard should quit even when wait fails")
|
|
}
|
|
if m.err == nil || !strings.Contains(m.err.Error(), "run never appeared") {
|
|
t.Fatalf("expected wait error in m.err, got %v", m.err)
|
|
}
|
|
res := m.Result()
|
|
if !res.Pushed {
|
|
t.Fatal("Pushed should remain true even if wait errored")
|
|
}
|
|
if res.Err == nil {
|
|
t.Fatal("Result.Err should reflect the wait failure")
|
|
}
|
|
if res.Success {
|
|
t.Fatal("Result.Success must be false when wait fails (issue #122)")
|
|
}
|
|
}
|
|
|
|
func TestWaitForRun_CompletedTelemetrySkippedOnWaitError(t *testing.T) {
|
|
r := &recorder{}
|
|
cfg := baseConfig(r)
|
|
cfg.CurrentBranch = "feat/x"
|
|
cfg.NeedsBranch = false
|
|
cfg.IsDirty = false
|
|
cfg.WaitForRun = func(context.Context, string) error {
|
|
return errors.New("timeout")
|
|
}
|
|
|
|
m := NewModel(cfg)
|
|
m = drain(m, m.Init())
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")})
|
|
|
|
if containsWizardEvent(r.telemetry, "completed", "", nil) {
|
|
t.Fatal("completed telemetry should not fire when wait fails")
|
|
}
|
|
if !containsWizardEvent(r.telemetry, "pushed", "step", "push") {
|
|
t.Fatal("pushed telemetry should still fire")
|
|
}
|
|
}
|
|
|
|
func TestWaitForRun_IgnoresStrayKeys(t *testing.T) {
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.CurrentBranch = "feat/x"
|
|
cfg.NeedsBranch = false
|
|
cfg.IsDirty = false
|
|
cfg.WaitForRun = func(context.Context, string) error { return nil }
|
|
|
|
m := NewModel(cfg)
|
|
m = drain(m, m.Init())
|
|
|
|
next, _ := m.executeStep(m.activeStep(), "")
|
|
m = next.(Model)
|
|
next, _ = m.Update(actionMsg{id: stepPush})
|
|
m = next.(Model)
|
|
|
|
if !m.waiting {
|
|
t.Fatal("expected wizard to be in wait-for-run mode")
|
|
}
|
|
if m.quitting {
|
|
t.Fatal("wait-for-run mode should not already be quitting")
|
|
}
|
|
|
|
for _, key := range []tea.KeyMsg{
|
|
{Type: tea.KeyRunes, Runes: []rune("x")},
|
|
{Type: tea.KeyEnter},
|
|
} {
|
|
next, _ = m.Update(key)
|
|
m = next.(Model)
|
|
if !m.waiting {
|
|
t.Fatalf("expected wait-for-run mode to ignore %q", key.String())
|
|
}
|
|
if m.quitting {
|
|
t.Fatalf("expected wait-for-run mode to keep running after %q", key.String())
|
|
}
|
|
if err := m.ctx.Err(); err != nil {
|
|
t.Fatalf("expected wait-for-run context to stay active after %q, got %v", key.String(), err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPushStep_DeclineAborts(t *testing.T) {
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.CurrentBranch = "feat/x"
|
|
cfg.NeedsBranch = false
|
|
cfg.IsDirty = false
|
|
r := &recorder{}
|
|
cfg.CreateBranch = r.deps().CreateBranch
|
|
cfg.CommitAll = r.deps().CommitAll
|
|
cfg.Push = r.deps().Push
|
|
cfg.SuggestBranch = r.deps().SuggestBranch
|
|
cfg.SuggestCommit = r.deps().SuggestCommit
|
|
m := NewModel(cfg)
|
|
m = drain(m, m.Init())
|
|
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("n")})
|
|
|
|
if r.pushedBranch != "" {
|
|
t.Fatalf("Push should not have run, got %q", r.pushedBranch)
|
|
}
|
|
if !m.aborted {
|
|
t.Fatal("expected aborted = true")
|
|
}
|
|
if m.success {
|
|
t.Fatal("declining push should not be success")
|
|
}
|
|
}
|
|
|
|
func TestActionFailure(t *testing.T) {
|
|
r := &recorder{createBranchErr: errors.New("branch exists")}
|
|
m := NewModel(baseConfig(r))
|
|
m = drain(m, m.Init())
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("dup")})
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyEnter})
|
|
|
|
if m.steps[0].status != statFailed {
|
|
t.Fatalf("expected failed, got %v", m.steps[0].status)
|
|
}
|
|
if m.steps[0].errMsg == "" {
|
|
t.Fatal("expected error message")
|
|
}
|
|
}
|
|
|
|
func TestRetryAfterFailure(t *testing.T) {
|
|
r := &recorder{createBranchErr: errors.New("branch exists")}
|
|
m := NewModel(baseConfig(r))
|
|
m = drain(m, m.Init())
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("dup")})
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyEnter})
|
|
if m.steps[0].status != statFailed {
|
|
t.Fatalf("precondition: expected failed, got %v", m.steps[0].status)
|
|
}
|
|
|
|
// Clear the error and press r to retry.
|
|
r.createBranchErr = nil
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("r")})
|
|
if m.steps[0].status != statInput {
|
|
t.Fatalf("expected retry to re-enter input, got %v", m.steps[0].status)
|
|
}
|
|
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("fresh")})
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyEnter})
|
|
if m.steps[0].status != statDone {
|
|
t.Fatalf("expected retry to succeed, got %v", m.steps[0].status)
|
|
}
|
|
}
|
|
|
|
func TestQuit_NoSideEffects_SinglePress(t *testing.T) {
|
|
m := NewModel(baseConfig(&recorder{}))
|
|
m = drain(m, m.Init())
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
|
|
if !m.aborted || !m.quitting {
|
|
t.Fatal("single q with no side-effects should quit immediately")
|
|
}
|
|
}
|
|
|
|
func TestQuit_WithSideEffects_RequiresConfirm(t *testing.T) {
|
|
r := &recorder{}
|
|
m := NewModel(baseConfig(r))
|
|
m = drain(m, m.Init())
|
|
// Complete branch step (side-effect).
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("feat/x")})
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyEnter})
|
|
if !m.branchCreated {
|
|
t.Fatal("precondition: branch should be created")
|
|
}
|
|
|
|
// First q sets confirm, does not quit.
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
|
|
if m.quitting {
|
|
t.Fatal("first q should not quit when there are side-effects")
|
|
}
|
|
if !m.confirmQuit {
|
|
t.Fatal("first q should set confirmQuit")
|
|
}
|
|
|
|
// Second q actually quits.
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
|
|
if !m.aborted || !m.quitting {
|
|
t.Fatal("second q should abort")
|
|
}
|
|
}
|
|
|
|
func TestConfirmQuit_ResetsOnOtherKey(t *testing.T) {
|
|
r := &recorder{}
|
|
m := NewModel(baseConfig(r))
|
|
m = drain(m, m.Init())
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("feat/x")})
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyEnter})
|
|
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
|
|
if !m.confirmQuit {
|
|
t.Fatal("expected confirmQuit set")
|
|
}
|
|
// Any other key resets.
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("x")})
|
|
if m.confirmQuit {
|
|
t.Fatal("non-q key should clear confirmQuit")
|
|
}
|
|
}
|
|
|
|
func TestQuit_CancelsInFlightSuggestion(t *testing.T) {
|
|
ctxCh := make(chan context.Context, 1)
|
|
done := make(chan tea.Msg, 1)
|
|
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.SuggestBranch = func(ctx context.Context) (string, error) {
|
|
ctxCh <- ctx
|
|
<-ctx.Done()
|
|
return "", ctx.Err()
|
|
}
|
|
|
|
m := NewModel(cfg)
|
|
cmd := m.suggestCmd(stepBranch)
|
|
go func() {
|
|
done <- cmd()
|
|
}()
|
|
|
|
var suggestCtx context.Context
|
|
select {
|
|
case suggestCtx = <-ctxCh:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for suggestion to start")
|
|
}
|
|
|
|
if err := suggestCtx.Err(); err != nil {
|
|
t.Fatalf("suggestion context should start active, got %v", err)
|
|
}
|
|
|
|
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
|
|
m = next.(Model)
|
|
|
|
if !m.aborted || !m.quitting {
|
|
t.Fatal("quit should abort the wizard")
|
|
}
|
|
|
|
select {
|
|
case msg := <-done:
|
|
suggestion, ok := msg.(suggestionMsg)
|
|
if !ok {
|
|
t.Fatalf("expected suggestionMsg, got %T", msg)
|
|
}
|
|
if !errors.Is(suggestion.err, context.Canceled) {
|
|
t.Fatalf("expected context canceled, got %v", suggestion.err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for suggestion cancellation")
|
|
}
|
|
}
|
|
|
|
func TestQuit_CancelsInFlightGitActions(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
run func(Model, string) tea.Cmd
|
|
wantMsg stepID
|
|
}{
|
|
{name: "create branch", run: func(m Model, value string) tea.Cmd { return m.runCreateBranch(value) }, wantMsg: stepBranch},
|
|
{name: "commit", run: func(m Model, value string) tea.Cmd { return m.runCommit(value) }, wantMsg: stepCommit},
|
|
{name: "push", run: func(m Model, value string) tea.Cmd { return m.runPush() }, wantMsg: stepPush},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
ctxCh := make(chan context.Context, 1)
|
|
done := make(chan tea.Msg, 1)
|
|
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.CurrentBranch = "feat/x"
|
|
cfg.NeedsBranch = false
|
|
cfg.IsDirty = false
|
|
cfg.CreateBranch = func(ctx context.Context, _ string) error {
|
|
ctxCh <- ctx
|
|
<-ctx.Done()
|
|
return ctx.Err()
|
|
}
|
|
cfg.CommitAll = func(ctx context.Context, _ string) error {
|
|
ctxCh <- ctx
|
|
<-ctx.Done()
|
|
return ctx.Err()
|
|
}
|
|
cfg.Push = func(ctx context.Context, _ string) error {
|
|
ctxCh <- ctx
|
|
<-ctx.Done()
|
|
return ctx.Err()
|
|
}
|
|
|
|
m := NewModel(cfg)
|
|
cmd := tc.run(m, "value")
|
|
go func() {
|
|
done <- cmd()
|
|
}()
|
|
|
|
var actionCtx context.Context
|
|
select {
|
|
case actionCtx = <-ctxCh:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for action to start")
|
|
}
|
|
|
|
if err := actionCtx.Err(); err != nil {
|
|
t.Fatalf("action context should start active, got %v", err)
|
|
}
|
|
|
|
next, _ := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")})
|
|
m = next.(Model)
|
|
|
|
if !m.aborted || !m.quitting {
|
|
t.Fatal("quit should abort the wizard")
|
|
}
|
|
|
|
select {
|
|
case msg := <-done:
|
|
action, ok := msg.(actionMsg)
|
|
if !ok {
|
|
t.Fatalf("expected actionMsg, got %T", msg)
|
|
}
|
|
if action.id != tc.wantMsg {
|
|
t.Fatalf("expected action id %v, got %v", tc.wantMsg, action.id)
|
|
}
|
|
if !errors.Is(action.err, context.Canceled) {
|
|
t.Fatalf("expected context canceled, got %v", action.err)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timed out waiting for action cancellation")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCtrlCAborts(t *testing.T) {
|
|
m := NewModel(baseConfig(&recorder{}))
|
|
m = drain(m, m.Init())
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyCtrlC})
|
|
if !m.aborted || !m.quitting {
|
|
t.Fatal("ctrl+c should abort regardless of side-effects")
|
|
}
|
|
}
|
|
|
|
func TestResult(t *testing.T) {
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.CurrentBranch = "feat/x"
|
|
cfg.NeedsBranch = false
|
|
cfg.IsDirty = false
|
|
r := &recorder{}
|
|
cfg.CreateBranch = r.deps().CreateBranch
|
|
cfg.CommitAll = r.deps().CommitAll
|
|
cfg.Push = r.deps().Push
|
|
cfg.SuggestBranch = r.deps().SuggestBranch
|
|
cfg.SuggestCommit = r.deps().SuggestCommit
|
|
m := NewModel(cfg)
|
|
m = drain(m, m.Init())
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")})
|
|
|
|
res := m.Result()
|
|
if !res.Success {
|
|
t.Fatal("expected Success")
|
|
}
|
|
if !res.Pushed {
|
|
t.Fatal("expected Pushed")
|
|
}
|
|
if res.TargetBranch != "feat/x" {
|
|
t.Fatalf("expected TargetBranch feat/x, got %q", res.TargetBranch)
|
|
}
|
|
}
|
|
|
|
func TestWaitForRun_CalledAfterPush(t *testing.T) {
|
|
cfg := baseConfig(&recorder{})
|
|
cfg.CurrentBranch = "feat/x"
|
|
cfg.NeedsBranch = false
|
|
cfg.IsDirty = false
|
|
|
|
called := 0
|
|
gotBranch := ""
|
|
cfg.WaitForRun = func(_ context.Context, branch string) error {
|
|
called++
|
|
gotBranch = branch
|
|
return nil
|
|
}
|
|
|
|
m := NewModel(cfg)
|
|
m = drain(m, m.Init())
|
|
m = advance(m, tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("y")})
|
|
|
|
if called != 1 {
|
|
t.Fatalf("WaitForRun called %d times, want 1", called)
|
|
}
|
|
if gotBranch != "feat/x" {
|
|
t.Fatalf("WaitForRun branch = %q, want %q", gotBranch, "feat/x")
|
|
}
|
|
if !m.success || !m.quitting {
|
|
t.Fatalf("expected successful quit after wait, got success=%v quitting=%v", m.success, m.quitting)
|
|
}
|
|
if m.err != nil {
|
|
t.Fatalf("unexpected wait error: %v", m.err)
|
|
}
|
|
if !m.Result().Pushed {
|
|
t.Fatal("expected pushed result to remain true")
|
|
}
|
|
}
|