v0.14.0 815fb98

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	return m
58}
59
60func (m *mergeUnit) merge() error {
61	_, err := m.mrs.Merge(context.Background(), domain.Root(), m.repo.Name, 1, ports.MergeParams{HeadID: m.head})
62	return err
63}
64
65// AC5: the merge rule under FailingChecksBlock.
66func TestMergeRuleOfFailingChecksBlock(t *testing.T) {
67	t.Parallel()
68	check := func(status domain.CIStatus, c domain.CIConclusion, name string) domain.CICheck {
69		return domain.CICheck{Name: name, Status: status, Conclusion: c}
70	}
71	tests := []struct {
72		name    string
73		checks  []domain.CICheck
74		blocked string // part of the message
75	}{
76		{"a failure blocks", []domain.CICheck{check(domain.CICompleted, domain.CISuccess, "a"), check(domain.CICompleted, domain.CIFailure, "build")}, "failing checks block the merge: build"},
77		{"a timeout blocks", []domain.CICheck{check(domain.CICompleted, domain.CITimedOut, "slow")}, "slow"},
78		{"a cancelled check blocks", []domain.CICheck{check(domain.CICompleted, domain.CICancelled, "c")}, "failing checks"},
79		{"action required blocks", []domain.CICheck{check(domain.CICompleted, domain.CIActionRequired, "env")}, "failing checks"},
80		{"a queued check blocks", []domain.CICheck{check(domain.CIQueued, "", "q")}, "have not finished: q"},
81		{"a running check blocks", []domain.CICheck{check(domain.CIInProgress, "", "r")}, "have not finished: r"},
82	}
83	for _, tt := range tests {
84		t.Run(tt.name, func(t *testing.T) {
85			t.Parallel()
86			m := newMergeUnit(t, true)
87			m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(tt.checks, nil)
88			// The merge stops at the rule: the branch mock expects no move.
89			err := m.merge()
90			require.ErrorIs(t, err, domain.ErrPreconditionFailed)
91			assert.Contains(t, err.Error(), tt.blocked)
92		})
93	}
94	t.Run("too many checks to tell", func(t *testing.T) {
95		t.Parallel()
96		m := newMergeUnit(t, true)
97		m.checks.EXPECT().CommitChecks(mock.Anything, m.repo.Name, m.head.String()).Return(nil, domain.ErrResourceExhausted)
98		err := m.merge()
99		require.ErrorIs(t, err, domain.ErrPreconditionFailed)
100		assert.Contains(t, err.Error(), "too many checks")
101	})
102	// The cases where the merge goes on (no check, all passing, rule off) need
103	// the whole merge: see TestCIMergeIsBlockedByChecks, which merges for real.
104}
105
106func TestCombineChecks(t *testing.T) {
107	t.Parallel()
108	ck := func(s domain.CIStatus, c domain.CIConclusion) domain.CICheck {
109		return domain.CICheck{Status: s, Conclusion: c}
110	}
111	assert.Equal(t, domain.CheckStateNone, domain.CombineChecks(nil))
112	assert.Equal(t, domain.CheckStateSuccess, domain.CombineChecks([]domain.CICheck{ck(domain.CICompleted, domain.CISuccess), ck(domain.CICompleted, domain.CISkipped)}))
113	assert.Equal(t, domain.CheckStatePending, domain.CombineChecks([]domain.CICheck{ck(domain.CICompleted, domain.CISuccess), ck(domain.CIQueued, "")}))
114	assert.Equal(t, domain.CheckStateFailure, domain.CombineChecks([]domain.CICheck{ck(domain.CIInProgress, ""), ck(domain.CICompleted, domain.CIFailure)}), "a failure wins over pending")
115}
116
117func TestLabelsMatchAndRunnerLabels(t *testing.T) {
118	t.Parallel()
119	have := domain.RunnerLabels([]string{"ubuntu-latest", "gpu"}, "linux", "x64")
120	assert.Equal(t, []string{"gpu", "linux", "self-hosted", "ubuntu-latest", "x64"}, have)
121	assert.True(t, domain.LabelsMatch([]string{"Ubuntu-Latest"}, have), "case-insensitive")
122	assert.True(t, domain.LabelsMatch([]string{"self-hosted", "linux", "x64"}, have))
123	assert.False(t, domain.LabelsMatch([]string{"self-hosted", "arm64"}, have))
124	assert.False(t, domain.LabelsMatch([]string{"windows-latest"}, have))
125	assert.True(t, domain.LabelsMatch(nil, have), "no requirement matches")
126	assert.False(t, domain.LabelsMatch([]string{"runner-name"}, have), "the runner name is not a label")
127}
128
129func TestStepRanges(t *testing.T) {
130	t.Parallel()
131	steps := []domain.CIStep{
132		{Number: 1, HasLines: true, LogStart: 0},
133		{Number: 2},
134		{Number: 3, HasLines: true, LogStart: 4},
135		{Number: 4, HasLines: true, LogStart: 9},
136		{Number: 5},
137	}
138	got := domain.StepRanges(steps, 12)
139	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)
140	assert.EqualValues(t, 3, domain.StepOfLine(steps, 5))
141	assert.EqualValues(t, 1, domain.StepOfLine(steps, 0))
142	assert.EqualValues(t, 4, domain.StepOfLine(steps, 11))
143}
144
145func TestJobIDsRoundTripAndRejectOthers(t *testing.T) {
146	t.Parallel()
147	id := domain.NewJobID(42, 3, 1)
148	run, index, combo, ok := domain.ParseJobID(id)
149	require.True(t, ok)
150	assert.Equal(t, []int{42, 3, 1}, []int{int(run), index, combo})
151	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"} {
152		_, _, _, ok := domain.ParseJobID(bad)
153		assert.False(t, ok, bad)
154	}
155}
156
157func TestOutputLimitsAndNames(t *testing.T) {
158	t.Parallel()
159	assert.NoError(t, domain.CheckOutputs(map[string]string{"a": "1", "_b-c": "2"}))
160	assert.Error(t, domain.CheckOutputs(map[string]string{"1a": "x"}), "starts with a digit")
161	assert.Error(t, domain.CheckOutputs(map[string]string{"": "x"}))
162	assert.Error(t, domain.CheckOutputs(map[string]string{"A": "x", "a": "y"}), "names differ only by case")
163	assert.Error(t, domain.CheckOutputs(map[string]string{"big": string(make([]byte, domain.CIMaxOutputBytes+1))}))
164	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")
165}
166
167func TestTokenShapes(t *testing.T) {
168	t.Parallel()
169	good := "ermitejob_" + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN-_0"
170	assert.True(t, domain.IsJobToken(good))
171	assert.False(t, domain.IsRunnerToken(good))
172	assert.False(t, domain.IsJobToken(good[:len(good)-1]))
173	assert.False(t, domain.IsJobToken("ermite_"+good[10:]))
174	assert.False(t, domain.IsJobToken(good+"="))
175}

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 3

Tags 44

↑↓ moveenter openesc close