main 2aca1df

cli_fake_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"bytes"
7	"context"
8	"errors"
9	"net/http"
10	"net/http/httptest"
11	"path/filepath"
12	"strings"
13	"sync"
14	"testing"
15	"time"
16
17	"connectrpc.com/connect"
18	"github.com/stretchr/testify/require"
19	"google.golang.org/protobuf/types/known/timestamppb"
20
21	"bnema.dev/ermite-api/gen/ermite"
22	"bnema.dev/ermite-api/gen/ermite/ermiteconnect"
23)
24
25// fakeAPI is a Connect server with stub services: each field, when set,
26// answers the matching RPC; the others answer unimplemented. It records the
27// requests it receives.
28type fakeAPI struct {
29	ermiteconnect.UnimplementedRepoServiceHandler
30	ermiteconnect.UnimplementedTokenServiceHandler
31	ermiteconnect.UnimplementedServerServiceHandler
32	ermiteconnect.UnimplementedEventServiceHandler
33	ermiteconnect.UnimplementedMergeRequestServiceHandler
34	ermiteconnect.UnimplementedIssueServiceHandler
35	ermiteconnect.UnimplementedSessionServiceHandler
36	ermiteconnect.UnimplementedRunServiceHandler
37	ermiteconnect.UnimplementedTrustedKeyServiceHandler
38
39	runners []*ermite.Runner // ListRunners answer; nil means one default runner
40
41	mu     sync.Mutex
42	calls  []string      // procedure names, in order
43	reqs   []any         // request messages, in order
44	header []http.Header // request headers, in order
45
46	listRepos   func(*ermite.ListReposRequest) (*ermite.ListReposResponse, error)
47	getRepo     func(*ermite.GetRepoRequest) (*ermite.GetRepoResponse, error)
48	createRepo  func(*ermite.CreateRepoRequest) (*ermite.CreateRepoResponse, error)
49	updateRepo  func(*ermite.UpdateRepoRequest) (*ermite.UpdateRepoResponse, error)
50	deleteRepo  func(*ermite.DeleteRepoRequest) (*ermite.DeleteRepoResponse, error)
51	createToken func(*ermite.CreateTokenRequest) (*ermite.CreateTokenResponse, error)
52	listTokens  func(*ermite.ListTokensRequest) (*ermite.ListTokensResponse, error)
53	revokeToken func(*ermite.RevokeTokenRequest) (*ermite.RevokeTokenResponse, error)
54	getVersion  func(*ermite.GetVersionRequest) (*ermite.GetVersionResponse, error)
55	watch       func(context.Context, *ermite.WatchRequest, *connect.ServerStream[ermite.WatchResponse]) error
56
57	createMR      func(*ermite.CreateMergeRequestRequest) (*ermite.CreateMergeRequestResponse, error)
58	listMR        func(*ermite.ListMergeRequestsRequest) (*ermite.ListMergeRequestsResponse, error)
59	getMR         func(*ermite.GetMergeRequestRequest) (*ermite.GetMergeRequestResponse, error)
60	addComment    func(*ermite.AddCommentRequest) (*ermite.AddCommentResponse, error)
61	resolveCmt    func(*ermite.ResolveCommentRequest) (*ermite.ResolveCommentResponse, error)
62	closeMR       func(*ermite.CloseMergeRequestRequest) (*ermite.CloseMergeRequestResponse, error)
63	mergeMR       func(*ermite.MergeMergeRequestRequest) (*ermite.MergeMergeRequestResponse, error)
64	markRead      func(*ermite.MarkReadRequest) (*ermite.MarkReadResponse, error)
65	enableAuto    func(*ermite.EnableAutoMergeRequest) (*ermite.EnableAutoMergeResponse, error)
66	cancelAuto    func(*ermite.CancelAutoMergeRequest) (*ermite.CancelAutoMergeResponse, error)
67	createIssue   func(*ermite.CreateIssueRequest) (*ermite.CreateIssueResponse, error)
68	listIssues    func(*ermite.ListIssuesRequest) (*ermite.ListIssuesResponse, error)
69	getIssue      func(*ermite.GetIssueRequest) (*ermite.GetIssueResponse, error)
70	addIssueCmt   func(*ermite.AddIssueCommentRequest) (*ermite.AddIssueCommentResponse, error)
71	closeIssue    func(*ermite.CloseIssueRequest) (*ermite.CloseIssueResponse, error)
72	reopenIssue   func(*ermite.ReopenIssueRequest) (*ermite.ReopenIssueResponse, error)
73	getSigningKey func(*ermite.GetSigningKeyRequest) (*ermite.GetSigningKeyResponse, error)
74
75	listPasskeys  func(*ermite.ListPasskeysRequest) (*ermite.ListPasskeysResponse, error)
76	removePasskey func(*ermite.RemovePasskeyRequest) (*ermite.RemovePasskeyResponse, error)
77
78	listTrustedKeys  func(*ermite.ListTrustedKeysRequest) (*ermite.ListTrustedKeysResponse, error)
79	addTrustedKey    func(*ermite.AddTrustedKeyRequest) (*ermite.AddTrustedKeyResponse, error)
80	removeTrustedKey func(*ermite.RemoveTrustedKeyRequest) (*ermite.RemoveTrustedKeyResponse, error)
81}
82
83func (f *fakeAPI) record(name string, h http.Header, req any) {
84	f.mu.Lock()
85	defer f.mu.Unlock()
86	f.calls = append(f.calls, name)
87	f.reqs = append(f.reqs, req)
88	f.header = append(f.header, h.Clone())
89}
90
91func (f *fakeAPI) snapshot() (calls []string, reqs []any) {
92	f.mu.Lock()
93	defer f.mu.Unlock()
94	return append([]string(nil), f.calls...), append([]any(nil), f.reqs...)
95}
96
97func respond[Req, Res any](f *fakeAPI, name string, req *connect.Request[Req], fn func(*Req) (*Res, error)) (*connect.Response[Res], error) {
98	f.record(name, req.Header(), req.Msg)
99	if fn == nil {
100		return nil, connect.NewError(connect.CodeUnimplemented, nil)
101	}
102	res, err := fn(req.Msg)
103	if err != nil {
104		return nil, err
105	}
106	return connect.NewResponse(res), nil
107}
108
109func (f *fakeAPI) ListRepos(_ context.Context, r *connect.Request[ermite.ListReposRequest]) (*connect.Response[ermite.ListReposResponse], error) {
110	return respond(f, "ListRepos", r, f.listRepos)
111}
112func (f *fakeAPI) GetRepo(_ context.Context, r *connect.Request[ermite.GetRepoRequest]) (*connect.Response[ermite.GetRepoResponse], error) {
113	return respond(f, "GetRepo", r, f.getRepo)
114}
115func (f *fakeAPI) CreateRepo(_ context.Context, r *connect.Request[ermite.CreateRepoRequest]) (*connect.Response[ermite.CreateRepoResponse], error) {
116	return respond(f, "CreateRepo", r, f.createRepo)
117}
118func (f *fakeAPI) UpdateRepo(_ context.Context, r *connect.Request[ermite.UpdateRepoRequest]) (*connect.Response[ermite.UpdateRepoResponse], error) {
119	return respond(f, "UpdateRepo", r, f.updateRepo)
120}
121func (f *fakeAPI) DeleteRepo(_ context.Context, r *connect.Request[ermite.DeleteRepoRequest]) (*connect.Response[ermite.DeleteRepoResponse], error) {
122	return respond(f, "DeleteRepo", r, f.deleteRepo)
123}
124func (f *fakeAPI) CreateToken(_ context.Context, r *connect.Request[ermite.CreateTokenRequest]) (*connect.Response[ermite.CreateTokenResponse], error) {
125	return respond(f, "CreateToken", r, f.createToken)
126}
127func (f *fakeAPI) ListTokens(_ context.Context, r *connect.Request[ermite.ListTokensRequest]) (*connect.Response[ermite.ListTokensResponse], error) {
128	return respond(f, "ListTokens", r, f.listTokens)
129}
130func (f *fakeAPI) RevokeToken(_ context.Context, r *connect.Request[ermite.RevokeTokenRequest]) (*connect.Response[ermite.RevokeTokenResponse], error) {
131	return respond(f, "RevokeToken", r, f.revokeToken)
132}
133func (f *fakeAPI) GetVersion(_ context.Context, r *connect.Request[ermite.GetVersionRequest]) (*connect.Response[ermite.GetVersionResponse], error) {
134	return respond(f, "GetVersion", r, f.getVersion)
135}
136func (f *fakeAPI) CreateMergeRequest(_ context.Context, r *connect.Request[ermite.CreateMergeRequestRequest]) (*connect.Response[ermite.CreateMergeRequestResponse], error) {
137	return respond(f, "CreateMergeRequest", r, f.createMR)
138}
139func (f *fakeAPI) ListMergeRequests(_ context.Context, r *connect.Request[ermite.ListMergeRequestsRequest]) (*connect.Response[ermite.ListMergeRequestsResponse], error) {
140	return respond(f, "ListMergeRequests", r, f.listMR)
141}
142func (f *fakeAPI) GetMergeRequest(_ context.Context, r *connect.Request[ermite.GetMergeRequestRequest]) (*connect.Response[ermite.GetMergeRequestResponse], error) {
143	return respond(f, "GetMergeRequest", r, f.getMR)
144}
145func (f *fakeAPI) AddComment(_ context.Context, r *connect.Request[ermite.AddCommentRequest]) (*connect.Response[ermite.AddCommentResponse], error) {
146	return respond(f, "AddComment", r, f.addComment)
147}
148func (f *fakeAPI) ResolveComment(_ context.Context, r *connect.Request[ermite.ResolveCommentRequest]) (*connect.Response[ermite.ResolveCommentResponse], error) {
149	return respond(f, "ResolveComment", r, f.resolveCmt)
150}
151func (f *fakeAPI) CloseMergeRequest(_ context.Context, r *connect.Request[ermite.CloseMergeRequestRequest]) (*connect.Response[ermite.CloseMergeRequestResponse], error) {
152	return respond(f, "CloseMergeRequest", r, f.closeMR)
153}
154func (f *fakeAPI) MergeMergeRequest(_ context.Context, r *connect.Request[ermite.MergeMergeRequestRequest]) (*connect.Response[ermite.MergeMergeRequestResponse], error) {
155	return respond(f, "MergeMergeRequest", r, f.mergeMR)
156}
157func (f *fakeAPI) EnableAutoMerge(_ context.Context, r *connect.Request[ermite.EnableAutoMergeRequest]) (*connect.Response[ermite.EnableAutoMergeResponse], error) {
158	return respond(f, "EnableAutoMerge", r, f.enableAuto)
159}
160func (f *fakeAPI) CancelAutoMerge(_ context.Context, r *connect.Request[ermite.CancelAutoMergeRequest]) (*connect.Response[ermite.CancelAutoMergeResponse], error) {
161	return respond(f, "CancelAutoMerge", r, f.cancelAuto)
162}
163func (f *fakeAPI) MarkRead(_ context.Context, r *connect.Request[ermite.MarkReadRequest]) (*connect.Response[ermite.MarkReadResponse], error) {
164	return respond(f, "MarkRead", r, f.markRead)
165}
166func (f *fakeAPI) CreateIssue(_ context.Context, r *connect.Request[ermite.CreateIssueRequest]) (*connect.Response[ermite.CreateIssueResponse], error) {
167	return respond(f, "CreateIssue", r, f.createIssue)
168}
169func (f *fakeAPI) ListIssues(_ context.Context, r *connect.Request[ermite.ListIssuesRequest]) (*connect.Response[ermite.ListIssuesResponse], error) {
170	return respond(f, "ListIssues", r, f.listIssues)
171}
172func (f *fakeAPI) GetIssue(_ context.Context, r *connect.Request[ermite.GetIssueRequest]) (*connect.Response[ermite.GetIssueResponse], error) {
173	return respond(f, "GetIssue", r, f.getIssue)
174}
175func (f *fakeAPI) AddIssueComment(_ context.Context, r *connect.Request[ermite.AddIssueCommentRequest]) (*connect.Response[ermite.AddIssueCommentResponse], error) {
176	return respond(f, "AddIssueComment", r, f.addIssueCmt)
177}
178func (f *fakeAPI) CloseIssue(_ context.Context, r *connect.Request[ermite.CloseIssueRequest]) (*connect.Response[ermite.CloseIssueResponse], error) {
179	return respond(f, "CloseIssue", r, f.closeIssue)
180}
181func (f *fakeAPI) ReopenIssue(_ context.Context, r *connect.Request[ermite.ReopenIssueRequest]) (*connect.Response[ermite.ReopenIssueResponse], error) {
182	return respond(f, "ReopenIssue", r, f.reopenIssue)
183}
184func (f *fakeAPI) GetSigningKey(_ context.Context, r *connect.Request[ermite.GetSigningKeyRequest]) (*connect.Response[ermite.GetSigningKeyResponse], error) {
185	return respond(f, "GetSigningKey", r, f.getSigningKey)
186}
187func (f *fakeAPI) ListPasskeys(_ context.Context, r *connect.Request[ermite.ListPasskeysRequest]) (*connect.Response[ermite.ListPasskeysResponse], error) {
188	return respond(f, "ListPasskeys", r, f.listPasskeys)
189}
190func (f *fakeAPI) RemovePasskey(_ context.Context, r *connect.Request[ermite.RemovePasskeyRequest]) (*connect.Response[ermite.RemovePasskeyResponse], error) {
191	return respond(f, "RemovePasskey", r, f.removePasskey)
192}
193func (f *fakeAPI) ListTrustedKeys(_ context.Context, r *connect.Request[ermite.ListTrustedKeysRequest]) (*connect.Response[ermite.ListTrustedKeysResponse], error) {
194	return respond(f, "ListTrustedKeys", r, f.listTrustedKeys)
195}
196func (f *fakeAPI) AddTrustedKey(_ context.Context, r *connect.Request[ermite.AddTrustedKeyRequest]) (*connect.Response[ermite.AddTrustedKeyResponse], error) {
197	return respond(f, "AddTrustedKey", r, f.addTrustedKey)
198}
199func (f *fakeAPI) RemoveTrustedKey(_ context.Context, r *connect.Request[ermite.RemoveTrustedKeyRequest]) (*connect.Response[ermite.RemoveTrustedKeyResponse], error) {
200	return respond(f, "RemoveTrustedKey", r, f.removeTrustedKey)
201}
202func (f *fakeAPI) Watch(ctx context.Context, r *connect.Request[ermite.WatchRequest], st *connect.ServerStream[ermite.WatchResponse]) error {
203	f.record("Watch", r.Header(), r.Msg)
204	if f.watch == nil {
205		return connect.NewError(connect.CodeUnimplemented, nil)
206	}
207	return f.watch(ctx, r.Msg, st)
208}
209
210// serve starts the fake under /-/api and returns its base URL.
211func (f *fakeAPI) serve(t *testing.T) string {
212	t.Helper()
213	mux := http.NewServeMux()
214	mount := func(path string, h http.Handler) { mux.Handle("/-/api"+path, http.StripPrefix("/-/api", h)) }
215	p, h := ermiteconnect.NewRepoServiceHandler(f)
216	mount(p, h)
217	p, h = ermiteconnect.NewTokenServiceHandler(f)
218	mount(p, h)
219	p, h = ermiteconnect.NewServerServiceHandler(f)
220	mount(p, h)
221	p, h = ermiteconnect.NewEventServiceHandler(f)
222	mount(p, h)
223	p, h = ermiteconnect.NewMergeRequestServiceHandler(f)
224	mount(p, h)
225	p, h = ermiteconnect.NewIssueServiceHandler(f)
226	mount(p, h)
227	p, h = ermiteconnect.NewSessionServiceHandler(f)
228	mount(p, h)
229	p, h = ermiteconnect.NewRunServiceHandler(f)
230	mount(p, h)
231	p, h = ermiteconnect.NewTrustedKeyServiceHandler(f)
232	mount(p, h)
233	srv := httptest.NewServer(mux)
234	t.Cleanup(srv.Close)
235	return srv.URL
236}
237
238var testNow = time.Date(2026, 10, 10, 12, 0, 0, 0, time.UTC)
239
240// result is the outcome of one CLI run.
241type result struct {
242	stdout, stderr string
243	err            error
244}
245
246// testEnv is a cliEnv with the given variables, the fixed clock, and a config
247// file path inside a fresh temp dir (no file yet) so that a test never reads
248// the developer's own ~/.config/ermite/config. token.cmd must not run unless
249// the test sets runTokenCmd.
250func testEnv(t *testing.T, vars map[string]string) *cliEnv {
251	t.Helper()
252	path := filepath.Join(t.TempDir(), "ermite", "config")
253	return &cliEnv{
254		getenv:     func(k string) string { return vars[k] },
255		now:        func() time.Time { return testNow },
256		version:    "v0.0.1",
257		sleep:      func(ctx context.Context, _ time.Duration) error { return ctx.Err() },
258		configPath: func() (string, error) { return path, nil },
259		runTokenCmd: func(context.Context, string) (string, error) {
260			t.Error("token.cmd must not run")
261			return "", errors.New("token.cmd must not run")
262		},
263	}
264}
265
266// runCLI runs the command tree in process with the given environment.
267func runCLI(t *testing.T, vars map[string]string, stdin string, args ...string) result {
268	t.Helper()
269	return runCLIWith(t, testEnv(t, vars), stdin, args...)
270}
271
272// runCLIWith runs the command tree in process with env.
273func runCLIWith(t *testing.T, env *cliEnv, stdin string, args ...string) result {
274	t.Helper()
275	root := newRootCmdWith(env)
276	var out, errb bytes.Buffer
277	root.SetOut(&out)
278	root.SetErr(&errb)
279	root.SetIn(strings.NewReader(stdin))
280	root.SetArgs(args)
281	ctx, cancel := context.WithTimeout(t.Context(), 20*time.Second)
282	defer cancel()
283	err := root.ExecuteContext(ctx)
284	return result{stdout: out.String(), stderr: errb.String(), err: err}
285}
286
287func (r result) ok(t *testing.T) result {
288	t.Helper()
289	require.NoError(t, r.err, "stderr: %s", r.stderr)
290	return r
291}
292
293func ts(s string) *timestamppb.Timestamp {
294	v, err := time.Parse(time.RFC3339, s)
295	if err != nil {
296		panic(err)
297	}
298	return timestamppb.New(v)
299}

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 43

↑↓ moveenter openesc close