1// SPDX-License-Identifier: MIT
2
3package workflow
4
5import (
6 "encoding/json"
7 "os"
8 "path/filepath"
9 "reflect"
10 "regexp"
11 "sort"
12 "strings"
13 "testing"
14)
15
16// refResult is what testdata/convert.json records for one file: the output of
17// @actions/workflow-parser 0.3.61 (see gen/convert.mjs).
18type refResult struct {
19 Errors []string `json:"errors"`
20 Jobs []struct {
21 ID string `json:"id"`
22 Name string `json:"name"`
23 If string `json:"if"`
24 Needs []string `json:"needs"`
25 Steps []struct {
26 ID string `json:"id"`
27 If string `json:"if"`
28 } `json:"steps"`
29 } `json:"jobs"`
30 Events map[string]any `json:"events"`
31}
32
33func loadRef(t *testing.T) map[string]refResult {
34 t.Helper()
35 data, err := os.ReadFile("testdata/convert.json")
36 if err != nil {
37 t.Fatal(err)
38 }
39 var m map[string]refResult
40 if err := json.Unmarshal(data, &m); err != nil {
41 t.Fatal(err)
42 }
43 return m
44}
45
46func errorTexts(errs []Error) []string {
47 out := make([]string, len(errs))
48 for i := range errs {
49 out[i] = errs[i].Error()
50 }
51 return out
52}
53
54// The reference (@actions/workflow-parser) words expression parse errors like
55// the JavaScript expression library: no position suffix, and the function name
56// in arity errors. package expr follows the C# runner, which is what runs the
57// workflow: "<description>: '<token>'. Located at position N within
58// expression: <expression>", with the "(" token in arity errors. Only the
59// reference's side is rewritten (sameErrorAsReference); what this package reports
60// is compared as it is, and must carry the runner's suffix.
61var (
62 runnerArity = regexp.MustCompile(`((?:Too few|Too many) parameters supplied|Even number of parameters supplied, requires an odd number of parameters): '[^']*'`)
63 // exprError recognises the messages of package expr that the runner ends
64 // with its position suffix.
65 exprError = regexp.MustCompile(`: (?:Unrecognized named-value|Unrecognized function|Unexpected symbol|Unexpected end of expression|Too few parameters supplied|Too many parameters supplied|Even number of parameters supplied, requires an odd number of parameters): '`)
66 runnerSuffix = regexp.MustCompile(`^\. Located at position \d+ within expression: .+$`)
67)
68
69// sameErrorAsReference reports whether got, the message of this package, is the
70// reference's want in the runner's wording: the reference's arity errors name
71// the function where the runner names "(", and its expression errors lack the
72// position suffix that the runner adds (when the reference has it already, as
73// for lexer errors, the message must be the same).
74func sameErrorAsReference(got, want string) bool {
75 want = runnerArity.ReplaceAllString(want, "$1: '('")
76 if !exprError.MatchString(want) {
77 return got == want
78 }
79 if strings.Contains(want, ". Located at position ") {
80 return got == want
81 }
82 rest, ok := strings.CutPrefix(got, want)
83 return ok && runnerSuffix.MatchString(rest)
84}
85
86func sameErrorsAsReference(got, want []string) bool {
87 if len(got) != len(want) {
88 return false
89 }
90 for i := range got {
91 if !sameErrorAsReference(got[i], want[i]) {
92 return false
93 }
94 }
95 return true
96}
97
98func TestSameErrorAsReference(t *testing.T) {
99 const loc = ". Located at position 3 within expression: a b"
100 for _, tc := range []struct {
101 name, got, want string
102 ok bool
103 }{
104 {"suffix added", "f.yml (Line: 1, Col: 1): Unrecognized named-value: 'a'" + loc, "f.yml (Line: 1, Col: 1): Unrecognized named-value: 'a'", true},
105 {"suffix missing", "f.yml (Line: 1, Col: 1): Unrecognized named-value: 'a'", "f.yml (Line: 1, Col: 1): Unrecognized named-value: 'a'", false},
106 {"suffix empty expression", "f.yml (Line: 1, Col: 1): Unexpected symbol: 'a'. Located at position 3 within expression: ", "f.yml (Line: 1, Col: 1): Unexpected symbol: 'a'", false},
107 {"arity names the paren", "f.yml (Line: 1, Col: 1): Too many parameters supplied: '('" + loc, "f.yml (Line: 1, Col: 1): Too many parameters supplied: 'failure'", true},
108 {"arity names the function", "f.yml (Line: 1, Col: 1): Too many parameters supplied: 'failure'" + loc, "f.yml (Line: 1, Col: 1): Too many parameters supplied: 'failure'", false},
109 {"different token", "f.yml (Line: 1, Col: 1): Unrecognized named-value: 'b'" + loc, "f.yml (Line: 1, Col: 1): Unrecognized named-value: 'a'", false},
110 {"reference has the suffix", "f.yml (Line: 1, Col: 1): Unexpected symbol: '+'. Located at position 3 within expression: 1 +", "f.yml (Line: 1, Col: 1): Unexpected symbol: '+'. Located at position 3 within expression: 1 +", true},
111 {"reference suffix differs", "f.yml (Line: 1, Col: 1): Unexpected symbol: '+'. Located at position 4 within expression: 1 +", "f.yml (Line: 1, Col: 1): Unexpected symbol: '+'. Located at position 3 within expression: 1 +", false},
112 {"not an expression error", "f.yml (Line: 1, Col: 1): Unexpected value '${{ x }}'", "f.yml (Line: 1, Col: 1): Unexpected value '${{ x }}'", true},
113 {"not an expression error with a suffix", "f.yml (Line: 1, Col: 1): Unexpected value '${{ x }}'" + loc, "f.yml (Line: 1, Col: 1): Unexpected value '${{ x }}'", false},
114 } {
115 if got := sameErrorAsReference(tc.got, tc.want); got != tc.ok {
116 t.Errorf("%s: got %v, want %v", tc.name, got, tc.ok)
117 }
118 }
119}
120
121// isYAMLError recognises the reference's YAML syntax errors, whose text is
122// the JavaScript library's and cannot be reproduced word for word.
123func isYAMLError(s string) bool { return strings.Contains(s, " at line ") }
124
125// ermiteOnly lists corpus files on which this package reports errors the
126// reference does not; the errors expected are given here instead.
127var ermiteOnly = map[string][]string{
128 "on_filters_both.yml": {
129 "on_filters_both.yml (Line: 2, Col: 3): Event 'push' cannot use both 'branches' and 'branches-ignore'. Use only one of them.",
130 "on_filters_both.yml (Line: 2, Col: 3): Event 'push' cannot use both 'tags' and 'tags-ignore'. Use only one of them.",
131 "on_filters_both.yml (Line: 2, Col: 3): Event 'push' cannot use both 'paths' and 'paths-ignore'. Use only one of them.",
132 },
133}
134
135func TestConvertAgainstReference(t *testing.T) {
136 ref := loadRef(t)
137 files, err := filepath.Glob("testdata/convert/*.yml")
138 if err != nil || len(files) == 0 {
139 t.Fatalf("no corpus: %v", err)
140 }
141 for _, f := range files {
142 name := filepath.Base(f)
143 want, ok := ref[name]
144 if !ok {
145 t.Errorf("%s: missing from convert.json", name)
146 continue
147 }
148 t.Run(strings.TrimSuffix(name, ".yml"), func(t *testing.T) {
149 content, err := os.ReadFile(f)
150 if err != nil {
151 t.Fatal(err)
152 }
153 w, errs := Parse(name, content)
154 got := errorTexts(errs)
155 if extra, ok := ermiteOnly[name]; ok {
156 if !reflect.DeepEqual(got, extra) || w != nil {
157 t.Errorf("errors:\n got %q\nwant %q", got, extra)
158 }
159 return
160 }
161 if len(want.Errors) == 1 && isYAMLError(want.Errors[0]) {
162 // Syntax errors: the Go parser words them differently. Only
163 // the fact that there is exactly one, on the file, is checked.
164 if len(got) != 1 || !strings.HasPrefix(got[0], name+": ") {
165 t.Errorf("syntax error: got %q, reference %q", got, want.Errors)
166 }
167 if w != nil {
168 t.Error("workflow returned with errors")
169 }
170 return
171 }
172 if !sameErrorsAsReference(nonNil(got), nonNil(want.Errors)) {
173 t.Errorf("errors differ\n got: %q\nwant: %q", got, want.Errors)
174 }
175 if len(want.Errors) > 0 {
176 if w != nil {
177 t.Error("workflow returned with errors")
178 }
179 return
180 }
181 if w == nil {
182 t.Fatal("no workflow and no errors")
183 }
184 if len(w.Jobs) != len(want.Jobs) {
185 t.Fatalf("jobs: got %d, reference %d", len(w.Jobs), len(want.Jobs))
186 }
187 for i, wj := range want.Jobs {
188 j := w.Jobs[i]
189 if j.ID != wj.ID || j.Name.String() != wj.Name || j.If.Expression != wj.If {
190 t.Errorf("job %d: got id=%q name=%q if=%q, reference id=%q name=%q if=%q", i, j.ID, j.Name, j.If.Expression, wj.ID, wj.Name, wj.If)
191 }
192 if !reflect.DeepEqual(nonNil(j.Needs), nonNil(wj.Needs)) && (!j.IsReusable() || len(wj.Needs) != 0) {
193 t.Errorf("job %s needs: got %v, reference %v", j.ID, j.Needs, wj.Needs)
194 }
195 if len(j.Steps) != len(wj.Steps) {
196 t.Errorf("job %s: %d steps, reference %d", j.ID, len(j.Steps), len(wj.Steps))
197 continue
198 }
199 for k, ws := range wj.Steps {
200 if j.Steps[k].ID != ws.ID || j.Steps[k].If.Expression != ws.If {
201 t.Errorf("job %s step %d: got id=%q if=%q, reference id=%q if=%q", j.ID, k, j.Steps[k].ID, j.Steps[k].If.Expression, ws.ID, ws.If)
202 }
203 }
204 }
205 checkEvents(t, w, want.Events)
206 })
207 }
208}
209
210func nonNil(s []string) []string {
211 if s == nil {
212 return []string{}
213 }
214 return s
215}
216
217// checkEvents compares the filters of the events with the reference.
218func checkEvents(t *testing.T, w *Workflow, want map[string]any) {
219 t.Helper()
220 var names []string
221 for _, e := range w.On.Events {
222 names = append(names, e.Name)
223 }
224 var wnames []string
225 for k := range want {
226 wnames = append(wnames, k)
227 }
228 sort.Strings(names)
229 sort.Strings(wnames)
230 if !reflect.DeepEqual(names, wnames) {
231 t.Errorf("events: got %v, reference %v", names, wnames)
232 return
233 }
234 for _, e := range w.On.Events {
235 wf, _ := want[e.Name].(map[string]any)
236 for key, got := range map[string][]string{
237 "branches": e.Branches, "branches-ignore": e.BranchesIgnore, "tags": e.Tags,
238 "tags-ignore": e.TagsIgnore, "paths": e.Paths, "paths-ignore": e.PathsIgnore, "types": e.Types,
239 } {
240 raw, present := wf[key]
241 if (got != nil) != present {
242 t.Errorf("event %s filter %s: present=%v, reference present=%v", e.Name, key, got != nil, present)
243 continue
244 }
245 if present {
246 var ws []string
247 for _, x := range raw.([]any) {
248 ws = append(ws, x.(string))
249 }
250 if !reflect.DeepEqual(nonNil(got), nonNil(ws)) {
251 t.Errorf("event %s filter %s: got %v, reference %v", e.Name, key, got, ws)
252 }
253 }
254 }
255 }
256}