1// SPDX-License-Identifier: MIT
2
3package template
4
5import (
6 "context"
7 "errors"
8 "strings"
9 "testing"
10
11 "bnema.dev/ermite-actions/expr"
12)
13
14func testEnv(t testing.TB) Env {
15 t.Helper()
16 gh, err := expr.FromJSON([]byte(`{"ref": "refs/heads/main", "n": 3, "flag": true}`))
17 if err != nil {
18 t.Fatal(err)
19 }
20 m, _ := expr.FromJSON([]byte(`{"os": "linux", "extra": {"a": "1", "b": "2"}}`))
21 return Env{Contexts: map[string]expr.Value{"github": gh, "matrix": m}}
22}
23
24func stepOf(t testing.TB, src string) *Mapping {
25 t.Helper()
26 tok, errs := readWorkflow(t, head+src)
27 if len(errs) > 0 {
28 t.Fatalf("read: %q", errs)
29 }
30 return tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping).Find("steps").(*Sequence).Items[0].(*Mapping)
31}
32
33func TestEvaluateTokens(t *testing.T) {
34 step := stepOf(t, " - name: n-${{ github.ref }}-${{ matrix.os }}\n run: echo\n env:\n A: ${{ github.n }}\n B: ${{ github.flag }}\n C: x\n")
35 env := testEnv(t)
36 ctx := context.Background()
37 name, err := EvaluateString(ctx, step.Find("name"), env)
38 if err != nil || name != "n-refs/heads/main-linux" {
39 t.Errorf("name = %q, %v", name, err)
40 }
41 v, err := Evaluate(ctx, step.Find("env"), env)
42 if err != nil {
43 t.Fatal(err)
44 }
45 if got := expr.ToJSON(v); got != "{\n \"A\": 3,\n \"B\": true,\n \"C\": \"x\"\n}" {
46 t.Errorf("env = %s", got)
47 }
48 s, err := EvaluateString(ctx, step.Find("env").(*Mapping).Find("A"), env)
49 if err != nil || s != "3" {
50 t.Errorf("A = %q, %v", s, err)
51 }
52}
53
54func TestInsertAndExpressionKeys(t *testing.T) {
55 tok, errs := readWorkflow(t, head+" - run: x\n env:\n ${{ insert }}: ${{ matrix.extra }}\n ${{ github.ref }}: v\n K: w\n")
56 if len(errs) > 0 {
57 t.Fatal(errs)
58 }
59 env := tok.(*Mapping).Find("jobs").(*Mapping).Find("a").(*Mapping).Find("steps").(*Sequence).Items[0].(*Mapping).Find("env")
60 v, err := Evaluate(context.Background(), env, testEnv(t))
61 if err != nil {
62 t.Fatal(err)
63 }
64 if got := strings.ReplaceAll(strings.ReplaceAll(expr.ToJSON(v), "\n", ""), " ", ""); got != `{"a": "1","b": "2","refs/heads/main": "v","K": "w"}` {
65 t.Errorf("env = %s", got)
66 }
67 // insert needs a mapping.
68 bad := NewMapping(Origin{})
69 bad.Add(&InsertExpression{}, NewString(Origin{}, "x"))
70 if _, err := Evaluate(context.Background(), bad, testEnv(t)); err == nil || !strings.Contains(err.Error(), "insert") {
71 t.Errorf("err = %v", err)
72 }
73}
74
75func TestEvaluateTypedHelpers(t *testing.T) {
76 ctx := context.Background()
77 env := testEnv(t)
78 ex := func(s string) *BasicExpression {
79 return NewExpression(Origin{File: "f.yml", Pos: Pos{4, 2}}, s, mustInfo(t, "step-env"))
80 }
81 if b, err := EvaluateBool(ctx, ex("github.flag"), env); err != nil || !b {
82 t.Errorf("bool = %v, %v", b, err)
83 }
84 if b, err := EvaluateBool(ctx, ex("'FALSE'"), env); err != nil || b {
85 t.Errorf("bool string = %v, %v", b, err)
86 }
87 if _, err := EvaluateBool(ctx, ex("github.n"), env); err == nil || !strings.Contains(err.Error(), "f.yml (Line: 4, Col: 2): Unexpected type 'NumberToken'") {
88 t.Errorf("bool number: %v", err)
89 }
90 if n, err := EvaluateNumber(ctx, ex("github.n"), env); err != nil || n != 3 {
91 t.Errorf("number = %v, %v", n, err)
92 }
93 if n, err := EvaluateNumber(ctx, ex("'12'"), env); err != nil || n != 12 {
94 t.Errorf("number string = %v, %v", n, err)
95 }
96 if _, err := EvaluateNumber(ctx, ex("'x'"), env); err == nil {
97 t.Error("number x: no error")
98 }
99 if _, err := EvaluateString(ctx, ex("matrix.extra"), env); err == nil || !strings.Contains(err.Error(), "MappingToken") {
100 t.Errorf("string of mapping: %v", err)
101 }
102 if s, err := EvaluateString(ctx, ex("null"), env); err != nil || s != "" {
103 t.Errorf("string of null = %q, %v", s, err)
104 }
105}
106
107func mustInfo(t testing.TB, name string) *DefinitionInfo {
108 t.Helper()
109 s, err := WorkflowSchema()
110 if err != nil {
111 t.Fatal(err)
112 }
113 return s.Info(name)
114}
115
116func TestExpressionErrors(t *testing.T) {
117 ctx := context.Background()
118 bad := NewExpression(Origin{File: "f.yml", Pos: Pos{1, 1}}, "github.", mustInfo(t, "step-env"))
119 if bad.Err() == nil {
120 t.Fatal("parse error expected")
121 }
122 _, err := Evaluate(ctx, bad, testEnv(t))
123 var te *Error
124 if !errors.As(err, &te) || !strings.HasPrefix(err.Error(), "f.yml (Line: 1, Col: 1): ") {
125 t.Errorf("err = %v", err)
126 }
127 // Not allowed context: secrets in job-if.
128 e := NewExpression(Origin{}, "secrets.x", mustInfo(t, "job-if"))
129 if e.Err() == nil || !strings.Contains(e.Err().Error(), "Unrecognized named-value: 'secrets'") {
130 t.Errorf("err = %v", e.Err())
131 }
132 // Evaluation errors wrap the expr error.
133 ev := NewExpression(Origin{}, "fromJSON('x')", mustInfo(t, "step-env"))
134 _, err = Evaluate(ctx, ev, testEnv(t))
135 var ee *expr.EvalError
136 if !errors.As(err, &ee) {
137 t.Errorf("err = %v (%T)", err, err)
138 }
139 // Cancellation.
140 cctx, cancel := context.WithCancel(ctx)
141 cancel()
142 if _, err := Evaluate(cctx, NewExpression(Origin{}, "github.ref", mustInfo(t, "step-env")), testEnv(t)); err == nil {
143 t.Error("cancelled: no error")
144 }
145 // Stray insert.
146 if _, err := Evaluate(ctx, &InsertExpression{}, testEnv(t)); err == nil {
147 t.Error("insert alone: no error")
148 }
149}
150
151func TestEvaluateDepthLimit(t *testing.T) {
152 var tok Token = NewString(Origin{}, "x")
153 for i := 0; i < MaxEvalDepth+5; i++ {
154 tok = NewSequence(Origin{}, tok)
155 }
156 if _, err := Evaluate(context.Background(), tok, Env{}); err == nil {
157 t.Error("deep token: no error")
158 }
159}
160
161func TestResultBudget(t *testing.T) {
162 text := "'xxxxxxxxxxxxxxxx'"
163 for i := 0; i < 7; i++ {
164 text = "format('{0}{0}{0}{0}', " + text + ")"
165 }
166 e := NewExpression(Origin{}, text, mustInfo(t, "step-env"))
167 if e.Err() != nil {
168 t.Fatal(e.Err())
169 }
170 env := testEnv(t)
171 env.MaxResultBytes = 1000
172 _, err := Evaluate(context.Background(), e, env)
173 var ee *expr.EvalError
174 if !errors.As(err, &ee) {
175 t.Fatalf("err = %v", err)
176 }
177 env.MaxResultBytes = -1
178 if _, err := Evaluate(context.Background(), e, env); err != nil {
179 t.Errorf("no limit: %v", err)
180 }
181}
182
183func TestMatchDirective(t *testing.T) {
184 tests := []struct {
185 in string
186 match bool
187 wantErr string
188 }{
189 {"insert", true, ""},
190 {"insert ", true, ""},
191 {"inserted", false, ""},
192 {"insert x", false, "Exactly 0 parameter(s) were expected following the directive 'insert'. Actual parameter count: 1"},
193 {"insert x y", false, "Actual parameter count: 1"},
194 {"github.ref", false, ""},
195 }
196 for _, tc := range tests {
197 got, err := matchDirective(tc.in, "insert", 0)
198 if got != tc.match || (tc.wantErr == "") != (err == nil) || (err != nil && !strings.Contains(err.Error(), tc.wantErr)) {
199 t.Errorf("matchDirective(%q) = %v, %v", tc.in, got, err)
200 }
201 }
202}
203
204func TestJSNumberString(t *testing.T) {
205 for in, want := range map[float64]string{
206 0: "0", 1: "1", -1: "-1", 1.5: "1.5", 100: "100", 1e21: "1e+21", 1e-7: "1e-7", 123456789012: "123456789012", 0.000001: "0.000001",
207 } {
208 if got := JSNumberString(in); got != want {
209 t.Errorf("JSNumberString(%v) = %s, want %s", in, got, want)
210 }
211 }
212}
213
214func TestUsesContext(t *testing.T) {
215 info := mustInfo(t, "step-env")
216 tests := []struct {
217 expr, ctx string
218 want bool
219 }{
220 {"matrix.os", "matrix", true},
221 {"MATRIX.os", "matrix", true},
222 {"github.ref == 'x' && matrix.os", "matrix", true},
223 {"format('{0}', fromJSON(matrix.a))", "matrix", true},
224 {"!(matrix.a)", "matrix", true},
225 {"github.event.matrix", "matrix", false},
226 {"github['matrix']", "matrix", false},
227 {"github[matrix.k]", "matrix", true},
228 {"'matrix.os'", "matrix", false},
229 {"contains(github.ref, 'matrix.')", "matrix", false},
230 {"matrix", "matrix", true},
231 {"env.matrix", "matrix", false},
232 }
233 for _, tc := range tests {
234 e := NewExpression(Origin{}, tc.expr, info)
235 if e.Err() != nil {
236 t.Errorf("%s: %v", tc.expr, e.Err())
237 continue
238 }
239 if got := e.UsesContext(tc.ctx); got != tc.want {
240 t.Errorf("UsesContext(%q, %q) = %v, want %v", tc.expr, tc.ctx, got, tc.want)
241 }
242 }
243 if (&BasicExpression{}).UsesContext("matrix") {
244 t.Error("empty expression uses a context")
245 }
246}