main 98594d0

matrix_test.go RawHistoryFolder

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}

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