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}