v0.19.0 55a688e

cli_watch_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"bufio"
7	"context"
8	"encoding/json"
9	"errors"
10	"strings"
11	"testing"
12
13	"connectrpc.com/connect"
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/require"
16
17	"bnema.dev/ermite-api/gen/ermite"
18)
19
20func evt(id string, kind func(*ermite.Event)) *ermite.WatchResponse {
21	e := &ermite.Event{Id: id, Time: ts("2026-10-10T12:00:00Z")}
22	kind(e)
23	return &ermite.WatchResponse{Event: e}
24}
25
26// sampleEvents is one of each kind, plus a kind this client does not know.
27func sampleEvents() []*ermite.WatchResponse {
28	return []*ermite.WatchResponse{
29		evt("1", func(e *ermite.Event) {
30			e.Kind = &ermite.Event_RepoCreated{RepoCreated: &ermite.RepoCreated{Repo: repoBeta}}
31		}),
32		evt("2", func(e *ermite.Event) {
33			e.Kind = &ermite.Event_RepoUpdated{RepoUpdated: &ermite.RepoUpdated{Repo: repoAlpha}}
34		}),
35		evt("3", func(e *ermite.Event) {
36			e.Kind = &ermite.Event_Pushed{Pushed: &ermite.Pushed{Repo: "alpha", RefUpdates: []*ermite.RefUpdate{
37				{Ref: "refs/heads/main", OldId: "1111111111111111", NewId: "2222222222222222"},
38				{Ref: "refs/heads/new", NewId: "3333333333333333"},
39				{Ref: "refs/tags/old", OldId: "4444444444444444"},
40			}}}
41		}),
42		evt("4", func(e *ermite.Event) {
43			e.Kind = &ermite.Event_RepoDeleted{RepoDeleted: &ermite.RepoDeleted{Repo: "beta"}}
44		}),
45		{Event: &ermite.Event{Kind: &ermite.Event_Gap{Gap: &ermite.Gap{}}}},
46		{Event: &ermite.Event{Id: "future"}}, // unknown kind: skipped
47	}
48}
49
50// finishing streams events, then answers permission_denied to end the command.
51func finishing(events []*ermite.WatchResponse) func(context.Context, *ermite.WatchRequest, *connect.ServerStream[ermite.WatchResponse]) error {
52	return func(_ context.Context, _ *ermite.WatchRequest, st *connect.ServerStream[ermite.WatchResponse]) error {
53		for _, e := range events {
54			if err := st.Send(e); err != nil {
55				return err
56			}
57		}
58		return connect.NewError(connect.CodePermissionDenied, errors.New("done"))
59	}
60}
61
62func TestWatchHumanLines(t *testing.T) {
63	f := &fakeAPI{watch: finishing(sampleEvents())}
64	res := runCLI(t, envFor(f.serve(t)), "", "watch")
65	require.Error(t, res.err)
66	assert.Equal(t, ""+
67		"2026-10-10T12:00:00Z  repo_created  beta (private)\n"+
68		"2026-10-10T12:00:00Z  repo_updated  alpha (public, default branch main)\n"+
69		"2026-10-10T12:00:00Z  pushed  alpha refs/heads/main 11111111..22222222, refs/heads/new created at 33333333, refs/tags/old deleted (was 44444444)\n"+
70		"2026-10-10T12:00:00Z  repo_deleted  beta\n"+
71		"-  gap  events were lost, reload what you display\n", res.stdout)
72}
73
74func TestWatchTopics(t *testing.T) {
75	f := &fakeAPI{watch: finishing(nil)}
76	url := f.serve(t)
77	_ = runCLI(t, envFor(url), "", "watch")
78	_ = runCLI(t, envFor(url), "", "watch", "alpha")
79	_, reqs := f.snapshot()
80	require.Len(t, reqs, 2)
81	assert.Equal(t, []string{"home"}, reqs[0].(*ermite.WatchRequest).GetTopics())
82	assert.Equal(t, []string{"repo:alpha"}, reqs[1].(*ermite.WatchRequest).GetTopics())
83	assert.Empty(t, reqs[0].(*ermite.WatchRequest).GetResumeAfter())
84}
85
86func TestWatchNDJSON(t *testing.T) {
87	f := &fakeAPI{watch: finishing(sampleEvents())}
88	res := runCLI(t, envFor(f.serve(t)), "", "watch", "--json")
89	require.Error(t, res.err)
90
91	var lines []string
92	sc := bufio.NewScanner(strings.NewReader(res.stdout))
93	for sc.Scan() {
94		lines = append(lines, sc.Text())
95		assert.True(t, json.Valid([]byte(sc.Text())), "one JSON value per line: %s", sc.Text())
96	}
97	require.Len(t, lines, 5)
98	assert.JSONEq(t, `{"id":"1","time":"2026-10-10T12:00:00Z","type":"repo_created","repo":"beta","repository":{"name":"beta","description":"","visibility":"private","default_branch":"trunk","created_at":"2026-02-02T00:00:00Z","pushed_at":null,"merge_modes":["squash"],"failing_checks_block":false}}`, lines[0])
99	assert.JSONEq(t, `{"id":"3","time":"2026-10-10T12:00:00Z","type":"pushed","repo":"alpha","ref_updates":[
100		{"ref":"refs/heads/main","old_id":"1111111111111111","new_id":"2222222222222222"},
101		{"ref":"refs/heads/new","old_id":"","new_id":"3333333333333333"},
102		{"ref":"refs/tags/old","old_id":"4444444444444444","new_id":""}]}`, lines[2])
103	assert.JSONEq(t, `{"id":"4","time":"2026-10-10T12:00:00Z","type":"repo_deleted","repo":"beta"}`, lines[3])
104	assert.JSONEq(t, `{"id":"","time":null,"type":"gap"}`, lines[4])
105}
106
107func TestWatchReconnectsAndResumes(t *testing.T) {
108	calls := 0
109	f := &fakeAPI{}
110	f.watch = func(_ context.Context, req *ermite.WatchRequest, st *connect.ServerStream[ermite.WatchResponse]) error {
111		calls++
112		switch calls {
113		case 1:
114			_ = st.Send(sampleEvents()[0])
115			return connect.NewError(connect.CodeUnavailable, errors.New("restarting"))
116		case 2:
117			assert.Equal(t, "1", req.GetResumeAfter(), "resumes after the last printed event")
118			_ = st.Send(&ermite.WatchResponse{Event: &ermite.Event{Kind: &ermite.Event_Gap{Gap: &ermite.Gap{}}}})
119			_ = st.Send(sampleEvents()[3])
120			return connect.NewError(connect.CodePermissionDenied, errors.New("done"))
121		}
122		return connect.NewError(connect.CodeInternal, nil)
123	}
124	res := runCLI(t, envFor(f.serve(t)), "", "watch")
125	require.Error(t, res.err)
126	assert.Equal(t, 2, calls)
127	assert.Contains(t, res.stderr, "ermite: connection lost")
128	assert.Contains(t, res.stderr, "restarting")
129	assert.Contains(t, res.stderr, "ermite: reconnected")
130	lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
131	require.Len(t, lines, 3)
132	assert.Contains(t, lines[0], "repo_created")
133	assert.Contains(t, lines[1], "gap")
134	assert.Contains(t, lines[2], "repo_deleted")
135}
136
137func TestWatchPermanentErrorIsFriendly(t *testing.T) {
138	f := &fakeAPI{watch: func(context.Context, *ermite.WatchRequest, *connect.ServerStream[ermite.WatchResponse]) error {
139		return connect.NewError(connect.CodeNotFound, errors.New("repository \"zzz\" not found"))
140	}}
141	res := runCLI(t, envFor(f.serve(t)), "", "watch", "zzz")
142	require.Error(t, res.err)
143	assert.Equal(t, "not found: repository \"zzz\" not found", res.err.Error())
144}
145
146func TestWatchRejectsTooManyArgs(t *testing.T) {
147	res := runCLI(t, envFor("http://127.0.0.1:1"), "", "watch", "a", "b")
148	require.Error(t, res.err)
149}
150
151func TestWatchMergeRequestEvents(t *testing.T) {
152	cmt := &ermite.Comment{Id: "2", Author: "agent-7", Path: "a.txt", Line: 5, Side: ermite.DiffSide_DIFF_SIDE_NEW, Text: "why?"}
153	events := []*ermite.WatchResponse{
154		evt("1", func(e *ermite.Event) {
155			e.Kind = &ermite.Event_MergeRequestOpened{MergeRequestOpened: &ermite.MergeRequestOpened{MergeRequest: mrOne}}
156		}),
157		evt("2", func(e *ermite.Event) {
158			e.Kind = &ermite.Event_CommentAdded{CommentAdded: &ermite.CommentAdded{Repo: "alpha", Number: 1, Comment: cmt}}
159		}),
160		evt("3", func(e *ermite.Event) {
161			e.Kind = &ermite.Event_MergeRequestMerged{MergeRequestMerged: &ermite.MergeRequestMerged{MergeRequest: mrOne}}
162		}),
163		evt("4", func(e *ermite.Event) {
164			e.Kind = &ermite.Event_MergeRequestClosed{MergeRequestClosed: &ermite.MergeRequestClosed{MergeRequest: mrOne}}
165		}),
166	}
167	f := &fakeAPI{watch: finishing(events)}
168	url := f.serve(t)
169
170	res := runCLI(t, envFor(url), "", "watch")
171	require.Error(t, res.err)
172	assert.Equal(t, ""+
173		"2026-10-10T12:00:00Z  merge_request_opened  alpha!1 feat/x -> main\n"+
174		"2026-10-10T12:00:00Z  comment_added  alpha!1 by agent-7\n"+
175		"2026-10-10T12:00:00Z  merge_request_merged  alpha!1 feat/x -> main\n"+
176		"2026-10-10T12:00:00Z  merge_request_closed  alpha!1 feat/x -> main\n", res.stdout)
177
178	res = runCLI(t, envFor(url), "", "watch", "--json")
179	lines := strings.Split(strings.TrimSpace(res.stdout), "\n")
180	require.Len(t, lines, 4)
181	assert.Equal(t, "merge_request_opened", decode[eventOut](t, lines[0]).Type)
182	opened := decode[eventOut](t, lines[0])
183	assert.Equal(t, "alpha", opened.Repo)
184	require.NotNil(t, opened.MergeRequest)
185	assert.EqualValues(t, 1, opened.MergeRequest.Number)
186	assert.Equal(t, headA, opened.MergeRequest.HeadID)
187	var added struct {
188		Type    string     `json:"type"`
189		Repo    string     `json:"repo"`
190		Number  int64      `json:"number"`
191		Comment commentOut `json:"comment"`
192	}
193	require.NoError(t, json.Unmarshal([]byte(lines[1]), &added))
194	assert.Equal(t, "comment_added", added.Type)
195	assert.Equal(t, "alpha", added.Repo)
196	assert.EqualValues(t, 1, added.Number)
197	assert.Equal(t, "a.txt", added.Comment.Path)
198	assert.Equal(t, "new", added.Comment.Side)
199	assert.Equal(t, "merge_request_merged", decode[eventOut](t, lines[2]).Type)
200	assert.Equal(t, "merge_request_closed", decode[eventOut](t, lines[3]).Type)
201}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close