v0.3.0 99dc266

catalogue_test.go RawHistoryFolder

1package persist
2
3import (
4	"encoding/binary"
5	"os"
6	"path/filepath"
7	"testing"
8
9	"github.com/stretchr/testify/require"
10
11	"github.com/bnema/vev/internal/domain"
12	"github.com/bnema/vev/internal/ports"
13	"github.com/bnema/vev/pkg/kv"
14	"github.com/bnema/vev/pkg/safedir"
15)
16
17func TestCatalogue(t *testing.T) {
18	t.Run("record-round-trip", testCatalogueRecordRoundTrip)
19	t.Run("duplicate-incarnations", testCatalogueDuplicateIncarnations)
20	t.Run("open-fails-closed", testCatalogueOpenFailsClosed)
21	t.Run("apply-rename-replace", testCatalogueApplyRenameReplace)
22	t.Run("metadata-update-preserves-authority", testCatalogueMetadataUpdatePreservesAuthority)
23	t.Run("metadata-update-is-deferred", testCatalogueMetadataUpdateIsDeferred)
24	t.Run("read-only-malformed", testCatalogueLoadReadOnlyRejectsMalformedValue)
25	t.Run("read-only-fresh-install", testCatalogueLoadReadOnlyFreshInstallHasNoSessions)
26	t.Run("read-only-ignores-stray-tmp", testCatalogueLoadReadOnlyIgnoresStrayTmp)
27}
28
29func testCatalogueRecordRoundTrip(t *testing.T) {
30	ref1 := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}}
31	tests := []domain.CatalogueRecord{
32		{Name: "fresh", IncarnationID: domain.IncarnationID{1}, Cwd: "/tmp"},
33		{Name: "healthy", IncarnationID: domain.IncarnationID{2}, Committed: ref1},
34		{Name: "broken", IncarnationID: domain.IncarnationID{4}, Committed: ref1, DegradedReason: "invalid manifest"},
35		{Name: "other-fresh", IncarnationID: domain.IncarnationID{5}},
36	}
37	for _, record := range tests {
38		t.Run(record.Name, func(t *testing.T) {
39			encoded, err := encodeRecordValue(record)
40			require.NoError(t, err)
41			got, err := decodeRecordValue(record.Name, encoded)
42			require.NoError(t, err)
43			require.Equal(t, record, got)
44			for prefix := range len(encoded) {
45				_, err := decodeRecordValue(record.Name, encoded[:prefix])
46				require.Error(t, err, "prefix length %d", prefix)
47			}
48			require.Error(t, func() error { _, err := decodeRecordValue(record.Name, append(encoded, 0)); return err }())
49		})
50	}
51}
52
53func TestOpenOrCreateReportsRecordsFromOlderProtocol(t *testing.T) {
54	record := domain.CatalogueRecord{
55		Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/workspace",
56		CreatedAt: 11, UpdatedAt: 22, LastUsedSeq: 33,
57		TabNames:  []string{"shell", "logs"},
58		Committed: &domain.CheckpointRef{Generation: 3, ManifestDigest: [32]byte{3}},
59	}
60	olderProtocol, err := encodeRecordValueForProtocol(record, ports.ProtocolVersion-1)
61	require.NoError(t, err)
62	protocolLess, err := encodeRecordValue(record)
63	require.NoError(t, err)
64	binary.BigEndian.PutUint16(protocolLess[len(catalogueMagic):], protocolLessCatalogueRecordVersion)
65	protocolLess = append(protocolLess[:len(catalogueMagic)+2], protocolLess[len(catalogueMagic)+4:]...)
66
67	currentProtocol, err := encodeRecordValue(record)
68	require.NoError(t, err)
69	for _, tt := range []struct {
70		name         string
71		encoded      []byte
72		incompatible bool
73	}{
74		{name: "current protocol", encoded: currentProtocol},
75		{name: "recorded older protocol", encoded: olderProtocol, incompatible: true},
76		{name: "protocol-less v4 record", encoded: protocolLess, incompatible: true},
77	} {
78		t.Run(tt.name, func(t *testing.T) {
79			dir := privateDir(t)
80			store, err := kv.Open(StorePath(dir))
81			require.NoError(t, err)
82			require.NoError(t, store.Set([]byte(record.Name), tt.encoded))
83			require.NoError(t, store.Close())
84
85			opened, err := OpenOrCreate(dir)
86			require.NoError(t, err)
87			defer func() { require.NoError(t, opened.Catalogue.Close()) }()
88			require.Equal(t, []domain.CatalogueRecord{record}, opened.Records)
89			if tt.incompatible {
90				require.Equal(t, []domain.CatalogueRecord{record}, opened.IncompatibleRecords)
91			} else {
92				require.Empty(t, opened.IncompatibleRecords)
93			}
94		})
95	}
96}
97
98func TestDecodeRecordValueRejectsMalformedCheckpointMarker(t *testing.T) {
99	encoded, err := encodeRecordValue(validRecord("work", 1))
100	require.NoError(t, err)
101	encoded[checkpointMarkerOffset(t, encoded)] = 99
102
103	_, err = decodeRecordValue("work", encoded)
104	require.ErrorIs(t, err, errMalformedRecord)
105}
106
107func checkpointMarkerOffset(t *testing.T, encoded []byte) int {
108	t.Helper()
109	r := valueReader{data: encoded}
110	_, ok := r.take(len(catalogueMagic) + 2 + 2 + len(domain.IncarnationID{}))
111	require.True(t, ok)
112	_, ok = r.str()
113	require.True(t, ok)
114	_, ok = r.take(8 + 8 + 8)
115	require.True(t, ok)
116	count, ok := r.u32()
117	require.True(t, ok)
118	for range count {
119		_, ok = r.str()
120		require.True(t, ok)
121	}
122	return len(encoded) - r.remaining()
123}
124
125func testCatalogueDuplicateIncarnations(t *testing.T) {
126	id := domain.IncarnationID{1}
127	require.Error(t, validateUniqueIncarnations([]domain.CatalogueRecord{
128		{Name: "one", IncarnationID: id},
129		{Name: "two", IncarnationID: id},
130	}))
131}
132
133func testCatalogueOpenFailsClosed(t *testing.T) {
134	dir := privateDir(t)
135	_, _, err := openCurrentCatalogue(dir, false)
136	require.Error(t, err)
137
138	require.NoError(t, safedir.EnsurePrivate(dir))
139	raw := []byte("corrupt catalogue")
140	require.NoError(t, os.WriteFile(StorePath(dir), raw, 0o600))
141	_, _, err = openCurrentCatalogue(dir, false)
142	require.ErrorIs(t, err, kv.ErrCorrupt)
143	after, readErr := os.ReadFile(StorePath(dir))
144	require.NoError(t, readErr)
145	require.Equal(t, raw, after)
146}
147
148func testCatalogueApplyRenameReplace(t *testing.T) {
149	dir := privateDir(t)
150	p, records, err := openCurrentCatalogue(dir, true)
151	require.NoError(t, err)
152	require.Empty(t, records)
153	defer func() { require.NoError(t, p.Close()) }()
154
155	one := domain.CatalogueRecord{Name: "one", IncarnationID: domain.IncarnationID{1}}
156	two := domain.CatalogueRecord{Name: "two", IncarnationID: domain.IncarnationID{2}}
157	require.NoError(t, p.Apply(map[string]*domain.CatalogueRecord{"one": &one, "two": &two}))
158	renamed := one
159	renamed.Name = "renamed"
160	require.NoError(t, p.Rename("one", renamed))
161	two.Cwd = "/next"
162	require.NoError(t, p.Replace("two", two))
163
164	got, err := p.LoadCatalogue()
165	require.NoError(t, err)
166	require.ElementsMatch(t, []domain.CatalogueRecord{renamed, two}, got)
167}
168
169func testCatalogueMetadataUpdatePreservesAuthority(t *testing.T) {
170	dir := privateDir(t)
171	p, _, err := openCurrentCatalogue(dir, true)
172	require.NoError(t, err)
173	defer func() { require.NoError(t, p.Close()) }()
174
175	committed := &domain.CheckpointRef{Generation: 9, ManifestDigest: [32]byte{1}}
176	original := domain.CatalogueRecord{
177		Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/old", CreatedAt: 11,
178		UpdatedAt: 12, LastUsedSeq: 13, TabNames: []string{"old"},
179		Committed: committed, DegradedReason: "repair pending",
180	}
181	require.NoError(t, p.Create(original))
182
183	next := domain.CatalogueRecord{Name: "work", IncarnationID: original.IncarnationID, Cwd: "/new", UpdatedAt: 22, LastUsedSeq: 23, TabNames: []string{"editor", "logs"}}
184	update := next.MetadataUpdate()
185	require.NoError(t, p.UpdateMetadata(update))
186	got, ok, err := p.Record("work")
187	require.NoError(t, err)
188	require.True(t, ok)
189	expected := original
190	expected.Cwd, expected.UpdatedAt, expected.LastUsedSeq, expected.TabNames = next.Cwd, next.UpdatedAt, next.LastUsedSeq, next.TabNames
191	require.Equal(t, expected, got)
192
193	lastUsedSeq := uint64(24)
194	require.NoError(t, p.UpdateMetadata(domain.CatalogueMetadataUpdate{
195		Name: "work", IncarnationID: original.IncarnationID, LastUsedSeq: &lastUsedSeq,
196	}))
197	expected.LastUsedSeq = lastUsedSeq
198	partiallyUpdated, ok, err := p.Record("work")
199	require.NoError(t, err)
200	require.True(t, ok)
201	require.Equal(t, expected, partiallyUpdated, "a partial metadata update must not clear other mutable or authority-owned fields")
202
203	stale := update
204	stale.IncarnationID = domain.IncarnationID{2}
205	require.Error(t, p.UpdateMetadata(stale))
206	unchanged, ok, err := p.Record("work")
207	require.NoError(t, err)
208	require.True(t, ok)
209	require.Equal(t, expected, unchanged)
210}
211
212func testCatalogueMetadataUpdateIsDeferred(t *testing.T) {
213	dir := privateDir(t)
214	p, _, err := openCurrentCatalogue(dir, true)
215	require.NoError(t, err)
216	defer func() { require.NoError(t, p.Close()) }()
217
218	record := domain.CatalogueRecord{Name: "work", IncarnationID: domain.IncarnationID{1}, Cwd: "/old"}
219	require.NoError(t, p.Create(record))
220	before, err := os.ReadFile(StorePath(dir))
221	require.NoError(t, err)
222
223	cwd := "/buffered"
224	require.NoError(t, p.UpdateMetadata(domain.CatalogueMetadataUpdate{
225		Name: record.Name, IncarnationID: record.IncarnationID, Cwd: &cwd,
226	}))
227	afterBuffered, err := os.ReadFile(StorePath(dir))
228	require.NoError(t, err)
229	require.Equal(t, before, afterBuffered, "metadata must remain buffered before Sync")
230
231	require.NoError(t, p.Sync())
232	afterSync, err := os.ReadFile(StorePath(dir))
233	require.NoError(t, err)
234	require.NotEqual(t, before, afterSync)
235
236	cwd = "/next-identity"
237	require.NoError(t, p.UpdateMetadata(domain.CatalogueMetadataUpdate{
238		Name: record.Name, IncarnationID: record.IncarnationID, Cwd: &cwd,
239	}))
240	record.Cwd = cwd
241	record.UpdatedAt = 2
242	require.NoError(t, p.Replace(record.Name, record))
243	afterIdentity, err := os.ReadFile(StorePath(dir))
244	require.NoError(t, err)
245	require.NotEqual(t, afterSync, afterIdentity, "identity writes must sync before returning")
246}
247
248func testCatalogueLoadReadOnlyRejectsMalformedValue(t *testing.T) {
249	dir := privateDir(t)
250	store, err := kv.Open(filepath.Join(dir, filename))
251	require.NoError(t, err)
252	require.NoError(t, store.Set([]byte("work"), []byte("malformed")))
253	require.NoError(t, store.Close())
254	_, err = LoadCatalogueReadOnly(dir)
255	require.Error(t, err)
256}
257
258// testCatalogueLoadReadOnlyFreshInstallHasNoSessions covers `vev ls` on a
259// machine with no daemon and no catalogue. This must yield an empty catalogue,
260// not an error.
261func testCatalogueLoadReadOnlyFreshInstallHasNoSessions(t *testing.T) {
262	dir := privateDir(t)
263	records, err := LoadCatalogueReadOnly(dir)
264	require.NoError(t, err)
265	require.Empty(t, records)
266}
267
268func testCatalogueLoadReadOnlyIgnoresStrayTmp(t *testing.T) {
269	dir := privateDir(t)
270	p, _, err := openCurrentCatalogue(dir, true)
271	require.NoError(t, err)
272	want := validRecord("work", 1)
273	require.NoError(t, p.Create(want))
274	require.NoError(t, p.Close())
275	require.NoError(t, os.WriteFile(StorePath(dir)+".tmp", []byte("partial rewrite"), 0o600))
276
277	records, err := LoadCatalogueReadOnly(dir)
278	require.NoError(t, err)
279	require.Equal(t, []domain.CatalogueRecord{want}, records)
280	tmp, err := os.ReadFile(StorePath(dir) + ".tmp")
281	require.NoError(t, err)
282	require.Equal(t, []byte("partial rewrite"), tmp)
283}

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 12

Tags 11

↑↓ moveenter openesc close