1// SPDX-License-Identifier: MIT
2
3package action
4
5import (
6 "encoding/json"
7 "fmt"
8 "os"
9 "path/filepath"
10 "regexp"
11 "strings"
12 "testing"
13
14 "bnema.dev/ermite-actions/template"
15)
16
17// testdata/runner.json is what the C# runner's own loader answers on the files
18// of testdata/cases (see gen/runner): the errors it reports and a summary of
19// what it loaded. TestAgainstRunner replays it.
20
21type runnerAnswer struct {
22 Name string `json:"name"`
23 Description string `json:"description"`
24 Errors []string `json:"errors"`
25 Inputs []struct {
26 Name string `json:"name"`
27 Default *string `json:"default"`
28 } `json:"inputs"`
29 Deprecated map[string]string `json:"deprecated"`
30 Execution *struct {
31 Type string `json:"type"`
32 Using string `json:"using"`
33 Image string `json:"image"`
34 Args []string `json:"args"`
35 Entrypoint *string `json:"entrypoint"`
36 Env [][2]string `json:"env"`
37 Main string `json:"main"`
38 Pre *string `json:"pre"`
39 PreIf string `json:"preIf"`
40 Post *string `json:"post"`
41 PostIf string `json:"postIf"`
42 Plugin string `json:"plugin"`
43 Steps []struct {
44 ID string `json:"id"`
45 If string `json:"if"`
46 Kind string `json:"kind"`
47 Name *string `json:"name"`
48 ContinueOnError *string `json:"continueOnError"`
49 Timeout *string `json:"timeout"`
50 Env [][2]string `json:"env"`
51 EnvExpr *string `json:"envExpr"`
52 WithExpr *string `json:"withExpr"`
53 Script *string `json:"script"`
54 Shell *string `json:"shell"`
55 WorkingDir *string `json:"workingDirectory"`
56 With [][2]string `json:"with"`
57 Reference *struct {
58 Type string `json:"type"`
59 Image string `json:"image"`
60 Name *string `json:"name"`
61 Ref *string `json:"ref"`
62 Path *string `json:"path"`
63 } `json:"reference"`
64 } `json:"steps"`
65 Outputs [][2]*string `json:"outputs"`
66 } `json:"execution"`
67}
68
69func loadRunner(t *testing.T) map[string]runnerAnswer {
70 t.Helper()
71 data, err := os.ReadFile("testdata/runner.json")
72 if err != nil {
73 t.Fatal(err)
74 }
75 var m map[string]runnerAnswer
76 if err := json.Unmarshal(data, &m); err != nil {
77 t.Fatal(err)
78 }
79 return m
80}
81
82func errTexts(errs []Error) []string {
83 out := make([]string, len(errs))
84 for i := range errs {
85 out[i] = errs[i].Error()
86 }
87 return out
88}
89
90func orEmpty(s *string) string {
91 if s == nil {
92 return ""
93 }
94 return *s
95}
96
97func pairsOf(m *template.Mapping) [][2]string {
98 if m == nil {
99 return nil
100 }
101 var out [][2]string
102 for _, p := range m.Pairs {
103 out = append(out, [2]string{p.Key.String(), p.Value.String()})
104 }
105 return out
106}
107
108func TestAgainstRunner(t *testing.T) {
109 want := loadRunner(t)
110 files, err := filepath.Glob("testdata/cases/*.yml")
111 if err != nil || len(files) == 0 {
112 t.Fatalf("no cases: %v", err)
113 }
114 if len(files) != len(want) {
115 t.Errorf("%d case files, %d answers: run gen/runner/build.sh", len(files), len(want))
116 }
117 for _, f := range files {
118 name := filepath.Base(f)
119 w, ok := want[name]
120 if !ok {
121 t.Errorf("%s: no answer in runner.json", name)
122 continue
123 }
124 t.Run(strings.TrimSuffix(name, ".yml"), func(t *testing.T) {
125 data, err := os.ReadFile(f)
126 if err != nil {
127 t.Fatal(err)
128 }
129 a, errs := Parse(name, data)
130 got := errTexts(errs)
131 if mismatch := compareErrors(got, w.Errors); mismatch != "" {
132 t.Fatalf("errors differ: %s\n got: %q\nwant: %q", mismatch, got, w.Errors)
133 }
134 if len(w.Errors) > 0 {
135 if a != nil {
136 t.Error("action returned with errors")
137 }
138 return
139 }
140 if a == nil {
141 t.Fatal("no action and no errors")
142 }
143 compareAction(t, a, w)
144 })
145 }
146}
147
148// compareErrors compares messages with the runner's, which are recorded
149// without their stack traces. A YAML syntax error has YamlDotNet's wording in
150// the runner and yaml.v3's here: only the shape is compared.
151// yamlDotNetError matches the runner's YAML syntax errors: "file.yml: (Line: 3,
152// Col: 1, Idx: 16) - (Line: 3, Col: 1, Idx: 16): While parsing a node, ...".
153var yamlDotNetError = regexp.MustCompile(`^[^ ]+: \(Line: \d+, Col: \d+, Idx: \d+\) - \(Line: \d+, Col: \d+, Idx: \d+\): `)
154
155func compareErrors(got, want []string) string {
156 if len(want) >= 1 && yamlDotNetError.MatchString(want[0]) {
157 if len(got) == 0 || !strings.HasPrefix(got[0], strings.SplitN(want[0], ":", 2)[0]+": ") {
158 return "syntax error not reported on the file"
159 }
160 // The runner follows a syntax error with the exception from the null
161 // root; compare the rest.
162 if len(got) != len(want) {
163 return fmt.Sprintf("%d errors, want %d", len(got), len(want))
164 }
165 for i := 1; i < len(got); i++ {
166 if got[i] != want[i] {
167 return fmt.Sprintf("error %d", i)
168 }
169 }
170 return ""
171 }
172 if len(got) != len(want) {
173 return fmt.Sprintf("%d errors, want %d", len(got), len(want))
174 }
175 for i := range got {
176 if got[i] != want[i] {
177 return fmt.Sprintf("error %d", i)
178 }
179 }
180 return ""
181}
182
183func compareAction(t *testing.T, a *Action, w runnerAnswer) {
184 t.Helper()
185 if a.Name != w.Name || a.Description != w.Description {
186 t.Errorf("name/description: got %q/%q, want %q/%q", a.Name, a.Description, w.Name, w.Description)
187 }
188 if len(a.Inputs) != len(w.Inputs) {
189 t.Fatalf("%d inputs, want %d", len(a.Inputs), len(w.Inputs))
190 }
191 for i, in := range w.Inputs {
192 g := a.Inputs[i]
193 if g.Name != in.Name || g.Default.String() != orEmpty(in.Default) {
194 t.Errorf("input %d: got %q default %q, want %q default %q", i, g.Name, g.Default.String(), in.Name, orEmpty(in.Default))
195 }
196 if dep := w.Deprecated[in.Name]; g.DeprecationMessage != dep {
197 t.Errorf("input %q: deprecation %q, want %q", in.Name, g.DeprecationMessage, dep)
198 }
199 }
200 e := w.Execution
201 r := a.Runs
202 if e == nil || r == nil {
203 t.Fatalf("execution: %v / %v", e, r)
204 }
205 kinds := map[string]RunsKind{"node": RunsNode, "docker": RunsDocker, "composite": RunsComposite, "plugin": RunsPlugin}
206 if r.Kind != kinds[e.Type] {
207 t.Fatalf("kind %v, want %s", r.Kind, e.Type)
208 }
209 switch r.Kind {
210 case RunsNode:
211 if r.Main != e.Main || r.Pre != orEmpty(e.Pre) || r.Post != orEmpty(e.Post) || r.PreIf != e.PreIf || r.PostIf != e.PostIf || !strings.EqualFold(r.Using, e.Using) {
212 t.Errorf("node: got %+v, want %+v", r, e)
213 }
214 case RunsDocker:
215 var args []string
216 if r.Args != nil {
217 for _, it := range r.Args.Items {
218 args = append(args, it.String())
219 }
220 }
221 if r.Image != e.Image || r.Entrypoint != orEmpty(e.Entrypoint) || r.PreEntrypoint != orEmpty(e.Pre) || r.PostEntrypoint != orEmpty(e.Post) || r.PreIf != e.PreIf || r.PostIf != e.PostIf {
222 t.Errorf("docker: got %+v, want %+v", r, e)
223 }
224 if fmt.Sprint(args) != fmt.Sprint(e.Args) {
225 t.Errorf("args: got %q, want %q", args, e.Args)
226 }
227 if fmt.Sprint(pairsOf(r.Env)) != fmt.Sprint(e.Env) {
228 t.Errorf("env: got %q, want %q", pairsOf(r.Env), e.Env)
229 }
230 case RunsPlugin:
231 if r.Plugin != e.Plugin {
232 t.Errorf("plugin: %q, want %q", r.Plugin, e.Plugin)
233 }
234 case RunsComposite:
235 if len(r.Steps) != len(e.Steps) {
236 t.Fatalf("%d steps, want %d", len(r.Steps), len(e.Steps))
237 }
238 for i, ws := range e.Steps {
239 s := r.Steps[i]
240 if s.ID != ws.ID || s.If.String() != "${{ "+ws.If+" }}" && s.If.Expression != ws.If {
241 t.Errorf("step %d: id %q if %q, want id %q if %q", i, s.ID, s.If.Expression, ws.ID, ws.If)
242 }
243 if tokStr(s.Name) != orEmpty(ws.Name) || tokStr(s.ContinueOnError) != orEmpty(ws.ContinueOnError) {
244 t.Errorf("step %d: name/continue-on-error %q/%q, want %q/%q", i, tokStr(s.Name), tokStr(s.ContinueOnError), orEmpty(ws.Name), orEmpty(ws.ContinueOnError))
245 }
246 if tokStr(s.EnvExpression) != orEmpty(ws.EnvExpr) || tokStr(s.WithExpression) != orEmpty(ws.WithExpr) {
247 t.Errorf("step %d env/with expression: got %q/%q, want %q/%q", i, tokStr(s.EnvExpression), tokStr(s.WithExpression), orEmpty(ws.EnvExpr), orEmpty(ws.WithExpr))
248 }
249 if fmt.Sprint(pairsOf(s.Env)) != fmt.Sprint(nilIfEmpty(ws.Env)) {
250 t.Errorf("step %d env: got %q, want %q", i, pairsOf(s.Env), ws.Env)
251 }
252 switch ws.Kind {
253 case "run":
254 if s.Run == nil || tokStr(s.Run) != orEmpty(ws.Script) || tokStr(s.Shell) != orEmpty(ws.Shell) || tokStr(s.WorkingDirectory) != orEmpty(ws.WorkingDir) {
255 t.Errorf("step %d run: got %q/%q/%q, want %+v", i, tokStr(s.Run), tokStr(s.Shell), tokStr(s.WorkingDirectory), ws)
256 }
257 case "uses":
258 if s.Uses == nil || fmt.Sprint(pairsOf(s.With)) != fmt.Sprint(nilIfEmpty(ws.With)) {
259 t.Errorf("step %d uses: got %v with %q, want %+v", i, s.Uses, pairsOf(s.With), ws)
260 }
261 u, err := ParseUses(s.Uses.Value)
262 if err != nil {
263 t.Fatalf("step %d: %v", i, err)
264 }
265 checkReference(t, i, u, ws.Reference)
266 }
267 }
268 if len(a.Outputs) != len(e.Outputs) {
269 t.Fatalf("%d outputs, want %d", len(a.Outputs), len(e.Outputs))
270 }
271 for i, o := range e.Outputs {
272 if a.Outputs[i].Name != *o[0] || tokStr(a.Outputs[i].Value) != orEmpty(o[1]) {
273 t.Errorf("output %d: got %q=%q, want %v", i, a.Outputs[i].Name, tokStr(a.Outputs[i].Value), o)
274 }
275 }
276 }
277}
278
279func nilIfEmpty(p [][2]string) [][2]string {
280 if len(p) == 0 {
281 return nil
282 }
283 return p
284}
285
286func tokStr(t template.Token) string {
287 if t == nil {
288 return ""
289 }
290 return t.String()
291}
292
293func checkReference(t *testing.T, i int, u Uses, ref *struct {
294 Type string `json:"type"`
295 Image string `json:"image"`
296 Name *string `json:"name"`
297 Ref *string `json:"ref"`
298 Path *string `json:"path"`
299}) {
300 t.Helper()
301 if ref == nil {
302 t.Errorf("step %d: no reference in the answer", i)
303 return
304 }
305 switch u.Kind {
306 case UsesDocker:
307 if ref.Type != "docker" || ref.Image != u.Image {
308 t.Errorf("step %d: %+v, want %+v", i, u, ref)
309 }
310 case UsesLocal:
311 if ref.Type != "self" || orEmpty(ref.Path) != u.Path {
312 t.Errorf("step %d: %+v, want %+v", i, u, ref)
313 }
314 case UsesSelfRepository:
315 if ref.Type != "selfRepository" || orEmpty(ref.Path) != u.Path {
316 t.Errorf("step %d: %+v, want %+v", i, u, ref)
317 }
318 case UsesRepository:
319 if ref.Type != "GitHub" || orEmpty(ref.Name) != u.Name || orEmpty(ref.Ref) != u.Ref || orEmpty(ref.Path) != u.Path {
320 t.Errorf("step %d: %+v, want %+v", i, u, ref)
321 }
322 }
323}