v0.22.0 4c072b1

home_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6	"bytes"
7	"context"
8	"errors"
9	"fmt"
10	"net/http"
11	"net/http/httptest"
12	"strings"
13	"sync/atomic"
14	"testing"
15	"time"
16
17	"github.com/stretchr/testify/assert"
18	"github.com/stretchr/testify/mock"
19	"github.com/stretchr/testify/require"
20
21	"bnema.dev/ermite/internal/domain"
22	"bnema.dev/ermite/internal/ports"
23	"bnema.dev/ermite/internal/ports/mocks"
24)
25
26// pushedRepo is a public repository that was pushed to.
27func pushedRepo(t testing.TB, name string) ports.RepoView {
28	t.Helper()
29	v := publicRepo(t, name)
30	v.PushedAt = time.Now().Add(-time.Hour)
31	return v
32}
33
34// homeCommits are n commits, newest first, with distinct ids and subjects.
35func homeCommits(t testing.TB, n int) []domain.Commit {
36	t.Helper()
37	var out []domain.Commit
38	for i := n; i >= 1; i-- {
39		out = append(out, domain.Commit{
40			ID:        objectID(t, fmt.Sprintf("%040x", i)),
41			Summary:   fmt.Sprintf("subject number %d", i),
42			Committer: domain.Signature{Time: time.Now().Add(-time.Duration(i) * time.Minute)},
43		})
44	}
45	return out
46}
47
48func renderRow(t testing.TB, r HomeRow) string {
49	t.Helper()
50	var b bytes.Buffer
51	require.NoError(t, HomeRowView(r).Render(context.Background(), &b))
52	return b.String()
53}
54
55func mustRevision(t testing.TB, s string) domain.Revision {
56	t.Helper()
57	r, err := domain.ParseRevision(s)
58	require.NoError(t, err)
59	return r
60}
61
62// Each row lists at most five commits of the default branch, read by the full
63// name of the branch.
64func TestHomeRowShowsFiveCommits(t *testing.T) {
65	t.Parallel()
66	browse := mocks.NewBrowseService(t)
67	browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListCommitsParams{
68		Revision: mustRevision(t, "refs/heads/main"), PageSize: homeRecent,
69	}).Return(ports.HistoryPage{Commits: homeCommits(t, 7)}, nil).Once()
70	srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
71
72	row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), time.Now(), false)
73	require.Len(t, row.Commits, homeRecent)
74	assert.Equal(t, "subject number 7", row.Commits[0].Subject)
75	assert.False(t, row.NoCommits)
76	assert.Nil(t, row.CI, "a server without CI shows no state")
77
78	out := renderRow(t, row)
79	assert.Equal(t, homeRecent, strings.Count(out, `class="rc-sha"`))
80	assert.Contains(t, out, `href="/proj/commit/`+fmt.Sprintf("%040x", 7)+`"`)
81	assert.Contains(t, out, `aria-label="Recent commits of proj"`)
82	assert.NotContains(t, out, `class="repo-ci"`)
83}
84
85func TestHomeRowEmptyRepository(t *testing.T) {
86	t.Parallel()
87	// Never pushed: nothing is read.
88	srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "new")), Browse: mocks.NewBrowseService(t), Runs: mocks.NewRunService(t)})
89	row := srv.homeRowOf(context.Background(), publicRepo(t, "new"), time.Now(), false)
90	assert.True(t, row.NoCommits)
91	out := renderRow(t, row)
92	assert.Contains(t, out, "no commits yet")
93	assert.Contains(t, out, "never pushed")
94	assert.NotContains(t, out, "repo-commits\" aria-label")
95}
96
97func TestHomeRowCI(t *testing.T) {
98	t.Parallel()
99	repo := repoName(t, "proj")
100	head := fmt.Sprintf("%040x", 2) // the newest of homeCommits(t, 2)
101	check := func(run int64, status domain.CIStatus, c domain.CIConclusion) ports.CheckView {
102		return ports.CheckView{CICheck: domain.CICheck{Repo: repo, Sha: head, Name: "ci / test", RunID: run, Status: status, Conclusion: c}}
103	}
104	tests := []struct {
105		name   string
106		checks []ports.CheckView
107		err    error
108		label  string // "" when no state is shown
109		href   string
110	}{
111		{"passing", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess)}, nil, "passing", "/proj/actions/runs/9"},
112		{"failing", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess), check(9, domain.CICompleted, domain.CIFailure)}, nil, "failing", "/proj/actions/runs/9"},
113		{"running", []ports.CheckView{check(9, domain.CIInProgress, "")}, nil, "running", "/proj/actions/runs/9"},
114		{"several runs link the commit", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess), check(10, domain.CICompleted, domain.CISuccess)}, nil, "passing", "/proj/commit/" + head},
115		{"no check on the head", nil, nil, "", ""},
116		{"the viewer cannot read CI", nil, domain.ErrNotFound, "", ""},
117		{"the read fails", nil, errors.New("boom"), "", ""},
118	}
119	for _, tt := range tests {
120		t.Run(tt.name, func(t *testing.T) {
121			t.Parallel()
122			browse := mocks.NewBrowseService(t)
123			browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
124			runs := mocks.NewRunService(t)
125			runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).
126				Return(ports.ChecksResult{Checks: tt.checks}, tt.err).Once()
127			srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse, Runs: runs})
128
129			row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), time.Now(), false)
130			require.Len(t, row.Commits, 2, "the commits do not depend on CI")
131			out := renderRow(t, row)
132			if tt.label == "" {
133				assert.Nil(t, row.CI)
134				assert.NotContains(t, out, "repo-ci")
135				return
136			}
137			require.NotNil(t, row.CI)
138			assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`)
139			assert.Contains(t, out, tt.label, "the state has a word, not only a mark")
140			assert.Contains(t, out, `<span class="sr">CI: </span>`)
141		})
142	}
143}
144
145// The private badge is for the signed-in owner only.
146func TestHomeRowPrivateBadge(t *testing.T) {
147	t.Parallel()
148	priv := pushedRepo(t, "secret")
149	priv.Visibility = domain.VisibilityPrivate
150	browse := mocks.NewBrowseService(t)
151	browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.HistoryPage{}, nil).Maybe()
152	srv := newTestServer(t, Config{Repos: reposServing(t, priv, pushedRepo(t, "open")), Browse: browse})
153
154	signed := context.WithValue(context.Background(), signedInKey{}, struct{}{})
155	assert.Contains(t, renderRow(t, srv.homeRowOf(signed, priv, time.Now(), false)), `<span class="pill">private</span>`)
156	assert.NotContains(t, renderRow(t, srv.homeRowOf(context.Background(), priv, time.Now(), false)), "private")
157	assert.NotContains(t, renderRow(t, srv.homeRowOf(signed, pushedRepo(t, "open"), time.Now(), false)), "private")
158}
159
160// A run update re-renders the home row, so its CI state follows the run.
161func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) {
162	t.Parallel()
163	repo := repoName(t, "proj")
164	head := fmt.Sprintf("%040x", 2)
165	browse := mocks.NewBrowseService(t)
166	browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
167	runs := mocks.NewRunService(t)
168	runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{
169		{CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CICompleted, Conclusion: domain.CISuccess}},
170	}}, nil).Once()
171	srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse, Runs: runs})
172
173	got, err := (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.RunUpdated{Run: domain.CIRun{Repo: repo, ID: 9}}})
174	require.NoError(t, err)
175	require.Len(t, got, 1)
176	assert.Equal(t, "home-list", got[0].event)
177	assert.Contains(t, got[0].html, `id="repo-proj"`)
178	assert.Contains(t, got[0].html, "passing")
179	assert.NotContains(t, got[0].html, "new push", "a run is not a push")
180
181	// A job that ended refreshes the row too; one in progress does not.
182	got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseRunning}}})
183	require.NoError(t, err)
184	assert.Empty(t, got)
185	runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{
186		{CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CICompleted, Conclusion: domain.CIFailure}},
187		{CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CIInProgress}},
188	}}, nil).Once()
189	got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseEnded}}})
190	require.NoError(t, err)
191	require.Len(t, got, 1)
192	assert.Contains(t, got[0].html, "failing", "a failed job shows before its run ends")
193}
194
195// A repository whose commits cannot be read keeps its row, and the page is
196// served; the others are not affected.
197func TestHomeSurvivesABrokenRepository(t *testing.T) {
198	t.Parallel()
199	browse := mocks.NewBrowseService(t)
200	browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "broken"), mock.Anything).
201		Return(ports.HistoryPage{}, errors.New("git exploded")).Once()
202	browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "fine"), mock.Anything).
203		Return(ports.HistoryPage{Commits: homeCommits(t, 3)}, nil).Once()
204	srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "broken"), pushedRepo(t, "fine")), Browse: browse})
205
206	rec := httptest.NewRecorder()
207	srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/?sort=name", nil))
208	require.Equal(t, http.StatusOK, rec.Code)
209	body := rec.Body.String()
210	assert.Contains(t, body, `id="repo-broken"`)
211	assert.Contains(t, body, `id="repo-fine"`)
212	assert.Equal(t, 3, strings.Count(body, `class="rc-sha"`), "only the readable repository lists commits")
213	assert.NotContains(t, body, "no commits yet", "a failed read is not an empty branch")
214}
215
216// The rows are read a few at a time, in the order of the list.
217func TestHomeRowsAreBoundedAndOrdered(t *testing.T) {
218	t.Parallel()
219	const n = 40
220	var views []ports.RepoView
221	for i := range n {
222		views = append(views, pushedRepo(t, fmt.Sprintf("r%02d", i)))
223	}
224	var running, peak atomic.Int32
225	browse := mocks.NewBrowseService(t)
226	browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
227		func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) (ports.HistoryPage, error) {
228			cur := running.Add(1)
229			for p := peak.Load(); cur > p && !peak.CompareAndSwap(p, cur); p = peak.Load() {
230			}
231			time.Sleep(2 * time.Millisecond)
232			running.Add(-1)
233			return ports.HistoryPage{Commits: homeCommits(t, 1)}, nil
234		})
235	srv := newTestServer(t, Config{Repos: reposServing(t, views...), Browse: browse})
236
237	rows := srv.homeRows(context.Background(), views, time.Now())
238	require.Len(t, rows, n)
239	for i, r := range rows {
240		assert.Equal(t, fmt.Sprintf("r%02d", i), r.Name)
241		assert.Len(t, r.Commits, 1)
242	}
243	assert.True(t, rows[0].Cursor)
244	assert.LessOrEqual(t, int(peak.Load()), homeWorkers)
245	assert.Greater(t, int(peak.Load()), 1, "the reads overlap")
246}

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