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":"","topics":[],"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}