v0.33.0 cf26365

ci_web_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"net/url"
7	"strings"
8	"testing"
9	"time"
10
11	"connectrpc.com/connect"
12	"github.com/stretchr/testify/assert"
13	"github.com/stretchr/testify/require"
14	"google.golang.org/protobuf/types/known/fieldmaskpb"
15
16	pb "bnema.dev/ermite-api/gen/ermite"
17	"bnema.dev/ermite/internal/domain"
18)
19
20// AC5, AC6: the pages of CI: the list of runs, a run, a job with its log (page,
21// raw and stream), the owner's buttons, and the checks on the commit, the
22// branch list, the commit list and the merge request.
23func TestWebActionsPages(t *testing.T) {
24	ci := newCIHarness(t, nil)
25	c := &cliHarness{harness: ci.harness, url: ci.srv.URL}
26	b := browser{ci.harness}
27	runner := ci.registerRunner("alpha", "ubuntu-latest")
28	_, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
29		Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
30	}))
31	require.NoError(t, err)
32	ci.push("main", map[string]string{"README": "x\n"}, "")
33	sha := ci.push("feat", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "add ci")
34	ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
35
36	// Queued: the list shows it, with a word for its state.
37	list := b.get("/proj/actions", nil)
38	require.Equal(t, 200, list.status, list.body)
39	assert.Contains(t, list.body, "queued")
40	assert.Contains(t, list.body, `href="/proj/actions/runs/1"`)
41	assert.Contains(t, list.body, `aria-label="queued"`)
42	assert.Contains(t, list.body, "/-/events?topic=repo%3Aproj&view=actions", "the page follows the repository stream")
43	assert.Contains(t, list.body, `aria-current="page">Actions <kbd`, "the tab")
44
45	// The MR page says "waiting for a runner" only when no runner matches.
46	mr, err := ci.root.mr.CreateMergeRequest(ci.ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "proj", Title: "Add CI", SourceBranch: "feat"}))
47	require.NoError(t, err)
48	mrPage := b.get("/proj/merge-requests/1", nil)
49	require.Equal(t, 200, mrPage.status)
50	assert.Contains(t, mrPage.body, `id="mr-checks"`)
51	assert.Contains(t, mrPage.body, "build")
52	assert.Contains(t, mrPage.body, "a failing or unfinished check blocks the merge", "the rule shown is the one that applies")
53	assert.NotContains(t, mrPage.body, "waiting for a runner", "a runner matches")
54	_ = mr
55
56	job := runner.await()
57	runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "##[group]Run echo hello", "hello <b>bold</b>", "##[endgroup]")
58	next := runner.await()
59	runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "##[error]Process completed with exit code 1.")
60	ci.waitFor("the run completes", func() bool { return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
61
62	run := b.get("/proj/actions/runs/1", nil)
63	require.Equal(t, 200, run.status, run.body)
64	assert.Contains(t, run.body, "build")
65	assert.Contains(t, run.body, "test")
66	assert.Contains(t, run.body, "failure")
67	assert.Contains(t, run.body, "on alpha (vm)", "the runner and its isolation are named")
68	assert.NotContains(t, run.body, `action="/proj/actions/runs/1/rerun"`, "no button for a visitor")
69
70	jobPage := b.get("/proj/actions/runs/1/job/"+job.GetJobId(), nil)
71	require.Equal(t, 200, jobPage.status, jobPage.body)
72	assert.Contains(t, jobPage.body, "Run echo hello")
73	assert.Contains(t, jobPage.body, "hello &lt;b&gt;bold&lt;/b&gt;", "log text is escaped, never markup")
74	assert.NotContains(t, jobPage.body, "<b>bold</b>")
75	assert.NotContains(t, jobPage.body, "##[group]", "markers are shown as structure")
76	assert.Contains(t, jobPage.body, `class="lg group"`)
77	failed := b.get("/proj/actions/runs/1/job/"+next.GetJobId(), nil)
78	assert.Contains(t, failed.body, `class="lg error"`)
79
80	raw := b.get("/proj/actions/runs/1/job/"+job.GetJobId()+"/raw", nil)
81	require.Equal(t, 200, raw.status)
82	assert.Equal(t, "text/plain; charset=utf-8", raw.hdr.Get("Content-Type"))
83	assert.Equal(t, "nosniff", raw.hdr.Get("X-Content-Type-Options"))
84	assert.Contains(t, raw.hdr.Get("Content-Security-Policy"), "sandbox")
85	assert.Contains(t, raw.body, "hello <b>bold</b>", "raw is raw text")
86	assert.Regexp(t, `(?m)^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z ##\[group\]Run echo hello$`, raw.body)
87
88	// The stream of a finished job sends its lines then ends.
89	stream := b.get("/proj/actions/runs/1/job/"+job.GetJobId()+"/stream?from=1", nil)
90	require.Equal(t, 200, stream.status)
91	assert.Equal(t, "text/event-stream", stream.hdr.Get("Content-Type"))
92	assert.Contains(t, stream.body, "event: ci-log")
93	assert.Contains(t, stream.body, "event: ci-end")
94	assert.NotContains(t, stream.body, "Run echo hello", "from=1 skips the first line")
95
96	// Checks next to the commit, the commits list and the branch list.
97	commit := b.get("/proj/commit/"+sha, nil)
98	require.Equal(t, 200, commit.status)
99	assert.Contains(t, commit.body, "checks failed")
100	assert.Contains(t, b.get("/proj/commits/feat", nil).body, "checks failed")
101	assert.Contains(t, b.get("/proj/refs", nil).body, "checks failed")
102
103	// Private repository: nothing to anonymous.
104	_, err = ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
105		Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PRIVATE, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
106	}))
107	require.NoError(t, err)
108	for _, p := range []string{"/proj/actions", "/proj/actions/runs/1", "/proj/actions/runs/1/job/" + job.GetJobId(), "/proj/actions/runs/1/job/" + job.GetJobId() + "/raw", "/proj/actions/runs/1/job/" + job.GetJobId() + "/stream"} {
109		assert.Equal(t, 404, b.get(p, nil).status, p)
110	}
111
112	// The owner sees the buttons and re-runs the failed job.
113	token := c.signIn(t)
114	owner := b.get("/proj/actions/runs/1", as(token))
115	require.Equal(t, 200, owner.status)
116	assert.Contains(t, owner.body, `action="/proj/actions/runs/1/rerun"`)
117	assert.Contains(t, owner.body, `action="/proj/actions/runs/1/rerun-failed"`)
118	assert.NotContains(t, owner.body, `action="/proj/actions/runs/1/cancel"`)
119	// A POST needs the owner's session and the origin.
120	assert.Equal(t, 403, b.do("POST", "/proj/actions/runs/1/rerun-failed", map[string]string{"Origin": publicOrigin}, "").status, "anonymous")
121	res := b.form("/proj/actions/runs/1/rerun-failed", token, url.Values{})
122	require.Equal(t, 303, res.status, res.body)
123	ci.waitFor("attempt 2 is queued", func() bool { return ci.runs()[0].GetAttempt() == 2 })
124	again := b.get("/proj/actions/runs/1?attempt=1", as(token))
125	assert.Contains(t, again.body, "attempt 1")
126	assert.NotContains(t, again.body, `action="/proj/actions/runs/1/rerun"`, "an earlier attempt has no button")
127
128	cancel := b.form("/proj/actions/runs/1/cancel", token, url.Values{})
129	require.Equal(t, 303, cancel.status, cancel.body)
130	ci.waitFor("cancelled", func() bool {
131		return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED && ci.runs()[0].GetConclusion() == pb.CiConclusion_CI_CONCLUSION_CANCELLED
132	})
133	assert.Equal(t, 409, b.form("/proj/actions/runs/1/cancel", token, url.Values{}).status, "a completed run cannot be cancelled")
134}
135
136// The live updates of the pages: the run page gets the body of the run when a
137// job changes, the job page its state, never a line of the log, and the page
138// of a merge request the checks of the head it shows (and only of that head).
139func TestWebActionsLiveFragments(t *testing.T) {
140	ci := newCIHarness(t, nil)
141	c := &cliHarness{harness: ci.harness, url: ci.srv.URL}
142	runner := ci.registerRunner("alpha", "ubuntu-latest")
143	_, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
144		Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
145	}))
146	require.NoError(t, err)
147	ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(ciWorkflow, "  test:")[0]}, "")
148	ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
149	token := c.signIn(t)
150
151	run := ci.sse("topic=repo:proj&view=run&n=1", "")
152	list := ci.sse("topic=repo:proj&view=actions", "")
153	job := runner.await()
154	jobStream := ci.sse("topic=repo:proj&view=job&job="+job.GetJobId(), "")
155	sha := ci.runs()[0].GetSha()
156	mrPage := ci.sse("topic=repo:proj&view=mr&n=1&ref=main&head="+sha, "")
157	otherHead := ci.sse("topic=repo:proj&view=mr&n=2&ref=x&head="+strings.Repeat("0", 40), "")
158	_ = token
159
160	runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "a secret value in the log")
161	deadline := time.After(10 * time.Second)
162	var sawRun, sawList, sawJob, sawChecks bool
163	for !sawRun || !sawList || !sawJob || !sawChecks {
164		select {
165		case ev := <-run.events:
166			sawRun = sawRun || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-body"`))
167			assert.NotContains(t, ev.data, "a secret value in the log")
168		case ev := <-list.events:
169			sawList = sawList || (ev.name == "ci-run" && strings.Contains(ev.data, `id="run-1"`))
170		case ev := <-jobStream.events:
171			sawJob = sawJob || (ev.name == "ci-job" && strings.Contains(ev.data, `id="job-head"`))
172			assert.NotContains(t, ev.data, "a secret value in the log", "the stream of states never carries the log")
173		case ev := <-mrPage.events:
174			sawChecks = sawChecks || (ev.name == "ci-run" && strings.Contains(ev.data, `id="mr-checks"`) && strings.Contains(ev.data, "success"))
175		case ev := <-otherHead.events:
176			assert.NotEqual(t, "ci-run", ev.name, "the checks of another head are not sent")
177		case <-deadline:
178			t.Fatalf("fragments: run %v, list %v, job %v, checks %v", sawRun, sawList, sawJob, sawChecks)
179		}
180	}
181	_ = domain.CISuccess
182}

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 3

Tags 44

↑↓ moveenter openesc close