Files
gitea-tea/internal/app/human_output_test.go
T
bircni 55574a9fd0 feat: fit list tables to the terminal and write TSV when piped
Lists had no header, wrapped every row once a title was longer than
the terminal, and were space-padded when piped, so cut -f and awk -F'\t'
could not split them and empty cells collapsed.

terminal.Table now takes column specs and a Display. On a terminal it
prints an uppercase header, aligns columns two spaces apart and shrinks
flexible columns, widest first, to Streams.Width (the terminal, then
$COLUMNS, then 80), cutting by display width with "…". States use the
terminal's ANSI 16 colors, hex colors fall back to the 256-color palette
unless COLORTERM advertises 24-bit, and OSC 8 links appear only on the
first column, independently of color (TEA_HYPERLINKS=auto|always|never).
Piped tables are tab-separated without header, color or links, and times
are RFC3339 UTC instead of "about 2 hours ago".

terminal.View lays out single resources (title, badges, metadata, fields,
body, footer URL) and renders markdown bodies with glamour on a terminal
only (TEA_MARKDOWN=raw keeps them raw). Shared formatters cover relative
times, timestamps, sizes, durations and status icons.

In the app, writeTable and writeView are the new list and view writers:
a "Showing N of M …" context line, an empty-state message on stderr and
the next-page hint. writeList, writeItem and batch keep working and now
render through the new table: tab-separated when piped, truncated at the
widest column on a terminal, with headers for issue list and pr list.

Signed-off-by: bircni <bircni@icloud.com>
2026-10-11 22:43:10 +02:00

366 lines
16 KiB
Go

// Copyright 2026 The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
package app
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"testing"
"time"
"gitea.dev/tea/internal/terminal"
"github.com/mattn/go-runewidth"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)
type recordingPager struct {
command string
input string
calls int
}
func (p *recordingPager) Page(_ context.Context, command string, input io.Reader, streams terminal.Streams) error {
p.calls++
p.command = command
data, err := io.ReadAll(input)
if err != nil {
return err
}
p.input = string(data)
_, err = io.WriteString(streams.Out, "PAGED:"+string(data))
return err
}
// pagedDeps returns serverDeps on a terminal stdout with a recording pager,
// extra environment variables and the given config settings.
func pagedDeps(t *testing.T, serverURL string, env map[string]string, config ...string) (Dependencies, *bytes.Buffer, *bytes.Buffer, *recordingPager) {
t.Helper()
deps, out, diagnostics := testDeps(t)
pager := &recordingPager{}
deps.Pager = pager
deps.Streams.OutTTY = true
deps.Getenv = serverEnv(serverURL, env)
for i := 0; i < len(config); i += 2 {
require.NoError(t, Execute(context.Background(), deps, []string{"config", "set", config[i], config[i+1]}))
}
return deps, out, diagnostics, pager
}
func TestLabelListAppliesColorAndPagesWithNoInput(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, "/api/v1/repos/alice/project/labels", r.URL.Path)
_, _ = w.Write([]byte(`[{"id":1,"name":"bug","color":"ff0000","description":"Defect"},{"id":2,"name":"docs","color":"00ff00","description":"Docs"}]`))
}))
defer server.Close()
deps, out, diagnostics, pager := pagedDeps(t, server.URL, map[string]string{"PAGER": "fake-pager", "COLORTERM": "truecolor"}, "color", "always")
require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"}))
require.Equal(t, 1, pager.calls)
require.Equal(t, "fake-pager", pager.command)
require.Contains(t, pager.input, "\x1b[38;2;255;0;0mbug\x1b[0m")
require.Contains(t, pager.input, "\x1b[38;2;0;255;0mdocs\x1b[0m")
require.Equal(t, "PAGED:"+pager.input, out.String())
require.Empty(t, diagnostics.String())
}
func TestIssueListRespectsColorNeverAndStillPages(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{"number":1,"title":"One","state":"open"},{"number":2,"title":"Two","state":"closed"}]`))
}))
defer server.Close()
deps, out, _, pager := pagedDeps(t, server.URL, nil, "color", "never", "pager", "configured-pager")
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "list", "-R", "alice/project", "--no-input"}))
require.Equal(t, 1, pager.calls)
require.Equal(t, "configured-pager", pager.command)
require.NotContains(t, pager.input, "\x1b[")
require.Contains(t, pager.input, "open")
require.Contains(t, pager.input, "closed")
require.Equal(t, "PAGED:"+pager.input, out.String())
}
func TestIssueViewJSONDoesNotPage(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = fmt.Fprint(w, `{"number":7,"title":"Title","html_url":"https://example.org/alice/project/issues/7","body":"line1\nline2\nline3"}`)
}))
defer server.Close()
deps, out, _, pager := pagedDeps(t, server.URL, map[string]string{"PAGER": "fake-pager"})
require.NoError(t, Execute(context.Background(), deps, []string{"issue", "view", "7", "-R", "alice/project", "--json=number,title", "--no-input"}))
require.Zero(t, pager.calls)
require.JSONEq(t, `{"number":7,"title":"Title"}`, out.String())
}
func TestConfiguredPagerOverridesPagerEnvironment(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{"number":1,"title":"One"},{"number":2,"title":"Two"}]`))
}))
defer server.Close()
deps, _, _, pager := pagedDeps(t, server.URL, map[string]string{"PAGER": "env-pager"})
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "list", "-R", "alice/project", "--no-input"}))
require.Equal(t, "env-pager", pager.command)
require.NoError(t, Execute(context.Background(), deps, []string{"config", "set", "pager", "configured-pager"}))
require.NoError(t, Execute(context.Background(), deps, []string{"pr", "list", "-R", "alice/project", "--no-input"}))
require.Equal(t, "configured-pager", pager.command)
}
func TestTeaPagerAndColorEnvironmentOverrides(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{"id":1,"name":"bug","color":"ff0000","description":""},{"id":2,"name":"docs","color":"00ff00","description":""}]`))
}))
defer server.Close()
env := map[string]string{"TEA_PAGER": "tea-pager", "PAGER": "generic-pager", "TEA_COLOR": "always"}
deps, out, _, pager := pagedDeps(t, server.URL, env, "pager", "configured-pager", "color", "never")
require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"}))
require.Equal(t, "tea-pager", pager.command)
require.Contains(t, pager.input, "\x1b[38;5;196mbug\x1b[0m")
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"config", "get", "pager", "--show-source", "--json"}))
require.JSONEq(t, `{"pager":{"value":"tea-pager","source":"TEA_PAGER"}}`, out.String())
out.Reset()
require.NoError(t, Execute(context.Background(), deps, []string{"config", "get", "color", "--show-source", "--json"}))
require.JSONEq(t, `{"color":{"value":"always","source":"TEA_COLOR"}}`, out.String())
}
func TestColorAutoUsesStdoutTTY(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{"id":1,"name":"bug","color":"ff0000","description":""}]`))
}))
defer server.Close()
deps, out, _ := serverDeps(t, server.URL)
deps.Streams.OutTTY = false
require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"}))
require.Equal(t, "1\tbug\tff0000\t\n", out.String())
out.Reset()
deps.Streams.OutTTY = true
require.NoError(t, Execute(context.Background(), deps, []string{"label", "list", "-R", "alice/project", "--no-input"}))
require.Contains(t, out.String(), "\x1b[38;5;196mbug\x1b[0m")
}
func TestMultiItemHumanOutputPagesOnce(t *testing.T) {
issue := func(n int) string {
return fmt.Sprintf(`{"number":%d,"title":"Issue %d","state":"closed","html_url":"https://example.org/i/%d"}`, n, n, n)
}
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/repos/alice/project/milestones/3":
_, _ = w.Write([]byte(`{"id":3,"title":"v1"}`))
case "/api/v1/repos/alice/project/issues":
_, _ = w.Write([]byte("[" + issue(1) + "," + issue(2) + "]"))
case "/api/v1/repos/alice/project/issues/1", "/api/v1/repos/alice/project/issues/2":
_, _ = w.Write([]byte(issue(int(r.URL.Path[len(r.URL.Path)-1] - '0'))))
case "/api/v1/repos/alice/project/branches":
_, _ = w.Write([]byte(`[{"name":"main","commit":{"id":"abc"}},{"name":"dev","commit":{"id":"def"}}]`))
case "/api/v1/repos/alice/project/issues/5/comments":
_, _ = w.Write([]byte(`[{"id":1,"body":"one","user":{"login":"alice"}},{"id":2,"body":"two","user":{"login":"bob"}}]`))
default:
t.Errorf("unexpected request %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
for _, tc := range []struct {
args []string
want string
}{
{[]string{"milestone", "issue", "list", "3"}, "1 Issue 1 closed\n2 Issue 2 closed\n"},
{[]string{"issue", "close", "1", "2"}, "1 Issue 1 closed\n2 Issue 2 closed\n"},
{[]string{"branch", "list"}, "main abc false\ndev def false\n"},
{[]string{"issue", "comment", "list", "5"}, "1 alice\none\n\n2 bob\ntwo\n\n"},
} {
t.Run(tc.args[0]+" "+tc.args[1], func(t *testing.T) {
deps, out, _, pager := pagedDeps(t, server.URL, map[string]string{"TEA_HYPERLINKS": "never"}, "pager", "fake-pager", "color", "never")
require.NoError(t, Execute(context.Background(), deps, append(tc.args, "-R", "alice/project", "--yes")))
require.Equal(t, 1, pager.calls)
require.Equal(t, tc.want, pager.input)
require.Equal(t, "PAGED:"+tc.want, out.String())
})
}
}
func TestListsHintWhenMorePagesExist(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
size, total := min(limit, 50), 120
items := []string{}
for id := (page-1)*size + 1; id <= min(page*size, total); id++ {
items = append(items, fmt.Sprintf(`{"id":%d,"username":"org%d","full_name":"owner/repo%d"}`, id, id, id))
}
w.Header().Set("X-Total-Count", strconv.Itoa(total))
_, _ = w.Write([]byte("[" + strings.Join(items, ",") + "]"))
}))
defer server.Close()
deps, out, diagnostics := serverDeps(t, server.URL)
for _, args := range [][]string{{"org", "list"}, {"repo", "list"}, {"issue", "list", "-R", "alice/project"}, {"pr", "list", "-R", "alice/project"}} {
out.Reset()
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, append(args, "--limit", "100")))
require.Len(t, strings.Split(strings.TrimSpace(out.String()), "\n"), 100, args)
require.Equal(t, "more results available; use --page 2\n", diagnostics.String(), args)
out.Reset()
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, append(args, "--limit", "100", "--page", "2")))
require.Len(t, strings.Split(strings.TrimSpace(out.String()), "\n"), 20, args)
require.Empty(t, diagnostics.String(), args)
diagnostics.Reset()
require.NoError(t, Execute(context.Background(), deps, append(args, "--json=id")))
require.Empty(t, diagnostics.String(), args)
}
}
func TestListsFitTheTerminalAndSplitWhenPiped(t *testing.T) {
server := newAuthenticatedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte(`[{"number":6,"title":"A very long issue title that keeps going and going to see how list output handles wrapping","state":"open","user":{"login":"alice"}},` +
`{"number":5,"title":"支持中文路径 (support CJK paths)","state":"open","user":{"login":""}}]`))
}))
defer server.Close()
for _, group := range []string{"issue", "pr"} {
t.Run(group, func(t *testing.T) {
deps, out, _ := serverDeps(t, server.URL)
args := []string{group, "list", "-R", "alice/project"}
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, "6\tA very long issue title that keeps going and going to see how list output handles wrapping\topen\talice\n"+
"5\t支持中文路径 (support CJK paths)\topen\t\n", out.String())
out.Reset()
deps.Streams.OutTTY, deps.Streams.Width = true, 50
deps.Getenv = serverEnv(server.URL, map[string]string{"NO_COLOR": "1"})
require.NoError(t, Execute(context.Background(), deps, args))
require.Equal(t, "ID TITLE STATE AUTHOR\n"+
"#6 A very long issue title that k… open alice\n"+
"#5 支持中文路径 (support CJK path… open\n", stripLinks(out.String()))
for _, line := range strings.Split(strings.TrimSpace(out.String()), "\n") {
require.LessOrEqual(t, runewidth.StringWidth(stripLinks(line)), 50, line)
}
})
}
}
// stripLinks removes OSC 8 hyperlinks from rendered output.
func stripLinks(s string) string {
for {
start := strings.Index(s, "\x1b]8;")
if start < 0 {
return s
}
end := strings.Index(s[start:], "\x1b\\")
s = s[:start] + s[start+end+2:]
}
}
// outputApplication returns an application over deps and a command writing to its streams.
func outputApplication(deps Dependencies, json string) (*application, *cobra.Command) {
deps.Now = func() time.Time { return time.Date(2026, 10, 11, 20, 0, 0, 0, time.UTC) }
a := &application{deps: deps, options: options{json: json}}
cmd := &cobra.Command{Use: "list"}
cmd.SetOut(deps.Streams.Out)
cmd.SetErr(deps.Streams.ErrOut)
cmd.SetContext(context.Background())
return a, cmd
}
type listed struct {
Number int64 `json:"number"`
Title string `json:"title"`
Updated time.Time `json:"updated"`
}
var listedSpec = listSpec[listed]{
Columns: []terminal.Column{{Header: "ID", Prefix: "#"}, {Header: "Title", Flex: true}, {Header: "Updated"}},
Row: func(item listed) []terminal.Cell {
return []terminal.Cell{{Text: strconv.FormatInt(item.Number, 10)}, {Text: item.Title}, {Time: item.Updated}}
},
Empty: "no open issues in alice/x",
Context: "open issues in alice/x",
}
func TestWriteTable(t *testing.T) {
updated := time.Date(2026, 10, 11, 18, 0, 0, 0, time.UTC)
items := []listed{{12, "Emoji in title 🚀", updated}, {1, "Login fails", updated}}
for _, tc := range []struct {
name string
tty bool
json string
items []listed
page listPage
out, stderr string
pagedHeadings bool
}{
{
name: "context line and header on a terminal", tty: true, items: items, page: listPage{Number: 1, More: true, Total: 112, Counted: true},
out: "Showing 2 of 112 open issues in alice/x · next: --page 2\n\n" +
"ID TITLE UPDATED\n#12 Emoji in title 🚀 about 2 hours ago\n#1 Login fails about 2 hours ago\n",
},
{
name: "counts a single page without a total", tty: true, items: items[:1], page: listPage{Number: 1},
out: "Showing 1 of 1 open issues in alice/x\n\nID TITLE UPDATED\n#12 Emoji in title 🚀 about 2 hours ago\n",
},
{
name: "tab-separated with the page hint on stderr when piped", items: items, page: listPage{Number: 2, More: true},
out: "12\tEmoji in title 🚀\t2026-10-11T18:00:00Z\n1\tLogin fails\t2026-10-11T18:00:00Z\n",
stderr: "more results available; use --page 3\n",
},
{name: "empty message on a terminal", tty: true, page: listPage{Number: 1}, stderr: "no open issues in alice/x\n"},
{name: "nothing when empty and piped", page: listPage{Number: 1}},
{name: "JSON", tty: true, json: "number", items: items[:1], page: listPage{Number: 1, More: true}, out: `[{"number":12}]` + "\n"},
} {
t.Run(tc.name, func(t *testing.T) {
deps, out, diagnostics := testDeps(t)
deps.Streams.OutTTY = tc.tty
deps.Getenv = envFunc(map[string]string{"NO_COLOR": "1"})
a, cmd := outputApplication(deps, tc.json)
require.NoError(t, writeTable(a, cmd, tc.items, listedSpec, tc.page))
require.Equal(t, tc.out, out.String())
require.Equal(t, tc.stderr, diagnostics.String())
})
}
}
func TestWriteView(t *testing.T) {
view := func(item listed) terminal.View {
return terminal.View{
Title: item.Title, ID: fmt.Sprintf("#%d", item.Number), Badges: []terminal.Cell{{Text: "Open", Color: "green"}},
Meta: "alice opened " + terminal.RelativeTime(time.Date(2026, 10, 11, 20, 0, 0, 0, time.UTC), item.Updated), Body: "Steps", Kind: "issue", URL: "https://example.org/1",
}
}
item := listed{1, "Login fails", time.Date(2026, 10, 11, 18, 0, 0, 0, time.UTC)}
for _, tc := range []struct {
name, json, want string
tty bool
}{
{name: "terminal", tty: true, want: "Login fails #1\nOpen • alice opened about 2 hours ago\n\nSteps\n\nView this issue on Gitea: https://example.org/1\n"},
{name: "piped", want: "title:\tLogin fails\nurl:\thttps://example.org/1\n--\nSteps\n"},
{name: "JSON", tty: true, json: "title", want: `{"title":"Login fails"}` + "\n"},
} {
t.Run(tc.name, func(t *testing.T) {
deps, out, _ := testDeps(t)
deps.Streams.OutTTY = tc.tty
deps.Getenv = envFunc(map[string]string{"NO_COLOR": "1", "TEA_MARKDOWN": "raw", "TEA_HYPERLINKS": "never"})
a, cmd := outputApplication(deps, tc.json)
require.NoError(t, writeView(a, cmd, item, view))
require.Equal(t, tc.want, out.String())
})
}
}
func TestShowing(t *testing.T) {
for _, tc := range []struct {
shown int
page listPage
want string
}{
{30, listPage{Number: 1, More: true, Total: 112, Counted: true}, "Showing 30 of 112 open issues · next: --page 2"},
{11, listPage{Number: 1, Total: 11, Counted: true}, "Showing 11 of 11 open issues"},
{11, listPage{Number: 1}, "Showing 11 of 11 open issues"},
{30, listPage{Number: 1, More: true}, "Showing 30 open issues · next: --page 2"},
{5, listPage{Number: 3}, "Showing 5 open issues"},
} {
require.Equal(t, tc.want, showing(tc.shown, tc.page, "open issues"), "%+v", tc.page)
}
}