v0.2.0 4f9c11b

job_eval_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package workflow
4
5import (
6	"reflect"
7	"strings"
8	"testing"
9)
10
11func TestEvaluateRunsOn(t *testing.T) {
12	w := mustParse(t, `on: push
13jobs:
14  s:
15    runs-on: ubuntu-latest
16    steps: [{run: a}]
17  l:
18    runs-on: [self-hosted, "${{ matrix.arch }}"]
19    steps: [{run: a}]
20  m:
21    runs-on:
22      group: org/big
23      labels: ${{ fromJSON('["a","b"]') }}
24    steps: [{run: a}]
25  e:
26    runs-on: ${{ matrix.os }}
27    steps: [{run: a}]
28  g:
29    runs-on:
30      group: ${{ matrix.g }}
31    steps: [{run: a}]
32  r:
33    uses: ./x.yml
34`)
35	env := envWith(t, map[string]string{"matrix": `{"arch": "arm64", "os": "linux", "g": "bogus/x", "n": 1}`})
36	tests := []struct {
37		job, labels, group, err string
38	}{
39		{"s", "ubuntu-latest", "", ""},
40		{"l", "self-hosted,arm64", "", ""},
41		{"m", "a,b", "org/big", ""},
42		{"e", "linux", "", ""},
43		{"g", "", "", "Invalid runs-on group name 'bogus/x. Please use"},
44		{"r", "", "", "has no runs-on"},
45	}
46	for _, tc := range tests {
47		labels, group, err := w.Job(tc.job).EvaluateRunsOn(ctxbg, env)
48		if tc.err != "" {
49			if err == nil || !strings.Contains(err.Error(), tc.err) {
50				t.Errorf("%s: err = %v, want %q", tc.job, err, tc.err)
51			}
52			continue
53		}
54		if err != nil || strings.Join(labels, ",") != tc.labels || group != tc.group {
55			t.Errorf("%s: %q, %q, %v", tc.job, labels, group, err)
56		}
57	}
58	// Non-string results.
59	bad := mustParse(t, "on: push\njobs:\n  a:\n    runs-on: ${{ matrix.n }}\n    steps: [{run: a}]\n  b:\n    runs-on: [\"${{ matrix.n }}\"]\n    steps: [{run: a}]\n")
60	for _, id := range []string{"a", "b"} {
61		_, _, err := bad.Job(id).EvaluateRunsOn(ctxbg, env)
62		if err == nil || !strings.Contains(err.Error(), "Unexpected type") {
63			t.Errorf("%s: err = %v", id, err)
64		}
65	}
66}
67
68func TestEvaluateIf(t *testing.T) {
69	w := mustParse(t, `on: push
70jobs:
71  a:
72    runs-on: x
73    steps: [{run: a}]
74  b:
75    needs: a
76    if: needs.a.result == 'failure'
77    runs-on: x
78    steps:
79      - run: a
80        if: failure()
81      - run: b
82      - run: c
83        if: always()
84  c:
85    needs: a
86    if: ${{ always() && !cancelled() }}
87    runs-on: x
88    steps: [{run: a}]
89`)
90	env := envWith(t, map[string]string{"needs": `{"a": {"result": "failure"}}`})
91	b := w.Job("b")
92	tests := []struct {
93		name string
94		got  func() (bool, error)
95		want bool
96	}{
97		{"job, success", func() (bool, error) { return b.EvaluateIf(ctxbg, env, StatusFuncs{Success: true}) }, true},
98		{"job, after failure", func() (bool, error) { return b.EvaluateIf(ctxbg, env, StatusFuncs{Failure: true}) }, false},
99		{"step failure()", func() (bool, error) { return b.Steps[0].EvaluateIf(ctxbg, env, StatusFuncs{Failure: true}) }, true},
100		{"step failure() when ok", func() (bool, error) { return b.Steps[0].EvaluateIf(ctxbg, env, StatusFuncs{Success: true}) }, false},
101		{"step default", func() (bool, error) { return b.Steps[1].EvaluateIf(ctxbg, env, StatusFuncs{Success: true}) }, true},
102		{"step default after failure", func() (bool, error) { return b.Steps[1].EvaluateIf(ctxbg, env, StatusFuncs{Failure: true}) }, false},
103		{"always", func() (bool, error) { return b.Steps[2].EvaluateIf(ctxbg, env, StatusFuncs{Cancelled: true}) }, true},
104		{"always and not cancelled", func() (bool, error) { return w.Job("c").EvaluateIf(ctxbg, env, StatusFuncs{Cancelled: true}) }, false},
105		{"always and not cancelled, ok", func() (bool, error) { return w.Job("c").EvaluateIf(ctxbg, env, StatusFuncs{Failure: true}) }, true},
106	}
107	for _, tc := range tests {
108		got, err := tc.got()
109		if err != nil || got != tc.want {
110			t.Errorf("%s = %v, %v; want %v", tc.name, got, err, tc.want)
111		}
112	}
113	// The caller's functions (hashFiles) are kept, and env is not modified.
114	env.Funcs = nil
115	if _, err := b.EvaluateIf(ctxbg, env, StatusFuncs{}); err != nil {
116		t.Fatal(err)
117	}
118	if env.Funcs != nil {
119		t.Error("env.Funcs was modified")
120	}
121}
122
123func TestJobLevels(t *testing.T) {
124	w := mustParse(t, `on: push
125jobs:
126  deploy:
127    needs: [test, lint]
128    runs-on: x
129    steps: [{run: a}]
130  test:
131    needs: build
132    runs-on: x
133    steps: [{run: a}]
134  lint:
135    runs-on: x
136    steps: [{run: a}]
137  build:
138    runs-on: x
139    steps: [{run: a}]
140  docs:
141    needs: [build]
142    runs-on: x
143    steps: [{run: a}]
144  notify:
145    needs: [deploy, docs]
146    runs-on: x
147    steps: [{run: a}]
148`)
149	var got []string
150	for _, l := range w.JobLevels() {
151		var ids []string
152		for _, j := range l {
153			ids = append(ids, j.ID)
154		}
155		got = append(got, strings.Join(ids, ","))
156	}
157	if want := "lint,build|test,docs|deploy|notify"; strings.Join(got, "|") != want {
158		t.Errorf("levels = %v, want %s", got, want)
159	}
160	// Stable across calls; a hand-built cycle is left out, not looped on.
161	cyc := &Workflow{Jobs: []*Job{{ID: "a", Needs: []string{"b"}}, {ID: "b", Needs: []string{"a"}}, {ID: "c"}, {ID: "d", Needs: []string{"zz"}}}}
162	l := cyc.JobLevels()
163	if len(l) != 1 || len(l[0]) != 1 || l[0][0].ID != "c" {
164		t.Errorf("cycle levels = %v", l)
165	}
166	if len((&Workflow{}).JobLevels()) != 0 {
167		t.Error("empty workflow has levels")
168	}
169}
170
171func TestDisplayNameAstWalk(t *testing.T) {
172	// The word "matrix." in a string literal or another context is not a use
173	// of the matrix context.
174	w := mustParse(t, `on: push
175jobs:
176  a:
177    name: "${{ github.event.matrix }} and matrix. text"
178    runs-on: x
179    strategy:
180      matrix:
181        os: [l]
182    steps: [{run: a}]
183  b:
184    name: ${{ format('{0}', matrix.os) }}
185    runs-on: x
186    strategy:
187      matrix:
188        os: [l]
189    steps: [{run: a}]
190`)
191	env := envWith(t, map[string]string{"github": `{"event": {"matrix": "m"}}`})
192	for id, want := range map[string]string{"a": "m and matrix. text (l)", "b": "l"} {
193		j := w.Job(id)
194		s, err := j.EvaluateStrategy(ctxbg, env)
195		if err != nil {
196			t.Fatal(err)
197		}
198		got, err := j.DisplayName(ctxbg, mustMatrixEnv(t, s, env, 0), s, 0)
199		if err != nil || got != want {
200			t.Errorf("%s: name = %q, %v; want %q", id, got, err, want)
201		}
202	}
203}
204
205// needsFailedDependency is a cut-down workflow of the recorded scenario of
206// that name (without with-failure, with-success and broken's outputs), plus a
207// made-up diamond job that GitHub did not run.
208const needsFailedDependency = `on:
209  push:
210    branches: ['scn/**']
211jobs:
212  broken:
213    runs-on: ubuntu-24.04
214    steps:
215      - run: exit 1
216  plain:
217    needs: broken
218    runs-on: ubuntu-24.04
219    steps:
220      - run: echo SHOULD-NOT-RUN
221  with-always:
222    needs: broken
223    if: always()
224    runs-on: ubuntu-24.04
225    steps:
226      - run: echo
227  chain-after-always:
228    needs: with-always
229    runs-on: ubuntu-24.04
230    steps:
231      - run: echo chain-after-always-runs-because-with-always-succeeded
232  diamond:
233    needs: [chain-after-always, plain, with-always]
234    runs-on: ubuntu-24.04
235    steps:
236      - run: echo
237`
238
239// TestAncestors checks the transitive needs that GitHub's default success()
240// looks at (recorded: needs-failed-dependency, chain-after-always was skipped).
241func TestAncestors(t *testing.T) {
242	w, errs := Parse("w.yml", []byte(needsFailedDependency))
243	if len(errs) > 0 {
244		t.Fatal(errs)
245	}
246	for id, want := range map[string][]string{
247		"broken":             {},
248		"plain":              {"broken"},
249		"with-always":        {"broken"},
250		"chain-after-always": {"broken", "with-always"},
251		"diamond":            {"broken", "plain", "with-always", "chain-after-always"},
252		"nowhere":            nil,
253	} {
254		if got := w.Ancestors(id); !reflect.DeepEqual(got, want) {
255			t.Errorf("Ancestors(%q) = %q, want %q", id, got, want)
256		}
257	}
258}
259
260// TestAncestorsCycleAndUnknownNeed covers what Parse rejects but a hand-built
261// Workflow may hold: a cycle ends, the start job and unknown ids are left out.
262func TestAncestorsCycleAndUnknownNeed(t *testing.T) {
263	w := &Workflow{Jobs: []*Job{
264		{ID: "a", Needs: []string{"b", "ghost"}},
265		{ID: "b", Needs: []string{"a"}},
266	}}
267	if got := w.Ancestors("a"); !reflect.DeepEqual(got, []string{"b"}) {
268		t.Errorf("Ancestors(a) = %q, want [b]", got)
269	}
270}

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 1

Tags 3

↑↓ moveenter openesc close