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}