detached fda3c2b

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"><nav data-mr-tabs`)
141	assert.NotContains(t, footer, `<details`)
142	assert.NotContains(t, footer, `<summary`)
143	// No hidden attribute: the CSS shows the panel of the URL fragment until
144	// mr.js takes over, so the tabs work as anchors without scripts.
145	assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="region"`)
146	assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="region"`)
147	assert.NotContains(t, footer, `data-mr-panel hidden`)
148	css, err := staticFS.ReadFile("static/css/60-mr.css")
149	require.NoError(t, err)
150	assert.Contains(t, string(css), `&:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }`)
151	assert.Contains(t, footer, `data-mr-tabs`)
152	assert.Contains(t, footer, `id="mr-commits"`)
153	assert.Contains(t, footer, `id="mr-checks"`)
154	assert.Contains(t, footer, `<table class="mr-commits">`)
155	assert.Contains(t, footer, "500+")
156	assert.Contains(t, footer, "more commits")
157	assert.Contains(t, footer, "1/2 failed")
158	rail := renderToString(t, mrRail(p))
159	assert.Contains(t, rail, "g f")
160	for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} {
161		assert.NotContains(t, rail, text)
162	}
163	more := renderToString(t, moreMenu(p))
164	for _, text := range []string{"Merge settings", "squash, merge", "failing or unfinished checks block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} {
165		assert.Contains(t, more, text)
166	}
167	merge := renderToString(t, mergeMenu(p))
168	assert.NotContains(t, merge, `<dl`)
169	assert.NotContains(t, merge, `mr-signing`)
170	assert.Contains(t, merge, "the source branch was deleted")
171	bar := renderToString(t, mrBar(MRPage{}))
172	assert.Contains(t, bar, `id="mr-ck"`)
173	assert.Contains(t, bar, `href="#mr-checks"`)
174	assert.Contains(t, bar, "Merge settings")
175	assert.NotContains(t, bar, ">Close</button>")
176}
177
178func TestMRReadPresentationCommitCount(t *testing.T) {
179	commits := func(n int) []domain.Commit { return make([]domain.Commit, n) }
180	for _, tc := range []struct {
181		name      string
182		commits   []domain.Commit
183		truncated bool
184		count     string
185		note      string
186	}{
187		{"empty", nil, false, "0", ""},
188		{"complete", commits(2), false, "2", ""},
189		{"truncated", commits(3), true, "3+", "the branch has more commits than the 3 listed."},
190	} {
191		t.Run(tc.name, func(t *testing.T) {
192			s := &Server{cfg: Config{Now: time.Now}}
193			p := &MRPage{}
194			s.mrReadPresentation(p, "demo", 1, ports.MergeRequestDetail{Commits: tc.commits, CommitsTruncated: tc.truncated}, false, false)
195			assert.Equal(t, tc.count, p.CommitCount)
196			assert.Equal(t, tc.note, p.CommitsNote)
197			assert.Len(t, p.Commits, len(tc.commits))
198		})
199	}
200}
201
202func TestMergeBoxAutoMerge(t *testing.T) {
203	t.Parallel()
204	mg := mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main")
205	mk := func(allow bool, runs ports.RunService) *MergeBox {
206		v := repoWithModes(domain.MergeModes()...)
207		v.Settings.AllowAutoMerge = allow
208		return (&Server{cfg: Config{Runs: runs}}).mergeBox(&repoData{repo: v}, 1, "feat", "abc", false, mg)
209	}
210	assert.False(t, mk(false, mocks.NewRunService(t)).CanAutoMerge)
211	assert.False(t, mk(true, nil).CanAutoMerge, "no CI, nothing to wait for")
212	on := mk(true, mocks.NewRunService(t))
213	assert.True(t, on.CanAutoMerge)
214
215	page := MRPage{Target: "main", Box: on}
216	html := renderToString(t, mergeMenu(page))
217	assert.Contains(t, html, "Merge when ready")
218	assert.Contains(t, html, `action="`+on.AutoAction+`"`)
219	assert.NotContains(t, html, "Cancel auto-merge")
220	// Current mergeability must only gate the immediate form. Every allowed
221	// mode remains selectable in the separate auto-merge form.
222	for i := range on.Modes {
223		on.Modes[i].Disabled = true
224	}
225	on.CanDeleteBranch = true
226	page.Source = "feat"
227	html = renderToString(t, mergeMenu(page))
228	_, autoForm, found := strings.Cut(html, `action="`+on.AutoAction+`"`)
229	require.True(t, found)
230	autoForm, _, _ = strings.Cut(autoForm, "</form>")
231	assert.NotContains(t, autoForm, "delete_branch")
232	assert.NotContains(t, autoForm, "disabled")
233	assert.Contains(t, autoForm, `name="head_id"`)
234	for _, mode := range on.Modes {
235		assert.Contains(t, autoForm, `value="`+mode.Value+`"`)
236	}
237	page.Box = mk(false, mocks.NewRunService(t))
238	assert.NotContains(t, renderToString(t, mergeMenu(page)), "Merge when ready")
239
240	on.AutoMerge = &AutoMergeView{Mode: "squash", EnabledBy: "owner", HeadShort: "abc"}
241	html = renderToString(t, mergeMenu(MRPage{Target: "main", Box: on}))
242	assert.Contains(t, html, "Auto-merge: squash when ready, enabled by owner")
243	assert.Contains(t, html, `action="`+on.CancelAutoAction+`"`)
244	assert.Contains(t, html, "Cancel auto-merge")
245}
246
247func autoMergeRequest(path, body string) *http.Request {
248	r := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
249	r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
250	r.SetPathValue("repo", "demo")
251	r.SetPathValue("n", "3")
252	return r
253}
254
255func TestAutoMergeHandlers(t *testing.T) {
256	t.Parallel()
257	name, err := domain.ParseRepoName("demo")
258	require.NoError(t, err)
259	ctx := context.Background()
260
261	head, err := domain.ParseObjectID(strings.Repeat("a", domain.ObjectIDLen))
262	require.NoError(t, err)
263	svc := mocks.NewMergeRequestService(t)
264	svc.EXPECT().EnableAutoMerge(mock.Anything, mock.Anything, name, int64(3), ports.EnableAutoMergeParams{Mode: domain.MergeModeSquash, HeadID: head}).
265		Return(domain.MergeRequest{}, nil).Once()
266	svc.EXPECT().CancelAutoMerge(mock.Anything, mock.Anything, name, int64(3)).Return(domain.MergeRequest{}, nil).Once()
267	s := &Server{cfg: Config{MergeRequests: svc}}
268	to, _, err := s.mrAutoMerge(ctx, autoMergeRequest("/demo/merge-requests/3/auto-merge", "mode=squash&head_id="+head.String()))
269	require.NoError(t, err)
270	assert.Equal(t, mrHref("demo", 3), to)
271	to, _, err = s.mrCancelAutoMerge(ctx, autoMergeRequest("/demo/merge-requests/3/auto-merge/cancel", ""))
272	require.NoError(t, err)
273	assert.Equal(t, mrHref("demo", 3), to)
274
275	// A bad mode is refused before the service.
276	_, _, err = s.mrAutoMerge(ctx, autoMergeRequest("/x", "mode=rebase"))
277	var re *rerender
278	require.ErrorAs(t, err, &re)
279	assert.Equal(t, http.StatusBadRequest, re.status)
280
281	// A refusal of the use case is shown in the box, like a refused merge.
282	svc2 := mocks.NewMergeRequestService(t)
283	svc2.EXPECT().EnableAutoMerge(mock.Anything, mock.Anything, name, int64(3), mock.Anything).
284		Return(domain.MergeRequest{}, domain.ErrPreconditionFailed).Once()
285	svc2.EXPECT().CancelAutoMerge(mock.Anything, mock.Anything, name, int64(3)).
286		Return(domain.MergeRequest{}, domain.ErrNotFound).Once()
287	s2 := &Server{cfg: Config{MergeRequests: svc2}}
288	_, _, err = s2.mrAutoMerge(ctx, autoMergeRequest("/x", "mode=squash&head_id="+head.String()))
289	require.ErrorAs(t, err, &re)
290	assert.Equal(t, http.StatusConflict, re.status)
291	_, _, err = s2.mrCancelAutoMerge(ctx, autoMergeRequest("/x", ""))
292	require.ErrorIs(t, err, domain.ErrNotFound)
293}
294
295func TestAutoMergeLiveEvents(t *testing.T) {
296	t.Parallel()
297	assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeEnabled{})))
298	assert.True(t, liveEvent(watchOf(domain.MergeRequestAutoMergeCancelled{})))
299}
300
301func TestMergeIgnoreChecks(t *testing.T) {
302	t.Parallel()
303	for _, kind := range []string{"bad", "running", "ok", ""} {
304		t.Run(kind, func(t *testing.T) {
305			t.Parallel()
306			box := (&Server{}).mergeBox(&repoData{repo: repoWithModes(domain.MergeModes()...)}, 3, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
307			html := renderToString(t, mergeMenu(MRPage{Box: box, ChecksSum: ChecksSummary{Kind: kind}}))
308			if kind == "bad" || kind == "running" {
309				assert.Contains(t, html, `name="ignore_checks"`)
310				assert.Contains(t, html, "Ignore checks")
311			} else {
312				assert.NotContains(t, html, `name="ignore_checks"`)
313			}
314			assert.True(t, box.CanMerge())
315		})
316	}
317	name, err := domain.ParseRepoName("demo")
318	require.NoError(t, err)
319	head, err := domain.ParseObjectID(strings.Repeat("a", domain.ObjectIDLen))
320	require.NoError(t, err)
321	for _, ignore := range []bool{false, true} {
322		svc := mocks.NewMergeRequestService(t)
323		svc.EXPECT().Merge(mock.Anything, mock.Anything, name, int64(3), ports.MergeParams{Mode: domain.MergeModeSquash, HeadID: head, IgnoreChecks: ignore}).Return(ports.MergeResult{}, nil).Once()
324		body := "mode=squash&head_id=" + head.String()
325		if ignore {
326			body += "&ignore_checks=on"
327		}
328		s := &Server{cfg: Config{MergeRequests: svc}}
329		to, _, err := s.mrMerge(context.Background(), autoMergeRequest("/demo/merge-requests/3/merge", body))
330		require.NoError(t, err)
331		assert.Equal(t, mrHref("demo", 3), to)
332	}
333}

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 45

↑↓ moveenter openesc close