1// SPDX-License-Identifier: MIT
2
3package workflow
4
5import (
6 "context"
7 "errors"
8 "fmt"
9 "reflect"
10 "strings"
11 "testing"
12 "time"
13
14 "bnema.dev/ermite-actions/expr"
15 "bnema.dev/ermite-actions/template"
16)
17
18var ctxbg = context.Background()
19
20func jsonValue(t testing.TB, s string) expr.Value {
21 t.Helper()
22 v, err := expr.FromJSON([]byte(s))
23 if err != nil {
24 t.Fatalf("FromJSON(%s): %v", s, err)
25 }
26 return v
27}
28
29func compact(s string) string {
30 return strings.NewReplacer("\n", "", " ", "", `": `, `":`).Replace(s)
31}
32
33// summarize renders combos as "k=v,k=v" lines with the display segments.
34func summarize(combos []MatrixCombo) []string {
35 var out []string
36 for _, c := range combos {
37 var kv []string
38 for _, p := range c.Values.Pairs() {
39 kv = append(kv, p.Key+"="+compact(expr.ToJSON(p.Value)))
40 }
41 out = append(out, strings.Join(kv, ",")+" | "+c.DisplayName("job"))
42 }
43 return out
44}
45
46func TestExpandMatrix(t *testing.T) {
47 tests := []struct {
48 name string
49 strategy string
50 want []string
51 }{
52 {"no strategy", `null`, []string{" | job"}},
53 {"strategy without matrix", `{"fail-fast": false}`, []string{" | job"}},
54 {"empty matrix", `{"matrix": {}}`, []string{" | job"}},
55 {
56 "one vector",
57 `{"matrix": {"os": ["ubuntu", "macos"]}}`,
58 []string{`os="ubuntu" | job (ubuntu)`, `os="macos" | job (macos)`},
59 },
60 {
61 "cross product, first key slowest",
62 `{"matrix": {"os": ["a", "b"], "node": [14, 16, 18]}}`,
63 []string{
64 `os="a",node=14 | job (a, 14)`, `os="a",node=16 | job (a, 16)`, `os="a",node=18 | job (a, 18)`,
65 `os="b",node=14 | job (b, 14)`, `os="b",node=16 | job (b, 16)`, `os="b",node=18 | job (b, 18)`,
66 },
67 },
68 {
69 // GitHub docs, "Expanding or adding matrix configurations".
70 "docs include example",
71 `{"matrix": {"fruit": ["apple", "pear"], "animal": ["cat", "dog"], "include": [
72 {"color": "green"},
73 {"color": "pink", "animal": "cat"},
74 {"fruit": "apple", "shape": "circle"},
75 {"fruit": "banana"},
76 {"fruit": "banana", "animal": "cat"}
77 ]}}`,
78 []string{
79 `fruit="apple",animal="cat",color="pink",shape="circle" | job (apple, cat, pink, circle)`,
80 `fruit="apple",animal="dog",color="green",shape="circle" | job (apple, dog, green, circle)`,
81 `fruit="pear",animal="cat",color="pink" | job (pear, cat, pink)`,
82 `fruit="pear",animal="dog",color="green" | job (pear, dog, green)`,
83 `fruit="banana" | job (banana)`,
84 `fruit="banana",animal="cat" | job (banana, cat)`,
85 },
86 },
87 {
88 // GitHub docs, "Excluding matrix configurations".
89 "docs exclude example",
90 `{"matrix": {"os": ["macos-latest", "windows-latest"], "version": [12, 14, 16], "environment": ["staging", "production"],
91 "exclude": [{"os": "macos-latest", "version": 12, "environment": "production"}, {"os": "windows-latest", "version": 16}]}}`,
92 []string{
93 `os="macos-latest",version=12,environment="staging" | job (macos-latest, 12, staging)`,
94 `os="macos-latest",version=14,environment="staging" | job (macos-latest, 14, staging)`,
95 `os="macos-latest",version=14,environment="production" | job (macos-latest, 14, production)`,
96 `os="macos-latest",version=16,environment="staging" | job (macos-latest, 16, staging)`,
97 `os="macos-latest",version=16,environment="production" | job (macos-latest, 16, production)`,
98 `os="windows-latest",version=12,environment="staging" | job (windows-latest, 12, staging)`,
99 `os="windows-latest",version=12,environment="production" | job (windows-latest, 12, production)`,
100 `os="windows-latest",version=14,environment="staging" | job (windows-latest, 14, staging)`,
101 `os="windows-latest",version=14,environment="production" | job (windows-latest, 14, production)`,
102 },
103 },
104 {
105 // GitHub docs, "Using a matrix with only include": the include
106 // entries do not overwrite a vector value.
107 "include does not overwrite original values",
108 `{"matrix": {"os": ["a", "b"], "include": [{"os": "c"}, {"os": "a", "node": 1}]}}`,
109 []string{
110 `os="a",node=1 | job (a, 1)`,
111 `os="b" | job (b)`,
112 `os="c" | job (c)`,
113 },
114 },
115 {
116 "include only",
117 `{"matrix": {"include": [{"os": "a", "n": 1}, {"os": "b", "n": 2}]}}`,
118 []string{`os="a",n=1 | job (a, 1)`, `os="b",n=2 | job (b, 2)`},
119 },
120 {
121 "added value overwritten by a later include",
122 `{"matrix": {"os": ["a"], "include": [{"color": "red"}, {"color": "blue", "os": "a"}]}}`,
123 []string{`os="a",color="blue" | job (a, blue)`},
124 },
125 {
126 "exclude then include re-adds",
127 `{"matrix": {"os": ["a", "b"], "exclude": [{"os": "a"}], "include": [{"os": "a", "x": 1}]}}`,
128 []string{`os="b" | job (b)`, `os="a",x=1 | job (a, 1)`},
129 },
130 {
131 "exclude partial match",
132 `{"matrix": {"a": [1, 2], "b": [1, 2], "exclude": [{"a": 1}]}}`,
133 []string{`a=2,b=1 | job (2, 1)`, `a=2,b=2 | job (2, 2)`},
134 },
135 {
136 "exclude no match",
137 `{"matrix": {"a": [1], "exclude": [{"a": 2}]}}`,
138 []string{`a=1 | job (1)`},
139 },
140 {
141 "booleans, null and numbers in names",
142 `{"matrix": {"a": [true, null, 1.5]}}`,
143 []string{`a=true | job (true)`, `a=null | job`, `a=1.5 | job (1.5)`},
144 },
145 {
146 "objects and arrays are not in names",
147 `{"matrix": {"v": [{"k": 1}, [1]], "w": ["x"]}}`,
148 []string{`v={"k":1},w="x" | job (x)`, `v=[1],w="x" | job (x)`},
149 },
150 {
151 "exclude by object containment",
152 `{"matrix": {"v": [{"k": 1, "m": 2}, {"k": 2}], "exclude": [{"v": {"k": 1}}]}}`,
153 []string{`v={"k":2} | job`},
154 },
155 {
156 "keys are case-insensitive for include matching",
157 `{"matrix": {"OS": ["a"], "include": [{"os": "a", "x": 1}]}}`,
158 []string{`OS="a",x=1 | job (a, 1)`},
159 },
160 }
161 for _, tc := range tests {
162 t.Run(tc.name, func(t *testing.T) {
163 combos, err := ExpandMatrix(ctxbg, jsonValue(t, tc.strategy))
164 if err != nil {
165 t.Fatal(err)
166 }
167 if got := summarize(combos); !reflect.DeepEqual(got, tc.want) {
168 t.Errorf("got:\n %s\nwant:\n %s", strings.Join(got, "\n "), strings.Join(tc.want, "\n "))
169 }
170 })
171 }
172}
173
174func TestExpandMatrixErrors(t *testing.T) {
175 tests := []struct{ name, strategy, want string }{
176 {"strategy not a mapping", `[1]`, "the strategy must be a mapping"},
177 {"matrix not a mapping", `{"matrix": [1]}`, "the matrix must be a mapping"},
178 {"vector empty", `{"matrix": {"a": []}}`, "matrix vector 'a' does not contain any values"},
179 {"vector scalar", `{"matrix": {"a": 1}}`, "matrix vector 'a' does not contain any values"},
180 {"include not a list", `{"matrix": {"include": 1}}`, "matrix include must be a sequence of mappings"},
181 {"include item", `{"matrix": {"include": [1]}}`, "matrix include must be a sequence of mappings"},
182 {"exclude item", `{"matrix": {"a": [1], "exclude": ["x"]}}`, "matrix exclude must be a sequence of mappings"},
183 {"exclude unknown key", `{"matrix": {"a": [1], "exclude": [{"zzz": 1}]}}`, "Matrix exclude key 'zzz' does not match any key within the matrix"},
184 {"exclude include-only key", `{"matrix": {"a": [1], "include": [{"b": 1}], "exclude": [{"b": 1}]}}`, "Matrix exclude key 'b' does not match any key within the matrix"},
185 {"exclude without vectors", `{"matrix": {"exclude": [{"a": 1}]}}`, "Matrix exclude key 'a' does not match any key within the matrix"},
186 {"too many includes", `{"matrix": {"include": ` + entries(257) + `}}`, "more than 256 entries"},
187 {"too many excludes", `{"matrix": {"a": [1], "exclude": ` + entries(257) + `}}`, "more than 256 entries"},
188 {"all excluded", `{"matrix": {"a": [1], "exclude": [{"a": 1}]}}`, "all of them are excluded"},
189 {"fail-fast", `{"fail-fast": "maybe"}`, "fail-fast"},
190 {"max-parallel", `{"max-parallel": 0}`, "max-parallel"},
191 {"max-parallel fraction", `{"max-parallel": 1.5}`, "max-parallel"},
192 }
193 for _, tc := range tests {
194 t.Run(tc.name, func(t *testing.T) {
195 _, err := ExpandMatrix(ctxbg, jsonValue(t, tc.strategy))
196 if err == nil || !strings.Contains(err.Error(), tc.want) {
197 t.Errorf("err = %v, want it to contain %q", err, tc.want)
198 }
199 })
200 }
201}
202
203func TestMatrixLimit(t *testing.T) {
204 vec := func(n int) string {
205 var items []string
206 for i := 0; i < n; i++ {
207 items = append(items, fmt.Sprint(i))
208 }
209 return "[" + strings.Join(items, ",") + "]"
210 }
211 if c, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(16)+`, "b": `+vec(16)+`}}`)); err != nil || len(c) != 256 {
212 t.Errorf("256 jobs: %d, %v", len(c), err)
213 }
214 _, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(16)+`, "b": `+vec(16)+`, "include": [{"a": 99}]}}`))
215 if err == nil || !strings.Contains(err.Error(), "Strategy produced 257 configurations which exceeds the maximum of 256 configurations") {
216 t.Errorf("257 jobs: %v", err)
217 }
218 _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(17)+`, "b": `+vec(16)+`}}`))
219 if err == nil {
220 t.Error("272 jobs: no error")
221 }
222 // Excluding brings it back under the limit.
223 if c, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(17)+`, "b": `+vec(16)+`, "exclude": [{"a": 16}]}}`)); err != nil || len(c) != 256 {
224 t.Errorf("272 minus 16: %d, %v", len(c), err)
225 }
226 // A huge product fails fast.
227 _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(200)+`, "b": `+vec(200)+`, "c": `+vec(200)+`, "d": `+vec(200)+`}}`))
228 if err == nil {
229 t.Error("huge product: no error")
230 }
231}
232
233func TestStrategyOptions(t *testing.T) {
234 s, err := ParseStrategy(ctxbg, jsonValue(t, `{"fail-fast": "false", "max-parallel": 2, "matrix": {"a": [1, 2, 3]}}`))
235 if err != nil {
236 t.Fatal(err)
237 }
238 if s.FailFast || s.MaxParallel != 2 || !s.HasMatrix || len(s.Combos) != 3 {
239 t.Errorf("%+v", s)
240 }
241 d := s.Context(1)
242 if got := expr.ToJSON(d); got != "{\n \"fail-fast\": false,\n \"job-index\": 1,\n \"job-total\": 3,\n \"max-parallel\": 2\n}" {
243 t.Errorf("context = %s", got)
244 }
245 s, err = ParseStrategy(ctxbg, nil)
246 if err != nil || !s.FailFast || s.MaxParallel != 0 || s.HasMatrix || len(s.Combos) != 1 {
247 t.Errorf("default: %+v, %v", s, err)
248 }
249 if got := expr.ToJSON(s.Context(0)); !strings.Contains(got, `"max-parallel": 1`) {
250 t.Errorf("default context = %s", got)
251 }
252}
253
254func jsonValue2(s string) expr.Value {
255 v, err := expr.FromJSON([]byte(s))
256 if err != nil {
257 return nil
258 }
259 return v
260}
261
262func entries(n int) string {
263 var items []string
264 for i := 0; i < n; i++ {
265 items = append(items, `{"a": 1}`)
266 }
267 return "[" + strings.Join(items, ",") + "]"
268}
269
270func TestMatrixEntryLimitsAtTheEdge(t *testing.T) {
271 if _, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": [1], "exclude": `+strings.ReplaceAll(entries(256), `"a": 1`, `"a": 9`)+`}}`)); err != nil {
272 t.Errorf("256 excludes: %v", err)
273 }
274 if c, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": [1], "include": `+entries(256)+`}}`)); err != nil || len(c) != 1 {
275 t.Errorf("256 includes: %d, %v", len(c), err)
276 }
277}
278
279// TestHostileMatrixFailsFast is the probe of the review: many vectors whose
280// product is huge and whose exclusions hide the 256-job limit used to run for
281// close to a minute.
282func TestHostileMatrixFailsFast(t *testing.T) {
283 vec := func(n int) string {
284 var items []string
285 for i := 0; i < n; i++ {
286 items = append(items, fmt.Sprint(i))
287 }
288 return "[" + strings.Join(items, ",") + "]"
289 }
290 src := `{"matrix": {"a": ` + vec(100) + `, "b": ` + vec(100) + `, "c": ` + vec(100) + `, "exclude": ` + strings.ReplaceAll(entries(256), `"a": 1`, `"c": 5`) + `}}`
291 start := time.Now()
292 _, err := ExpandMatrix(ctxbg, jsonValue(t, src))
293 if err == nil || !strings.Contains(err.Error(), "matrix too large") {
294 t.Errorf("err = %v", err)
295 }
296 if d := time.Since(start); d > time.Second {
297 t.Errorf("took %v", d)
298 }
299 // Just under the product cap, with nothing to stop the loop early, ends.
300 var parts []string
301 for _, k := range []string{"a", "b", "c", "d"} {
302 parts = append(parts, `"`+k+`": `+vec(16))
303 }
304 start = time.Now()
305 _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {`+strings.Join(parts, ",")+`, "exclude": [{"a": 0}, {"a": 1}, {"a": 2}, {"a": 3}, {"a": 4}, {"a": 5}, {"a": 6}, {"a": 7}, {"a": 8}, {"a": 9}, {"a": 10}, {"a": 11}, {"a": 12}, {"a": 13}, {"a": 14}]}}`))
306 if err == nil {
307 t.Error("65536 combinations of which 4096 remain: no error")
308 }
309 if d := time.Since(start); d > 2*time.Second {
310 t.Errorf("took %v", d)
311 }
312}
313
314func TestMatrixProductCap(t *testing.T) {
315 vec := func(n int) string {
316 var items []string
317 for i := 0; i < n; i++ {
318 items = append(items, fmt.Sprint(i))
319 }
320 return "[" + strings.Join(items, ",") + "]"
321 }
322 // 2^16 is allowed by the cap (and fails on the job limit), 2^16+1 is refused outright.
323 _, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(MaxMatrixProduct/2)+`, "b": `+vec(2)+`, "c": `+vec(1)+`}}`))
324 var limit *configurationLimitError
325 if !errors.As(err, &limit) || limit.Count != MaxMatrixProduct {
326 t.Errorf("err = %v", err)
327 }
328 if !errors.Is(err, ErrTooManyConfigurations) {
329 t.Errorf("errors.Is(%v, ErrTooManyConfigurations) = false", err)
330 }
331 _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": `+vec(MaxMatrixProduct+1)+`}}`))
332 if err == nil || !strings.Contains(err.Error(), "matrix too large") {
333 t.Errorf("one huge vector: %v", err)
334 }
335}
336
337func TestMatrixContextIsHonoured(t *testing.T) {
338 ctx, cancel := context.WithCancel(context.Background())
339 cancel()
340 // 3000 combinations, nearly all excluded, so the job limit does not stop the loop.
341 var items []string
342 for i := 0; i < 3000; i++ {
343 items = append(items, fmt.Sprint(i))
344 }
345 _, err := ExpandMatrix(ctx, jsonValue(t, `{"matrix": {"a": [`+strings.Join(items, ",")+`], "exclude": [{"a": 1}]}}`))
346 if !errors.Is(err, context.Canceled) {
347 t.Errorf("err = %v", err)
348 }
349 _, err = ParseStrategy(ctx, jsonValue(t, `{"matrix": {"a": [`+strings.Join(items, ",")+`]}}`))
350 if !errors.Is(err, context.Canceled) {
351 t.Errorf("ParseStrategy err = %v", err)
352 }
353}
354
355// TestRecordedMatrixNames follows the job names GitHub gave in the scenarios
356// matrix-edge-values (b-null-values.yml) and matrix-job-name.
357func TestRecordedMatrixNames(t *testing.T) {
358 names := func(matrix string) []string {
359 t.Helper()
360 combos, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": `+matrix+`}`))
361 if err != nil {
362 t.Fatal(err)
363 }
364 var out []string
365 for _, c := range combos {
366 out = append(out, c.DisplayName("default-name"))
367 }
368 return out
369 }
370 // v: [1, null, '', true, 1.50, 'x y'] gave default-name, default-name,
371 // default-name (1), default-name (1.5), default-name (true) and
372 // default-name (x y): null and '' add nothing, 1.50 is 1.5.
373 want := []string{"default-name (1)", "default-name", "default-name", "default-name (true)", "default-name (1.5)", "default-name (x y)"}
374 if got := names(`{"v": [1, null, "", true, 1.50, "x y"]}`); !reflect.DeepEqual(got, want) {
375 t.Errorf("one axis: got %q, want %q", got, want)
376 }
377 // a: [x, null], b: ['', y] gave "two-axes (x)", "two-axes (x, y)",
378 // "two-axes (y)" and "two-axes".
379 want = []string{"default-name (x)", "default-name (x, y)", "default-name", "default-name (y)"}
380 if got := names(`{"a": ["x", null], "b": ["", "y"]}`); !reflect.DeepEqual(got, want) {
381 t.Errorf("two axes: got %q, want %q", got, want)
382 }
383 // matrix-job-name: os [a, b] x n [1, 2] gave "default-name (a, 1)" ...
384 want = []string{"default-name (a, 1)", "default-name (a, 2)", "default-name (b, 1)", "default-name (b, 2)"}
385 if got := names(`{"os": ["a", "b"], "n": [1, 2]}`); !reflect.DeepEqual(got, want) {
386 t.Errorf("matrix-job-name: got %q, want %q", got, want)
387 }
388 // Objects are not listed. Assumed, not recorded: matrix-value-types has
389 // custom names only.
390 if got := names(`{"n": [1], "obj": [{"k": "v"}]}`); !reflect.DeepEqual(got, []string{"default-name (1)"}) {
391 t.Errorf("object value: got %q", got)
392 }
393}
394
395// TestRecordedExcludeUnknownKey is matrix-edge-values a-exclude-unknown-key.yml:
396// exclude: [{nokey: x}, {os: one, nokey: y}] made the workflow invalid. The
397// recording holds only the failed run; the message, once per offending entry,
398// was observed on the github.com run page.
399func TestRecordedExcludeUnknownKey(t *testing.T) {
400 _, err := ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"os": ["one", "two"], "exclude": [{"nokey": "x"}, {"os": "one", "nokey": "y"}]}}`))
401 const one = "Matrix exclude key 'nokey' does not match any key within the matrix"
402 if err == nil || err.Error() != one+"\n"+one {
403 t.Fatalf("err = %v, want the message once per offending entry", err)
404 }
405 if !errors.Is(err, ErrExcludeUnknownKey) {
406 t.Errorf("errors.Is(%v, ErrExcludeUnknownKey) = false", err)
407 }
408 if errors.Is(err, ErrTooManyConfigurations) {
409 t.Errorf("errors.Is(%v, ErrTooManyConfigurations) = true", err)
410 }
411 // An entry that is fine does not add one.
412 _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"os": ["one", "two"], "exclude": [{"os": "one"}, {"nokey": "x"}]}}`))
413 if err == nil || err.Error() != one {
414 t.Errorf("err = %v, want %q", err, one)
415 }
416 if !errors.Is(err, ErrExcludeUnknownKey) {
417 t.Errorf("errors.Is(%v, ErrExcludeUnknownKey) = false", err)
418 }
419}
420
421// TestExcludeUnknownKeySentinelFromJob checks that the sentinel survives
422// Job.EvaluateStrategy, which is what a server calls, on the workflow of the
423// recorded scenario (matrix-edge-values, a-exclude-unknown-key.yml). The
424// workflow parses: GitHub still refuses it, and names the run after the file.
425func TestExcludeUnknownKeySentinelFromJob(t *testing.T) {
426 w, errs := Parse("a.yml", []byte(`name: a-exclude-unknown-key
427on:
428 push:
429 branches: ['scn/**']
430jobs:
431 main:
432 runs-on: ubuntu-24.04
433 strategy:
434 matrix:
435 os: [one, two]
436 exclude:
437 - nokey: x
438 - os: one
439 nokey: y
440 steps:
441 - run: echo "os=${{ matrix.os }}"
442`))
443 if len(errs) > 0 {
444 t.Fatal(errs)
445 }
446 _, err := w.Job("main").EvaluateStrategy(ctxbg, template.Env{})
447 if !errors.Is(err, ErrExcludeUnknownKey) {
448 t.Fatalf("EvaluateStrategy err = %v, want ErrExcludeUnknownKey", err)
449 }
450 // A key that only an include adds is no vector either.
451 _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": [1], "include": [{"b": 1}], "exclude": [{"b": 1}]}}`))
452 if !errors.Is(err, ErrExcludeUnknownKey) {
453 t.Errorf("exclude of an include-only key: %v", err)
454 }
455 // Other matrix errors do not match it.
456 _, err = ExpandMatrix(ctxbg, jsonValue(t, `{"matrix": {"a": [1], "exclude": [{"a": 1}]}}`))
457 if err == nil || errors.Is(err, ErrExcludeUnknownKey) {
458 t.Errorf("all excluded: %v", err)
459 }
460}
461
462// TestRecordedNullAndEmptyJobIndex is matrix-edge-values b-null-values.yml,
463// v: [1, null, "", true, 1.50, "x y"]. The recording has strategy.job-index 1
464// for matrix.v null and 2 for matrix.v "" (jobs 0..5 in the order of the
465// vector), which is what this package produces. conformance compare listed the
466// two as swapped only because both legs are named "default-name" and it pairs
467// equal names by their position in each source's job listing, which GitHub
468// does not give in matrix order. So the expansion order is the recorded one and
469// this test pins it.
470func TestRecordedNullAndEmptyJobIndex(t *testing.T) {
471 s, err := ParseStrategy(ctxbg, jsonValue(t, `{"matrix": {"v": [1, null, "", true, 1.50, "x y"]}}`))
472 if err != nil {
473 t.Fatal(err)
474 }
475 want := []string{`1`, `null`, `""`, `true`, `1.5`, `"x y"`}
476 if len(s.Combos) != len(want) {
477 t.Fatalf("%d combinations, want %d", len(s.Combos), len(want))
478 }
479 for i, c := range s.Combos {
480 v, _ := c.Values.Get("v")
481 if got := expr.ToJSON(v); got != want[i] {
482 t.Errorf("combination %d: matrix.v = %s, want %s", i, got, want[i])
483 }
484 idx, _ := s.Context(i).Get("job-index")
485 if idx != expr.Number(i) {
486 t.Errorf("combination %d: strategy.job-index = %v", i, expr.ToJSON(idx))
487 }
488 }
489}
490
491// TestRecordedMatrixOver256 is the scenario matrix-over-256: 17 x 16 = 272
492// configurations in job too-many made the workflow invalid. The recording
493// holds only the failed run; the message was observed on the github.com run
494// page.
495func TestRecordedMatrixOver256(t *testing.T) {
496 w, errs := Parse("ci.yml", []byte(`on: push
497jobs:
498 too-many:
499 runs-on: x
500 strategy:
501 matrix:
502 a: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]
503 b: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16]
504 steps: [{run: a}]
505`))
506 if len(errs) > 0 {
507 t.Fatal(errs)
508 }
509 _, err := w.Job("too-many").EvaluateStrategy(ctxbg, template.Env{})
510 const want = "Strategy for job 'too-many' produced 272 configurations which exceeds the maximum of 256 configurations"
511 if err == nil || err.Error() != want {
512 t.Errorf("err = %v, want %q", err, want)
513 }
514 var limit *configurationLimitError
515 if !errors.As(err, &limit) || limit.Job != "too-many" || limit.Count != 272 {
516 t.Errorf("err = %#v", err)
517 }
518 if !errors.Is(err, ErrTooManyConfigurations) {
519 t.Errorf("errors.Is(%v, ErrTooManyConfigurations) = false", err)
520 }
521}
522
523func TestMatrixEnvBounds(t *testing.T) {
524 s, _ := ParseStrategy(ctxbg, jsonValue(t, `{"matrix": {"a": [1, 2]}}`))
525 for _, i := range []int{-1, 2, 99} {
526 if _, err := s.MatrixEnv(template.Env{}, i); err == nil {
527 t.Errorf("MatrixEnv(%d): no error", i)
528 }
529 }
530 j := &Job{Name: template.NewString(template.Origin{}, "n")}
531 if _, err := j.DisplayName(ctxbg, template.Env{}, s, 5); err == nil {
532 t.Error("DisplayName out of range: no error")
533 }
534}