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"><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, "pushed")
87
88 // Past the cap of the walk, the figures are lower bounds.
89 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{
90 Commits: 300000, Recent: 100000, Activity: make([]int, ports.ActivityDays), Partial: true,
91 }, nil).Once()
92 out = renderRow(t, srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false))
93 assert.Contains(t, out, "<dt>last 30 days</dt><dd>100\u202f000+</dd>")
94 assert.Contains(t, out, `aria-label="Activity: at least 0 commits in the last 90 days"`)
95
96 // A failed read leaves the figures out, not the row.
97 browse.EXPECT().RepoStats(mock.Anything, mock.Anything, repo, head, now).Return(ports.RepoStats{}, errors.New("boom")).Once()
98 row = srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), now, false)
99 assert.Nil(t, row.Stats)
100 assert.Len(t, row.Commits, 2)
101 assert.NotContains(t, renderRow(t, row), "<dt>commits</dt>")
102}
103
104func TestGrouped(t *testing.T) {
105 t.Parallel()
106 for n, want := range map[int]string{0: "0", 999: "999", 1000: "1\u202f000", 1234567: "1\u202f234\u202f567"} {
107 assert.Equal(t, want, grouped(n), n)
108 }
109}
110
111func renderRow(t testing.TB, r HomeRow) string {
112 t.Helper()
113 var b bytes.Buffer
114 require.NoError(t, HomeRowView(r).Render(context.Background(), &b))
115 return b.String()
116}
117
118func mustRevision(t testing.TB, s string) domain.Revision {
119 t.Helper()
120 r, err := domain.ParseRevision(s)
121 require.NoError(t, err)
122 return r
123}
124
125// Each row lists at most five commits of the default branch, read by the full
126// name of the branch.
127func TestHomeRowShowsFiveCommits(t *testing.T) {
128 t.Parallel()
129 browse := homeBrowse(t)
130 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "proj"), ports.ListCommitsParams{
131 Revision: mustRevision(t, "refs/heads/main"), PageSize: homeRecent,
132 }).Return(ports.HistoryPage{Commits: homeCommits(t, 7)}, nil).Once()
133 srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse})
134
135 row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), time.Now(), false)
136 require.Len(t, row.Commits, homeRecent)
137 assert.Equal(t, "subject number 7", row.Commits[0].Subject)
138 assert.False(t, row.NoCommits)
139 assert.Nil(t, row.CI, "a server without CI shows no state")
140
141 out := renderRow(t, row)
142 assert.Equal(t, homeRecent, strings.Count(out, `class="rc-sha"`))
143 assert.Contains(t, out, `class="repo-commits" data-full`, "a full list fades its last commit")
144 assert.Contains(t, out, `href="/proj/commit/`+fmt.Sprintf("%040x", 7)+`"`)
145 assert.Contains(t, out, `aria-label="Recent commits of proj"`)
146 assert.NotContains(t, out, `class="repo-ci"`)
147}
148
149func TestHomeRowEmptyRepository(t *testing.T) {
150 t.Parallel()
151 // Never pushed: nothing is read.
152 srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "new")), Browse: mocks.NewBrowseService(t), Runs: mocks.NewRunService(t)})
153 row := srv.homeRowOf(context.Background(), publicRepo(t, "new"), time.Now(), false)
154 assert.True(t, row.NoCommits)
155 out := renderRow(t, row)
156 assert.Contains(t, out, "no commits yet")
157 assert.Contains(t, out, `<dt>last activity</dt><dd class="mute">never</dd>`)
158 assert.NotContains(t, out, "<dt>commits</dt>", "no figures without a commit")
159 assert.NotContains(t, out, "repo-commits\" aria-label")
160}
161
162func TestHomeRowCI(t *testing.T) {
163 t.Parallel()
164 repo := repoName(t, "proj")
165 head := fmt.Sprintf("%040x", 2) // the newest of homeCommits(t, 2)
166 check := func(run int64, status domain.CIStatus, c domain.CIConclusion) ports.CheckView {
167 return ports.CheckView{CICheck: domain.CICheck{Repo: repo, Sha: head, Name: "ci / test", RunID: run, Status: status, Conclusion: c}}
168 }
169 tests := []struct {
170 name string
171 checks []ports.CheckView
172 err error
173 label string // "" when no state is shown
174 href string
175 }{
176 {"passing", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess)}, nil, "passing", "/proj/actions/runs/9"},
177 {"failing", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess), check(9, domain.CICompleted, domain.CIFailure)}, nil, "failing", "/proj/actions/runs/9"},
178 {"running", []ports.CheckView{check(9, domain.CIInProgress, "")}, nil, "running", "/proj/actions/runs/9"},
179 {"several runs link the commit", []ports.CheckView{check(9, domain.CICompleted, domain.CISuccess), check(10, domain.CICompleted, domain.CISuccess)}, nil, "passing", "/proj/commit/" + head},
180 {"no check on the head", nil, nil, "", ""},
181 {"the viewer cannot read CI", nil, domain.ErrNotFound, "", ""},
182 {"the read fails", nil, errors.New("boom"), "", ""},
183 }
184 for _, tt := range tests {
185 t.Run(tt.name, func(t *testing.T) {
186 t.Parallel()
187 browse := homeBrowse(t)
188 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
189 runs := mocks.NewRunService(t)
190 runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).
191 Return(ports.ChecksResult{Checks: tt.checks}, tt.err).Once()
192 srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse, Runs: runs})
193
194 row := srv.homeRowOf(context.Background(), pushedRepo(t, "proj"), time.Now(), false)
195 require.Len(t, row.Commits, 2, "the commits do not depend on CI")
196 out := renderRow(t, row)
197 if tt.label == "" {
198 assert.Nil(t, row.CI)
199 assert.NotContains(t, out, "repo-ci")
200 return
201 }
202 require.NotNil(t, row.CI)
203 assert.Contains(t, out, `class="repo-ci" href="`+tt.href+`"`)
204 assert.Contains(t, out, tt.label, "the state has a word, not only a mark")
205 assert.Contains(t, out, `<span class="sr">CI: </span>`)
206 })
207 }
208}
209
210// The private badge is for the signed-in owner only.
211func TestHomeRowPrivateBadge(t *testing.T) {
212 t.Parallel()
213 priv := pushedRepo(t, "secret")
214 priv.Visibility = domain.VisibilityPrivate
215 browse := homeBrowse(t)
216 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(ports.HistoryPage{}, nil).Maybe()
217 srv := newTestServer(t, Config{Repos: reposServing(t, priv, pushedRepo(t, "open")), Browse: browse})
218
219 signed := context.WithValue(context.Background(), signedInKey{}, struct{}{})
220 assert.Contains(t, renderRow(t, srv.homeRowOf(signed, priv, time.Now(), false)), `<span class="pill">private</span>`)
221 assert.NotContains(t, renderRow(t, srv.homeRowOf(context.Background(), priv, time.Now(), false)), "private")
222 assert.NotContains(t, renderRow(t, srv.homeRowOf(signed, pushedRepo(t, "open"), time.Now(), false)), "private")
223}
224
225// A run update re-renders the home row, so its CI state follows the run.
226func TestRunUpdatedRefreshesTheHomeRow(t *testing.T) {
227 t.Parallel()
228 repo := repoName(t, "proj")
229 head := fmt.Sprintf("%040x", 2)
230 browse := homeBrowse(t)
231 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repo, mock.Anything).Return(ports.HistoryPage{Commits: homeCommits(t, 2)}, nil)
232 runs := mocks.NewRunService(t)
233 runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{
234 {CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CICompleted, Conclusion: domain.CISuccess}},
235 }}, nil).Once()
236 srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "proj")), Browse: browse, Runs: runs})
237
238 got, err := (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.RunUpdated{Run: domain.CIRun{Repo: repo, ID: 9}}})
239 require.NoError(t, err)
240 require.Len(t, got, 1)
241 assert.Equal(t, "home-list", got[0].event)
242 assert.Contains(t, got[0].html, `id="repo-proj"`)
243 assert.Contains(t, got[0].html, "passing")
244 assert.NotContains(t, got[0].html, "new push", "a run is not a push")
245
246 // A job that ended refreshes the row too; one in progress does not.
247 got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseRunning}}})
248 require.NoError(t, err)
249 assert.Empty(t, got)
250 runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: []ports.CheckView{
251 {CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CICompleted, Conclusion: domain.CIFailure}},
252 {CICheck: domain.CICheck{Repo: repo, Sha: head, RunID: 9, Status: domain.CIInProgress}},
253 }}, nil).Once()
254 got, err = (&liveStream{s: srv}).render(t.Context(), ports.WatchEvent{Event: domain.JobUpdated{Job: domain.CIJob{Repo: repo, Phase: domain.CIPhaseEnded}}})
255 require.NoError(t, err)
256 require.Len(t, got, 1)
257 assert.Contains(t, got[0].html, "failing", "a failed job shows before its run ends")
258}
259
260// A repository whose commits cannot be read keeps its row, and the page is
261// served; the others are not affected.
262func TestHomeSurvivesABrokenRepository(t *testing.T) {
263 t.Parallel()
264 browse := homeBrowse(t)
265 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "broken"), mock.Anything).
266 Return(ports.HistoryPage{}, errors.New("git exploded")).Once()
267 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, repoName(t, "fine"), mock.Anything).
268 Return(ports.HistoryPage{Commits: homeCommits(t, 3)}, nil).Once()
269 srv := newTestServer(t, Config{Repos: reposServing(t, pushedRepo(t, "broken"), pushedRepo(t, "fine")), Browse: browse})
270
271 rec := httptest.NewRecorder()
272 srv.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/?sort=name", nil))
273 require.Equal(t, http.StatusOK, rec.Code)
274 body := rec.Body.String()
275 assert.Contains(t, body, `id="repo-broken"`)
276 assert.Contains(t, body, `id="repo-fine"`)
277 assert.Equal(t, 3, strings.Count(body, `class="rc-sha"`), "only the readable repository lists commits")
278 assert.NotContains(t, body, "no commits yet", "a failed read is not an empty branch")
279}
280
281// The rows are read a few at a time, in the order of the list.
282func TestHomeRowsAreBoundedAndOrdered(t *testing.T) {
283 t.Parallel()
284 const n = 40
285 var views []ports.RepoView
286 for i := range n {
287 views = append(views, pushedRepo(t, fmt.Sprintf("r%02d", i)))
288 }
289 var running, peak atomic.Int32
290 browse := homeBrowse(t)
291 browse.EXPECT().ListCommits(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
292 func(context.Context, domain.Principal, domain.RepoName, ports.ListCommitsParams) (ports.HistoryPage, error) {
293 cur := running.Add(1)
294 for p := peak.Load(); cur > p && !peak.CompareAndSwap(p, cur); p = peak.Load() {
295 }
296 time.Sleep(2 * time.Millisecond)
297 running.Add(-1)
298 return ports.HistoryPage{Commits: homeCommits(t, 1)}, nil
299 })
300 srv := newTestServer(t, Config{Repos: reposServing(t, views...), Browse: browse})
301
302 rows := srv.homeRows(context.Background(), views, time.Now())
303 require.Len(t, rows, n)
304 for i, r := range rows {
305 assert.Equal(t, fmt.Sprintf("r%02d", i), r.Name)
306 assert.Len(t, r.Commits, 1)
307 }
308 assert.True(t, rows[0].Cursor)
309 assert.LessOrEqual(t, int(peak.Load()), homeWorkers)
310 assert.Greater(t, int(peak.Load()), 1, "the reads overlap")
311}