1// SPDX-License-Identifier: MIT
2
3package executor
4
5import (
6 "regexp"
7 "strings"
8 "testing"
9 "time"
10
11 "bnema.dev/ermite-actions/expr"
12)
13
14// Texts recorded on github.com (ermite-conformance, scenarios match-basic).
15func TestMatcherFileErrorsAreTheRunners(t *testing.T) {
16 _, err := parseMatchersConfig([]byte("not json\n"))
17 const want = "Unexpected character encountered while parsing value: n. Path '', line 0, position 0."
18 if err == nil || err.Error() != want {
19 t.Errorf("not JSON: %v", err)
20 }
21 cfg, err := parseMatchersConfig([]byte(`{"problemMatcher":[{"owner":"badregexp","pattern":[{"regexp":"^INVALID (.+$","message":1}]}]}`))
22 if err != nil {
23 t.Fatal(err)
24 }
25 err = cfg.validate()
26 const wantRe = "Invalid pattern '^INVALID (.+$' at offset 13. Not enough )'s."
27 if err == nil || err.Error() != wantRe {
28 t.Errorf("regexp: %v", err)
29 }
30 // Json.NET accepts what RFC 8259 does not.
31 if _, err := parseMatchersConfig([]byte(`{'problemMatcher':[],}`)); err != nil {
32 t.Errorf("lenient JSON: %v", err)
33 }
34}
35
36// cmd-add-path-disabled: ArgUtil.NotNullOrEmpty throws ArgumentNullException,
37// whose message is "Value cannot be null. (Parameter 'path')".
38func TestAddPathWithoutPathMessage(t *testing.T) {
39 needTools(t, "bash")
40 rec, _ := runLocal(t, wfHead+" steps:\n - name: p\n env:\n ACTIONS_ALLOW_UNSECURE_COMMANDS: 'true'\n run: echo '::add-path::'\n", "j", nil)
41 if got := logOf(rec.step(t, "p")); !strings.Contains(got, "##[error]Value cannot be null. (Parameter 'path')") || strings.Contains(got, "null or empty") {
42 t.Error(got)
43 }
44}
45
46// cmd-stop-commands: the exception of ValidateStopToken is not inside the try
47// block of TryProcessCommand, so the step fails with the message alone (no
48// "Unable to process command") and the output after it is not read.
49func TestInvalidStopTokenFailsTheStepWithTheMessageOnly(t *testing.T) {
50 needTools(t, "bash")
51 for _, tok := range []string{"pause-logging", "", "warning", "PAUSE-LOGGING"} {
52 rec, res := runLocal(t, wfHead+" steps:\n - name: s\n run: |\n echo '::stop-commands::"+tok+"'\n echo '::warning::still processed'\n", "j", nil)
53 got := logOf(rec.step(t, "s"))
54 if strings.Contains(got, "Unable to process command") || strings.Contains(got, "still processed\n") && strings.Contains(got, "##[warning]") {
55 t.Errorf("%q: %s", tok, got)
56 }
57 if !strings.Contains(got, "##[error]"+unsupportedStopTokenDisabled) || rec.step(t, "s").Conclusion != ConclusionFailure || res.Conclusion != ConclusionFailure {
58 t.Errorf("%q: %s", tok, got)
59 }
60 }
61}
62
63// ann-levels, ann-error-fails, match-basic: GitHub reports end_line = line (and
64// end_column = col) when the command gave only the start.
65func TestAnnotationEndsWhereItStartsByDefault(t *testing.T) {
66 j := &job{}
67 j.masker.Store(newMasker())
68 a := j.annotationOf(issue{Level: LevelWarning, Props: map[string]string{"line": "2", "col": "3"}}, "m", 0)
69 if a.StartLine != 2 || a.EndLine != 2 || a.StartColumn != 3 || a.EndColumn != 3 {
70 t.Errorf("%+v", a)
71 }
72 a = j.annotationOf(issue{Level: LevelWarning, Props: map[string]string{"line": "2", "endline": "5"}}, "m", 0)
73 if a.EndLine != 5 || a.EndColumn != 0 {
74 t.Errorf("%+v", a)
75 }
76}
77
78// sum-env: the file of a step is still there in the next one.
79func TestFileCommandsOfAStepSurviveTheNextStep(t *testing.T) {
80 needTools(t, "bash")
81 rec, _ := runLocal(t, wfHead+` steps:
82 - run: echo "$GITHUB_STEP_SUMMARY" > "$RUNNER_TEMP/p"
83 - name: next
84 run: test -e "$(cat "$RUNNER_TEMP/p")" && echo yes || echo no
85`, "j", nil)
86 if got := logOf(rec.step(t, "next")); !strings.HasSuffix(got, "\nyes") {
87 t.Error(got)
88 }
89}
90
91// ctr-*, svc-*, act-docker-image: a step made by the runner is in the steps
92// context under a 32-hex id (a Guid in "N" format), with its outcome and
93// conclusion and no outputs.
94func TestRunnerStepsAreInTheStepsContext(t *testing.T) {
95 st := newTestState(t)
96 j := &job{state: st}
97 s := &stepRun{j: j, ctxID: newRunnerStepID()}
98 j.finishStep(s, resSucceeded)
99 v, ok := st.Values(nil)["steps"].(*expr.Dictionary)
100 if !ok || len(v.Pairs()) != 1 {
101 t.Fatalf("%v", v)
102 }
103 p := v.Pairs()[0]
104 if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(p.Key) {
105 t.Errorf("id %q", p.Key)
106 }
107 d, _ := p.Value.(*expr.Dictionary)
108 if d == nil || strings.Join(d.Keys(), ",") != "outputs,outcome,conclusion" {
109 t.Errorf("%v", p.Value)
110 }
111 if o, _ := d.Get("outcome"); o != expr.String("success") {
112 t.Errorf("outcome %v", o)
113 }
114}
115
116// sum-too-large: a summary of exactly 1 MiB is accepted (the runner tests the
117// size of the file), 1 MiB plus one byte is refused with "got 1024k", 2 MiB
118// with "got 2048k".
119func TestStepSummarySizeLimit(t *testing.T) {
120 needTools(t, "bash")
121 rr, _ := runResults(t, wfHead+` steps:
122 - name: exact
123 run: head -c $((1024 * 1024)) /dev/zero | tr '\0' b >> "$GITHUB_STEP_SUMMARY"
124 - name: plus1
125 run: head -c $((1024 * 1024 + 1)) /dev/zero | tr '\0' c >> "$GITHUB_STEP_SUMMARY"
126 - name: two
127 run: head -c $((2 * 1024 * 1024)) /dev/zero | tr '\0' d >> "$GITHUB_STEP_SUMMARY"
128`, nil)
129 const tail = ", supports content up to a size of 1024k, got "
130 if got := logOf(rr.step(t, "exact")); strings.Contains(got, "upload aborted") {
131 t.Errorf("exact: %.200s", got)
132 }
133 if r := rr.results[2]; !r.HasSummary || len(r.Summary) != stepSummaryLimit {
134 t.Errorf("exact: has=%v len=%d", r.HasSummary, len(r.Summary))
135 }
136 if got := logOf(rr.step(t, "plus1")); !strings.Contains(got, "##[error]$GITHUB_STEP_SUMMARY upload aborted"+tail+"1024k.") {
137 t.Errorf("plus1: %.300s", got)
138 }
139 if got := logOf(rr.step(t, "two")); !strings.Contains(got, "##[error]$GITHUB_STEP_SUMMARY upload aborted"+tail+"2048k.") {
140 t.Errorf("two: %.300s", got)
141 }
142}
143
144// An invalid stop-commands token ends the process: the step does not wait for
145// the script (here 600 s) and fails with the endToken message.
146func TestInvalidStopTokenStopsTheProcess(t *testing.T) {
147 needTools(t, "bash", "sleep")
148 start := time.Now()
149 rec, res := runLocal(t, wfHead+" steps:\n - name: s\n run: |\n echo '::stop-commands::warning'\n sleep 600\n echo after\n", "j", nil)
150 if d := time.Since(start); d > 30*time.Second {
151 t.Fatalf("the step took %v", d)
152 }
153 got := logOf(rec.step(t, "s"))
154 if !strings.Contains(got, "##[error]"+unsupportedStopTokenDisabled) || strings.Contains(got, "The operation was canceled.") {
155 t.Error(got)
156 }
157 for _, l := range rec.step(t, "s").Log {
158 if strings.TrimSpace(l) == "after" {
159 t.Errorf("output after the fatal line was logged: %q", l)
160 }
161 }
162 if rec.step(t, "s").Conclusion != ConclusionFailure || res.Conclusion != ConclusionFailure {
163 t.Errorf("%v %v", rec.step(t, "s").Conclusion, res.Conclusion)
164 }
165}
166
167// Output written after the fatal line, a lot of it, does not block the process
168// or the executor.
169func TestInvalidStopTokenDrainsHighVolumeOutput(t *testing.T) {
170 needTools(t, "bash", "yes", "head")
171 done := make(chan struct{})
172 var rec *recorder
173 go func() {
174 defer close(done)
175 rec, _ = runLocal(t, wfHead+" steps:\n - name: s\n run: |\n echo '::stop-commands::'\n yes '::warning::x' | head -c 200000000\n sleep 600\n", "j", nil)
176 }()
177 select {
178 case <-done:
179 case <-time.After(60 * time.Second):
180 t.Fatal("deadlock")
181 }
182 got := logOf(rec.step(t, "s"))
183 if !strings.Contains(got, "##[error]"+unsupportedStopTokenDisabled) || strings.Contains("\n"+got+"\n", "\n::warning::x\n") || strings.Contains(got, "##[warning]x") {
184 t.Errorf("%.300s", got)
185 }
186}
187
188// svc-redis (unhealthy): the github context of a step that runs before any
189// other step ran has no action_ref, action_repository, event_path nor the file
190// command paths: the runner sets them while it runs a step (ActionRunner.cs:
191// 147-153, FileCommandManager.cs:71, ExecutionContext.cs:1334), after the env
192// of the step was evaluated; the context object is shared by the steps, so a
193// later step has them. The recorded dump step of the unhealthy job of svc-redis
194// is the first step that ran and has none; a step run before it (the harness's
195// ermite-mask) gives it all of them.
196func TestGithubContextOfTheFirstStepThatRunsHasNoStepKeys(t *testing.T) {
197 needTools(t, "bash")
198 rec, _ := runLocal(t, wfHead+` steps:
199 - name: skipped
200 if: false
201 run: echo no
202 - name: dump
203 run: echo "KEYS=$KEYS"
204 env:
205 KEYS: ${{ join(github.*, ',') != '' && 'x' }}${{ contains(toJSON(github), 'action_ref') }},${{ contains(toJSON(github), 'event_path') }},${{ contains(toJSON(github), 'artifacts') }},${{ contains(toJSON(github), 'step_summary') }}
206`, "j", nil)
207 if got := logOf(rec.step(t, "dump")); !strings.Contains(got, "KEYS=xfalse,false,false,false") {
208 t.Error(got)
209 }
210}