detached 59d5a0b

ci_concurrency_test.go RawHistoryFolder

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}

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 2

Tags 45

↑↓ moveenter openesc close