1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6 "context"
7 "fmt"
8 "strings"
9 "testing"
10
11 "github.com/stretchr/testify/assert"
12 "github.com/stretchr/testify/require"
13
14 "bnema.dev/ermite-actions/expr"
15 "bnema.dev/ermite-actions/template"
16 "bnema.dev/ermite-actions/workflow"
17 "bnema.dev/ermite/internal/domain"
18 "bnema.dev/ermite/internal/usecase"
19)
20
21const concurrencyYAML = `on: push
22concurrency: %s
23jobs:
24 j:
25 runs-on: ubuntu-latest
26 steps:
27 - run: echo
28`
29
30func evalGroup(t *testing.T, value string) (domain.CIConcurrency, error) {
31 t.Helper()
32 wf, errs := workflow.Parse("w.yml", []byte(fmt.Sprintf(concurrencyYAML, value)))
33 require.Empty(t, errs)
34 gh, err := expr.FromJSON([]byte(`{"ref":"refs/heads/main","workflow":"ci"}`))
35 require.NoError(t, err)
36 env := template.Env{Contexts: map[string]expr.Value{"github": gh, "inputs": expr.NewDictionary(), "vars": expr.NewDictionary()}}
37 return usecase.EvalConcurrency(context.Background(), wf.Concurrency, env)
38}
39
40func TestEvalConcurrency(t *testing.T) {
41 t.Parallel()
42 tests := []struct {
43 name, yaml string
44 want domain.CIConcurrency
45 err string
46 }{
47 {"a string is the group", `Deploy`, domain.CIConcurrency{Group: "Deploy"}, ""},
48 {"an expression is the group", `${{ github.workflow }}-${{ github.ref }}`, domain.CIConcurrency{Group: "ci-refs/heads/main"}, ""},
49 {"mapping", "{group: g}", domain.CIConcurrency{Group: "g"}, ""},
50 {"cancel true", "{group: g, cancel-in-progress: true}", domain.CIConcurrency{Group: "g", Cancel: true}, ""},
51 {"cancel expression", "{group: g, cancel-in-progress: \"${{ github.ref == 'refs/heads/main' }}\"}", domain.CIConcurrency{Group: "g", Cancel: true}, ""},
52 {"cancel expression false", "{group: g, cancel-in-progress: \"${{ github.ref != 'refs/heads/main' }}\"}", domain.CIConcurrency{Group: "g"}, ""},
53 {"queue single", "{group: g, queue: single}", domain.CIConcurrency{Group: "g"}, ""},
54 {"queue max", "{group: g, queue: max}", domain.CIConcurrency{Group: "g", Max: true}, ""},
55 {"queue max with cancel false", "{group: g, queue: max, cancel-in-progress: false}", domain.CIConcurrency{Group: "g", Max: true}, ""},
56 {"queue max from an expression", "{group: g, queue: \"${{ 'max' }}\"}", domain.CIConcurrency{Group: "g", Max: true}, ""},
57 {"queue max with cancel true", "{group: g, queue: max, cancel-in-progress: true}", domain.CIConcurrency{}, "'queue: max' cannot be combined with 'cancel-in-progress: true'."},
58 {"queue max with cancel expression true", "{group: g, queue: max, cancel-in-progress: \"${{ github.ref == 'refs/heads/main' }}\"}", domain.CIConcurrency{}, "'queue: max' cannot be combined with 'cancel-in-progress: true'."},
59 {"queue max with cancel expression false", "{group: g, queue: max, cancel-in-progress: \"${{ github.ref != 'refs/heads/main' }}\"}", domain.CIConcurrency{Group: "g", Max: true}, ""},
60 {"queue expression of another value", "{group: g, queue: \"${{ github.workflow }}\"}", domain.CIConcurrency{}, "Unexpected value 'ci'"},
61 {"empty group", `${{ vars.NOPE }}`, domain.CIConcurrency{}, "concurrency group name cannot be empty"},
62 }
63 for _, tt := range tests {
64 t.Run(tt.name, func(t *testing.T) {
65 t.Parallel()
66 got, err := evalGroup(t, tt.yaml)
67 if tt.err != "" {
68 require.Error(t, err)
69 assert.Equal(t, tt.err, err.Error())
70 return
71 }
72 require.NoError(t, err)
73 assert.Equal(t, tt.want, got)
74 })
75 }
76 // GitHub's limit counts UTF-16 code units, like C#'s String.Length.
77 for _, tc := range []struct {
78 name, group string
79 ok bool
80 }{
81 {"400 BMP characters", strings.Repeat("é", 400), true},
82 {"401 BMP characters", strings.Repeat("é", 401), false},
83 {"200 emoji are 400 units", strings.Repeat("😀", 200), true},
84 {"201 emoji are 402 units", strings.Repeat("😀", 201), false},
85 {"199 emoji and 2 letters", strings.Repeat("😀", 199) + "ab", true},
86 {"199 emoji and 3 letters", strings.Repeat("😀", 199) + "abc", false},
87 } {
88 t.Run(tc.name, func(t *testing.T) {
89 t.Parallel()
90 _, err := evalGroup(t, `"`+tc.group+`"`)
91 if tc.ok {
92 assert.NoError(t, err)
93 return
94 }
95 require.Error(t, err)
96 assert.Contains(t, err.Error(), "less than 400 characters")
97 })
98 }
99 t.Run("too long", func(t *testing.T) {
100 t.Parallel()
101 _, err := evalGroup(t, strings.Repeat("a", 401))
102 require.Error(t, err)
103 assert.Contains(t, err.Error(), "less than 400 characters")
104 })
105}
106
107func TestConcurrencyIdentity(t *testing.T) {
108 t.Parallel()
109 assert.Equal(t, domain.ConcurrencyKey("Deploy"), domain.ConcurrencyKey("DEPLOY"))
110 assert.Equal(t, "Canceling since a higher priority waiting request for 'g' exists", domain.ConcurrencyCancelMessage("g"))
111 pending := domain.CIJob{Phase: domain.CIPhaseWaiting, Concurrency: domain.CIConcurrency{State: domain.CIConcurrencyPending}}
112 assert.Equal(t, domain.CIPending, pending.Status())
113 pending.Phase = domain.CIPhaseEnded
114 assert.Equal(t, domain.CICompleted, pending.Status(), "an ended job is not pending any more")
115 assert.Equal(t, domain.CIQueued, domain.CIJob{Phase: domain.CIPhaseWaiting}.Status())
116}
117
118// A literal queue other than single or max is refused by the schema when the
119// workflow is parsed ("concurrency-queue": allowed-values single, max).
120func TestConcurrencyQueueLiteralIsChecked(t *testing.T) {
121 t.Parallel()
122 _, errs := workflow.Parse("w.yml", []byte(fmt.Sprintf(concurrencyYAML, "{group: g, queue: often}")))
123 require.NotEmpty(t, errs)
124}