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}