v0.35.1 2aca1df

summary_actions_test.go RawHistoryFolder

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			t.Parallel()
137			runs := mocks.NewRunService(t)
138			var checks []ports.CheckView
139			if kind != "" {
140				check := domain.CICheck{Name: "ci", Status: domain.CICompleted, Conclusion: domain.CISuccess}
141				if kind == "bad" {
142					check.Conclusion = domain.CIFailure
143				}
144				if kind == "running" {
145					check.Status, check.Conclusion = domain.CIQueued, ""
146				}
147				checks = []ports.CheckView{{CICheck: check}}
148			}
149			runs.EXPECT().ListChecks(mock.Anything, mock.Anything, repo, head).Return(ports.ChecksResult{Checks: checks}, nil)
150			srv := newTestServer(t, Config{Repos: reposServing(t, publicRepo(t, "proj")), Browse: mocks.NewBrowseService(t), Runs: runs})
151
152			// A visitor's stream never carries the owner's merge form.
153			visitor, err := (&liveStream{s: srv, repo: "proj", head: head, view: "mr"}).mrChecks(t.Context())
154			require.NoError(t, err)
155			require.Len(t, visitor, 2)
156			for _, f := range visitor {
157				assert.NotContains(t, f.html, `mr-merge-checks`)
158			}
159
160			owner := context.WithValue(t.Context(), signedInKey{}, struct{}{})
161			ls := &liveStream{s: srv, repo: "proj", head: head, view: "mr"}
162			got, err := ls.mrChecks(owner)
163			require.NoError(t, err)
164			require.Len(t, got, 3)
165			assert.Contains(t, got[2].html, `data-keep-checks`, "a swap keeps the ticked box")
166			assert.Equal(t, "ci-run", got[2].event)
167			assert.Contains(t, got[2].html, `id="mr-merge-checks"`)
168			assert.Equal(t, renderToString(t, MRMergeChecks(checksSummary(repo, head, checks))), got[2].html)
169			if kind == "bad" || kind == "running" {
170				assert.Contains(t, got[2].html, `name="ignore_checks"`)
171			} else {
172				assert.NotContains(t, got[2].html, `name="ignore_checks"`)
173			}
174			// An unrelated job event with identical checks must not reset the checkbox.
175			got, err = ls.mrChecks(owner)
176			require.NoError(t, err)
177			require.Len(t, got, 2)
178		})
179	}
180}

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