detached 98594d0

action_test.go RawHistoryFolder

1// SPDX-License-Identifier: MIT
2
3package action
4
5import (
6	"sort"
7	"strings"
8	"testing"
9
10	"bnema.dev/ermite-actions/template"
11)
12
13func mustParse(t *testing.T, src string) *Action {
14	t.Helper()
15	a, errs := Parse("action.yml", []byte(src))
16	if len(errs) > 0 {
17		t.Fatalf("errors: %q", errTexts(errs))
18	}
19	return a
20}
21
22func TestModel(t *testing.T) {
23	a := mustParse(t, `
24name: Say
25description: Says things
26author: me
27inputs:
28  Who:
29    description: the one
30    required: true
31    default: world
32  old:
33    deprecationMessage: use who
34  expr:
35    default: ${{ github.ref }}
36outputs:
37  out:
38    description: the out
39    value: ${{ steps.a.outputs.r }}
40branding:
41  icon: award
42  color: blue
43runs:
44  using: Node20
45  main: dist/index.js
46  post: dist/post.js
47`)
48	if a.Name != "Say" || a.Description != "Says things" || a.Author != "me" {
49		t.Errorf("header: %+v", a)
50	}
51	if len(a.Inputs) != 3 || a.Inputs[0].Name != "Who" || !a.Inputs[0].Required || a.Inputs[0].Default.String() != "world" || a.Inputs[0].Description != "the one" {
52		t.Errorf("inputs: %+v", a.Inputs)
53	}
54	if in := a.Input("who"); in == nil || in.Name != "Who" {
55		t.Errorf("Input(who) = %v", in)
56	}
57	if in := a.Input("nope"); in != nil {
58		t.Errorf("Input(nope) = %v", in)
59	}
60	if !a.Inputs[1].Deprecated() || a.Inputs[1].DeprecationMessage != "use who" || a.Inputs[1].HasDefault || a.Inputs[1].Default.String() != "" {
61		t.Errorf("deprecated input: %+v", a.Inputs[1])
62	}
63	if got := a.Inputs[2].Default.String(); got != "${{ github.ref }}" || !a.Inputs[2].HasDefault {
64		t.Errorf("expression default: %q", got)
65	}
66	if len(a.Outputs) != 1 || a.Outputs[0].Name != "out" || a.Outputs[0].Value.String() != "${{ steps.a.outputs.r }}" || a.Outputs[0].Description != "the out" {
67		t.Errorf("outputs: %+v", a.Outputs)
68	}
69	r := a.Runs
70	if r.Kind != RunsNode || r.Using != "node20" || r.Main != "dist/index.js" || r.Post != "dist/post.js" || r.Pre != "" {
71		t.Errorf("runs: %+v", r)
72	}
73	// The runner defaults both conditions to always(), pre or not.
74	if r.PreIf != "always()" || r.PostIf != "always()" || r.HasPre() || !r.HasPost() {
75		t.Errorf("conditions: %+v", r)
76	}
77}
78
79func TestRunsConditions(t *testing.T) {
80	a := mustParse(t, "runs:\n  using: docker\n  image: Dockerfile\n  pre-entrypoint: p.sh\n  pre-if: runner.os == 'Linux'\n  post-entrypoint: q.sh\n  post-if: failure()\n  entrypoint: e.sh\n  args: [a, '${{ inputs.x }}']\n  env: {K: V}\n")
81	r := a.Runs
82	// Conditions are kept as written: the runner does not add success().
83	if r.PreIf != "runner.os == 'Linux'" || r.PostIf != "failure()" || !r.HasPre() || !r.HasPost() {
84		t.Errorf("%+v", r)
85	}
86	if r.Image != "Dockerfile" || r.Entrypoint != "e.sh" || r.PreEntrypoint != "p.sh" || r.PostEntrypoint != "q.sh" {
87		t.Errorf("%+v", r)
88	}
89	if r.Args == nil || len(r.Args.Items) != 2 || r.Args.Items[1].String() != "${{ inputs.x }}" || r.Env == nil || r.Env.Pairs[0].Value.String() != "V" {
90		t.Errorf("args/env: %+v %+v", r.Args, r.Env)
91	}
92	c := mustParse(t, "runs:\n  using: composite\n  steps: []\n")
93	if c.Runs.PreIf != "always()" || c.Runs.PostIf != "always()" || c.Runs.HasPre() || c.Runs.HasPost() {
94		t.Errorf("composite: %+v", c.Runs)
95	}
96}
97
98func TestCompositeSteps(t *testing.T) {
99	a := mustParse(t, `
100runs:
101  using: composite
102  steps:
103    - run: echo
104      shell: bash
105    - uses: actions/checkout@v4
106      with:
107        fetch-depth: 0
108    - id: named
109      if: always()
110      run: echo
111      shell: sh
112      working-directory: sub
113      env:
114        A: ${{ inputs.a }}
115      continue-on-error: true
116      name: n
117    - uses: docker://alpine:3
118    - uses: ./x
119    - uses: actions/checkout@v4
120`)
121	var ids []string
122	for _, s := range a.Runs.Steps {
123		ids = append(ids, s.ID)
124	}
125	if got, want := strings.Join(ids, ","), "__run,__actions_checkout,named,__alpine_3,__self,__actions_checkout_2"; got != want {
126		t.Errorf("ids %s, want %s", got, want)
127	}
128	s := a.Runs.Steps[2]
129	if !s.Explicit || s.If.Expression != "always()" || s.Shell.String() != "bash" && s.Shell.String() != "sh" || s.WorkingDirectory.String() != "sub" || s.Name.String() != "n" || s.ContinueOnError.String() != "true" {
130		t.Errorf("step: %+v", s)
131	}
132	if got := a.Runs.Steps[0].If.Expression; got != "success()" {
133		t.Errorf("default if %q", got)
134	}
135	if got := a.Runs.Steps[1].With.Pairs[0].Value.String(); got != "0" {
136		t.Errorf("with %q", got)
137	}
138}
139
140func TestStepIfIsWrappedLikeTheRunner(t *testing.T) {
141	a := mustParse(t, "runs:\n  using: composite\n  steps:\n    - run: x\n      shell: sh\n      if: github.ref == 'main'\n    - run: y\n      shell: sh\n      if: ${{ failure() }}\n    - run: z\n      shell: sh\n      if: vars.A == needs.b.result\n")
142	want := []string{"success() && (github.ref == 'main')", "failure()", "success() && (vars.A == needs.b.result)"}
143	for i, s := range a.Runs.Steps {
144		if s.If.Expression != want[i] {
145			t.Errorf("step %d: %q, want %q", i, s.If.Expression, want[i])
146		}
147	}
148}
149
150func TestErrorsAreTheRunners(t *testing.T) {
151	tests := []struct {
152		name, src string
153		want      []string
154	}{
155		{"unsupported using", "runs:\n  using: node99\n  main: x\n", []string{
156			"System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. (Parameter ''using: node99' is not supported, use 'docker', 'node12', 'node16', 'node20' or 'node24' instead.')"}},
157		{"missing using", "runs:\n  main: x\n", []string{
158			"System.NotSupportedException: Missing 'using' value. 'using' requires 'composite', 'docker', 'node12', 'node16', 'node20' or 'node24'."}},
159		{"no main", "runs:\n  using: node20\n", []string{
160			"action.yml (Line: 2, Col: 3): There's not enough info to determine what you meant. Add one of these properties: args, entrypoint, env, image, main, post, post-entrypoint, pre, pre-entrypoint, steps",
161			"System.ArgumentNullException: Value cannot be null. (Parameter 'You are using a JavaScript Action but there is not an entry JavaScript file provided in action.yml.')"}},
162		{"no image", "runs:\n  using: docker\n", []string{
163			"action.yml (Line: 2, Col: 3): There's not enough info to determine what you meant. Add one of these properties: args, entrypoint, env, image, main, post, post-entrypoint, pre, pre-entrypoint, steps",
164			"System.ArgumentNullException: Value cannot be null. (Parameter 'You are using a Container Action but an image is not provided in action.yml.')"}},
165		{"no steps", "runs:\n  using: composite\n", []string{
166			"action.yml (Line: 2, Col: 3): There's not enough info to determine what you meant. Add one of these properties: args, entrypoint, env, image, main, post, post-entrypoint, pre, pre-entrypoint, steps",
167			"System.ArgumentNullException: Value cannot be null. (Parameter 'You are using a composite action but there are no steps provided in action.yml.')"}},
168		{"no runs", "name: a\n", []string{"Top level 'runs:' section is required for action.yml"}},
169		{"shell required", "runs:\n  using: composite\n  steps:\n    - run: x\n", []string{
170			"action.yml (Line: 4, Col: 7): Required property is missing: shell"}},
171	}
172	for _, tc := range tests {
173		t.Run(tc.name, func(t *testing.T) {
174			a, errs := Parse("action.yml", []byte(tc.src))
175			if a != nil {
176				t.Fatal("action returned")
177			}
178			got := errTexts(errs)
179			if tc.name == "shell required" {
180				// The runner follows the schema error with the exception that
181				// stops the load.
182				if len(got) != 2 || got[0] != tc.want[0] || !strings.HasPrefix(got[1], "GitHub.DistributedTask.ObjectTemplating.TemplateValidationException: The template is not valid. action.yml (Line: 4, Col: 7): Required property is missing: shell") {
183					t.Errorf("got %q", got)
184				}
185				return
186			}
187			if strings.Join(got, "\n") != strings.Join(tc.want, "\n") {
188				t.Errorf("got  %q\nwant %q", got, tc.want)
189			}
190		})
191	}
192	if got := LoadFailed("action.yml"); got != "Failed to load action.yml" {
193		t.Errorf("LoadFailed: %q", got)
194	}
195}
196
197func TestLimits(t *testing.T) {
198	// Ten errors at most, as the runner keeps.
199	var b strings.Builder
200	b.WriteString("runs:\n  using: node20\n  main: m\n")
201	for i := 0; i < 25; i++ {
202		b.WriteString("  k" + string(rune('a'+i)) + ": 1\n")
203	}
204	_, errs := Parse("action.yml", []byte(b.String()))
205	if len(errs) != 10 {
206		t.Errorf("%d errors, want 10", len(errs))
207	}
208	// The runner's TemplateMemory allows 100 nested collections, the root
209	// mapping being the first: 99 brackets pass, 100 do not (depth_seq_99 and
210	// depth_seq_100 in the oracle).
211	deep := func(n int) string {
212		return "x: " + strings.Repeat("[", n) + strings.Repeat("]", n) + "\nruns:\n  using: node20\n  main: m\n"
213	}
214	if _, errs := Parse("action.yml", []byte(deep(99))); len(errs) != 0 {
215		t.Errorf("99 levels: %q", errTexts(errs))
216	}
217	_, errs = Parse("action.yml", []byte(deep(100)))
218	if len(errs) != 2 || errs[0].Error() != "action.yml: Maximum object depth exceeded" {
219		t.Errorf("100 levels: %q", errTexts(errs))
220	}
221}
222
223func TestLintIsTheLanguageService(t *testing.T) {
224	// The runner runs this file; the language service refuses it.
225	src := "icon: x.svg\nruns:\n  using: node20\n  main: m.js\n"
226	if _, errs := Parse("action.yml", []byte(src)); len(errs) != 0 {
227		t.Errorf("Parse: %q", errTexts(errs))
228	}
229	got := errTexts(Lint("action.yml", []byte(src)))
230	want := []string{
231		"action.yml (Line: 1, Col: 1): Unexpected value 'icon'",
232		"action.yml (Line: 1, Col: 1): Required property is missing: name",
233		"action.yml (Line: 1, Col: 1): Required property is missing: description",
234	}
235	// Order: the reference reports the unexpected key first, the missing
236	// properties after the mapping.
237	if strings.Join(got, "\n") != strings.Join(want, "\n") {
238		t.Errorf("Lint:\n got %q\nwant %q", got, want)
239	}
240	ok := "name: a\ndescription: d\nruns:\n  using: node20\n  main: m.js\n"
241	if errs := Lint("action.yml", []byte(ok)); len(errs) != 0 {
242		t.Errorf("Lint of a valid file: %q", errTexts(errs))
243	}
244}
245
246func contexts(t *testing.T, tok interface {
247	Definition() *template.DefinitionInfo
248}) string {
249	t.Helper()
250	d := tok.Definition()
251	if d == nil {
252		t.Fatal("token without definition")
253	}
254	names := append([]string(nil), d.NamedContexts()...)
255	for _, f := range d.Functions() {
256		names = append(names, f.Name+"()")
257	}
258	sort.Strings(names)
259	return strings.Join(names, ",")
260}
261
262// The contexts an expression may use come from the schema, key by key; the
263// tokens carry them so that a caller can evaluate with the right environment.
264func TestContextsPerKey(t *testing.T) {
265	a := mustParse(t, `
266inputs:
267  d:
268    default: ${{ github.ref }}
269outputs:
270  o:
271    value: ${{ steps.a.outputs.x }}
272runs:
273  using: composite
274  steps:
275    - run: echo ${{ inputs.d }}
276      shell: sh
277      env:
278        A: ${{ matrix.a }}
279    - uses: a/b@c
280      with:
281        k: ${{ steps.a.outputs.x }}
282`)
283	const stepCtx = "env,github,hashFiles(),inputs,job,matrix,runner,steps,strategy"
284	if got, want := contexts(t, a.Inputs[0].Default), "github,hashFiles(),job,matrix,runner,strategy"; got != want {
285		t.Errorf("input default: %s, want %s", got, want)
286	}
287	if got, want := contexts(t, a.Outputs[0].Value), "env,github,inputs,job,matrix,runner,steps,strategy"; got != want {
288		t.Errorf("output value: %s, want %s", got, want)
289	}
290	if got := contexts(t, a.Runs.Steps[0].Run); got != stepCtx {
291		t.Errorf("run: %s, want %s", got, stepCtx)
292	}
293	if got := contexts(t, a.Runs.Steps[0].Env.Pairs[0].Value); got != stepCtx {
294		t.Errorf("env: %s", got)
295	}
296	if got := contexts(t, a.Runs.Steps[1].With.Pairs[0].Value); got != stepCtx {
297		t.Errorf("with: %s", got)
298	}
299	// "if" takes the status functions, and the runner adds vars and needs.
300	if got, want := contexts(t, a.Runs.Steps[0].If), "always(),cancelled(),env,failure(),github,hashFiles(),inputs,job,matrix,needs,runner,steps,strategy,success(),vars"; got != want {
301		t.Errorf("if: %s, want %s", got, want)
302	}
303	b := mustParse(t, "runs:\n  using: docker\n  image: Dockerfile\n  args: ['${{ inputs.x }}']\n  env:\n    K: ${{ inputs.y }}\n")
304	if got := contexts(t, b.Runs.Args.Items[0]); got != "inputs" {
305		t.Errorf("args: %s", got)
306	}
307	if got := contexts(t, b.Runs.Env.Pairs[0].Value); got != "inputs" {
308		t.Errorf("env: %s", got)
309	}
310}

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