mirror of
https://gitea.com/gitea/tea.git
synced 2026-10-26 11:01:25 +01:00
- `internal/terminal`: streams and the prompting policy, interactive forms (issue, PR, review, merge, milestone), editor / pager / browser launching, JSON and `--jq` output. - An aligned table renderer and a record renderer for human output; both strip control and escape characters from server-supplied text so titles and bodies cannot inject terminal sequences. Reviewed-on: https://gitea.com/gitea/tea/pulls/1124 Reviewed-by: TheFox0x7 <95654+thefox0x7@noreply.gitea.com>
106 lines
3.1 KiB
Go
106 lines
3.1 KiB
Go
// Copyright 2026 The Gitea Authors. All rights reserved.
|
|
// SPDX-License-Identifier: MIT
|
|
|
|
package terminal
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestDefaultPullTitle(t *testing.T) {
|
|
for _, tc := range []struct{ in, want string }{
|
|
{"", ""},
|
|
{"feature", "Feature"},
|
|
{"fix-my-bug", "Fix My Bug"},
|
|
{"alice:add_login", "Add Login"},
|
|
{"über-fix", "Über Fix"},
|
|
} {
|
|
require.Equal(t, tc.want, DefaultPullTitle(tc.in), tc.in)
|
|
}
|
|
}
|
|
|
|
func TestPullFormMarksRequiredFields(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name, input string
|
|
choices PullFormChoices
|
|
want, without []string
|
|
}{
|
|
{
|
|
"without head", "n\nmain\nalice\nfeature\ny\nTitle\n",
|
|
PullFormChoices{},
|
|
[]string{"Target branch (required):", "Source branch (required):", "Issue title (required):"},
|
|
nil,
|
|
},
|
|
{
|
|
"with head", "n\n\n\n\n\nTitle\n",
|
|
PullFormChoices{Base: "main", HeadOwner: "alice", HeadBranch: "feature"},
|
|
[]string{"Source branch:", "Allow maintainers to push to the source branch:"},
|
|
[]string{"Source branch (required)"},
|
|
},
|
|
{"agit", "y\nmain\ntopic\nTitle\n", PullFormChoices{}, []string{"Topic branch (required):"}, nil},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
p, rendered := accessiblePrompter(t, tc.input)
|
|
tc.choices.EditBody = noEdit
|
|
_, err := p.CreatePull(context.Background(), tc.choices)
|
|
require.NoError(t, err)
|
|
for _, want := range tc.want {
|
|
require.Contains(t, rendered.String(), want)
|
|
}
|
|
for _, unwanted := range tc.without {
|
|
require.NotContains(t, rendered.String(), unwanted)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// stagedInput feeds one form's keystrokes per stage. A finished form's canceled read loop can still be inside Read,
|
|
// and bubbletea drops the byte that Read returns. When a stage runs dry, the finished form's loop blocks first and
|
|
// the next form's loop second, so the next stage is handed to that second reader and never to the stale one.
|
|
type stagedInput struct {
|
|
mu sync.Mutex
|
|
cur string
|
|
stages []string
|
|
idle int
|
|
}
|
|
|
|
func (s *stagedInput) Read(p []byte) (int, error) {
|
|
s.mu.Lock()
|
|
for s.cur == "" {
|
|
s.idle++
|
|
if s.idle < 2 || len(s.stages) == 0 {
|
|
s.mu.Unlock()
|
|
select {} // no more input for this reader; the loop is abandoned with its form
|
|
}
|
|
s.cur, s.stages, s.idle = s.stages[0], s.stages[1:], 0
|
|
}
|
|
p[0], s.cur = s.cur[0], s.cur[1:]
|
|
s.mu.Unlock()
|
|
return 1, nil
|
|
}
|
|
|
|
func TestCreatePullPreservesDraftOnEditorFailure(t *testing.T) {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
path := filepath.Join(t.TempDir(), "draft.md")
|
|
called := false
|
|
p, _ := testPrompter(&stagedInput{cur: "n\r", stages: []string{"main\ralice\rfeature\ry\r", "Title\r"}})
|
|
values, err := p.CreatePull(ctx, PullFormChoices{
|
|
BaseOwner: "alice", Base: "main", HeadOwner: "alice", HeadBranch: "feature",
|
|
EditBody: func(context.Context, string) (Draft, error) {
|
|
called = true
|
|
return Draft{Path: path, Body: "partial"}, errors.New("editor failed")
|
|
},
|
|
})
|
|
require.True(t, called)
|
|
require.ErrorContains(t, err, "editor failed")
|
|
require.Equal(t, path, values.Draft.Path)
|
|
}
|