v0.35.0 e86dbd6

cisummaries_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package cisummaries_test
4
5import (
6	"os"
7	"path/filepath"
8	"strings"
9	"testing"
10
11	"github.com/stretchr/testify/assert"
12	"github.com/stretchr/testify/require"
13
14	"bnema.dev/ermite/internal/adapters/cisummaries"
15	"bnema.dev/ermite/internal/domain"
16	"bnema.dev/ermite/internal/ports"
17)
18
19var ref = ports.CILogRef{RunID: 7, JobID: "7-0-0", Attempt: 1}
20
21func TestPutListReplaceAndRemove(t *testing.T) {
22	ctx := t.Context()
23	root := t.TempDir()
24	s := cisummaries.New(root)
25
26	got, more, err := s.List(ctx, ref, 20, 0)
27	require.NoError(t, err)
28	assert.Empty(t, got)
29	assert.False(t, more)
30
31	require.NoError(t, s.Put(ctx, ref, 10, "# ten"))
32	require.NoError(t, s.Put(ctx, ref, 2, "two"))
33	// The layout is the documented one.
34	b, err := os.ReadFile(filepath.Join(root, "7", "7-0-0", "1", "2.md"))
35	require.NoError(t, err)
36	assert.Equal(t, "two", string(b))
37
38	got, _, err = s.List(ctx, ref, 20, 0)
39	require.NoError(t, err)
40	assert.Equal(t, []domain.CIStepSummary{{Step: 2, Markdown: "two"}, {Step: 10, Markdown: "# ten"}}, got, "by step number, not by name")
41
42	// Replace, and an empty markdown removes.
43	require.NoError(t, s.Put(ctx, ref, 2, "again"))
44	got, _, _ = s.List(ctx, ref, 20, 0)
45	assert.Equal(t, "again", got[0].Markdown)
46	require.NoError(t, s.Put(ctx, ref, 2, ""))
47	require.NoError(t, s.Put(ctx, ref, 3, ""), "removing what is not there is fine")
48	got, _, _ = s.List(ctx, ref, 20, 0)
49	require.Len(t, got, 1)
50	assert.EqualValues(t, 10, got[0].Step)
51
52	// Another attempt and another job have their own.
53	other := ports.CILogRef{RunID: 7, JobID: "7-0-0", Attempt: 2}
54	got, _, _ = s.List(ctx, other, 20, 0)
55	assert.Empty(t, got)
56
57	// A write leaves no temporary file behind.
58	entries, err := os.ReadDir(filepath.Join(root, "7", "7-0-0", "1"))
59	require.NoError(t, err)
60	for _, e := range entries {
61		assert.False(t, strings.HasPrefix(e.Name(), ".tmp"), e.Name())
62	}
63}
64
65func TestListStopsAtTheLimit(t *testing.T) {
66	ctx := t.Context()
67	s := cisummaries.New(t.TempDir())
68	for i := int32(1); i <= 25; i++ {
69		require.NoError(t, s.Put(ctx, ref, i, "s"))
70	}
71	got, more, err := s.List(ctx, ref, 20, 0)
72	require.NoError(t, err)
73	assert.Len(t, got, 20)
74	assert.True(t, more)
75	assert.EqualValues(t, 20, got[19].Step, "the first 20 steps that have one")
76	got, more, _ = s.List(ctx, ref, 25, 0)
77	assert.Len(t, got, 25)
78	assert.False(t, more)
79}
80
81// A temporary file left by a crash, or a stray file, is not a summary.
82func TestListIgnoresStrayFiles(t *testing.T) {
83	ctx := t.Context()
84	root := t.TempDir()
85	s := cisummaries.New(root)
86	require.NoError(t, s.Put(ctx, ref, 1, "one"))
87	dir := filepath.Join(root, "7", "7-0-0", "1")
88	for _, n := range []string{".tmp-123", "x.md", "01.md", "0.md", "-1.md", "notes.txt"} {
89		require.NoError(t, os.WriteFile(filepath.Join(dir, n), []byte("stray"), 0o600))
90	}
91	got, _, err := s.List(ctx, ref, 20, 0)
92	require.NoError(t, err)
93	require.Len(t, got, 1)
94	assert.Equal(t, "one", got[0].Markdown)
95}
96
97func TestRemoveJobRemoveRunsAndRunIDs(t *testing.T) {
98	ctx := t.Context()
99	s := cisummaries.New(t.TempDir())
100	ids, err := s.RunIDs(ctx)
101	require.NoError(t, err)
102	assert.Empty(t, ids, "no directory yet")
103
104	second := ports.CILogRef{RunID: 7, JobID: "7-1-0", Attempt: 1}
105	third := ports.CILogRef{RunID: 8, JobID: "8-0-0", Attempt: 1}
106	for _, r := range []ports.CILogRef{ref, second, third} {
107		require.NoError(t, s.Put(ctx, r, 1, "x"))
108	}
109	ids, _ = s.RunIDs(ctx)
110	assert.ElementsMatch(t, []int64{7, 8}, ids)
111
112	require.NoError(t, s.RemoveJob(ctx, ref))
113	got, _, _ := s.List(ctx, ref, 20, 0)
114	assert.Empty(t, got)
115	got, _, _ = s.List(ctx, second, 20, 0)
116	assert.Len(t, got, 1, "the other job of the run stays")
117
118	require.NoError(t, s.RemoveRuns(ctx, []int64{7, 0, -3, 99}))
119	got, _, _ = s.List(ctx, second, 20, 0)
120	assert.Empty(t, got)
121	got, _, _ = s.List(ctx, third, 20, 0)
122	assert.Len(t, got, 1)
123	ids, _ = s.RunIDs(ctx)
124	assert.Equal(t, []int64{8}, ids)
125}
126
127// A reference that could leave the directory is refused, never joined.
128func TestRefsCannotEscape(t *testing.T) {
129	ctx := t.Context()
130	root := t.TempDir()
131	s := cisummaries.New(filepath.Join(root, "summaries"))
132	for _, bad := range []ports.CILogRef{
133		{RunID: 7, JobID: "../../x", Attempt: 1},
134		{RunID: 7, JobID: "7-0-0/../..", Attempt: 1},
135		{RunID: 0, JobID: "7-0-0", Attempt: 1},
136		{RunID: 7, JobID: "7-0-0", Attempt: 0},
137	} {
138		assert.ErrorIs(t, s.Put(ctx, bad, 1, "x"), domain.ErrNotFound)
139		got, _, err := s.List(ctx, bad, 20, 0)
140		require.NoError(t, err)
141		assert.Empty(t, got)
142	}
143	assert.ErrorIs(t, s.Put(ctx, ref, 0, "x"), domain.ErrNotFound)
144	assert.ErrorIs(t, s.Put(ctx, ref, domain.CIMaxSteps+1, "x"), domain.ErrNotFound)
145	entries, _ := os.ReadDir(root)
146	assert.Empty(t, entries, "nothing was written")
147}
148
149func TestASummaryOfTheMaximumSizeRoundTrips(t *testing.T) {
150	ctx := t.Context()
151	s := cisummaries.New(t.TempDir())
152	big := strings.Repeat("x", domain.CIMaxSummaryBytes)
153	require.NoError(t, s.Put(ctx, ref, 1, big))
154	got, _, err := s.List(ctx, ref, 20, 0)
155	require.NoError(t, err)
156	assert.Len(t, got[0].Markdown, domain.CIMaxSummaryBytes)
157}
158
159// With a byte budget the files are sized before they are read: a summary that
160// does not fit, and every one after it, comes back Omitted and is never opened.
161func TestListReadsNoMoreThanTheBudget(t *testing.T) {
162	ctx := t.Context()
163	root := t.TempDir()
164	s := cisummaries.New(root)
165	require.NoError(t, s.Put(ctx, ref, 1, strings.Repeat("a", 100)))
166	require.NoError(t, s.Put(ctx, ref, 2, strings.Repeat("b", 100)))
167	require.NoError(t, s.Put(ctx, ref, 3, strings.Repeat("c", 10)))
168	require.NoError(t, s.Put(ctx, ref, 4, "d"))
169	dir := filepath.Join(root, "7", "7-0-0", "1")
170	// A file that cannot be opened proves it was sized, not read.
171	if os.Geteuid() != 0 {
172		for _, n := range []string{"2.md", "3.md", "4.md"} {
173			require.NoError(t, os.Chmod(filepath.Join(dir, n), 0))
174		}
175	}
176
177	got, more, err := s.List(ctx, ref, 20, 150)
178	require.NoError(t, err)
179	assert.False(t, more)
180	require.Len(t, got, 4)
181	assert.Equal(t, strings.Repeat("a", 100), got[0].Markdown)
182	assert.False(t, got[0].Omitted)
183	for _, o := range got[1:] {
184		assert.True(t, o.Omitted, "step %d", o.Step)
185		assert.Empty(t, o.Markdown)
186	}
187	assert.EqualValues(t, []int32{1, 2, 3, 4}, []int32{got[0].Step, got[1].Step, got[2].Step, got[3].Step}, "the steps are still listed")
188}
189
190func TestListBudgetBoundaries(t *testing.T) {
191	ctx := t.Context()
192	s := cisummaries.New(t.TempDir())
193	require.NoError(t, s.Put(ctx, ref, 1, "12345"))
194	require.NoError(t, s.Put(ctx, ref, 2, "678"))
195
196	got, _, _ := s.List(ctx, ref, 20, 8)
197	assert.False(t, got[0].Omitted || got[1].Omitted, "exactly the budget fits")
198	got, _, _ = s.List(ctx, ref, 20, 7)
199	assert.False(t, got[0].Omitted)
200	assert.True(t, got[1].Omitted, "one byte over")
201	got, _, _ = s.List(ctx, ref, 20, 4)
202	assert.True(t, got[0].Omitted && got[1].Omitted, "the first does not fit")
203	got, _, _ = s.List(ctx, ref, 20, 0)
204	assert.False(t, got[0].Omitted || got[1].Omitted, "no budget, no bound")
205
206	// A smaller file after one that does not fit is still omitted: the order
207	// of steps is kept and nothing is read past the first omission.
208	require.NoError(t, s.Put(ctx, ref, 3, "x"))
209	got, _, _ = s.List(ctx, ref, 20, 7)
210	assert.True(t, got[2].Omitted)
211}

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