main 98594d0

convert_test.go RawHistoryFolder

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}

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