1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package web
4
5import (
6 "bytes"
7 "context"
8 "errors"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"
16
17 "bnema.dev/ermite/internal/domain"
18 "bnema.dev/ermite/internal/ports"
19 "bnema.dev/ermite/internal/ports/mocks"
20)
21
22func testRuns(repo domain.RepoName, n int) []domain.CIRun {
23 var runs []domain.CIRun
24 for i := n; i >= 1; i-- {
25 runs = append(runs, domain.CIRun{
26 Repo: repo, ID: int64(i), Number: int64(i), Attempt: 1, WorkflowName: "ci", Ref: "refs/heads/main",
27 Sha: strings.Repeat("a", 40), Status: domain.CICompleted, Conclusion: domain.CISuccess,
28 CreatedAt: time.Now().Add(-time.Duration(i) * time.Minute),
29 })
30 }
31 return runs
32}
33
34func servingRuns(t *testing.T, repo domain.RepoName, runs []domain.CIRun, err error) (*Server, *mocks.RunService) {
35 t.Helper()
36 m := mocks.NewRunService(t)
37 m.EXPECT().ListRuns(mock.Anything, mock.Anything, repo, ports.ListRunsParams{PageSize: recentRuns}).
38 Return(ports.RunPage{Runs: runs}, err).Maybe()
39 return newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, repo.String())), Browse: mocks.NewBrowseService(t), Runs: m}), m
40}
41
42func renderRail(t *testing.T, p SummaryPage) string {
43 t.Helper()
44 var b bytes.Buffer
45 require.NoError(t, summaryRail(p).Render(t.Context(), &b))
46 return b.String()
47}
48
49func TestSummaryRailRecentActions(t *testing.T) {
50 t.Parallel()
51 repo := repoName(t, "proj")
52 srv, _ := servingRuns(t, repo, testRuns(repo, 5), nil)
53
54 p := SummaryPage{Actions: srv.recentActions(t.Context(), repo, time.Now())}
55 require.NotNil(t, p.Actions)
56 out := renderRail(t, p)
57 assert.Contains(t, out, `id="recent-actions"`)
58 assert.Contains(t, out, "Recent actions")
59 assert.Contains(t, out, `href="/proj/actions"`, "view all")
60 assert.Equal(t, 5, strings.Count(out, `class="rr-name"`))
61 assert.Contains(t, out, `href="/proj/actions/runs/5"`)
62 assert.Contains(t, out, `aria-label="success"`, "the status icon")
63 assert.Contains(t, out, `href="/proj/tree/main"`, "the ref")
64 assert.Contains(t, out, "#5")
65 assert.NotContains(t, out, `id="recent-actions" aria-labelledby="r-actions" hidden`)
66}
67
68func TestSummaryRailRecentActionsHidden(t *testing.T) {
69 t.Parallel()
70 repo := repoName(t, "proj")
71
72 // The viewer cannot read CI: no block at all, not even to receive events.
73 srv, _ := servingRuns(t, repo, nil, domain.ErrNotFound)
74 assert.Nil(t, srv.recentActions(t.Context(), repo, time.Now()))
75 assert.NotContains(t, renderRail(t, SummaryPage{}), "recent-actions")
76
77 // A failure of the read does not break the page.
78 srv, _ = servingRuns(t, repo, nil, errors.New("boom"))
79 assert.Nil(t, srv.recentActions(t.Context(), repo, time.Now()))
80
81 // No CI on this server.
82 noCI := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t)})
83 assert.Nil(t, noCI.recentActions(t.Context(), repo, time.Now()))
84
85 // No run yet: the block is there for the first run, but hidden.
86 srv, _ = servingRuns(t, repo, nil, nil)
87 a := srv.recentActions(t.Context(), repo, time.Now())
88 require.NotNil(t, a)
89 assert.Contains(t, renderRail(t, SummaryPage{Actions: a}), `id="recent-actions" aria-labelledby="r-actions" hidden`)
90}
91
92func TestRunUpdatedReplacesRecentActionsOnSummary(t *testing.T) {
93 t.Parallel()
94 repo := repoName(t, "proj")
95 runs := testRuns(repo, 3)
96 runs[0].Status, runs[0].Conclusion = domain.CIInProgress, ""
97 srv, _ := servingRuns(t, repo, runs, nil)
98
99 ev := ports.WatchEvent{Event: domain.RunUpdated{Run: runs[0]}}
100 ls := &liveStream{s: srv, repo: "proj", view: "summary"}
101 got, err := ls.render(t.Context(), ev)
102 require.NoError(t, err)
103 require.Len(t, got, 1)
104 assert.Equal(t, "ci-run", got[0].event)
105 assert.True(t, strings.HasPrefix(got[0].html, `<section id="recent-actions"`), got[0].html)
106 assert.Equal(t, 3, strings.Count(got[0].html, `class="rr-name"`))
107 assert.Contains(t, got[0].html, `aria-label="running"`)
108
109 // The other pages do not get the block.
110 for _, view := range []string{"refs", "commits", "mrs"} {
111 got, err = (&liveStream{s: srv, repo: "proj", view: view}).render(t.Context(), ev)
112 require.NoError(t, err, view)
113 assert.Empty(t, got, view)
114 }
115}
116
117func TestRunUpdatedOnSummaryWhenCIIsNotReadable(t *testing.T) {
118 t.Parallel()
119 repo := repoName(t, "proj")
120 srv, _ := servingRuns(t, repo, nil, domain.ErrNotFound)
121
122 ls := &liveStream{s: srv, repo: "proj", view: "summary"}
123 got, err := ls.render(t.Context(), ports.WatchEvent{Event: domain.RunUpdated{Run: testRuns(repo, 1)[0]}})
124 require.NoError(t, err)
125 require.Len(t, got, 1)
126 assert.Contains(t, got[0].html, "hidden", "the block empties and hides, no run leaks")
127 assert.NotContains(t, got[0].html, "rr-name")
128}
129
130func TestMRChecksLiveMergeControls(t *testing.T) {
131 t.Parallel()
132 repo := repoName(t, "proj")
133 head := strings.Repeat("a", domain.ObjectIDLen)
134 for _, kind := range []string{"bad", "running", "ok", ""} {
135 t.Run(kind, func(t *testing.T) {
136 runs := mocks.NewRunService(t)
137 var checks []ports.CheckView
138 if kind != "" {
139 check := domain.CICheck{Name: "ci", Status: domain.CICompleted, Conclusion: domain.CISuccess}
140 if kind == "bad" {
141 check.Conclusion = domain.CIFailure
142 }
143 if kind == "running" {
144 check.Status, check.Conclusion = domain.CIQueued, ""
145 }
146 checks = []ports.CheckView{{CICheck: check}}
147 }
148 runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: checks}, nil)
149 srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs})
150
151 // A visitor's stream never carries the owner's merge form.
152 visitor, err := (&liveStream{s: srv, repo: "proj", head: head, view: "mr"}).mrChecks(t.Context())
153 require.NoError(t, err)
154 require.Len(t, visitor, 2)
155 for _, f := range visitor {
156 assert.NotContains(t, f.html, `mr-merge-checks`)
157 }
158
159 owner := context.WithValue(t.Context(), signedInKey{}, struct{}{})
160 ls := &liveStream{s: srv, repo: "proj", head: head, view: "mr"}
161 got, err := ls.mrChecks(owner)
162 require.NoError(t, err)
163 require.Len(t, got, 3)
164 assert.Contains(t, got[2].html, `data-keep-checks`, "a swap keeps the ticked box")
165 assert.Equal(t, "ci-run", got[2].event)
166 assert.Contains(t, got[2].html, `id="mr-merge-checks"`)
167 assert.Equal(t, renderToString(t, MRMergeChecks(checksSummary(repo, head, checks))), got[2].html)
168 if kind == "bad" || kind == "running" {
169 assert.Contains(t, got[2].html, `name="ignore_checks"`)
170 } else {
171 assert.NotContains(t, got[2].html, `name="ignore_checks"`)
172 }
173 // An unrelated job event with identical checks must not reset the checkbox.
174 got, err = ls.mrChecks(owner)
175 require.NoError(t, err)
176 require.Len(t, got, 2)
177 })
178 }
179}