1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "reflect"
7 "strings"
8 "testing"
9
10 "bnema.dev/ermite-actions/expr"
11)
12
13const testContexts = `{
14 "github": {"repository":"octo/hello","repository_owner":"octo","run_id":"42","run_number":"7","run_attempt":"1",
15 "ref":"refs/heads/main","ref_name":"main","ref_type":"branch","sha":"abc","event_name":"push","job":"build",
16 "ref_protected":false,"workflow":"CI","actor":"octo","server_url":"https://ermite.example",
17 "event":{"ref":"refs/heads/main","n":1},"retention_days":"90","unknown_key":"x"},
18 "needs": {"a":{"result":"success","outputs":{"o":"1"}}},
19 "strategy": {"fail-fast":true,"job-index":0,"job-total":1,"max-parallel":1},
20 "matrix": {"os":"linux"},
21 "inputs": {},
22 "vars": {"V":"1"}
23}`
24
25func newTestState(t *testing.T) *State {
26 t.Helper()
27 s, _, err := NewState(testContexts, `{"os":"linux"}`, "build", RunnerInfo{Name: "r1"}, "")
28 if err != nil {
29 t.Fatal(err)
30 }
31 return s
32}
33
34func TestNewStateLayoutAndGitHub(t *testing.T) {
35 s := newTestState(t)
36 l := s.Layout()
37 if l.Workspace != "/home/runner/work/hello/hello" || l.RunnerWorkspace != "/home/runner/work/hello" ||
38 l.Temp != "/home/runner/work/_temp" || l.ToolCache != "/opt/hostedtoolcache" || l.Home != "/home/runner" {
39 t.Fatalf("%+v", l)
40 }
41 if s.GitHub("workspace") != l.Workspace || s.GitHub("job") != "build" {
42 t.Fatalf("workspace %q job %q", s.GitHub("workspace"), s.GitHub("job"))
43 }
44 // token comes first (ExecutionContext.cs:1023): an empty token, no GITHUB_TOKEN yet.
45 gh := s.Values(nil)["github"].(*expr.Dictionary)
46 if gh.Keys()[0] != "token" {
47 t.Fatalf("keys %v", gh.Keys())
48 }
49}
50
51func TestRuntimeEnv(t *testing.T) {
52 s := newTestState(t)
53 env := s.RuntimeEnv()
54 want := map[string]string{
55 "GITHUB_REPOSITORY": "octo/hello", "GITHUB_REPOSITORY_OWNER": "octo", "GITHUB_RUN_ID": "42",
56 "GITHUB_RUN_NUMBER": "7", "GITHUB_RUN_ATTEMPT": "1", "GITHUB_REF": "refs/heads/main",
57 "GITHUB_REF_NAME": "main", "GITHUB_REF_TYPE": "branch", "GITHUB_SHA": "abc", "GITHUB_EVENT_NAME": "push",
58 "GITHUB_JOB": "build", "GITHUB_REF_PROTECTED": "false", "GITHUB_WORKFLOW": "CI", "GITHUB_ACTOR": "octo",
59 "GITHUB_SERVER_URL": "https://ermite.example", "GITHUB_RETENTION_DAYS": "90",
60 "GITHUB_WORKSPACE": "/home/runner/work/hello/hello",
61 "RUNNER_OS": "Linux", "RUNNER_NAME": "r1", "RUNNER_ENVIRONMENT": "self-hosted",
62 "RUNNER_TEMP": "/home/runner/work/_temp", "RUNNER_TOOL_CACHE": "/opt/hostedtoolcache",
63 "RUNNER_WORKSPACE": "/home/runner/work/hello",
64 }
65 for k, v := range want {
66 if got, ok := env.Get(k); !ok || got != v {
67 t.Errorf("%s = %q (%v), want %q", k, got, ok, v)
68 }
69 }
70 // Only allow-listed string/boolean keys; objects are not exported.
71 for _, k := range []string{"GITHUB_EVENT", "GITHUB_UNKNOWN_KEY", "GITHUB_TOKEN"} {
72 if _, ok := env.Get(k); ok {
73 t.Errorf("%s must not be set", k)
74 }
75 }
76 if a, _ := env.Get("RUNNER_ARCH"); a != "X64" && a != "ARM64" {
77 t.Errorf("RUNNER_ARCH = %q", a)
78 }
79}
80
81func TestRunnerContextOrderAndValues(t *testing.T) {
82 s := newTestState(t)
83 r := s.Values(nil)["runner"].(*expr.Dictionary)
84 if got := strings.Join(r.Keys(), ","); got != "os,arch,name,environment,tool_cache,temp,workspace" {
85 t.Fatal(got)
86 }
87 s2, _, _ := NewState(testContexts, "", "build", RunnerInfo{Name: "h", Environment: "github-hosted", Arch: "ARM64"}, "")
88 r2 := s2.Values(nil)["runner"].(*expr.Dictionary)
89 if str(r2, "environment") != "github-hosted" || str(r2, "arch") != "ARM64" {
90 t.Fatal(expr.ToJSON(r2))
91 }
92}
93
94func TestServerContextsAndDefaults(t *testing.T) {
95 s := newTestState(t)
96 v := s.Values(nil)
97 if expr.ToJSON(v["matrix"]) != "{\n \"os\": \"linux\"\n}" {
98 t.Fatal(expr.ToJSON(v["matrix"]))
99 }
100 for _, k := range []string{"needs", "strategy", "inputs", "vars", "steps", "env", "job", "secrets"} {
101 if v[k] == nil {
102 t.Errorf("context %s missing", k)
103 }
104 }
105 // Absent contexts: needs/inputs/vars are empty objects, matrix is null
106 // (also when the server sends {}), as the recordings show.
107 s2, _, err := NewState(`{"github":{"repository":"o/r"}}`, "{}", "j", RunnerInfo{}, "")
108 if err != nil {
109 t.Fatal(err)
110 }
111 v2 := s2.Values(nil)
112 if _, ok := v2["matrix"].(expr.Null); !ok {
113 t.Errorf("matrix without a matrix: %v, want null", v2["matrix"])
114 }
115 // The server's "{}" in the contexts is null too.
116 s3, _, err := NewState(`{"github":{"repository":"o/r"},"matrix":{}}`, "{}", "j", RunnerInfo{}, "")
117 if err != nil {
118 t.Fatal(err)
119 }
120 if _, ok := s3.Values(nil)["matrix"].(expr.Null); !ok {
121 t.Errorf("empty matrix: %v, want null", s3.Values(nil)["matrix"])
122 }
123 if d, ok := v2["needs"].(*expr.Dictionary); !ok || d.Len() != 0 {
124 t.Errorf("needs %v", v2["needs"])
125 }
126}
127
128func TestNewStateErrors(t *testing.T) {
129 for _, tc := range []struct{ ctx, matrix, repo, want string }{
130 {`[]`, "", "", "want a JSON object"},
131 {`{`, "", "", "job contexts"},
132 {`{"github":1}`, "", "", "github must be an object"},
133 {`{"github":{}}`, "", "", "github.repository is missing"},
134 {`{"github":{"repository":"o/r"}}`, "{", "", "job matrix"},
135 } {
136 _, _, err := NewState(tc.ctx, tc.matrix, "j", RunnerInfo{}, tc.repo)
137 if err == nil || !strings.Contains(err.Error(), tc.want) {
138 t.Errorf("%q: %v, want %q", tc.ctx, err, tc.want)
139 }
140 }
141 // The repository name can come from the job when the context lacks it.
142 if _, _, err := NewState(`{"github":{}}`, "", "j", RunnerInfo{}, "hello"); err != nil {
143 t.Fatal(err)
144 }
145}
146
147func TestJobStatus(t *testing.T) {
148 s := newTestState(t)
149 job := func() *expr.Dictionary { return s.Values(nil)["job"].(*expr.Dictionary) }
150 if str(job(), "status") != "success" || job().Keys()[0] != "status" {
151 t.Fatal(expr.ToJSON(job()))
152 }
153 s.SetStatus("failure")
154 if str(job(), "status") != "failure" || s.Status() != "failure" {
155 t.Fatal(expr.ToJSON(job()))
156 }
157}
158
159func TestEnvOrderAndOverride(t *testing.T) {
160 o := newOrdered()
161 o.Set("A", "1")
162 o.Set("B", "2")
163 o.Set("A", "3") // keeps its position
164 if !reflect.DeepEqual(o.Keys(), []string{"A", "B"}) || o.vals["A"] != "3" {
165 t.Fatalf("%v", o)
166 }
167 c := o.Clone()
168 c.Set("C", "4")
169 if o.Len() != 2 || c.Len() != 3 {
170 t.Fatal("Clone shares state")
171 }
172 // Case-sensitive keys, like Linux.
173 o.Set("a", "x")
174 if o.Len() != 3 {
175 t.Fatal("keys must be case-sensitive")
176 }
177}
178
179func TestStepsContext(t *testing.T) {
180 s := newTestState(t)
181 s.SetOutput("one", "out", "v")
182 s.SetOutput("__run", "ignored", "x") // generated ids are not in the context
183 s.SetStepResult("one", "failure", "success")
184 s.SetStepResult("__run_2", "success", "success")
185 s.SetStepResult("skipped", "skipped", "skipped")
186 steps := s.Values(nil)["steps"].(*expr.Dictionary)
187 if got := strings.Join(steps.Keys(), ","); got != "one,skipped" {
188 t.Fatalf("keys %s", got)
189 }
190 one, _ := steps.Get("one")
191 od := one.(*expr.Dictionary)
192 if str(od, "outcome") != "failure" || str(od, "conclusion") != "success" {
193 t.Fatal(expr.ToJSON(one))
194 }
195 outs, _ := od.Get("outputs")
196 if str(outs.(*expr.Dictionary), "out") != "v" || s.Output("one", "out") != "v" || s.Output("none", "x") != "" {
197 t.Fatal(expr.ToJSON(outs))
198 }
199 // A step that has output but has not completed has no outcome yet.
200 s.SetOutput("running", "k", "v")
201 r, _ := s.Values(nil)["steps"].(*expr.Dictionary).Get("running")
202 if _, ok := r.(*expr.Dictionary).Get("outcome"); ok {
203 t.Fatal("outcome before completion")
204 }
205}
206
207func TestAddPathMovesRepeatedEntryToTheEnd(t *testing.T) {
208 s := newTestState(t)
209 for _, p := range []string{"/a", "/b", "/a"} {
210 s.AddPath(p)
211 }
212 if !reflect.DeepEqual(s.Prepend(), []string{"/b", "/a"}) {
213 t.Fatal(s.Prepend())
214 }
215}
216
217func TestEnvContextForSteps(t *testing.T) {
218 s := newTestState(t)
219 s.SetEnv("JOB", "j")
220 step := s.JobEnv()
221 step.Set("STEP", "s")
222 env := s.Values(step)["env"].(*expr.Dictionary)
223 if str(env, "JOB") != "j" || str(env, "STEP") != "s" {
224 t.Fatal(expr.ToJSON(env))
225 }
226 // The job environment is untouched by the step's copy.
227 if _, ok := s.JobEnv().Get("STEP"); ok {
228 t.Fatal("step env leaked into the job env")
229 }
230}
231
232func TestEventJSON(t *testing.T) {
233 s := newTestState(t)
234 if got := s.EventJSON(); !strings.Contains(got, `"ref": "refs/heads/main"`) {
235 t.Fatal(got)
236 }
237 s2, _, _ := NewState(`{"github":{"repository":"o/r"}}`, "", "j", RunnerInfo{}, "")
238 if s2.EventJSON() != "" {
239 t.Fatal("no event, no event.json")
240 }
241}
242
243func TestWithLayout(t *testing.T) {
244 s := newTestState(t)
245 l := NewLayout("/tmp/h", "hello", "/tmp/tc")
246 s.WithLayout(l)
247 env := s.RuntimeEnv()
248 for k, v := range map[string]string{"GITHUB_WORKSPACE": "/tmp/h/work/hello/hello", "RUNNER_TEMP": "/tmp/h/work/_temp", "RUNNER_TOOL_CACHE": "/tmp/tc"} {
249 if got, _ := env.Get(k); got != v {
250 t.Errorf("%s = %q", k, got)
251 }
252 }
253}
254
255func TestGitHubKeysAddedByTheRunnerKeepTheSortedOrder(t *testing.T) {
256 // A server that lists its keys sorted, as the recordings do: the keys the
257 // runner adds land at their sorted place, replaced keys stay, token is first.
258 s, _, err := NewState(`{"github":{"actor":"a","event_name":"push","job":"j","repository":"o/r","sha":"s","zzz":"1"}}`, "", "j", RunnerInfo{}, "")
259 if err != nil {
260 t.Fatal(err)
261 }
262 for _, k := range []string{"secret_source", "artifacts", "action", "event_path"} {
263 s.SetGitHub(k, "x")
264 }
265 s.SetGitHub("sha", "t")
266 got := strings.Join(s.Values(nil)["github"].(*expr.Dictionary).Keys(), ",")
267 want := "token,action,actor,artifacts,event_name,event_path,job,repository,secret_source,sha,workspace,zzz"
268 if got != want {
269 t.Errorf("keys\n got %s\nwant %s", got, want)
270 }
271}