v0.35.0 e86dbd6

mr_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"net/http"
8	"net/http/httptest"
9	"strings"
10	"testing"
11	"time"
12
13	"github.com/stretchr/testify/assert"
14	"github.com/stretchr/testify/mock"
15	"github.com/stretchr/testify/require"
16
17	"bnema.dev/ermite/internal/domain"
18	"bnema.dev/ermite/internal/ports"
19	"bnema.dev/ermite/internal/ports/mocks"
20)
21
22func repoWithModes(modes ...domain.MergeMode) ports.RepoView {
23	var v ports.RepoView
24	v.Settings.AllowedMergeModes = modes
25	return v
26}
27
28func watchOf(ev domain.Event) ports.WatchEvent { return ports.WatchEvent{Event: ev} }
29
30func TestMergeabilityView(t *testing.T) {
31	t.Parallel()
32	for _, tt := range []struct {
33		name          string
34		m             domain.Mergeability
35		label, detail string
36		ffOK, mergeOK bool
37		note          string
38		paths         []string
39	}{
40		{"up to date", domain.Mergeability{State: domain.MergeabilityUpToDate}, "Ready to merge", "", true, true, "", nil},
41		{"behind", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 2}, "Behind main by 2 commits", "merges without conflicts", false, true, "", nil},
42		{"behind by one", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "Behind main by 1 commit", "merges without conflicts", false, true, "", nil},
43		{"behind, capped", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1000, BehindCapped: true}, "Behind main by 1000+ commits", "merges without conflicts", false, true, "", nil},
44		{"conflict", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}}, "Conflicts with main", "rebase needed", false, false, "", []string{"a.go"}},
45		{"conflict, truncated", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}, ConflictPathsTruncated: true}, "Conflicts with main", "rebase needed", false, false, "more files conflict than are listed.", []string{"a.go"}},
46		{"no common history", domain.Mergeability{State: domain.MergeabilityConflict}, "Conflicts with main", "rebase needed", false, false, "no common history.", nil},
47		{"checking", domain.Mergeability{State: domain.MergeabilityChecking}, "Checking…", "", false, false, "", nil},
48		{"none", domain.Mergeability{}, "Checking…", "", false, false, "", nil},
49	} {
50		t.Run(tt.name, func(t *testing.T) {
51			t.Parallel()
52			v := mergeabilityView(tt.m, "main")
53			assert.Equal(t, tt.label, v.Label)
54			assert.Equal(t, tt.detail, v.Detail)
55			assert.Equal(t, tt.ffOK, v.FFOk)
56			assert.Equal(t, tt.mergeOK, v.MergeOk)
57			assert.Equal(t, tt.note, v.PathsNote)
58			assert.Equal(t, tt.paths, v.Paths)
59			assert.Equal(t, !tt.ffOK, v.FFReason != "", "a mode that is not possible says why")
60			assert.Equal(t, !tt.mergeOK, v.MergeReason != "")
61		})
62	}
63}
64
65func TestMergeBoxModes(t *testing.T) {
66	t.Parallel()
67	d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
68	s := &Server{}
69
70	behind := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main"))
71	require.Len(t, behind.Modes, 3)
72	assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind")
73	assert.NotEmpty(t, behind.Modes[0].Reason)
74	assert.False(t, behind.Modes[1].Disabled)
75	assert.False(t, behind.Modes[2].Disabled)
76	assert.Equal(t, 1, behind.Checked(), "the first possible mode is checked")
77	assert.True(t, behind.CanMerge())
78	assert.True(t, behind.Signed)
79
80	conflict := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main"))
81	assert.False(t, conflict.CanMerge())
82	assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled")
83	assert.Equal(t, 0, conflict.Checked())
84	assert.Contains(t, conflict.CLI.Lines[2], "--mode merge")
85	assert.NotContains(t, conflict.CLI.Lines[1], "--squash")
86}
87
88func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) {
89	t.Parallel()
90	d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)}
91	b := (&Server{}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
92	assert.False(t, b.Signed, "no commit is created, nothing to explain")
93	assert.Len(t, b.Modes, 1)
94}
95
96func TestMergeBoxNamesTheSigningKey(t *testing.T) {
97	t.Parallel()
98	key := mocks.NewSigningKeyService(t)
99	key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}})
100	d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
101	b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{}, "main"))
102	assert.Equal(t, "SHA256:abc", b.Fingerprint)
103}
104
105func TestMergeErrorTravelsInTheContext(t *testing.T) {
106	t.Parallel()
107	assert.Empty(t, mergeErrorOf(context.Background()))
108	assert.Equal(t, "boom", mergeErrorOf(withMergeError(context.Background(), "boom")))
109}
110
111func TestMergeabilityLiveEvents(t *testing.T) {
112	t.Parallel()
113	assert.True(t, liveEvent(watchOf(domain.MergeRequestMergeability{Number: 1})))
114	svc := mocks.NewMergeRequestService(t)
115	svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)).
116		Return(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 3}, nil)
117	l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", target: "main"}
118	ctx := context.WithValue(context.Background(), signedInKey{}, struct{}{})
119
120	fs, err := l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 1}))
121	require.NoError(t, err)
122	require.Len(t, fs, 1)
123	assert.Equal(t, "mr-mergeability", fs[0].event)
124	assert.Contains(t, fs[0].html, "Behind main by 3 commits")
125
126	// Another request, or a page that is not one: nothing.
127	fs, err = l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 2}))
128	require.NoError(t, err)
129	assert.Empty(t, fs)
130	// The box is the owner's.
131	fs, err = l.repoPage(context.Background(), watchOf(domain.MergeRequestMergeability{Number: 1}))
132	require.NoError(t, err)
133	assert.Empty(t, fs)
134}
135
136func TestMRPanelsAndMenus(t *testing.T) {
137	p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true,
138		ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}}
139	footer := renderToString(t, mrFooter(p))
140	assert.Contains(t, footer, `<footer class="mr-footer"><div data-mr-tabs data-js role="group"`)
141	assert.NotContains(t, footer, `<details`)
142	assert.NotContains(t, footer, `<summary`)
143	// Both panels are readable without scripts; JS enhances native buttons.
144	assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="group"`)
145	assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="group"`)
146	assert.NotContains(t, footer, `data-mr-panel hidden`)
147	css, err := staticFS.ReadFile("static/css/60-mr.css")
148	require.NoError(t, err)
149	assert.NotContains(t, string(css), `:not([data-panels])`)
150	assert.Contains(t, footer, `<button type="button" id="mr-tab-commits" aria-expanded="true" aria-controls="mr-commits">`)
151	assert.Contains(t, footer, `<button type="button" id="mr-tab-checks" aria-expanded="true" aria-controls="mr-checks-panel">`)
152	assert.Contains(t, footer, `data-mr-tabs`)
153	assert.Contains(t, footer, `id="mr-commits"`)
154	assert.Contains(t, footer, `id="mr-checks"`)
155	assert.Contains(t, footer, `<table class="mr-commits" aria-label="Commits">`)
156	assert.Contains(t, footer, "500+")
157	assert.Contains(t, footer, "more commits")
158	assert.Contains(t, footer, "1/2 failed")
159	rail := renderToString(t, mrRail(p))
160	assert.Contains(t, rail, `<kbd aria-hidden="true">l</kbd>`)
161	for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} {
162		assert.NotContains(t, rail, text)
163	}
164	more := renderToString(t, moreMenu(p))
165	for _, text := range []string{"Merge settings", "squash, merge", "failing or unfinished checks block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} {
166		assert.Contains(t, more, text)
167	}
168	merge := renderToString(t, mergeMenu(p))
169	assert.NotContains(t, merge, `<dl`)
170	assert.NotContains(t, merge, `mr-signing`)
171	assert.Contains(t, merge, "the source branch was deleted")
172	bar := renderToString(t, mrBar(MRPage{}))
173	assert.Contains(t, bar, `id="mr-ck"`)
174	assert.Contains(t, bar, `href="#mr-checks"`)
175	assert.Contains(t, bar, "Merge settings")
176	assert.NotContains(t, bar, ">Close</button>")
177}
178
179func TestMRReadPresentationCommitCount(t *testing.T) {
180	commits := func(n int) []domain.Commit { return make([]domain.Commit, n) }
181	for _, tc := range []struct {
182		name      string
183		commits   []domain.Commit
184		truncated bool
185		count     string
186		note      string
187	}{
188		{"empty", nil, false, "0", ""},
189		{"complete", commits(2), false, "2", ""},
190		{"truncated", commits(3), true, "3+", "the branch has more commits than the 3 listed."},
191	} {
192		t.Run(tc.name, func(t *testing.T) {
193			s := &Server{cfg: Config{Now: time.Now}}
194			p := &MRPage{}
195			s.mrReadPresentation(p, "demo", 1, ports.MergeRequestDetail{Commits: tc.commits, CommitsTruncated: tc.truncated}, false, false)
196			assert.Equal(t, tc.count, p.CommitCount)
197			assert.Equal(t, tc.note, p.CommitsNote)
198			assert.Len(t, p.Commits, len(tc.commits))
199		})
200	}
201}
202
203func TestMergeBoxAutoMerge(t *testing.T) {
204	t.Parallel()
205	mg := mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main")
206	mk := func(allow bool, runs ports.RunService) *MergeBox {
207		v := repoWithModes(domain.MergeModes()...)
208		v.Settings.AllowAutoMerge = allow
209		return (&Server{cfg: Config{Runs: runs}}).mergeBox(&repoData{repo: v}, 1, "feat", "abc", false, mg)
210	}
211	assert.False(t, mk(false, mocks.NewRunService(t)).CanAutoMerge)
212	assert.False(t, mk(true, nil).CanAutoMerge, "no CI, nothing to wait for")
213	on := mk(true, mocks.NewRunService(t))
214	assert.True(t, on.CanAutoMerge)
215
216	page := MRPage{Target: "main", Box: on}
217	html := renderToString(t, mergeMenu(page))
218	assert.Contains(t, html, "Merge when ready")
219	assert.Contains(t, html, `action="`+on.AutoAction+`"`)
220	assert.NotContains(t, html, "Cancel auto-merge")
221	// Current mergeability must only gate the immediate form. Every allowed
222	// mode remains selectable in the separate auto-merge form.
223	for i := range on.Modes {
224		on.Modes[i].Disabled = true
225	}
226	on.CanDeleteBranch = true
227	page.Source = "feat"
228	html = renderToString(t, mergeMenu(page))
229	_, autoForm, found := strings.Cut(html, `action="`+on.AutoAction+`"`)
230	require.True(t, found)
231	autoForm, _, _ = strings.Cut(autoForm, "</form>")
232	assert.NotContains(t, autoForm, "delete_branch")
233	assert.NotContains(t, autoForm, "disabled")
234	assert.Contains(t, autoForm, `name="head_id"`)
235	for _, mode := range on.Modes {
236		assert.Contains(t, autoForm, `value="`+mode.Value+`"`)
237	}
238	page.Box = mk(false, mocks.NewRunService(t))
239	assert.NotContains(t, renderToString(t, mergeMenu(page)), "Merge when ready")
240
241	on.AutoMerge = &AutoMergeView{Mode: "squash", EnabledBy: "owner", HeadShort: "abc"}
242	html = renderToString(t, mergeMenu(MRPage{Target: "main", Box: on}))
243	assert.Contains(t, html, "Auto-merge: squash when ready, enabled by owner")
244	assert.Contains(t, html, `action="`+on.CancelAutoAction+`"`)
245	assert.Contains(t, html, "Cancel auto-merge")
246}
247
248func autoMergeRequest(path, body string) *http.Request {
249	r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
250	r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
251	r.SetPathValue("repo", "demo")
252	r.SetPathValue("n", "3")
253	return r
254}
255
256func TestAutoMergeHandlers(t *testing.T) {
257	t.Parallel()
258	name, err := domain.ParseRepoName("demo")
259	require.NoError(t, err)
260	ctx := context.Background()
261
262	head, err := domain.ParseObjectID(strings.Repeat("a", domain.ObjectIDLen))
263	require.NoError(t, err)
264	svc := mocks.NewMergeRequestService(t)
265	svc.EXPECT().EnableAutoMerge(mock.Anything, mock.Anything, name, int64(3), ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash, HeadID: head}).
266		Return(domain.MergeRequest{}, nil).Once()
267	svc.EXPECT().CancelAutoMerge(mock.Anything, mock.Anything, name, int64(3)).Return(domain.MergeRequest{}, nil).Once()
268	s := &Server{cfg: Config{MergeRequests: svc}}
269	to, _, err := s.mrAutoMerge(ctx, autoMergeRequest("/demo/merge-requests/3/auto-merge", "mode=squash&head_id="+head.String()))
270	require.NoError(t, err)
271	assert.Equal(t, mrHref("demo", 3), to)
272	to, _, err = s.mrCancelAutoMerge(ctx, autoMergeRequest("/demo/merge-requests/3/auto-merge/cancel", ""))
273	require.NoError(t, err)
274	assert.Equal(t, mrHref("demo", 3), to)
275
276	// A bad mode is refused before the service.
277	_, _, err = s.mrAutoMerge(ctx, autoMergeRequest("/x", "mode=rebase"))
278	var re *rerender
279	require.ErrorAs(t, err, &re)
280	assert.Equal(t, http.StatusBadRequest, re.status)
281
282	// A refusal of the use case is shown in the box, like a refused merge.
283	svc2 := mocks.NewMergeRequestService(t)
284	svc2.EXPECT().EnableAutoMerge(mock.Anything, mock.Anything, name, int64(3), mock.Anything).
285		Return(domain.MergeRequest{}, domain.ErrPreconditionFailed).Once()
286	svc2.EXPECT().CancelAutoMerge(mock.Anything, mock.Anything, name, int64(3)).
287		Return(domain.MergeRequest{}, domain.ErrNotFound).Once()
288	s2 := &Server{cfg: Config{MergeRequests: svc2}}
289	_, _, err = s2.mrAutoMerge(ctx, autoMergeRequest("/x", "mode=squash&head_id="+head.String()))
290	require.ErrorAs(t, err, &re)
291	assert.Equal(t, http.StatusConflict, re.status)
292	_, _, err = s2.mrCancelAutoMerge(ctx, autoMergeRequest("/x", ""))
293	require.ErrorIs(t, err, domain.ErrNotFound)
294}
295
296func TestAutoMergeLiveEvents(t *testing.T) {
297	t.Parallel()
298	assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeEnabled{})))
299	assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeCancelled{})))
300}
301
302func TestMergeIgnoreChecks(t *testing.T) {
303	t.Parallel()
304	for _, kind := range []string{"bad", "running", "ok", ""} {
305		t.Run(kind, func(t *testing.T) {
306			t.Parallel()
307			box := (&Server{}).mergeBox(&repoData{repo: repoWithModes(domain.MergeModes()...)}, 3, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
308			html := renderToString(t, mergeMenu(MRPage{Box: box, ChecksSum: ChecksSummary{Kind: kind}}))
309			if kind == "bad" || kind == "running" {
310				assert.Contains(t, html, `name="ignore_checks"`)
311				assert.Contains(t, html, "Ignore checks")
312			} else {
313				assert.NotContains(t, html, `name="ignore_checks"`)
314			}
315			assert.True(t, box.CanMerge())
316		})
317	}
318	name, err := domain.ParseRepoName("demo")
319	require.NoError(t, err)
320	head, err := domain.ParseObjectID(strings.Repeat("a", domain.ObjectIDLen))
321	require.NoError(t, err)
322	for _, ignore := range []bool{false, true} {
323		svc := mocks.NewMergeRequestService(t)
324		svc.EXPECT().Merge(mock.Anything, mock.Anything, name, int64(3), ports.MergeParams{Mode: domain.MergeModeSquash, HeadID: head, IgnoreChecks: ignore}).Return(ports.MergeResult{}, nil).Once()
325		body := "mode=squash&head_id=" + head.String()
326		if ignore {
327			body += "&ignore_checks=on"
328		}
329		s := &Server{cfg: Config{MergeRequests: svc}}
330		to, _, err := s.mrMerge(context.Background(), autoMergeRequest("/demo/merge-requests/3/merge", body))
331		require.NoError(t, err)
332		assert.Equal(t, mrHref("demo", 3), to)
333	}
334}

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 4

Tags 45

↑↓ moveenter openesc close