1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "strings"
7 "testing"
8
9 "connectrpc.com/connect"
10 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"
12
13 pb "bnema.dev/ermite-api/gen/ermite"
14 "bnema.dev/ermite/internal/domain"
15)
16
17// The CLI over a real server: `run list|view|watch|cancel|rerun`, `runner
18// list|remove`, and the runner token preset.
19func TestCLIRunsAndRunners(t *testing.T) {
20 ci := newCIHarness(t, nil)
21 h := &cliHarness{harness: ci.harness, url: ci.srv.URL}
22 runner := ci.registerRunner("alpha", "ubuntu-latest")
23 ci.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow}, "")
24 ci.waitFor("a run", func() bool { return len(ci.runs()) == 1 })
25
26 job := runner.await()
27 runner.runJob(job, pb.CiConclusion_CI_CONCLUSION_SUCCESS, nil, "hello from the job")
28 next := runner.await()
29 runner.runJob(next, pb.CiConclusion_CI_CONCLUSION_FAILURE, nil, "this one failed")
30 ci.waitFor("the run completes", func() bool { return ci.runs()[0].GetStatus() == pb.CiStatus_CI_STATUS_COMPLETED })
31 id := "1"
32
33 // list
34 out := h.cli(t, adminToken, "run", "list", "proj")
35 require.Empty(t, out.err, out.stderr)
36 assert.Contains(t, out.stdout, "RESULT")
37 assert.Contains(t, out.stdout, "failure")
38 assert.Contains(t, out.stdout, "ci")
39
40 js := h.cli(t, adminToken, "run", "list", "proj", "--json")
41 require.NoError(t, js.err)
42 runs := decode[[]jsonRun](t, js.stdout)
43 require.Len(t, runs, 1)
44 assert.Equal(t, "failure", runs[0].Conclusion)
45 assert.Equal(t, "completed", runs[0].Status)
46 assert.Equal(t, "refs/heads/main", runs[0].Ref)
47 assert.Equal(t, []string{"actor", "attempt", "completed_at", "conclusion", "created_at", "error", "event", "id", "name", "number", "ref", "repo", "sha", "started_at", "status", "title", "workflow", "workflow_path"}, keys(t, strings.TrimSpace(strings.Trim(strings.TrimSpace(js.stdout), "[]"))))
48
49 none := h.cli(t, adminToken, "run", "list", "proj", "--status", "queued")
50 require.NoError(t, none.err)
51 assert.Contains(t, none.stderr, "no runs")
52 bad := h.cli(t, adminToken, "run", "list", "proj", "--status", "weird")
53 require.Error(t, bad.err)
54
55 // view
56 view := h.cli(t, adminToken, "run", "view", "proj", id)
57 require.NoError(t, view.err, view.stderr)
58 assert.Contains(t, view.stdout, "result")
59 assert.Contains(t, view.stdout, "build")
60 assert.Contains(t, view.stdout, "[alpha]")
61 assert.Contains(t, view.stdout, "Set up job")
62
63 vjs := h.cli(t, adminToken, "run", "view", "proj", id, "--json")
64 detail := decode[jsonRunDetail](t, vjs.stdout)
65 require.Len(t, detail.Jobs, 2)
66 assert.Equal(t, "build", detail.Jobs[0].Key)
67 assert.Equal(t, "vm", detail.Jobs[0].Isolation)
68 assert.Equal(t, int32(1), detail.LatestAttempt)
69
70 // --log, all jobs and one job
71 all := h.cli(t, adminToken, "run", "view", "proj", id, "--log")
72 require.NoError(t, all.err, all.stderr)
73 assert.Contains(t, all.stdout, "== build ==")
74 assert.Contains(t, all.stdout, "hello from the job")
75 assert.Contains(t, all.stdout, "this one failed")
76 one := h.cli(t, adminToken, "run", "view", "proj", id, "--log", "--job", "test")
77 require.NoError(t, one.err)
78 assert.Contains(t, one.stdout, "this one failed")
79 assert.NotContains(t, one.stdout, "hello from the job")
80 missing := h.cli(t, adminToken, "run", "view", "proj", id, "--log", "--job", "nope")
81 require.Error(t, missing.err)
82 assert.Contains(t, missing.err.Error(), `no job "nope"`)
83
84 // watch: a completed failing run prints once and exits with an error.
85 w := h.cli(t, adminToken, "run", "watch", "proj", id)
86 require.Error(t, w.err)
87 assert.Contains(t, w.err.Error(), "failure")
88 assert.Contains(t, w.stdout, "build success")
89
90 // rerun --failed, then cancel the new attempt.
91 re := h.cli(t, adminToken, "run", "rerun", "proj", id, "--failed")
92 require.NoError(t, re.err, re.stderr)
93 assert.Contains(t, re.stderr, "attempt 2 started")
94 ca := h.cli(t, adminToken, "run", "cancel", "proj", id, "--json")
95 require.NoError(t, ca.err, ca.stderr)
96 assert.Equal(t, int32(2), decode[jsonRun](t, ca.stdout).Attempt)
97 // No runner holds a job of attempt 2, so the cancel ended it at once: a
98 // completed run cannot be cancelled again.
99 again := h.cli(t, adminToken, "run", "cancel", "proj", id)
100 require.Error(t, again.err)
101 assert.Equal(t, connect.CodeFailedPrecondition, connect.CodeOf(again.err))
102
103 // A reader may look but not cancel or rerun.
104 reader := h.token(scopes(domain.ScopeRepoRead))
105 assert.NoError(t, h.cli(t, reader, "run", "view", "proj", id).err)
106 denied := h.cli(t, reader, "run", "cancel", "proj", id)
107 require.Error(t, denied.err)
108 assert.Equal(t, connect.CodePermissionDenied, connect.CodeOf(denied.err))
109 assert.Error(t, h.cli(t, adminToken, "run", "view", "proj", "abc").err)
110
111 // runners
112 rl := h.cli(t, adminToken, "runner", "list")
113 require.NoError(t, rl.err, rl.stderr)
114 assert.Contains(t, rl.stdout, "alpha")
115 assert.Contains(t, rl.stdout, "vm")
116 rjs := decode[[]jsonRunner](t, h.cli(t, adminToken, "runner", "list", "--json").stdout)
117 require.Len(t, rjs, 1)
118 assert.Equal(t, []string{"linux", "self-hosted", "ubuntu-latest", "x64"}, rjs[0].Labels)
119 rm := h.cli(t, adminToken, "runner", "remove", rjs[0].ID)
120 require.NoError(t, rm.err, rm.stderr)
121 assert.Error(t, h.cli(t, adminToken, "runner", "remove", rjs[0].ID).err, "already removed")
122 assert.Contains(t, h.cli(t, adminToken, "runner", "list").stderr, "no runners")
123
124 // The runner token preset makes a token that registers runners and nothing
125 // else.
126 tok := h.cli(t, adminToken, "token", "create", "runner host", "--preset", "runner", "--json")
127 require.NoError(t, tok.err, tok.stderr)
128 created := decode[jsonCreatedToken](t, tok.stdout)
129 assert.Equal(t, []string{"runner:register"}, created.Scopes)
130 assert.NoError(t, h.cli(t, created.Secret, "runner", "list").err)
131 assert.Error(t, h.cli(t, created.Secret, "run", "cancel", "proj", id).err)
132}