diff --git a/internal/git/sync_test.go b/internal/git/sync_test.go index 98981f44..59836a15 100644 --- a/internal/git/sync_test.go +++ b/internal/git/sync_test.go @@ -145,6 +145,10 @@ func TestSyncBranchRefusesWhatItCannotFastForward(t *testing.T) { f.git(f.work, "switch", "experiment") }, }, + { + name: "checked out in another worktree", message: "main is checked out in the worktree", + prepare: func(f syncFixture) { f.git(f.work, "worktree", "add", filepath.Join(t.TempDir(), "linked"), "main") }, + }, } { t.Run(tc.name, func(t *testing.T) { f := newSyncFixture(t) @@ -158,24 +162,6 @@ func TestSyncBranchRefusesWhatItCannotFastForward(t *testing.T) { } } -func TestSyncBranchRefusesABranchCheckedOutInAnotherWorktree(t *testing.T) { - f := newSyncFixture(t) - linked := filepath.Join(t.TempDir(), "linked") - f.git(f.work, "worktree", "add", linked, "main") - before := f.git(f.work, "rev-parse", "main") - _, err := f.sync("main") - require.ErrorContains(t, err, "main is checked out in the worktree") - require.Equal(t, before, f.git(f.work, "rev-parse", "main")) - require.Equal(t, "experiment", f.git(f.work, "branch", "--show-current")) -} - -func TestWorktreeWithBranch(t *testing.T) { - porcelain := "worktree /src\nHEAD abc\nbranch refs/heads/experiment\n\nworktree /linked\nHEAD def\nbranch refs/heads/main\n\nworktree /detached\nHEAD 123\ndetached\n" - require.Equal(t, "/linked", worktreeWithBranch(porcelain, "refs/heads/main")) - require.Equal(t, "/src", worktreeWithBranch(porcelain, "refs/heads/experiment")) - require.Empty(t, worktreeWithBranch(porcelain, "refs/heads/other")) -} - func TestSyncBranchValidatesItsInput(t *testing.T) { f := newSyncFixture(t) _, err := SyncBranch(context.Background(), f.runner, SyncOptions{Directory: f.work, Remote: "origin"}) diff --git a/internal/services/actions_test.go b/internal/services/actions_test.go index 026bec51..3f0b9322 100644 --- a/internal/services/actions_test.go +++ b/internal/services/actions_test.go @@ -111,31 +111,6 @@ func TestWatchReportsEveryPollWithJobsUntilTheRunCompletes(t *testing.T) { require.True(t, errors.Is(err, context.Canceled)) } -func TestRunCancellationPreservesHistory(t *testing.T) { - for _, force := range []bool{false, true} { - t.Run(fmt.Sprint(force), func(t *testing.T) { - mutations := 0 - action := "cancel" - if force { - action = "force-cancel" - } - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mutations++ - require.Equal(t, http.MethodPost, r.Method) - require.Equal(t, "/api/v1/repos/owner/repo/actions/runs/7/"+action, r.URL.Path) - _, _ = w.Write([]byte(`{"id":7,"status":"completed","conclusion":"cancelled"}`)) //nolint:misspell // Gitea API spelling. - })) - defer server.Close() - service := Actions{Client: transport.New(server.URL, "", false), Repository: "owner/repo"} - result, err := service.CancelRun(context.Background(), 7, force) - require.NoError(t, err) - require.EqualValues(t, 7, result.ID) - require.Equal(t, "cancelled", result.Conclusion) //nolint:misspell // Gitea API spelling. - require.Equal(t, 1, mutations) - }) - } -} - func TestActionMutationsUseTheirEndpoints(t *testing.T) { for _, tc := range []struct { name string @@ -158,6 +133,15 @@ func TestActionMutationsUseTheirEndpoints(t *testing.T) { {"delete", "/api/v1/repos/owner/repo/actions/runs/7", http.MethodDelete, func(s Actions) error { return s.DeleteRun(context.Background(), 7) }}, + {"cancel", "/api/v1/repos/owner/repo/actions/runs/7/cancel", http.MethodPost, func(s Actions) error { + run, err := s.CancelRun(context.Background(), 7, false) + require.EqualValues(t, 7, run.ID) + return err + }}, + {"force-cancel", "/api/v1/repos/owner/repo/actions/runs/7/force-cancel", http.MethodPost, func(s Actions) error { + _, err := s.CancelRun(context.Background(), 7, true) + return err + }}, } { t.Run(tc.name, func(t *testing.T) { mutations := 0 @@ -165,8 +149,7 @@ func TestActionMutationsUseTheirEndpoints(t *testing.T) { mutations++ require.Equal(t, tc.method, r.Method) require.Equal(t, tc.path, r.URL.Path) - if tc.name == "rerun" { - w.WriteHeader(http.StatusCreated) + if tc.method == http.MethodPost { _, _ = w.Write([]byte(`{"id":7,"status":"waiting"}`)) return } diff --git a/internal/terminal/form_fields_test.go b/internal/terminal/form_fields_test.go index 17ad48a2..a5d0aac0 100644 --- a/internal/terminal/form_fields_test.go +++ b/internal/terminal/form_fields_test.go @@ -57,12 +57,3 @@ func TestFormsRefuseTerminalsWithoutSize(t *testing.T) { require.NoError(t, err) require.Equal(t, "Title", values.Title) } - -func TestPullFormNamesNoIssueFields(t *testing.T) { - p, rendered := accessiblePrompter(t, "n\nmain\nalice\nfeature\ny\nTitle\nBody\n") - _, err := p.CreatePull(context.Background(), PullFormChoices{}) - require.NoError(t, err) - require.Contains(t, rendered.String(), "Title (required):") - require.Contains(t, rendered.String(), BodyTitle) - require.NotContains(t, rendered.String(), "Issue") -} diff --git a/internal/terminal/issue_form_test.go b/internal/terminal/issue_form_test.go index eca45943..e3134127 100644 --- a/internal/terminal/issue_form_test.go +++ b/internal/terminal/issue_form_test.go @@ -6,11 +6,9 @@ package terminal import ( "bytes" "context" - "io" "path/filepath" "strings" "testing" - "time" "github.com/stretchr/testify/require" ) @@ -31,55 +29,28 @@ func TestCollectIssuePropertiesUsesEditBodyDraft(t *testing.T) { require.NotEmpty(t, values.Draft.Path) } -// lineReader returns one line per read, so that each accessible prompt reads only its answer. -type lineReader struct{ text string } - -func (r *lineReader) Read(p []byte) (int, error) { - if r.text == "" { - return 0, io.EOF - } - line, _, _ := strings.Cut(r.text, "\n") - n := copy(p, line+"\n") - r.text = r.text[min(n, len(r.text)):] - return n, nil -} - func TestIssueFormAsksWhatsNextBeforeMetadata(t *testing.T) { choices := IssueFormChoices{AskNext: true, CanPush: true, Assignees: []string{"alice"}, EditBody: noEdit} - for _, test := range []struct { + for _, tc := range []struct { name, input string - canceled bool + want IssueFormValues + err error }{ - {"submit", "Title\n1\n", false}, - {"cancel", "Title\n3\n", true}, + {name: "submit", input: "Title\n1\n", want: IssueFormValues{Title: "Title"}}, + {name: "cancel", input: "Title\n3\n", err: ErrCanceled}, + {name: "metadata", input: "Title\n2\n1\n0\n2028-01-15\n", want: IssueFormValues{Title: "Title", Assignees: []string{"alice"}, Deadline: mustDueDate(t, "2028-01-15")}}, } { - t.Run(test.name, func(t *testing.T) { - t.Setenv("TERM", "dumb") - p, rendered := testPrompter(&lineReader{text: test.input}) + t.Run(tc.name, func(t *testing.T) { + p, rendered := linePrompter(t, tc.input) values, err := p.collectIssueProperties(context.Background(), "", "", choices) + require.ErrorIs(t, err, tc.err) require.Contains(t, rendered.String(), "What's next?") - require.Contains(t, rendered.String(), "Add metadata") - require.NotContains(t, rendered.String(), "Assignees:") - if test.canceled { - require.ErrorIs(t, err, ErrCanceled) - return + require.Equal(t, tc.name == "metadata", strings.Contains(rendered.String(), "Assignees:")) + if tc.err == nil { + require.Equal(t, tc.want, values) } - require.NoError(t, err) - require.Equal(t, "Title", values.Title) - require.Empty(t, values.Assignees) }) } - t.Setenv("TERM", "dumb") - p, rendered := testPrompter(&lineReader{text: "Title\n2\n1\n0\n2028-01-15\n"}) - values, err := p.collectIssueProperties(context.Background(), "", "", choices) - require.NoError(t, err) - require.Contains(t, rendered.String(), "Assignees:") - require.Equal(t, []string{"alice"}, values.Assignees) - require.Equal(t, "2028-01-15", values.Deadline.Format(time.DateOnly)) - p, rendered = accessiblePrompter(t, "Title\n") - _, err = p.collectIssueProperties(context.Background(), "", "", IssueFormChoices{EditBody: noEdit}) - require.NoError(t, err) - require.NotContains(t, rendered.String(), "What's next?") } func TestIssueFormMarksTitleRequired(t *testing.T) { @@ -87,4 +58,5 @@ func TestIssueFormMarksTitleRequired(t *testing.T) { _, err := p.collectIssueProperties(context.Background(), "", "", IssueFormChoices{EditBody: noEdit}) require.NoError(t, err) require.Contains(t, rendered.String(), "Title (required):") + require.NotContains(t, rendered.String(), "What's next?") } diff --git a/internal/terminal/streams_test.go b/internal/terminal/streams_test.go index 7e4fc8c7..08fc092a 100644 --- a/internal/terminal/streams_test.go +++ b/internal/terminal/streams_test.go @@ -45,15 +45,6 @@ func TestPromptPolicy(t *testing.T) { } { require.Equal(t, tc.expected, tc.policy.CanPrompt(tc.streams)) } - prompt := &askingPrompt{} - value := "supplied" - asked, err := RequireInput(context.Background(), &value, "title", "title", nil, Streams{}, Policy{}, prompt) - require.NoError(t, err) - require.False(t, asked) - require.Empty(t, prompt.titles) - value = "" - _, err = RequireInput(context.Background(), &value, "title", "title", nil, Streams{}, Policy{}, prompt) - require.ErrorContains(t, err, "--title") } // askingPrompt answers Asker prompts from scripted answers. Ask skips answers that validate rejects, @@ -99,17 +90,19 @@ func TestConfirmAction(t *testing.T) { c Confirmation yes bool streams Streams - prompt Prompter + prompt *askingPrompt code int message string rejected []string + defaults []bool }{ {name: "accepted by flag", c: deleteLabel, yes: true, prompt: &askingPrompt{}}, {name: "names the action without a terminal", c: deleteLabel, prompt: &askingPrompt{}, code: clierr.Usage, message: `confirmation required to delete label "bug" from alice/x; rerun with --yes`}, {name: "names the prompted action without a terminal", c: Confirmation{Prompt: "Delete alice/x? It becomes read-only."}, prompt: &askingPrompt{}, code: clierr.Usage, message: "confirmation required to delete alice/x; rerun with --yes"}, {name: "without a terminal or action", c: Confirmation{Prompt: "Go?", DefaultYes: true}, prompt: &askingPrompt{}, code: clierr.Usage, message: "confirmation required; rerun with --yes"}, - {name: "accepted", c: deleteLabel, streams: terminal, prompt: &askingPrompt{accept: true}}, - {name: "declined", c: deleteLabel, streams: terminal, prompt: &askingPrompt{}, code: clierr.Canceled, message: "canceled"}, + {name: "accepted", c: deleteLabel, streams: terminal, prompt: &askingPrompt{accept: true}, defaults: []bool{false}}, + {name: "passes the default", c: Confirmation{Prompt: "Create pull request?", DefaultYes: true}, streams: terminal, prompt: &askingPrompt{accept: true}, defaults: []bool{true}}, + {name: "declined", c: deleteLabel, streams: terminal, prompt: &askingPrompt{}, code: clierr.Canceled, message: "canceled", defaults: []bool{false}}, {name: "re-prompts until the name is typed", c: typed, streams: terminal, prompt: &askingPrompt{answers: []string{"alice/y", " Alice/X "}}, rejected: []string{"alice/y"}}, } { t.Run(tc.name, func(t *testing.T) { @@ -121,22 +114,12 @@ func TestConfirmAction(t *testing.T) { if tc.code == clierr.Canceled { require.ErrorIs(t, err, ErrCanceled) } - if asker, ok := tc.prompt.(*askingPrompt); ok { - require.Equal(t, tc.rejected, asker.rejected) - } + require.Equal(t, tc.rejected, tc.prompt.rejected) + require.Equal(t, tc.defaults, tc.prompt.defaults) }) } } -func TestConfirmActionPassesTheDefault(t *testing.T) { - prompt := &askingPrompt{accept: true} - streams := Streams{InTTY: true, ErrTTY: true} - require.NoError(t, ConfirmAction(context.Background(), Confirmation{Prompt: "Create pull request?", DefaultYes: true}, false, streams, Policy{Enabled: true}, prompt)) - require.NoError(t, ConfirmAction(context.Background(), Confirmation{Prompt: "Delete branch?"}, false, streams, Policy{Enabled: true}, prompt)) - require.Equal(t, []bool{true, false}, prompt.defaults) - require.Equal(t, []string{"Create pull request?", "Delete branch?"}, prompt.titles) -} - func TestRequireInput(t *testing.T) { streams, policy := Streams{InTTY: true, ErrTTY: true}, Policy{Enabled: true} hex := func(s string) error { @@ -155,6 +138,9 @@ func TestRequireInput(t *testing.T) { require.Equal(t, []string{"", "red"}, asker.rejected) require.Equal(t, []string{"Color (hex)"}, asker.titles) + asked, err = RequireInput(context.Background(), &value, "color", "Color (hex)", hex, Streams{}, policy, asker) + require.NoError(t, err, "a supplied value needs no prompt") + require.False(t, asked) value = "" _, err = RequireInput(context.Background(), &value, "color", "Color (hex)", hex, Streams{}, policy, asker) require.EqualError(t, err, "missing required input: supply --color") diff --git a/internal/terminal/table_test.go b/internal/terminal/table_test.go index 39690e88..65935874 100644 --- a/internal/terminal/table_test.go +++ b/internal/terminal/table_test.go @@ -4,11 +4,9 @@ package terminal import ( - "strings" "testing" "time" - "github.com/mattn/go-runewidth" "github.com/stretchr/testify/require" ) @@ -29,8 +27,7 @@ func TestTable(t *testing.T) { }{ {"empty", issues, nil, tty, ""}, {"aligns legacy rows without header", nil, [][]Cell{row("7", "Fix login", "open"), row("12", "Crash", "closed")}, tty, "7 Fix login open\n12 Crash closed\n"}, - {"counts runes, not bytes", nil, [][]Cell{row("1", "äöü", "x"), row("2", "abcd", "y")}, tty, "1 äöü x\n2 abcd y\n"}, - {"counts wide runes twice", nil, [][]Cell{row("1", "修复", "x"), row("2", "abcd", "y")}, tty, "1 修复 x\n2 abcd y\n"}, + {"aligns by display width", nil, [][]Cell{row("1", "äöü", "x"), row("2", "修复", "y"), row("3", "abcd", "z")}, tty, "1 äöü x\n2 修复 y\n3 abcd z\n"}, {"trims empty trailing cells", nil, [][]Cell{row("1", "bug", ""), row("22", "feature", "new work")}, tty, "1 bug\n22 feature new work\n"}, {"pads ragged rows", nil, [][]Cell{row("1"), row("2", "two")}, tty, "1\n2 two\n"}, { @@ -122,19 +119,6 @@ func TestTable(t *testing.T) { } } -func TestTableFitsWidthWithWideRunes(t *testing.T) { - titles := []string{"Emoji in title 🚀 works?", "支持中文路径 (support CJK paths)", strings.Repeat("long ", 40)} - rows := make([][]Cell, len(titles)) - for i, title := range titles { - rows[i] = row("#1", title, "alice") - } - for _, width := range []int{30, 50, 80} { - for _, line := range strings.Split(strings.TrimSuffix(Table([]Column{{}, {Flex: true}, {}}, rows, Display{TTY: true, Width: width}), "\n"), "\n") { - require.LessOrEqual(t, runewidth.StringWidth(line), width, line) - } - } -} - func TestSafeText(t *testing.T) { for _, tc := range []struct{ name, in, text, line string }{ {"plain", "fix login", "fix login", "fix login"},