v0.32.0 53e25a3

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
48// homeBrowse is a BrowseService whose figures are always there: 1234
49// commits, 7 of them recent, no tag. Tests that care set their own.
50func homeBrowse(t *testing.T) *mocks.BrowseService {
51	t.Helper()
52	b := mocks.NewBrowseService(t)
53	b.EXPECT().RepoStats(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
54		Return(ports.RepoStats{Commits: 1234, Recent: 7}, nil).Maybe()
55	return b
56}
57
58// Each row shows the figures of its repository: commits, recent commits,
59// latest tag and last activity; no branch name.
60func TestHomeRowFigures(t *testing.T) {
61	t.Parallel()
62	repo := repoName(t, "proj")
63	head := objectID(t, fmt.Sprintf("%040x", 2))
64	now := time.Now()
65	browse := mocks.NewBrowseService(t)
66	browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
67	browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{
68		Commits: 12345, Recent: 42, LatestTag: &domain.TagInfo{Name: "v1.2.0", At: now.Add(-3 * time.Hour), CommitID: head},
69		Activity: make([]int, ports.ActivityDays),
70	}, nil).Once()
71	srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
72
73	view := pushedRepo(t, "proj")
74	view.Topics = []string{"go", "git"}
75	row := srv.homeRowOf(context.Background(), view, now, false)
76	require.NotNil(t, row.Stats)
77	out := renderRow(t, row)
78	assert.Contains(t, out, "<dt>commits</dt><dd>12\u202f345</dd>")
79	assert.Contains(t, out, "<dt>last 30 days</dt><dd>42</dd>")
80	assert.Contains(t, out, `<a href="/proj/tree/`+head.String()+`" title="v1.2.0 · made 3h ago">v1.2.0</a>`, "by its commit: a branch may share its name")
81	assert.Contains(t, out, "<dt>last activity</dt>")
82	assert.NotContains(t, out, "i-branch", "the default branch is not shown")
83	assert.Contains(t, out, `<ul class="repo-topics" aria-label="Topics of proj" data-fit><li>go</li><li>git</li></ul>`)
84	assert.Contains(t, out, `role="img" aria-label="Activity: 0 commits in the last 90 days"`)
85	assert.Contains(t, out, `<linearGradient id="hb-proj"`)
86	assert.NotContains(t, out, "pulse-ring", "nothing moves")
87	assert.NotContains(t, out, "90 days ago")
88	assert.NotContains(t, out, "pushed")
89	// The tag comes last, after the last activity.
90	assert.Regexp(t, `<dt>last activity</dt>.*<dt>last tag</dt>`, out)
91
92	// Without topics nor figures, the row keeps their room: every card has the
93	// same height.
94	bare := renderRow(t, HomeRow{ID: "repo-bare", Name: "bare", Href: "/bare"})
95	assert.Contains(t, bare, `<div class="repo-topics" aria-hidden="true"></div>`)
96	assert.Contains(t, bare, `<div class="repo-pulse" aria-hidden="true"></div>`)
97	assert.NotContains(t, bare, "last tag")
98
99	// Past the cap of the walk, the figures are lower bounds.
100	browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{
101		Commits: 300000, Recent: 100000, Activity: make([]int, ports.ActivityDays), Partial: true,
102	}, nil).Once()
103	out = renderRow(t, srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false))
104	assert.Contains(t, out, "<dt>last 30 days</dt><dd>100\u202f000+</dd>")
105	assert.Contains(t, out, `aria-label="Activity: at least 0 commits in the last 90 days"`)
106
107	// A failed read leaves the figures out, not the row.
108	browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{}, errors.New("boom")).Once()
109	row = srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false)
110	assert.Nil(t, row.Stats)
111	assert.Len(t, row.Commits, 2)
112	assert.NotContains(t, renderRow(t, row), "<dt>commits</dt>")
113}
114
115func TestGrouped(t *testing.T) {
116	t.Parallel()
117	for n, want := range map[int]string{0: "0", 999: "999", 1000: "1\u202f000", 1234567: "1\u202f234\u202f567"} {
118		assert.Equal(t, want, grouped(n), n)
119	}
120}
121
122func renderRow(t testing.TB, r HomeRow) string {
123	t.Helper()
124	var b bytes.Buffer
125	require.NoError(t, HomeRowView(r).Render(context.Background(), &b))
126	return b.String()
127}
128
129func mustRevision(t testing.TB, s string) domain.Revision {
130	t.Helper()
131	r, err := domain.ParseRevision(s)
132	require.NoError(t, err)
133	return r
134}
135
136// Each row lists at most five commits of the default branch, read by the full
137// name of the branch.
138func TestHomeRowShowsFiveCommits(t *testing.T) {
139	t.Parallel()
140	browse := homeBrowse(t)
141	browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListCommitsParams{
142		Revision: mustRevision(t, "refs/heads/main"), PageSize: homeRecent,
143	}).Return(ports.HistoryPage{Commits: homeCommits(t, 7)}, nil).Once()
144	srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
145
146	row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), time.Now(), false)
147	require.Len(t, row.Commits, homeRecent)
148	assert.Equal(t, "subject number 7", row.Commits[0].Subject)
149	assert.False(t, row.NoCommits)
150	assert.Nil(t, row.CI, "a server without CI shows no state")
151
152	out := renderRow(t, row)
153	assert.Equal(t, homeRecent, strings.Count(out, `class="rc-sha"`))
154	assert.Contains(t, out, `class="repo-commits" data-full`, "a full list fades its last commit")
155	assert.Contains(t, out, `href="/proj/commit/`+fmt.Sprintf("%040x", 7)+`"`)
156	assert.Contains(t, out, `aria-label="Recent commits of proj"`)
157	assert.NotContains(t, out, `class="repo-ci"`)
158}
159
160func TestHomeRowEmptyRepository(t *testing.T) {
161	t.Parallel()
162	// Never pushed: nothing is read.
163	srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "new")), Browse: mocks.NewBrowseService(t), Runs: mocks.NewRunService(t)})
164	row := srv.homeRowOf(context.Background(), publicRepo(t, "new"), time.Now(), false)
165	assert.True(t, row.NoCommits)
166	out := renderRow(t, row)
167	assert.Contains(t, out, "no commits yet")
168	assert.Contains(t, out, `<dt>last activity</dt><dd class="mute">never</dd>`)
169	assert.NotContains(t, out, "<dt>commits</dt>", "no figures without a commit")
170	assert.NotContains(t, out, "repo-commits\" aria-label")
171}
172
173func TestHomeRowCI(t *testing.T) {
174	t.Parallel()
175	repo := repoName(t, "proj")
176	head := fmt.Sprintf("%040x", 2) // the newest of homeCommits(t, 2)
177	check := func(run int64, status domain.CIStatus, c domain.CIConclusion) ports.CheckView {
178		return ports.CheckView{CICheck: domain.CICheck{Repo: repo, Sha: head, Name: "ci / test", RunID: run, Status: status, Conclusion: c}}
179	}
180	tests := []struct {
181		name   string
182		checks []ports.CheckView
183		err    error
184		label  string // "" when no state is shown
185		href   string
186	}{
187		{"passing", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess)}, nil, "passing", "/proj/actions/runs/9"},
188		{"failing", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess), check(9, domain.CICompleted, domain.CIFailure)}, nil, "failing", "/proj/actions/runs/9"},
189		{"running", []ports.CheckView{check(9, domain.CIInProgress, "")}, nil, "running", "/proj/actions/runs/9"},
190		{"several runs link the commit", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess), check(10, domain.CICompleted, domain.CISuccess)}, nil, "passing", "/proj/commit/" + head},
191		{"no check on the head", nil, nil, "", ""},
192		{"the viewer cannot read CI", nil, domain.ErrNotFound, "", ""},
193		{"the read fails", nil, errors.New("boom"), "", ""},
194	}
195	for _, tt := range tests {
196		t.Run(tt.name, func(t *testing.T) {
197			t.Parallel()
198			browse := homeBrowse(t)
199			browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
200			runs := mocks.NewRunService(t)
201			runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).
202				Return(ports.ChecksResult{Checks: tt.checks}, tt.err).Once()
203			srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse, Runs: runs})
204
205			row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), time.Now(), false)
206			require.Len(t, row.Commits, 2, "the commits do not depend on CI")
207			out := renderRow(t, row)
208			if tt.label == "" {
209				assert.Nil(t, row.CI)
210				assert.NotContains(t, out, "repo-ci")
211				return
212			}
213			require.NotNil(t, row.CI)
214			assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`)
215			assert.Contains(t, out, `<span class="ci-label">`+tt.label+`</span>`, "the state has a word, not only a mark; compact hides it")
216			assert.Contains(t, out, `<span class="sr">CI: </span>`)
217		})
218	}
219}
220
221// The private badge is for the signed-in owner only.
222func TestHomeRowPrivateBadge(t *testing.T) {
223	t.Parallel()
224	priv := pushedRepo(t, "secret")
225	priv.Visibility = domain.VisibilityPrivate
226	browse := homeBrowse(t)
227	browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.HistoryPage{}, nil).Maybe()
228	srv := newTestServer(t, Config{Repos: reposServing(t, priv, pushedRepo(t, "open")), Browse: browse})
229
230	signed := context.WithValue(context.Background(), signedInKey{}, struct{}{})
231	assert.Contains(t, renderRow(t, srv.homeRowOf(signed, priv, time.Now(), false)), `<span class="pill">private</span>`)
232	assert.NotContains(t, renderRow(t, srv.homeRowOf(context.Background(), priv, time.Now(), false)), "private")
233	assert.NotContains(t, renderRow(t, srv.homeRowOf(signed, pushedRepo(t, "open"), time.Now(), false)), "private")
234}
235
236// A run update re-renders the home row, so its CI state follows the run.
237func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) {
238	t.Parallel()
239	repo := repoName(t, "proj")
240	head := fmt.Sprintf("%040x", 2)
241	browse := homeBrowse(t)
242	browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
243	runs := mocks.NewRunService(t)
244	runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{
245		{CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CICompleted, Conclusion: domain.CISuccess}},
246	}}, nil).Once()
247	srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse, Runs: runs})
248
249	got, err := (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.RunUpdated{Run: domain.CIRun{Repo: repo, ID: 9}}})
250	require.NoError(t, err)
251	require.Len(t, got, 1)
252	assert.Equal(t, "home-list", got[0].event)
253	assert.Contains(t, got[0].html, `id="repo-proj"`)
254	assert.Contains(t, got[0].html, "passing")
255	assert.NotContains(t, got[0].html, "new activity", "a run is not a push")
256
257	// A job that ended refreshes the row too; one in progress does not.
258	got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseRunning}}})
259	require.NoError(t, err)
260	assert.Empty(t, got)
261	runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{
262		{CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CICompleted, Conclusion: domain.CIFailure}},
263		{CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CIInProgress}},
264	}}, nil).Once()
265	got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseEnded}}})
266	require.NoError(t, err)
267	require.Len(t, got, 1)
268	assert.Contains(t, got[0].html, "failing", "a failed job shows before its run ends")
269}
270
271// A repository whose commits cannot be read keeps its row, and the page is
272// served; the others are not affected.
273func TestHomeSurvivesABrokenRepository(t *testing.T) {
274	t.Parallel()
275	browse := homeBrowse(t)
276	browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "broken"), mock.Anything).
277		Return(ports.HistoryPage{}, errors.New("git exploded")).Once()
278	browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "fine"), mock.Anything).
279		Return(ports.HistoryPage{Commits: homeCommits(t, 3)}, nil).Once()
280	srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "broken"), pushedRepo(t, "fine")), Browse: browse})
281
282	rec := httptest.NewRecorder()
283	srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/?sort=name", nil))
284	require.Equal(t, http.StatusOK, rec.Code)
285	body := rec.Body.String()
286	assert.Contains(t, body, `id="repo-broken"`)
287	assert.Contains(t, body, `id="repo-fine"`)
288	assert.Equal(t, 3, strings.Count(body, `class="rc-sha"`), "only the readable repository lists commits")
289	assert.NotContains(t, body, "no commits yet", "a failed read is not an empty branch")
290}
291
292// The rows are read a few at a time, in the order of the list.
293func TestHomeRowsAreBoundedAndOrdered(t *testing.T) {
294	t.Parallel()
295	const n = 40
296	var views []ports.RepoView
297	for i := range n {
298		views = append(views, pushedRepo(t, fmt.Sprintf("r%02d", i)))
299	}
300	var running, peak atomic.Int32
301	browse := homeBrowse(t)
302	browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
303		func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) (ports.HistoryPage, error) {
304			cur := running.Add(1)
305			for p := peak.Load(); cur > p && !peak.CompareAndSwap(p, cur); p = peak.Load() {
306			}
307			time.Sleep(2 * time.Millisecond)
308			running.Add(-1)
309			return ports.HistoryPage{Commits: homeCommits(t, 1)}, nil
310		})
311	srv := newTestServer(t, Config{Repos: reposServing(t, views...), Browse: browse})
312
313	rows := srv.homeRows(context.Background(), views, time.Now())
314	require.Len(t, rows, n)
315	for i, r := range rows {
316		assert.Equal(t, fmt.Sprintf("r%02d", i), r.Name)
317		assert.Len(t, r.Commits, 1)
318	}
319	assert.True(t, rows[0].Cursor)
320	assert.LessOrEqual(t, int(peak.Load()), homeWorkers)
321	assert.Greater(t, int(peak.Load()), 1, "the reads overlap")
322}
323
324// The page starts in the cards layout (a script applies the stored choice) and
325// offers the three layouts, "cards" pressed; the store loads before the app.
326func TestHomeOffersTheLayouts(t *testing.T) {
327	t.Parallel()
328	browse := homeBrowse(t)
329	browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
330	srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
331
332	rec := httptest.NewRecorder()
333	srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", nil))
334	require.Equal(t, http.StatusOK, rec.Code)
335	body := rec.Body.String()
336	assert.Contains(t, body, `<div class="home" data-layout="cards">`)
337	assert.Contains(t, body, `<div class="layout-switch" role="group" aria-label="Layout" data-js>`)
338	for layout, pressed := range map[string]string{"cards": "true", "split": "false", "compact": "false"} {
339		assert.Contains(t, body, `data-set-layout="`+layout+`" aria-pressed="`+pressed+`"`)
340		assert.Contains(t, body, `class="ico i-`+layout+`"`)
341	}
342	store := strings.Index(body, "/js/store.")
343	app := strings.Index(body, "/js/ermite.")
344	require.NotEqual(t, -1, store)
345	assert.Less(t, store, app, "the store loads before the app script")
346}
347
348func TestHomeActivityOrderAndFallback(t *testing.T) {
349	t.Parallel()
350	now := time.Now().UTC()
351	a, b, c := publicRepo(t, "a"), publicRepo(t, "b"), publicRepo(t, "c")
352	a.PushedAt = now.Add(-time.Hour)
353	a.ActiveAt = now
354	b.PushedAt = now.Add(-time.Minute)
355	c.CreatedAt = now.Add(-30 * time.Minute)
356	repos := []ports.RepoView{b, c, a}
357	sortRepos(repos, "activity", true)
358	assert.Equal(t, []ports.RepoView{a, b, c}, repos)
359	sortRepos(repos, "activity", false)
360	assert.Equal(t, []ports.RepoView{c, b, a}, repos)
361	assert.Equal(t, rfc3339(now), homeRow(a, now, false, false).ActiveAt)
362	c.PushedAt = c.CreatedAt.Add(-time.Hour)
363	assert.Equal(t, c.CreatedAt, repoActiveAt(c), "fallback is the later of creation and push")
364}
365
366func TestActivityEventsRefreshTheHomeRow(t *testing.T) {
367	t.Parallel()
368	repo := publicRepo(t, "proj")
369	srv := newTestServer(t, Config{Repos: reposServing(t, repo), Browse: homeBrowse(t)})
370	mr := domain.MergeRequest{Repo: repo.Name}
371	issue := domain.Issue{Repo: repo.Name}
372	for _, ev := range []domain.Event{
373		domain.PushEvent{Repo: repo.Name},
374		domain.MergeRequestOpened{MergeRequest: mr}, domain.MergeRequestMerged{MergeRequest: mr},
375		domain.MergeRequestClosed{MergeRequest: mr}, domain.MergeRequestReady{MergeRequest: mr},
376		domain.CommentAdded{Repo: repo.Name}, domain.IssueOpened{Issue: issue},
377		domain.IssueClosed{Issue: issue}, domain.IssueReopened{Issue: issue}, domain.IssueCommented{Repo: repo.Name},
378	} {
379		t.Run(ev.EventKind(), func(t *testing.T) {
380			t.Parallel()
381			got, err := (&liveStream{s: srv}).home(t.Context(), ports.WatchEvent{Event: ev})
382			require.NoError(t, err)
383			require.Len(t, got, 1)
384			assert.Equal(t, "home-list", got[0].event)
385			assert.Contains(t, got[0].html, `id="repo-proj"`)
386			assert.Contains(t, got[0].html, `data-activity`)
387			assert.Contains(t, got[0].html, "new activity")
388		})
389	}
390	for _, ev := range []domain.Event{domain.IssueSubmitted{Repo: repo.Name}, domain.IssuePendingRemoved{Repo: repo.Name}} {
391		got, err := (&liveStream{s: srv}).home(t.Context(), ports.WatchEvent{Event: ev})
392		require.NoError(t, err)
393		assert.Empty(t, got)
394	}
395}

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