main 1fb2421

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

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