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}