v0.30.0 c899179

mr_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"context"
7	"time"
8	"testing"
9
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/mock"
12	"github.com/stretchr/testify/require"
13
14	"bnema.dev/ermite/internal/domain"
15	"bnema.dev/ermite/internal/ports"
16	"bnema.dev/ermite/internal/ports/mocks"
17)
18
19func repoWithModes(modes ...domain.MergeMode) ports.RepoView {
20	var v ports.RepoView
21	v.Settings.AllowedMergeModes = modes
22	return v
23}
24
25func watchOf(ev domain.Event) ports.WatchEvent { return ports.WatchEvent{Event: ev} }
26
27func TestMergeabilityView(t *testing.T) {
28	t.Parallel()
29	for _, tt := range []struct {
30		name          string
31		m             domain.Mergeability
32		label, detail string
33		ffOK, mergeOK bool
34		note          string
35		paths         []string
36	}{
37		{"up to date", domain.Mergeability{State: domain.MergeabilityUpToDate}, "Ready to merge", "", true, true, "", nil},
38		{"behind", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 2}, "Behind main by 2 commits", "merges without conflicts", false, true, "", nil},
39		{"behind by one", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "Behind main by 1 commit", "merges without conflicts", false, true, "", nil},
40		{"behind, capped", domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1000, BehindCapped: true}, "Behind main by 1000+ commits", "merges without conflicts", false, true, "", nil},
41		{"conflict", domain.Mergeability{State: domain.MergeabilityConflict, ConflictPaths: []string{"a.go"}}, "Conflicts with main", "rebase needed", false, false, "", []string{"a.go"}},
42		{"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"}},
43		{"no common history", domain.Mergeability{State: domain.MergeabilityConflict}, "Conflicts with main", "rebase needed", false, false, "no common history.", nil},
44		{"checking", domain.Mergeability{State: domain.MergeabilityChecking}, "Checking…", "", false, false, "", nil},
45		{"none", domain.Mergeability{}, "Checking…", "", false, false, "", nil},
46	} {
47		t.Run(tt.name, func(t *testing.T) {
48			t.Parallel()
49			v := mergeabilityView(tt.m, "main")
50			assert.Equal(t, tt.label, v.Label)
51			assert.Equal(t, tt.detail, v.Detail)
52			assert.Equal(t, tt.ffOK, v.FFOk)
53			assert.Equal(t, tt.mergeOK, v.MergeOk)
54			assert.Equal(t, tt.note, v.PathsNote)
55			assert.Equal(t, tt.paths, v.Paths)
56			assert.Equal(t, !tt.ffOK, v.FFReason != "", "a mode that is not possible says why")
57			assert.Equal(t, !tt.mergeOK, v.MergeReason != "")
58		})
59	}
60}
61
62func TestMergeBoxModes(t *testing.T) {
63	t.Parallel()
64	d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
65	s := &Server{}
66
67	behind := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 1}, "main"))
68	require.Len(t, behind.Modes, 3)
69	assert.True(t, behind.Modes[0].Disabled, "fast-forward is not possible when behind")
70	assert.NotEmpty(t, behind.Modes[0].Reason)
71	assert.False(t, behind.Modes[1].Disabled)
72	assert.False(t, behind.Modes[2].Disabled)
73	assert.Equal(t, 1, behind.Checked(), "the first possible mode is checked")
74	assert.True(t, behind.CanMerge())
75	assert.True(t, behind.Signed)
76
77	conflict := s.mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityConflict}, "main"))
78	assert.False(t, conflict.CanMerge())
79	assert.NotEmpty(t, conflict.MergeTitle(), "the button says why it is disabled")
80	assert.Equal(t, 0, conflict.Checked())
81	assert.Contains(t, conflict.CLI.Lines[2], "--mode merge")
82	assert.NotContains(t, conflict.CLI.Lines[1], "--squash")
83}
84
85func TestMergeBoxOnlyFastForwardIsNotSigned(t *testing.T) {
86	t.Parallel()
87	d := &repoData{repo: repoWithModes(domain.MergeModeFastForward)}
88	b := (&Server{}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{State: domain.MergeabilityUpToDate}, "main"))
89	assert.False(t, b.Signed, "no commit is created, nothing to explain")
90	assert.Len(t, b.Modes, 1)
91}
92
93func TestMergeBoxNamesTheSigningKey(t *testing.T) {
94	t.Parallel()
95	key := mocks.NewSigningKeyService(t)
96	key.EXPECT().Info().Return(domain.SigningKeyInfo{Key: domain.SigningPublicKey{Fingerprint: "SHA256:abc"}})
97	d := &repoData{repo: repoWithModes(domain.MergeModes()...)}
98	b := (&Server{cfg: Config{SigningKey: key}}).mergeBox(d, 1, "feat", "abc", true, mergeabilityView(domain.Mergeability{}, "main"))
99	assert.Equal(t, "SHA256:abc", b.Fingerprint)
100}
101
102func TestMergeErrorTravelsInTheContext(t *testing.T) {
103	t.Parallel()
104	assert.Empty(t, mergeErrorOf(context.Background()))
105	assert.Equal(t, "boom", mergeErrorOf(withMergeError(context.Background(), "boom")))
106}
107
108func TestMergeabilityLiveEvents(t *testing.T) {
109	t.Parallel()
110	assert.True(t, liveEvent(watchOf(domain.MergeRequestMergeability{Number: 1})))
111	svc := mocks.NewMergeRequestService(t)
112	svc.EXPECT().Mergeability(mock.Anything, mock.Anything, mock.Anything, int64(1)).
113		Return(domain.Mergeability{State: domain.MergeabilityBehind, Behind: 3}, nil)
114	l := liveStream{s: &Server{cfg: Config{MergeRequests: svc}}, repo: "demo", view: "mr", n: "1", target: "main"}
115	ctx := context.WithValue(context.Background(), signedInKey{}, struct{}{})
116
117	fs, err := l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 1}))
118	require.NoError(t, err)
119	require.Len(t, fs, 1)
120	assert.Equal(t, "mr-mergeability", fs[0].event)
121	assert.Contains(t, fs[0].html, "Behind main by 3 commits")
122
123	// Another request, or a page that is not one: nothing.
124	fs, err = l.repoPage(ctx, watchOf(domain.MergeRequestMergeability{Number: 2}))
125	require.NoError(t, err)
126	assert.Empty(t, fs)
127	// The box is the owner's.
128	fs, err = l.repoPage(context.Background(), watchOf(domain.MergeRequestMergeability{Number: 1}))
129	require.NoError(t, err)
130	assert.Empty(t, fs)
131}
132
133func TestMRPanelsAndMenus(t *testing.T) {
134	p := MRPage{CommitCount: "500+", CommitsNote: "more commits", Modes: []string{"squash", "merge"}, ChecksBlock: true,
135		ChecksSum: ChecksSummary{Total: 2, Label: "1/2", Title: "1/2 failed"}, Box: &MergeBox{Signed: true, Fingerprint: "fingerprint", HeadGone: true}}
136	footer := renderToString(t, mrFooter(p))
137	assert.Contains(t, footer, `<footer class="mr-footer"><nav data-mr-tabs`)
138	assert.NotContains(t, footer, `<details`)
139	assert.NotContains(t, footer, `<summary`)
140	// No hidden attribute: the CSS shows the panel of the URL fragment until
141	// mr.js takes over, so the tabs work as anchors without scripts.
142	assert.Contains(t, footer, `id="mr-commits" data-mr-panel role="region"`)
143	assert.Contains(t, footer, `id="mr-checks-panel" data-mr-panel role="region"`)
144	assert.NotContains(t, footer, `data-mr-panel hidden`)
145	css, err := staticFS.ReadFile("static/css/60-mr.css")
146	require.NoError(t, err)
147	assert.Contains(t, string(css), `&:not([data-panels]) [data-mr-panel]:not(:target, :has(:target)) { display: none; }`)
148	assert.Contains(t, footer, `data-mr-tabs`)
149	assert.Contains(t, footer, `id="mr-commits"`)
150	assert.Contains(t, footer, `id="mr-checks"`)
151	assert.Contains(t, footer, `<table class="mr-commits">`)
152	assert.Contains(t, footer, "500+")
153	assert.Contains(t, footer, "more commits")
154	assert.Contains(t, footer, "1/2 failed")
155	rail := renderToString(t, mrRail(p))
156	assert.Contains(t, rail, "g f")
157	for _, text := range []string{"Checks", "Commits", "Settings", "mr-checks"} {
158		assert.NotContains(t, rail, text)
159	}
160	more := renderToString(t, moreMenu(p))
161	for _, text := range []string{"Merge settings", "squash, merge", "failing ones block", `id="mr-signing"`, "fingerprint", "ermite signing-key show"} {
162		assert.Contains(t, more, text)
163	}
164	merge := renderToString(t, mergeMenu(p))
165	assert.NotContains(t, merge, `<dl`)
166	assert.NotContains(t, merge, `mr-signing`)
167	assert.Contains(t, merge, "the source branch was deleted")
168	bar := renderToString(t, mrBar(MRPage{}))
169	assert.Contains(t, bar, `id="mr-ck"`)
170	assert.Contains(t, bar, `href="#mr-checks"`)
171	assert.Contains(t, bar, "Merge settings")
172	assert.NotContains(t, bar, ">Close</button>")
173}
174
175func TestMRReadPresentationCommitCount(t *testing.T) {
176	commits := func(n int) []domain.Commit { return make([]domain.Commit, n) }
177	for _, tc := range []struct {
178		name      string
179		commits   []domain.Commit
180		truncated bool
181		count     string
182		note      string
183	}{
184		{"empty", nil, false, "0", ""},
185		{"complete", commits(2), false, "2", ""},
186		{"truncated", commits(3), true, "3+", "the branch has more commits than the 3 listed."},
187	} {
188		t.Run(tc.name, func(t *testing.T) {
189			s := &Server{cfg: Config{Now: time.Now}}
190			p := &MRPage{}
191			s.mrReadPresentation(p, "demo", 1, ports.MergeRequestDetail{Commits: tc.commits, CommitsTruncated: tc.truncated}, false, false)
192			assert.Equal(t, tc.count, p.CommitCount)
193			assert.Equal(t, tc.note, p.CommitsNote)
194			assert.Len(t, p.Commits, len(tc.commits))
195		})
196	}
197}

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