v0.26.1 dfd6f0b

ci_results_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6	"strings"
7	"testing"
8	"time"
9
10	"connectrpc.com/connect"
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/require"
13	"google.golang.org/protobuf/types/known/fieldmaskpb"
14
15	pb "bnema.dev/ermite-api/gen/ermite"
16	"bnema.dev/ermite/internal/domain"
17)
18
19// CI-2 results (phase E2): annotations and step summaries, from the runner's
20// calls to the API, the pages and the CLI.
21
22const resultsWorkflow = `name: ci
23on:
24  push:
25    branches: [main]
26jobs:
27  build:
28    runs-on: ubuntu-latest
29    steps:
30      - run: echo hello
31  lint:
32    runs-on: ubuntu-latest
33    steps:
34      - run: echo lint
35`
36
37func ann(level pb.AnnotationLevel, step int32, msg string) *pb.Annotation {
38	return &pb.Annotation{Level: level, Message: msg, StepNumber: step}
39}
40
41func (r *fakeRunner) update(j *pb.Job, steps []*pb.StepUpdate, anns ...*pb.Annotation) error {
42	_, err := r.jobAPI(j).runner.UpdateJob(r.c.ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: j.GetJobId(), Steps: steps, Annotations: anns}))
43	return err
44}
45
46func (r *fakeRunner) summary(j *pb.Job, step int32, md string) error {
47	_, err := r.jobAPI(j).runner.UploadSummary(r.c.ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: j.GetJobId(), StepNumber: step, Markdown: md}))
48	return err
49}
50
51func (r *fakeRunner) heartbeat(j *pb.Job) {
52	r.c.t.Helper()
53	_, err := r.jobAPI(j).runner.Heartbeat(r.c.ctx, connect.NewRequest(&pb.HeartbeatRequest{JobId: j.GetJobId()}))
54	require.NoError(r.c.t, err)
55}
56
57func (c *ciHarness) annotations(runID int64, jobID string) *pb.ListAnnotationsResponse {
58	c.t.Helper()
59	res, err := c.root.run.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID, JobId: jobID}))
60	require.NoError(c.t, err)
61	return res.Msg
62}
63
64func (c *ciHarness) summaries(jobID string) *pb.GetStepSummariesResponse {
65	c.t.Helper()
66	res, err := c.root.run.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: jobID}))
67	require.NoError(c.t, err)
68	return res.Msg
69}
70
71func code(err error) connect.Code { return connect.CodeOf(err) }
72
73func TestCIAnnotationsAndSummariesOverTheAPI(t *testing.T) {
74	c := newCIHarness(t, nil)
75	runner := c.registerRunner("alpha", "ubuntu-latest")
76	c.push("main", map[string]string{".github/workflows/ci.yml": resultsWorkflow}, "")
77	c.waitFor("a run", func() bool { return len(c.runs()) == 1 })
78	runID := c.runs()[0].GetId()
79
80	ver, err := c.root.server.GetVersion(c.ctx, connect.NewRequest(&pb.GetVersionRequest{}))
81	require.NoError(t, err)
82	assert.Contains(t, ver.Msg.GetFeatures(), "ci.annotations")
83	assert.Contains(t, ver.Msg.GetFeatures(), "ci.summaries")
84
85	a, b := runner.await(), runner.await()
86	runner.heartbeat(a)
87	runner.heartbeat(b)
88	start := now()
89	running := []*pb.StepUpdate{{Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_IN_PROGRESS, StartedAt: start}}
90	require.NoError(t, runner.update(a, running))
91	done := &pb.StepUpdate{Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()}
92
93	notice, warning, failure := pb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE, pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, pb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE
94	t.Run("an annotation of a step that UpdateJob never named is stored", func(t *testing.T) {
95		full := &pb.Annotation{Level: failure, Title: "Vet", Message: "unused variable", File: "main.go", StartLine: 3, EndLine: 4, StartColumn: 2, EndColumn: 8, StepNumber: 7}
96		require.NoError(t, runner.update(a, nil, full, ann(notice, 7, "second")))
97		got := c.annotations(runID, a.GetJobId())
98		require.Len(t, got.GetAnnotations(), 2)
99		assert.Equal(t, a.GetJobId(), got.GetAnnotations()[0].GetJobId())
100		assert.EqualValues(t, full.GetStartLine(), got.GetAnnotations()[0].GetAnnotation().GetStartLine())
101		assert.Equal(t, "Vet", got.GetAnnotations()[0].GetAnnotation().GetTitle())
102		assert.Equal(t, "second", got.GetAnnotations()[1].GetAnnotation().GetMessage(), "in the order the step produced them")
103		assert.False(t, got.GetTruncated())
104	})
105
106	t.Run("replacement per step, a retry changes nothing, a step absent is kept", func(t *testing.T) {
107		require.NoError(t, runner.update(a, nil, ann(warning, 3, "three")))
108		require.NoError(t, runner.update(a, nil, ann(warning, 3, "three")))
109		require.NoError(t, runner.update(a, nil, ann(notice, 7, "only one now")))
110		got := c.annotations(runID, a.GetJobId()).GetAnnotations()
111		require.Len(t, got, 2, "step 3 is kept, step 7 holds one annotation")
112		assert.EqualValues(t, 3, got[0].GetAnnotation().GetStepNumber(), "ordered by step")
113		assert.Equal(t, "only one now", got[1].GetAnnotation().GetMessage())
114	})
115
116	t.Run("a COMPLETED step can still have its annotations replaced", func(t *testing.T) {
117		require.NoError(t, runner.update(a, []*pb.StepUpdate{done}, ann(notice, 1, "before")))
118		require.NoError(t, runner.update(a, nil, ann(failure, 1, "after")))
119		require.NoError(t, runner.update(a, []*pb.StepUpdate{done}), "the same step again changes nothing")
120		got := c.annotations(runID, a.GetJobId()).GetAnnotations()
121		assert.Equal(t, "after", got[0].GetAnnotation().GetMessage())
122		assert.Equal(t, failure, got[0].GetAnnotation().GetLevel())
123		// The rule that a completed step cannot change still holds for the step.
124		changed := &pb.StepUpdate{Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_FAILURE, StartedAt: start, CompletedAt: done.GetCompletedAt()}
125		assert.Equal(t, connect.CodeFailedPrecondition, code(runner.update(a, []*pb.StepUpdate{changed})))
126	})
127
128	t.Run("an empty list for a step is not a request: only a step present is replaced", func(t *testing.T) {
129		before := len(c.annotations(runID, a.GetJobId()).GetAnnotations())
130		require.NoError(t, runner.update(a, nil))
131		assert.Len(t, c.annotations(runID, a.GetJobId()).GetAnnotations(), before)
132	})
133
134	t.Run("the limits, and a call applied whole or not at all", func(t *testing.T) {
135		before := c.annotations(runID, a.GetJobId()).GetAnnotations()
136		many := func(level pb.AnnotationLevel, n int) []*pb.Annotation {
137			var out []*pb.Annotation
138			for range n {
139				out = append(out, ann(level, 9, "m"))
140			}
141			return out
142		}
143		require.NoError(t, runner.update(a, nil, many(warning, 10)...), "ten of a level is allowed")
144		require.NoError(t, runner.update(a, nil, append(many(warning, 10), many(failure, 10)...)...), "the limit is per level")
145		bad := map[string][]*pb.Annotation{
146			"eleven of a level":  many(notice, 11),
147			"no level":           {{Message: "m", StepNumber: 1}},
148			"no message":         {ann(notice, 1, "")},
149			"message too long":   {ann(notice, 1, strings.Repeat("é", 4097))},
150			"title too long":     {{Level: notice, Message: "m", Title: strings.Repeat("t", 4097), StepNumber: 1}},
151			"file too long":      {{Level: notice, Message: "m", File: strings.Repeat("f", 1025), StepNumber: 1}},
152			"step zero":          {ann(notice, 0, "m")},
153			"step beyond 1000":   {ann(notice, 1001, "m")},
154			"end before start":   {{Level: notice, Message: "m", StartLine: 5, EndLine: 4, StepNumber: 1}},
155			"a good one and bad": {ann(notice, 1, "good"), ann(notice, 1, "")},
156		}
157		for name, anns := range bad {
158			assert.Equal(t, connect.CodeInvalidArgument, code(runner.update(a, nil, anns...)), name)
159		}
160		// Characters, not bytes: 4096 two-byte characters pass.
161		require.NoError(t, runner.update(a, nil, ann(notice, 11, strings.Repeat("é", 4096))))
162		require.NoError(t, runner.update(a, nil, &pb.Annotation{Level: notice, Message: "m", Title: strings.Repeat("t", 4096), File: strings.Repeat("f", 1024), StepNumber: 11}))
163		// An invalid call stored nothing: steps 1, 3 and 7 are as they were.
164		after := c.annotations(runID, a.GetJobId()).GetAnnotations()
165		require.GreaterOrEqual(t, len(after), len(before))
166		var step1 []string
167		for _, x := range after {
168			if x.GetAnnotation().GetStepNumber() == 1 {
169				step1 = append(step1, x.GetAnnotation().GetMessage())
170			}
171		}
172		assert.Equal(t, []string{"after"}, step1)
173		// A step of a bad call that is valid is not applied either.
174		assert.Equal(t, connect.CodeInvalidArgument, code(runner.update(a, []*pb.StepUpdate{{Number: 5, State: pb.StepState_STEP_STATE_IN_PROGRESS, StartedAt: now()}}, ann(notice, 1, ""))))
175		for _, s := range c.getRun(runID).GetJobs()[0].GetSteps() {
176			assert.NotEqual(t, int32(5), s.GetNumber())
177		}
178	})
179
180	t.Run("summaries: stored, replaced, removed, bounded", func(t *testing.T) {
181		require.NoError(t, runner.summary(a, 2, "# one"))
182		require.NoError(t, runner.summary(a, 2, "# one"))
183		require.NoError(t, runner.summary(a, 2, "# two"))
184		require.NoError(t, runner.summary(a, 12, "later"))
185		got := c.summaries(a.GetJobId())
186		require.Len(t, got.GetSummaries(), 2)
187		assert.EqualValues(t, 2, got.GetSummaries()[0].GetStepNumber())
188		assert.Equal(t, "# two", got.GetSummaries()[0].GetMarkdown())
189		assert.EqualValues(t, 12, got.GetSummaries()[1].GetStepNumber(), "numeric order")
190		require.NoError(t, runner.summary(a, 12, ""))
191		assert.Len(t, c.summaries(a.GetJobId()).GetSummaries(), 1, "an empty markdown removes it")
192
193		assert.Equal(t, connect.CodeInvalidArgument, code(runner.summary(a, 0, "x")))
194		assert.Equal(t, connect.CodeInvalidArgument, code(runner.summary(a, 1001, "x")))
195		assert.Equal(t, connect.CodeInvalidArgument, code(runner.summary(a, 2, strings.Repeat("x", 1<<20+1))))
196		require.NoError(t, runner.summary(a, 3, strings.Repeat("x", 1<<20)), "exactly 1 MiB is accepted")
197		assert.Len(t, c.summaries(a.GetJobId()).GetSummaries(), 2)
198		assert.Equal(t, "# two", c.summaries(a.GetJobId()).GetSummaries()[0].GetMarkdown(), "an invalid call changes nothing")
199	})
200
201	t.Run("at most 20 summaries per job, with truncated", func(t *testing.T) {
202		for i := int32(1); i <= 22; i++ {
203			require.NoError(t, runner.summary(b, i, "s"))
204		}
205		got := c.summaries(b.GetJobId())
206		assert.Len(t, got.GetSummaries(), 20)
207		assert.True(t, got.GetTruncated())
208		assert.EqualValues(t, 20, got.GetSummaries()[19].GetStepNumber())
209	})
210
211	t.Run("the token of another job is refused", func(t *testing.T) {
212		other := runner.jobAPI(b).runner
213		_, err := other.UpdateJob(c.ctx, connect.NewRequest(&pb.UpdateJobRequest{JobId: a.GetJobId(), Annotations: []*pb.Annotation{ann(notice, 1, "x")}}))
214		assert.Equal(t, connect.CodePermissionDenied, code(err))
215		_, err = other.UploadSummary(c.ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: a.GetJobId(), StepNumber: 1, Markdown: "x"}))
216		assert.Equal(t, connect.CodePermissionDenied, code(err))
217		_, err = other.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: a.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Annotations: []*pb.Annotation{ann(notice, 1, "x")}}))
218		assert.Equal(t, connect.CodePermissionDenied, code(err))
219		// No credential, a runner token and a stored token are not job tokens either.
220		for _, secret := range []string{"", runner.secret, adminToken} {
221			cl := c.api(secret).runner
222			_, err = cl.UploadSummary(c.ctx, connect.NewRequest(&pb.UploadSummaryRequest{JobId: a.GetJobId(), StepNumber: 1, Markdown: "x"}))
223			assert.Equal(t, connect.CodeUnauthenticated, code(err), secret)
224		}
225		assert.Equal(t, "# two", c.summaries(a.GetJobId()).GetSummaries()[0].GetMarkdown())
226	})
227
228	t.Run("CompleteJob applies annotations with the steps and the outcome", func(t *testing.T) {
229		steps := []*pb.StepUpdate{
230			done,
231			{
232				Number: 2, Name: "Run make", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS,
233				Outcome: pb.CiConclusion_CI_CONCLUSION_FAILURE, StartedAt: start, CompletedAt: now(),
234			},
235		}
236		_, err := runner.jobAPI(a).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
237			JobId: a.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Steps: steps,
238			Annotations: []*pb.Annotation{ann(warning, 2, "from complete"), ann(notice, 1, "step one, replaced")},
239		}))
240		require.NoError(t, err)
241		got := c.annotations(runID, a.GetJobId()).GetAnnotations()
242		var msgs []string
243		for _, x := range got {
244			if s := x.GetAnnotation().GetStepNumber(); s == 1 || s == 2 {
245				msgs = append(msgs, x.GetAnnotation().GetMessage())
246			}
247		}
248		assert.Equal(t, []string{"step one, replaced", "from complete"}, msgs)
249
250		job := c.getRun(runID).GetJobs()[0]
251		assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, job.GetSteps()[1].GetConclusion())
252		assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_FAILURE, job.GetSteps()[1].GetOutcome(), "continue-on-error: failed, then succeeded")
253		assert.Equal(t, pb.CiConclusion_CI_CONCLUSION_SUCCESS, job.GetSteps()[0].GetOutcome(), "a runner that sent none: read as the conclusion")
254	})
255
256	t.Run("a job that ended rejects annotations and summaries; a retry of CompleteJob changes nothing", func(t *testing.T) {
257		before := len(c.annotations(runID, a.GetJobId()).GetAnnotations())
258		err := runner.update(a, nil, ann(notice, 1, "too late"))
259		require.Equal(t, connect.CodeFailedPrecondition, code(err))
260		assert.NotEmpty(t, errDetails(err), "with a JobEnded detail")
261		err = runner.summary(a, 1, "too late")
262		assert.Equal(t, connect.CodeFailedPrecondition, code(err))
263		assert.NotEmpty(t, errDetails(err))
264		res, err := runner.jobAPI(a).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{
265			JobId: a.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Annotations: []*pb.Annotation{ann(notice, 1, "ignored")},
266		}))
267		require.NoError(t, err)
268		assert.False(t, res.Msg.GetApplied())
269		assert.Len(t, c.annotations(runID, a.GetJobId()).GetAnnotations(), before)
270		for _, x := range c.annotations(runID, a.GetJobId()).GetAnnotations() {
271			assert.NotEqual(t, "too late", x.GetAnnotation().GetMessage())
272			assert.NotEqual(t, "ignored", x.GetAnnotation().GetMessage())
273		}
274		assert.Equal(t, "# two", c.summaries(a.GetJobId()).GetSummaries()[0].GetMarkdown(), "still readable after the end")
275	})
276
277	t.Run("reading: the run, one job, a job that is not of the run, access", func(t *testing.T) {
278		all := c.annotations(runID, "")
279		jobs := map[string]int{}
280		for _, x := range all.GetAnnotations() {
281			jobs[x.GetJobId()]++
282		}
283		assert.Len(t, jobs, 1, "job b has none")
284		assert.Empty(t, c.annotations(runID, b.GetJobId()).GetAnnotations())
285
286		_, err := c.root.run.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID, JobId: "99-0-0"}))
287		assert.Equal(t, connect.CodeNotFound, code(err))
288		_, err = c.root.run.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID, Attempt: 2}))
289		assert.Equal(t, connect.CodeNotFound, code(err))
290		_, err = c.root.run.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: 99}))
291		assert.Equal(t, connect.CodeNotFound, code(err))
292		_, err = c.root.run.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: "99-0-0"}))
293		assert.Equal(t, connect.CodeNotFound, code(err))
294		_, err = c.root.run.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: a.GetJobId(), Attempt: 3}))
295		assert.Equal(t, connect.CodeNotFound, code(err))
296
297		// The repository is private: an anonymous caller sees nothing of it, a
298		// reader sees everything, as for GetRun.
299		anon := c.api("").run
300		_, err = anon.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID}))
301		assert.Equal(t, connect.CodeNotFound, code(err))
302		_, err = anon.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: a.GetJobId()}))
303		assert.Equal(t, connect.CodeNotFound, code(err))
304		reader := c.api(c.token(scopes(domain.ScopeRepoRead))).run
305		_, err = reader.ListAnnotations(c.ctx, connect.NewRequest(&pb.ListAnnotationsRequest{Repo: c.repo, RunId: runID}))
306		assert.NoError(t, err)
307		_, err = reader.GetStepSummaries(c.ctx, connect.NewRequest(&pb.GetStepSummariesRequest{Repo: c.repo, JobId: a.GetJobId()}))
308		assert.NoError(t, err)
309	})
310
311	t.Run("the summaries of an ended job stay readable", func(t *testing.T) {
312		_, err := runner.jobAPI(b).runner.CompleteJob(c.ctx, connect.NewRequest(&pb.CompleteJobRequest{JobId: b.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_FAILURE}))
313		require.NoError(t, err)
314		assert.Len(t, c.summaries(b.GetJobId()).GetSummaries(), 20)
315	})
316}
317
318// errDetails returns the Connect error details of err.
319func errDetails(err error) []*connect.ErrorDetail {
320	var ce *connect.Error
321	if connect.CodeOf(err) == connect.CodeUnknown {
322		return nil
323	}
324	if e, ok := err.(*connect.Error); ok {
325		ce = e
326	}
327	if ce == nil {
328		return nil
329	}
330	return ce.Details()
331}
332
333// The pages: the Annotations box of the run, the annotations at their step on
334// the job page, the summaries rendered under the run, with nothing of the
335// workflow's markup reaching the page as markup.
336func TestWebAnnotationsAndSummaries(t *testing.T) {
337	ci := newCIHarness(t, nil)
338	b := browser{ci.harness}
339	runner := ci.registerRunner("alpha", "ubuntu-latest")
340	_, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
341		Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
342	}))
343	require.NoError(t, err)
344	sha := ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(resultsWorkflow, "  lint:")[0], "src/main.go": "package main\n"}, "")
345	ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
346
347	job := runner.await()
348	runner.heartbeat(job)
349	start := now()
350	steps := []*pb.StepUpdate{
351		{Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()},
352		{Number: 2, Name: "Run <b>vet</b>", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()},
353	}
354	require.NoError(t, runner.update(job, steps))
355	_, err = runner.jobAPI(job).runner.AppendLog(ci.ctx, connect.NewRequest(&pb.AppendLogRequest{JobId: job.GetJobId(), Step: 2, Lines: []*pb.LogLine{{Time: now(), Text: "vet"}}}))
356	require.NoError(t, err)
357	evil := `<img src=x onerror=alert(1)>`
358	require.NoError(t, runner.update(job, nil,
359		&pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE, Title: "Vet <i>failed</i>", Message: "unused " + evil, File: "src/main.go", StartLine: 1, EndLine: 3, StepNumber: 2},
360		&pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, Message: "outside", File: "../../etc/passwd", StartLine: 1, StepNumber: 2},
361		&pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE, Message: "plain notice", StepNumber: 1},
362	))
363	require.NoError(t, runner.summary(job, 2, strings.Join([]string{
364		"# Results",
365		"",
366		"<script>alert('summary')</script>",
367		"",
368		"[click](javascript:alert(1)) and [ok](https://example.org/x)",
369		"",
370		"![pixel](https://evil.example/p.png)",
371		"",
372		"<img src=x onerror=alert(2)>",
373		"",
374		"| a | b |", "|---|---|", "| 1 | 2 |",
375	}, "\n")))
376	_, err = runner.jobAPI(job).runner.CompleteJob(ci.ctx, connect.NewRequest(&pb.CompleteJobRequest{
377		JobId: job.GetJobId(), Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Steps: steps,
378	}))
379	require.NoError(t, err)
380	ci.waitFor("the run completes", func() bool { return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
381
382	run := b.get("/proj/actions/runs/1", nil)
383	require.Equal(t, 200, run.status, run.body)
384	assert.Contains(t, run.body, "Annotations")
385	assert.Contains(t, run.body, `aria-label="error"`, "the level has a symbol and a word")
386	assert.Contains(t, run.body, `aria-label="warning"`)
387	assert.Contains(t, run.body, "Vet &lt;i&gt;failed&lt;/i&gt;", "a title is text")
388	assert.Contains(t, run.body, "unused &lt;img src=x onerror=alert(1)&gt;")
389	assert.NotContains(t, run.body, evil)
390	assert.Contains(t, run.body, "Run &lt;b&gt;vet&lt;/b&gt;", "the step name is text")
391	assert.Contains(t, run.body, `href="/proj/blob/`+sha+`/src/main.go#L1-L3"`, "file:line links to the file at the commit")
392	assert.Contains(t, run.body, "src/main.go:1-3")
393	assert.Contains(t, run.body, "../../etc/passwd:1")
394	assert.NotContains(t, run.body, `href="/proj/blob/`+sha+`/../`, "a path that leaves the repository is not a link")
395	assert.NotContains(t, run.body, `etc/passwd:1</a>`)
396	assert.Contains(t, run.body, `href="/proj/actions/runs/1/job/`+job.GetJobId()+`"`, "the job of the annotation")
397
398	// The summary is rendered by the README renderer: markup dropped.
399	assert.Contains(t, run.body, "<h1", "Markdown is rendered")
400	assert.Contains(t, run.body, "Results")
401	assert.Contains(t, run.body, "<table>")
402	assert.Contains(t, run.body, `href="https://example.org/x"`)
403	assert.Contains(t, run.body, `rel="nofollow noopener noreferrer"`)
404	assert.NotContains(t, run.body, "alert('summary')")
405	assert.NotContains(t, run.body, "<script>alert")
406	assert.NotContains(t, run.body, "javascript:")
407	assert.NotContains(t, run.body, "onerror=alert(2)")
408	assert.NotContains(t, run.body, `<img src=x`)
409	assert.NotContains(t, run.body, "evil.example/p.png", "images are never loaded")
410	assert.Contains(t, run.body, `<span class="alt">[pixel]</span>`)
411	assert.Contains(t, run.body, `click`, "the text of a dropped link stays")
412
413	// The job page: each annotation at its step, the steps linking to the log.
414	jobPage := b.get("/proj/actions/runs/1/job/"+job.GetJobId(), nil)
415	require.Equal(t, 200, jobPage.status, jobPage.body)
416	assert.Contains(t, jobPage.body, `id="job-annotations"`)
417	assert.Contains(t, jobPage.body, "plain notice")
418	assert.Contains(t, jobPage.body, "unused &lt;img src=x onerror=alert(1)&gt;")
419	assert.NotContains(t, jobPage.body, evil)
420	assert.Less(t, strings.Index(jobPage.body, "plain notice"), strings.Index(jobPage.body, "unused &lt;img"), "by step")
421
422}
423
424// The job page follows the annotations live: a report of the runner replaces the
425// annotations element, and the stream still never carries the log.
426func TestWebJobAnnotationsAreLive(t *testing.T) {
427	ci := newCIHarness(t, nil)
428	runner := ci.registerRunner("alpha", "ubuntu-latest")
429	_, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
430		Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
431	}))
432	require.NoError(t, err)
433	ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(resultsWorkflow, "  lint:")[0]}, "")
434	ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
435	job := runner.await()
436	stream := ci.sse("topic=repo:proj&view=job&job="+job.GetJobId(), "")
437	runner.heartbeat(job)
438	require.NoError(t, runner.update(job, []*pb.StepUpdate{{Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_IN_PROGRESS, StartedAt: now()}},
439		ann(pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, 1, "live <b>warning</b>")))
440	deadline := time.After(10 * time.Second)
441	for {
442		select {
443		case ev := <-stream.events:
444			if ev.name == "ci-job" && strings.Contains(ev.data, `id="job-annotations"`) && strings.Contains(ev.data, "live &lt;b&gt;warning&lt;/b&gt;") {
445				assert.NotContains(t, ev.data, "<b>warning</b>")
446				return
447			}
448		case <-deadline:
449			t.Fatal("the annotation did not reach the stream of the job")
450		}
451	}
452}
453
454// A run that is not over has no summary block yet, and a run without result has
455// neither box.
456func TestWebRunWithoutAnnotationsOrSummaries(t *testing.T) {
457	ci := newCIHarness(t, nil)
458	b := browser{ci.harness}
459	runner := ci.registerRunner("alpha", "ubuntu-latest")
460	_, err := ci.root.repo.UpdateRepo(ci.ctx, connect.NewRequest(&pb.UpdateRepoRequest{
461		Repo: "proj", Visibility: pb.Visibility_VISIBILITY_PUBLIC, UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{"visibility"}},
462	}))
463	require.NoError(t, err)
464	ci.push("main", map[string]string{".github/workflows/ci.yml": strings.Split(resultsWorkflow, "  lint:")[0]}, "")
465	ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
466	queued := b.get("/proj/actions/runs/1", nil)
467	require.Equal(t, 200, queued.status)
468	assert.NotContains(t, queued.body, "Annotations")
469
470	job := runner.await()
471	runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "hello")
472	done := b.get("/proj/actions/runs/1", nil)
473	require.Equal(t, 200, done.status)
474	assert.NotContains(t, done.body, "Annotations")
475	assert.NotContains(t, done.body, `class="summaries"`)
476	page := b.get("/proj/actions/runs/1/job/"+job.GetJobId(), nil)
477	require.Equal(t, 200, page.status)
478	assert.Contains(t, page.body, `id="job-annotations"`, "the slot the live updates fill")
479	assert.NotContains(t, page.body, "Annotations <")
480}
481
482// `run view` prints the ANNOTATIONS section and `--summary` the Markdown.
483func TestCLIRunViewAnnotationsAndSummary(t *testing.T) {
484	ci := newCIHarness(t, nil)
485	h := &cliHarness{harness: ci.harness, url: ci.srv.URL}
486	runner := ci.registerRunner("alpha", "ubuntu-latest")
487	ci.push("main", map[string]string{".github/workflows/ci.yml": resultsWorkflow}, "")
488	ci.waitFor("the run", func() bool { return len(ci.runs()) == 1 })
489
490	// A run without annotation prints none.
491	a, b := runner.await(), runner.await()
492	runner.heartbeat(a)
493	runner.heartbeat(b)
494	plain := h.cli(t, adminToken, "run", "view", "proj", "1")
495	require.NoError(t, plain.err, plain.stderr)
496	assert.NotContains(t, plain.stdout, "ANNOTATIONS")
497	none := h.cli(t, adminToken, "run", "view", "proj", "1", "--summary")
498	require.NoError(t, none.err, none.stderr)
499	assert.Contains(t, none.stderr, "no job summary")
500
501	start := now()
502	steps := []*pb.StepUpdate{
503		{Number: 1, Name: "Set up job", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, StartedAt: start, CompletedAt: now()},
504		{Number: 2, Name: "Run make", State: pb.StepState_STEP_STATE_COMPLETED, Conclusion: pb.CiConclusion_CI_CONCLUSION_SUCCESS, Outcome: pb.CiConclusion_CI_CONCLUSION_FAILURE, StartedAt: start, CompletedAt: now()},
505	}
506	require.NoError(t, runner.update(a, steps,
507		&pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_FAILURE, Title: "Vet", Message: "unused\x1b[31m variable", File: "main.go", StartLine: 3, EndLine: 5, StepNumber: 2},
508		&pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_WARNING, Message: "careful", StepNumber: 2},
509		&pb.Annotation{Level: pb.AnnotationLevel_ANNOTATION_LEVEL_NOTICE, Message: "fyi", File: "README.md", StepNumber: 1},
510	))
511	require.NoError(t, runner.summary(a, 2, "# Hello\n\nfrom a\x1b[2J step\n"))
512	require.NoError(t, runner.summary(b, 2, "from b"))
513
514	view := h.cli(t, adminToken, "run", "view", "proj", "1")
515	require.NoError(t, view.err, view.stderr)
516	assert.Contains(t, view.stdout, "\nANNOTATIONS\n")
517	assert.Contains(t, view.stdout, "LEVEL")
518	for _, want := range []string{"error", "Vet: unused", "main.go:3-5", "warning", "careful", "notice", "fyi", "README.md", "build"} {
519		assert.Contains(t, view.stdout, want)
520	}
521	assert.NotContains(t, view.stdout, "\x1b", "control characters of the workflow never reach the terminal")
522	assert.Less(t, strings.Index(view.stdout, "Set up job"), strings.Index(view.stdout, "ANNOTATIONS"), "after the jobs")
523	// Ordered by job, step, production order: notice (step 1) before the two of step 2.
524	assert.Less(t, strings.Index(view.stdout, "fyi"), strings.Index(view.stdout, "Vet: unused"))
525	assert.Less(t, strings.Index(view.stdout, "Vet: unused"), strings.Index(view.stdout, "careful"))
526
527	js := h.cli(t, adminToken, "run", "view", "proj", "1", "--json")
528	require.NoError(t, js.err, js.stderr)
529	d := decode[jsonRunDetail](t, js.stdout)
530	require.Len(t, d.Annotations, 3)
531	assert.Equal(t, jsonAnnotation{
532		Job: "build", JobID: a.GetJobId(), Step: 1, Level: "notice", Message: "fyi", File: "README.md",
533	}, d.Annotations[0])
534	assert.Equal(t, "failure", d.Annotations[1].Level, "the API's name in JSON")
535	assert.EqualValues(t, 5, d.Annotations[1].EndLine)
536	assert.False(t, d.AnnotationsTruncated)
537	assert.Equal(t, "failure", d.Jobs[0].Steps[1].Outcome)
538	assert.Equal(t, "success", d.Jobs[0].Steps[1].Conclusion)
539	assert.Equal(t, "success", d.Jobs[0].Steps[0].Outcome, "none stored: the conclusion")
540
541	// --summary: raw Markdown, a header per job and step when there are several.
542	sum := h.cli(t, adminToken, "run", "view", "proj", "1", "--summary")
543	require.NoError(t, sum.err, sum.stderr)
544	assert.Contains(t, sum.stdout, "== build: Run make ==\n# Hello\n")
545	assert.Contains(t, sum.stdout, "== lint: step 2 ==\nfrom b\n")
546	assert.NotContains(t, sum.stdout, "\x1b")
547	one := h.cli(t, adminToken, "run", "view", "proj", "1", "--summary", "--job", "lint")
548	require.NoError(t, one.err, one.stderr)
549	assert.Equal(t, "from b\n", one.stdout, "a single summary is printed as it is")
550	assert.Error(t, h.cli(t, adminToken, "run", "view", "proj", "1", "--summary", "--log").err)
551	assert.Error(t, h.cli(t, adminToken, "run", "view", "proj", "1", "--summary", "--json").err)
552	assert.Error(t, h.cli(t, adminToken, "run", "view", "proj", "1", "--summary", "--job", "nope").err)
553}

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 2

Tags 45

↑↓ moveenter openesc close