detached 3483340

ci_unit_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package usecase_test
4
5import (
6	"context"
7	"testing"
8
9	"github.com/stretchr/testify/assert"
10	"github.com/stretchr/testify/mock"
11	"github.com/stretchr/testify/require"
12
13	"bnema.dev/ermite/internal/domain"
14	"bnema.dev/ermite/internal/ports"
15	"bnema.dev/ermite/internal/ports/mocks"
16	"bnema.dev/ermite/internal/usecase"
17)
18
19// mergeUnit is a merge request that is ready to merge, with a checks reader.
20type mergeUnit struct {
21	*mrUnit
22	checks *mocks.CIChecksReader
23	trig   *mocks.WorkflowTrigger
24	head   domain.ObjectID
25}
26
27func newMergeUnit(t *testing.T, block bool) *mergeUnit {
28	t.Helper()
29	u := &mrUnit{
30		meta:  mocks.NewRepoMetaStore(t),
31		store: mocks.NewMergeRequestStore(t),
32		repos: mocks.NewGitRepos(t),
33		git:   mocks.NewMergeGit(t),
34		repo:  domain.Repo{Name: name(t, "proj"), Visibility: domain.VisibilityPrivate},
35	}
36	u.repo.Settings = domain.DefaultRepoSettings()
37	u.repo.Settings.FailingChecksBlock = block
38	clock := mocks.NewClock(t)
39	clock.EXPECT().Now().Return(now).Maybe()
40	sched := mocks.NewMaintenanceScheduler(t)
41	sched.EXPECT().Schedule(mock.Anything, mock.Anything).Return().Maybe()
42	m := &mergeUnit{mrUnit: u, checks: mocks.NewCIChecksReader(t), trig: mocks.NewWorkflowTrigger(t), head: someID(t)}
43	u.mrs = usecase.NewMergeRequests(usecase.MergeRequestsConfig{
44		Meta: u.meta, Store: u.store, Repos: u.repos, Git: u.git,
45		Signer: mocks.NewCommitSigner(t), Bus: mocks.NewEventBus(t), Clock: clock, Maint: sched,
46		CI: m.trig, Checks: m.checks,
47	})
48	u.meta.EXPECT().Get(mock.Anything, u.repo.Name).Return(u.repo, nil)
49	u.store.EXPECT().Get(mock.Anything, u.repo.Name, int64(1)).Return(domain.MergeRequest{
50		Repo: u.repo.Name, Number: 1, State: domain.MRStateOpen, Source: "feat", Target: "main",
51	}, nil)
52	tip, err := domain.ParseObjectID("1111111111111111111111111111111111111111")
53	require.NoError(t, err)
54	u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "feat").Return(m.head, nil)
55	u.git.EXPECT().BranchHead(mock.Anything, u.repo.Name, "main").Return(tip, nil)
56	u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, tip, m.head).Return(true, nil)
57	u.git.EXPECT().IsAncestor(mock.Anything, u.repo.Name, m.head, tip).Return(false, nil)
58	return m
59}
60
61func (m *mergeUnit) merge() error {
62	_, err := m.mrs.Merge(context.Background(), domain.Root(), m.repo.Name, 1, ports.MergeParams{HeadID: m.head})
63	return err
64}
65
66// AC5: the merge rule under FailingChecksBlock.
67func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
68	t.Parallel()
69	check := func(status domain.CIStatus, c domain.CIConclusion, name string) domain.CICheck {
70		return domain.CICheck{Name: name, Status: status, Conclusion: c}
71	}
72	tests := []struct {
73		name    string
74		checks  []domain.CICheck
75		blocked string // part of the message
76	}{
77		{"a failure blocks", []domain.CICheck{check(domain.CICompleted, domain.CISuccess, "a"), check(domain.CICompleted, domain.CIFailure, "build")}, "failing checks block the merge: build"},
78		{"a timeout blocks", []domain.CICheck{check(domain.CICompleted, domain.CITimedOut, "slow")}, "slow"},
79		{"a cancelled check blocks", []domain.CICheck{check(domain.CICompleted, domain.CICancelled, "c")}, "failing checks"},
80		{"action required blocks", []domain.CICheck{check(domain.CICompleted, domain.CIActionRequired, "env")}, "failing checks"},
81		{"a queued check blocks", []domain.CICheck{check(domain.CIQueued, "", "q")}, "have not finished: q"},
82		{"a running check blocks", []domain.CICheck{check(domain.CIInProgress, "", "r")}, "have not finished: r"},
83	}
84	for _, tt := range tests {
85		t.Run(tt.name, func(t *testing.T) {
86			t.Parallel()
87			m := newMergeUnit(t, true)
88			m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(tt.checks, nil)
89			// The merge stops at the rule: the branch mock expects no move.
90			err := m.merge()
91			require.ErrorIs(t, err, domain.ErrPreconditionFailed)
92			assert.Contains(t, err.Error(), tt.blocked)
93		})
94	}
95	t.Run("too many checks to tell", func(t *testing.T) {
96		t.Parallel()
97		m := newMergeUnit(t, true)
98		m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(nil, domain.ErrResourceExhausted)
99		err := m.merge()
100		require.ErrorIs(t, err, domain.ErrPreconditionFailed)
101		assert.Contains(t, err.Error(), "too many checks")
102	})
103	// The cases where the merge goes on (no check, all passing, rule off) need
104	// the whole merge: see TestCIMergeIsBlockedByChecks, which merges for real.
105}
106
107func TestCombineChecks(t *testing.T) {
108	t.Parallel()
109	ck := func(s domain.CIStatus, c domain.CIConclusion) domain.CICheck {
110		return domain.CICheck{Status: s, Conclusion: c}
111	}
112	assert.Equal(t, domain.CheckStateNone, domain.CombineChecks(nil))
113	assert.Equal(t, domain.CheckStateSuccess, domain.CombineChecks([]domain.CICheck{ck(domain.CICompleted, domain.CISuccess), ck(domain.CICompleted, domain.CISkipped)}))
114	assert.Equal(t, domain.CheckStatePending, domain.CombineChecks([]domain.CICheck{ck(domain.CICompleted, domain.CISuccess), ck(domain.CIQueued, "")}))
115	assert.Equal(t, domain.CheckStateFailure, domain.CombineChecks([]domain.CICheck{ck(domain.CIInProgress, ""), ck(domain.CICompleted, domain.CIFailure)}), "a failure wins over pending")
116}
117
118func TestLabelsMatchAndRunnerLabels(t *testing.T) {
119	t.Parallel()
120	have := domain.RunnerLabels([]string{"ubuntu-latest", "gpu"}, "linux", "x64")
121	assert.Equal(t, []string{"gpu", "linux", "self-hosted", "ubuntu-latest", "x64"}, have)
122	assert.True(t, domain.LabelsMatch([]string{"Ubuntu-Latest"}, have), "case-insensitive")
123	assert.True(t, domain.LabelsMatch([]string{"self-hosted", "linux", "x64"}, have))
124	assert.False(t, domain.LabelsMatch([]string{"self-hosted", "arm64"}, have))
125	assert.False(t, domain.LabelsMatch([]string{"windows-latest"}, have))
126	assert.True(t, domain.LabelsMatch(nil, have), "no requirement matches")
127	assert.False(t, domain.LabelsMatch([]string{"runner-name"}, have), "the runner name is not a label")
128}
129
130func TestStepRanges(t *testing.T) {
131	t.Parallel()
132	steps := []domain.CIStep{
133		{Number: 1, HasLines: true, LogStart: 0},
134		{Number: 2},
135		{Number: 3, HasLines: true, LogStart: 4},
136		{Number: 4, HasLines: true, LogStart: 9},
137		{Number: 5},
138	}
139	got := domain.StepRanges(steps, 12)
140	assert.Equal(t, []domain.StepRange{{Start: 0, End: 4}, {Start: 4, End: 4}, {Start: 4, End: 9}, {Start: 9, End: 12}, {Start: 12, End: 12}}, got)
141	assert.EqualValues(t, 3, domain.StepOfLine(steps, 5))
142	assert.EqualValues(t, 1, domain.StepOfLine(steps, 0))
143	assert.EqualValues(t, 4, domain.StepOfLine(steps, 11))
144}
145
146func TestJobIDsRoundTripAndRejectOthers(t *testing.T) {
147	t.Parallel()
148	id := domain.NewJobID(42, 3, 1)
149	run, index, combo, ok := domain.ParseJobID(id)
150	require.True(t, ok)
151	assert.Equal(t, []int{42, 3, 1}, []int{int(run), index, combo})
152	for _, bad := range []string{"", "1-2", "1-2-3-4", "a-b-c", "0-1-1", "1--2", "01-2-3", "../../x", "1-2-3/4"} {
153		_, _, _, ok := domain.ParseJobID(bad)
154		assert.False(t, ok, bad)
155	}
156}
157
158func TestOutputLimitsAndNames(t *testing.T) {
159	t.Parallel()
160	assert.NoError(t, domain.CheckOutputs(map[string]string{"a": "1", "_b-c": "2"}))
161	assert.Error(t, domain.CheckOutputs(map[string]string{"1a": "x"}), "starts with a digit")
162	assert.Error(t, domain.CheckOutputs(map[string]string{"": "x"}))
163	assert.Error(t, domain.CheckOutputs(map[string]string{"A": "x", "a": "y"}), "names differ only by case")
164	assert.Error(t, domain.CheckOutputs(map[string]string{"big": string(make([]byte, domain.CIMaxOutputBytes+1))}))
165	assert.Error(t, domain.CheckOutputs(map[string]string{"a": string(make([]byte, domain.CIMaxOutputBytes/2+1)), "b": string(make([]byte, domain.CIMaxOutputBytes/2+1))}), "1 MiB together")
166}
167
168func TestTokenShapes(t *testing.T) {
169	t.Parallel()
170	good := "ermitejob_" + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN-_0"
171	assert.True(t, domain.IsJobToken(good))
172	assert.False(t, domain.IsRunnerToken(good))
173	assert.False(t, domain.IsJobToken(good[:len(good)-1]))
174	assert.False(t, domain.IsJobToken("ermite_"+good[10:]))
175	assert.False(t, domain.IsJobToken(good+"="))
176}

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 4

Tags 45

↑↓ moveenter openesc close