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}
133
134func TestRefTextShowsMergeRequests(t *testing.T) {
135 assert.Equal(t, "!12", refText("refs/pull/12/merge"))
136 assert.Equal(t, "main", refText("refs/heads/main"))
137 assert.Equal(t, "v1", refText("refs/tags/v1"))
138}
139
140// `ermite workflow disable|enable|list`, over a real server: right after
141// `repo create`, before any push, then once the file exists.
142func TestCLIWorkflowDisableEnableList(t *testing.T) {
143 ci := newCIHarness(t, nil)
144 h := &cliHarness{harness: ci.harness, url: ci.srv.URL}
145
146 dis := h.cli(t, adminToken, "workflow", "disable", "proj", "release.yml")
147 require.NoError(t, dis.err, dis.stderr)
148 assert.Contains(t, dis.stderr, "disabled .github/workflows/release.yml in proj")
149
150 empty := h.cli(t, adminToken, "workflow", "list", "proj")
151 require.NoError(t, empty.err, empty.stderr)
152 assert.Contains(t, empty.stdout, "FILE")
153 assert.Contains(t, empty.stdout, ".github/workflows/release.yml")
154 assert.Contains(t, empty.stdout, "disabled")
155
156 ci.push("main", map[string]string{".github/workflows/ci.yml": ciWorkflow, releaseWF: wfOn("release", " push:\n")}, "")
157 js := h.cli(t, adminToken, "workflow", "list", "proj", "--json")
158 require.NoError(t, js.err, js.stderr)
159 list := decode[[]jsonWorkflow](t, js.stdout)
160 assert.Equal(t, []jsonWorkflow{
161 {Repo: "proj", File: ".github/workflows/ci.yml", Name: "ci", State: "enabled"},
162 {Repo: "proj", File: releaseWF, Name: "release", State: "disabled"},
163 }, list)
164 raw := decode[[]map[string]any](t, js.stdout)
165 assert.Len(t, raw[0], 4, "repo, file, name, state")
166 for _, k := range []string{"repo", "file", "name", "state"} {
167 assert.Contains(t, raw[0], k)
168 }
169
170 en := h.cli(t, adminToken, "workflow", "enable", "proj", "release.yml", "--json")
171 require.NoError(t, en.err, en.stderr)
172 assert.Equal(t, jsonWorkflow{Repo: "proj", File: releaseWF, State: "enabled"}, decode[jsonWorkflow](t, en.stdout))
173 after := decode[[]jsonWorkflow](t, h.cli(t, adminToken, "workflow", "list", "proj", "--json").stdout)
174 assert.Equal(t, "enabled", after[1].State)
175
176 bad := h.cli(t, adminToken, "workflow", "disable", "proj", "sub/x.yml")
177 require.Error(t, bad.err)
178 missing := h.cli(t, adminToken, "workflow", "disable", "nope", "x.yml")
179 require.Error(t, missing.err)
180
181 reader := h.cli(t, ci.token(scopes(domain.ScopeRepoRead), "proj"), "workflow", "disable", "proj", "release.yml")
182 require.Error(t, reader.err)
183 assert.Contains(t, reader.err.Error(), "permission denied")
184
185 help := h.cli(t, adminToken, "workflow", "disable", "--help")
186 require.NoError(t, help.err)
187 assert.Contains(t, help.stdout, "JSON fields of a workflow (--json): repo, file")
188 assert.Contains(t, help.stdout, "repo:admin")
189}