detached 33a243c

contexts_test.go RawHistoryFolder

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}

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 4

Tags 11

↑↓ moveenter openesc close