snapshot_test.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"errors"
6	"testing"
7	"time"
8
9	"github.com/stretchr/testify/mock"
10	"github.com/stretchr/testify/require"
11
12	renderer "github.com/bnema/vev-vt"
13	"github.com/bnema/vev/internal/domain"
14	"github.com/bnema/vev/internal/ports"
15	portsmocks "github.com/bnema/vev/internal/ports/mocks"
16	recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
17)
18
19// TestWithSnapshotRepositoryRejectsTypedNil verifies typed nil repositories are disabled.
20func TestWithSnapshotRepositoryRejectsTypedNil(t *testing.T) {
21	var repository *snapshotAcceptanceRepository
22	d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
23
24	WithSnapshotRepository(repository)(d)
25
26	require.Nil(t, d.snapshotRepository)
27	require.False(t, d.snapsEnabled)
28}
29
30func TestDurableWriterFailureNamesIncludesBufferedCapture(t *testing.T) {
31	d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
32	capture := &snapshotCapture{name: "work", session: newSnapshotTestSession(t, "work", false, "/work")}
33	d.snapshotWorkerMu.Lock()
34	d.snapshotAdmitted[capture] = struct{}{}
35	d.snapshotJobs <- capture
36	d.snapshotWorkerMu.Unlock()
37
38	require.Equal(t, []string{"work"}, d.durableWriterFailureNames())
39}
40
41func TestCheckpointCatalogueFailureKeepsCaptureRetryable(t *testing.T) {
42	d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
43	catalogue := portsmocks.NewMockCatalogue(t)
44	repository := portsmocks.NewMockSnapshotRepository(t)
45	record := domain.CatalogueRecord{
46		Name:          "work",
47		IncarnationID: domain.IncarnationID{1},
48	}
49	catalogue.EXPECT().Record("work").Return(record, true, nil).Once()
50	repository.EXPECT().Publish(mock.Anything, mock.Anything).Return(nil).Once()
51	catalogue.EXPECT().Replace("work", mock.Anything).Return(errors.New("catalogue unavailable")).Once()
52	WithSnapshotRepository(repository)(d)
53	WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d)
54	startSnapshotEncodeWorker(t, d)
55
56	sess := newSnapshotTestSession(t, "work", false, "/work")
57	require.True(t, d.captureSession(sess))
58	awaitSnapshotIdle(t, sess)
59	require.True(t, sess.snapDirty.Load(), "failed catalogue commit must remain retryable")
60}
61
62// TestSnapshotWorkerPublishesContentAddressedCapture verifies that all new
63// checkpoints use the repository publication contract.
64func TestSnapshotWorkerPublishesContentAddressedCapture(t *testing.T) {
65	d := newTestDaemon(t, portsmocks.NewMockPTYFactory(t), stubClock{})
66	repository := portsmocks.NewMockSnapshotRepository(t)
67	WithSnapshotRepository(repository)(d)
68	startSnapshotEncodeWorker(t, d)
69
70	repository.EXPECT().Publish(mock.Anything, mock.MatchedBy(func(p ports.SnapshotPublication) bool {
71		return p.Name == "work" && p.Generation == 1 && len(p.Manifest) > 0 && len(p.Objects) > 0
72	})).Return(nil).Once()
73
74	sess := newSnapshotTestSession(t, "work", false, "/work")
75	require.True(t, d.captureSession(sess))
76	awaitSnapshotClean(t, sess)
77}
78
79func newSnapshotTestSession(t *testing.T, name string, ephemeral bool, cwd string) *session {
80	t.Helper()
81	pty := portsmocks.NewMockPTY(t)
82	pty.EXPECT().Pid().Return(1234).Maybe()
83	pty.EXPECT().Read(mock.Anything).Return(0, errors.New("unused")).Maybe()
84	pty.EXPECT().Write(mock.Anything).Return(0, errors.New("unused")).Maybe()
85	pty.EXPECT().Resize(mock.Anything).Return(nil).Maybe()
86	pty.EXPECT().ForegroundPgid().Return(0, nil).Maybe()
87
88	tb := newTab(pty, domain.Size{Cols: 8, Rows: 3})
89	p := tb.panes["pane-1"]
90	p.screen.Write([]byte("hello"))
91	appendHistoryRow(t, p.history, []renderer.Cell{{Rune: 'h'}, {Rune: 'i'}})
92	sess := &session{sessionCore: sessionCore{id: domain.SessionID("sess-" + name), name: name, ephemeral: ephemeral, createdAt: 42}, ctx: context.Background(), cancel: func() {}, tabs: []*tab{tb}, cwd: cwd}
93	if !ephemeral {
94		sess.incarnation = domain.IncarnationID{1}
95	}
96	sess.snapEligible.Store(!ephemeral && name != "")
97	return sess
98}
99
100// noOpSnapshotRepository supplies the current durable repository contract to
101// focused test sinks that only need to observe publication or deletion calls.
102type noOpSnapshotRepository struct{}
103
104var _ ports.SnapshotRepository = noOpSnapshotRepository{}
105
106func (noOpSnapshotRepository) Publish(context.Context, ports.SnapshotPublication) error { return nil }
107func (noOpSnapshotRepository) LoadCheckpoint(context.Context, domain.IncarnationID, string, ports.CheckpointRef) (ports.SnapshotGeneration, error) {
108	return ports.SnapshotGeneration{}, errors.New("unused")
109}
110func (noOpSnapshotRepository) ReconcileCheckpoint(context.Context, domain.IncarnationID, ports.CheckpointRef) error {
111	return nil
112}
113func (noOpSnapshotRepository) DeleteIncarnation(context.Context, domain.IncarnationID) error {
114	return nil
115}
116func (noOpSnapshotRepository) SnapshotIncarnations(context.Context) ([]domain.IncarnationID, error) {
117	return nil, nil
118}
119func (noOpSnapshotRepository) CollectIncarnationGarbage(context.Context, domain.IncarnationID, *domain.CheckpointRef) error {
120	return nil
121}
122
123func awaitSnapshotIdle(t testing.TB, sess *session) {
124	t.Helper()
125	timer := time.NewTimer(testWaitTimeout)
126	defer timer.Stop()
127	for {
128		sess.snapshotMu.Lock()
129		pending := sess.snapshotPending
130		changed := sess.snapshotChangeLocked()
131		sess.snapshotMu.Unlock()
132		if !pending {
133			return
134		}
135		select {
136		case <-changed:
137		case <-timer.C:
138			t.Fatal("timed out waiting for snapshot to become idle")
139		}
140	}
141}
142
143func awaitSnapshotClean(t *testing.T, sess *session) {
144	t.Helper()
145	timer := time.NewTimer(testWaitTimeout)
146	defer timer.Stop()
147	for {
148		sess.snapshotMu.Lock()
149		clean := !sess.snapDirty.Load()
150		changed := sess.snapshotChangeLocked()
151		sess.snapshotMu.Unlock()
152		if clean {
153			return
154		}
155		select {
156		case <-changed:
157		case <-timer.C:
158			t.Fatal("timed out waiting for snapshot to become clean")
159		}
160	}
161}
162
163func startSnapshotEncodeWorker(t *testing.T, d *Daemon) {
164	t.Helper()
165	d.startSnapshotEncodeWorker()
166	t.Cleanup(d.stopSnapshotEncodeWorker)
167}

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