main 98594d0

oracle_test.go RawHistoryFolder

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}

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