1package daemon
2
3import (
4 "context"
5 "errors"
6 "math"
7 "strings"
8 "sync"
9 "testing"
10
11 "github.com/stretchr/testify/mock"
12 "github.com/stretchr/testify/require"
13
14 vt "github.com/bnema/vev-vt"
15 renderer "github.com/bnema/vev-vt/ansi"
16 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/domain/layout"
18 "github.com/bnema/vev/internal/ports"
19 portsmocks "github.com/bnema/vev/internal/ports/mocks"
20 snapcodec "github.com/bnema/vev/internal/snapshotcodec"
21 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
22)
23
24type snapshotAcceptanceRepository struct {
25 noOpSnapshotRepository
26 mu sync.Mutex
27 names []string
28 generations map[string]ports.SnapshotGeneration
29 publishErr error
30 listErr error
31 loadErr error
32 publishes []ports.SnapshotPublication
33 loadMutate func(ports.SnapshotGeneration, ports.SnapshotPublication) ports.SnapshotGeneration
34}
35
36func (r *snapshotAcceptanceRepository) Publish(ctx context.Context, p ports.SnapshotPublication) error {
37 if err := ctx.Err(); err != nil {
38 return err
39 }
40 r.mu.Lock()
41 defer r.mu.Unlock()
42 r.publishes = append(r.publishes, p)
43 if r.publishErr != nil {
44 return r.publishErr
45 }
46 r.generations[p.Name] = cloneAcceptanceGeneration(snapshotGeneration(p))
47 found := false
48 for _, name := range r.names {
49 found = found || name == p.Name
50 }
51 if !found {
52 r.names = append(r.names, p.Name)
53 }
54 return nil
55}
56
57func (r *snapshotAcceptanceRepository) List(ctx context.Context) ([]string, error) {
58 if err := ctx.Err(); err != nil {
59 return nil, err
60 }
61 r.mu.Lock()
62 defer r.mu.Unlock()
63 if r.listErr != nil {
64 return nil, r.listErr
65 }
66 return append([]string(nil), r.names...), nil
67}
68
69func (r *snapshotAcceptanceRepository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) {
70 if err := ctx.Err(); err != nil {
71 return ports.SnapshotGeneration{}, err
72 }
73 r.mu.Lock()
74 defer r.mu.Unlock()
75 if r.loadErr != nil {
76 return ports.SnapshotGeneration{}, r.loadErr
77 }
78 generation, ok := r.generations[name]
79 if !ok {
80 return ports.SnapshotGeneration{}, errors.New("missing generation")
81 }
82 generation = cloneAcceptanceGeneration(generation)
83 if r.loadMutate != nil && len(r.publishes) > 0 {
84 generation = r.loadMutate(generation, r.publishes[len(r.publishes)-1])
85 }
86 return cloneAcceptanceGeneration(generation), nil
87}
88
89func (r *snapshotAcceptanceRepository) LoadCheckpoint(ctx context.Context, id domain.IncarnationID, name string, ref ports.CheckpointRef) (ports.SnapshotGeneration, error) {
90 generation, err := r.Load(ctx, name)
91 if err != nil {
92 return ports.SnapshotGeneration{}, err
93 }
94 if generation.IncarnationID != id || generation.Generation != ref.Generation || snapcodec.ManifestDigest(generation.Manifest) != ref.ManifestDigest {
95 return ports.SnapshotGeneration{}, errors.New("checkpoint unavailable")
96 }
97 return generation, nil
98}
99
100func TestSnapshotAcceptanceRepositoryLoadMutationDoesNotChangeStoredGeneration(t *testing.T) {
101 digest := ports.SnapshotDigest{1}
102 stored := ports.SnapshotGeneration{
103 Name: "work",
104 Manifest: []byte("manifest"),
105 Objects: map[ports.SnapshotDigest][]byte{digest: []byte("object")},
106 }
107 repository := &snapshotAcceptanceRepository{
108 generations: map[string]ports.SnapshotGeneration{"work": stored},
109 publishes: []ports.SnapshotPublication{{Name: "work"}},
110 loadMutate: func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration {
111 g.Manifest[0] = 'M'
112 g.Objects[digest][0] = 'O'
113 delete(g.Objects, digest)
114 return g
115 },
116 }
117
118 _, err := repository.Load(context.Background(), "work")
119 require.NoError(t, err)
120 require.Equal(t, []byte("manifest"), repository.generations["work"].Manifest)
121 require.Equal(t, []byte("object"), repository.generations["work"].Objects[digest])
122}
123
124func cloneAcceptanceGeneration(g ports.SnapshotGeneration) ports.SnapshotGeneration {
125 out := g
126 out.Manifest = append([]byte(nil), g.Manifest...)
127 out.Objects = make(map[ports.SnapshotDigest][]byte, len(g.Objects))
128 for digest, data := range g.Objects {
129 out.Objects[digest] = append([]byte(nil), data...)
130 }
131 return out
132}
133
134func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session {
135 t.Helper()
136 history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxBytes: 8 * 293 * 32, ChunkRows: 2})
137 for _, ch := range []rune{'a', 'b', 'c', 'd', 'e'} {
138 row := make([]renderer.Cell, 293)
139 for i := range row {
140 row[i] = renderer.Cell{Rune: ch}
141 }
142 appendHistoryRow(t, history, row)
143 }
144 view := history.SnapshotView()
145 sealed := make([][]byte, view.ChunkCount())
146 for i := range sealed {
147 var err error
148 sealed[i], err = vt.MarshalHistoryChunk(view.Chunk(i))
149 require.NoError(t, err)
150 }
151 tail, err := vt.MarshalHistoryTail(view)
152 require.NoError(t, err)
153
154 screen := vt.NewScreen(293, 4)
155 screen.Write([]byte("primary-visible"))
156 screen.Write([]byte("\x1b[?1049h"))
157 screen.Write([]byte("alternate-active"))
158 transcript, err := screen.RecoveryTranscriptSnapshot().Marshal()
159 require.NoError(t, err)
160
161 paneID := layout.PaneID("pane-7")
162 return snapcodec.Session{
163 Name: name, CreatedAt: 77, Active: 0,
164 Tabs: []snapcodec.Tab{{
165 StableID: "tab-stable", Cols: 293, Rows: 4, NextPaneID: 8, Focus: paneID,
166 Tree: &layout.Tree{Root: layout.NewLeaf(paneID), Focus: paneID},
167 Panes: []snapcodec.Pane{{
168 ID: paneID, StableID: "pane-stable", Cwd: "/snapshot/cwd", SealedChunks: sealed, Tail: tail, Transcript: transcript,
169 Process: &snapcodec.Process{Argv: []string{"sleep", "99"}, Strategy: "argv"},
170 }},
171 }},
172 }
173}
174
175func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation uint64) ports.SnapshotGeneration {
176 t.Helper()
177 publication, err := acceptancePublication(snapshot)
178 require.NoError(t, err)
179 manifest, err := snapcodec.UnmarshalManifest(publication.Manifest)
180 require.NoError(t, err)
181 manifest.Generation = generation
182 encoded, err := snapcodec.MarshalManifest(manifest)
183 require.NoError(t, err)
184 publication.Generation = generation
185 publication.Manifest = encoded
186 return cloneAcceptanceGeneration(snapshotGeneration(publication))
187}
188
189func TestValidateRestoreSessionSnapshot(t *testing.T) {
190 valid := restoreAcceptanceSession(t, "restored")
191 for _, test := range []struct {
192 name string
193 snap snapcodec.Session
194 }{
195 {"valid", valid},
196 {"empty name", func() snapcodec.Session { snap := valid; snap.Name = ""; return snap }()},
197 {"active without tabs", func() snapcodec.Session { snap := valid; snap.Tabs = nil; snap.Active = 1; return snap }()},
198 {"active beyond tabs", func() snapcodec.Session { snap := valid; snap.Active = 1; return snap }()},
199 {"created at overflow", func() snapcodec.Session { snap := valid; snap.CreatedAt = math.MaxInt64 + 1; return snap }()},
200 } {
201 t.Run(test.name, func(t *testing.T) {
202 err := validateRestoreSessionSnapshot(test.snap)
203 if test.name == "valid" {
204 require.NoError(t, err)
205 return
206 }
207 require.Error(t, err)
208 })
209 }
210}
211
212func TestRestoreIncrementalGenerationAcceptance(t *testing.T) {
213 snapshot := restoreAcceptanceSession(t, "restored")
214 generation := acceptanceGeneration(t, snapshot, 9)
215 repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}}
216 pty, release := newBlockingPTY(t)
217 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
218 d.baseEnv = []string{"TERM=xterm-kitty", "COLORTERM=truecolor"}
219 checkpoint := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)}
220 record := domain.CatalogueRecord{Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), Committed: &checkpoint}
221 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
222 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
223 WithSnapshotRepository(repository)(d)
224 WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d)
225 t.Cleanup(func() { release(); d.sessWg.Wait() })
226
227 d.restoreIncrementalSnapshots(context.Background())
228
229 d.mu.Lock()
230 restored := d.findByNameLocked(snapshot.Name)
231 d.mu.Unlock()
232 require.NotNil(t, restored)
233 require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean")
234 require.Equal(t, uint64(9), restored.snapshotPublishedGeneration, "the loaded manifest is the repository generation head")
235 require.Equal(t, 0, testAttachmentTabIndex(restored))
236 require.Equal(t, "/snapshot/cwd", restored.cwd)
237 require.Len(t, restored.tabs, 1)
238 tab := restored.tabs[0]
239 require.Equal(t, 293, tab.size.Cols)
240 require.Equal(t, layout.PaneID("pane-7"), tab.tree.Focus)
241 pane := tab.panes[layout.PaneID("pane-7")]
242 pane.mu.Lock()
243 wantHistory := []string{strings.Repeat("a", 293), strings.Repeat("b", 293), strings.Repeat("c", 293), strings.Repeat("d", 293), strings.Repeat("e", 293), "primary-visible", "alternate-active"}
244 require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "bounded history must precede primary and active alternate transcript rows")
245 for y := range pane.screen.Rows() {
246 for x := range pane.screen.Columns() {
247 require.Truef(t, pane.screen.Cell(x, y).Equal(renderer.BlankCell()), "restored frame cell (%d,%d) must be blank", x, y)
248 }
249 }
250 require.Equal(t, 7, pane.history.Len())
251 cells := 0
252 view := pane.history.View()
253 for y := range view.Len() {
254 cells += view.RowWidth(y)
255 }
256 require.Equal(t, 7*293, cells)
257 require.Equal(t, defaultScrollbackRows, pane.history.Cap())
258 require.Equal(t, defaultScrollbackBytes, pane.history.ByteCap())
259 require.LessOrEqual(t, pane.history.LogicalBytes(), pane.history.ByteCap())
260 pane.screen.Write([]byte("new-output"))
261 pane.screen.Write([]byte("\x1b[?1049h\x1b[?1049l"))
262 require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "new output and redraw must not remove recovered history")
263 pane.mu.Unlock()
264
265 startSnapshotEncodeWorker(t, d)
266 markSnapshotDirty(restored)
267 require.True(t, d.scheduleSnapshot(restored))
268 awaitSnapshotClean(t, restored)
269 require.Len(t, repository.publishes, 1)
270 require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream")
271}
272
273type paneLayoutSnapshotFixture struct {
274 snapshot snapcodec.Session
275 tree *layout.Tree
276 tabStableID string
277 paneStableIDs map[layout.PaneID]string
278}
279
280type restoredPaneLayout struct {
281 tree *layout.Tree
282 tabStableID string
283 paneStableIDs map[layout.PaneID]string
284}
285
286func TestSnapshotCodecPreservesConsumedAndExpelledPaneLayout(t *testing.T) {
287 fixture := captureConsumedAndExpelledPaneLayout(t)
288 require.Len(t, fixture.snapshot.Tabs, 1)
289 decodedTab := fixture.snapshot.Tabs[0]
290
291 require.Equal(t, fixture.tabStableID, decodedTab.StableID)
292 require.Equal(t, layout.PaneID("pane-3"), decodedTab.Focus, "snapshot tab focus must be preserved")
293 assertConsumedAndExpelledColumns(t, decodedTab.Tree)
294 require.Equal(t, fixture.tree, decodedTab.Tree, "manifest codec must preserve the complete rearranged tree")
295 require.Equal(t, fixture.paneStableIDs, snapshotPaneStableIDs(decodedTab.Panes), "manifest codec must preserve every pane stable ID")
296}
297
298func TestSnapshotRuntimeRestorePreservesConsumedAndExpelledPaneLayout(t *testing.T) {
299 fixture := captureConsumedAndExpelledPaneLayout(t)
300 restoreDaemon := newTestDaemon(t, newFactory(t, &paneRearrangePTY{}), stubClock{})
301 restoredTabs, err := restoreDaemon.restoreSnapshotTabs(context.Background(), restoreDaemon.serveCtx, fixture.snapshot)
302 require.NoError(t, err)
303 defer closeRestoredTabs(restoredTabs)
304 require.Len(t, restoredTabs, 1)
305
306 restored := captureRestoredPaneLayout(restoredTabs[0])
307 require.Equal(t, fixture.tabStableID, restored.tabStableID)
308 assertConsumedAndExpelledColumns(t, restored.tree)
309 require.Equal(t, fixture.tree, restored.tree, "runtime restore must preserve the complete rearranged tree")
310 require.Equal(t, fixture.paneStableIDs, restored.paneStableIDs, "runtime restore must preserve every pane stable ID")
311}
312
313func TestSnapshotRestoreUsesDaemonTerminalCapabilities(t *testing.T) {
314 tests := []struct {
315 name string
316 baseEnv []string
317 wantTerm string
318 wantColorTerm string
319 notWantEntries []string
320 }{
321 {
322 name: "truecolor",
323 baseEnv: []string{
324 "TERM=xterm-kitty",
325 "COLORTERM=truecolor",
326 "XDG_RUNTIME_DIR=/run/user/1000",
327 "WAYLAND_DISPLAY=wayland-1",
328 },
329 wantTerm: "TERM=xterm-256color",
330 wantColorTerm: "COLORTERM=truecolor",
331 notWantEntries: []string{"TERM=xterm-kitty"},
332 },
333 {
334 name: "256-color fallback",
335 baseEnv: []string{
336 "TERM=xterm-256color",
337 "COLORTERM=old",
338 "XDG_RUNTIME_DIR=/run/user/1000",
339 "WAYLAND_DISPLAY=wayland-1",
340 },
341 wantTerm: "TERM=xterm-256color",
342 wantColorTerm: "COLORTERM=truecolor",
343 notWantEntries: []string{"COLORTERM=old"},
344 },
345 }
346
347 for _, tt := range tests {
348 t.Run(tt.name, func(t *testing.T) {
349 snapshot := restoreAcceptanceSession(t, "restored")
350 pty, release := newBlockingPTY(t)
351 defer release()
352
353 var gotEnv []string
354 factory := portsmocks.NewMockPTYFactory(t)
355 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
356 RunAndReturn(func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) {
357 gotEnv = append([]string(nil), env...)
358 return pty, nil
359 }).Once()
360
361 d := newTestDaemon(t, factory, stubClock{})
362 d.baseEnv = tt.baseEnv
363 tabs, err := d.restoreSnapshotTabs(context.Background(), d.serveCtx, snapshot)
364 require.NoError(t, err)
365 defer closeRestoredTabs(tabs)
366
367 require.Contains(t, gotEnv, tt.wantTerm)
368 if tt.wantColorTerm != "" {
369 require.Contains(t, gotEnv, tt.wantColorTerm)
370 }
371 require.Contains(t, gotEnv, "XDG_RUNTIME_DIR=/run/user/1000")
372 require.Contains(t, gotEnv, "WAYLAND_DISPLAY=wayland-1")
373 for _, notWant := range tt.notWantEntries {
374 require.NotContains(t, gotEnv, notWant)
375 }
376 })
377 }
378}
379
380func captureConsumedAndExpelledPaneLayout(t *testing.T) paneLayoutSnapshotFixture {
381 t.Helper()
382 h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, paneLayoutSnapshotTree())
383 h.session.createdAt = 77
384 h.session.incarnation = domain.IncarnationID{7}
385 tabStableID := "tab-stable-rearranged"
386 paneStableIDs := map[layout.PaneID]string{
387 "pane-1": "pane-stable-1",
388 "pane-2": "pane-stable-2",
389 "pane-3": "pane-stable-3",
390 "pane-4": "pane-stable-4",
391 "pane-5": "pane-stable-5",
392 }
393 setPaneLayoutSnapshotIDs(h.tab, tabStableID, paneStableIDs)
394 applyPaneLayoutSnapshotOperations(t, h)
395
396 h.tab.mu.Lock()
397 wantTree := h.tab.tree.Clone()
398 h.tab.mu.Unlock()
399 capture, ok := h.daemon.captureSnapshotState(h.session, 7)
400 require.True(t, ok)
401 publication, err := h.daemon.incrementalPublication(capture)
402 require.NoError(t, err)
403 decoded, err := sessionFromGeneration(snapshotGeneration(publication))
404 require.NoError(t, err)
405
406 return paneLayoutSnapshotFixture{
407 snapshot: decoded,
408 tree: wantTree,
409 tabStableID: tabStableID,
410 paneStableIDs: paneStableIDs,
411 }
412}
413
414func paneLayoutSnapshotTree() *layout.Tree {
415 return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Weight: 9, Children: []*layout.Node{
416 {Kind: layout.Leaf, Leaf: "pane-1", Weight: 40},
417 {Kind: layout.Split, Dir: layout.Vertical, Weight: 80, Children: []*layout.Node{
418 {Kind: layout.Leaf, Leaf: "pane-2", Weight: 10},
419 {Kind: layout.Leaf, Leaf: "pane-3", Weight: 20},
420 }},
421 {Kind: layout.Stack, Weight: 40, Expanded: "pane-5", Children: []*layout.Node{
422 {Kind: layout.Leaf, Leaf: "pane-4", Weight: 7},
423 {Kind: layout.Leaf, Leaf: "pane-5", Weight: 3},
424 }},
425 }}, Focus: "pane-4"}
426}
427
428func setPaneLayoutSnapshotIDs(tb *tab, tabStableID string, paneStableIDs map[layout.PaneID]string) {
429 tb.mu.Lock()
430 defer tb.mu.Unlock()
431 tb.stableID = tabStableID
432 tb.nextPaneID = 6
433 for id, stableID := range paneStableIDs {
434 tb.panes[id].mu.Lock()
435 tb.panes[id].stableID = stableID
436 tb.panes[id].mu.Unlock()
437 }
438}
439
440func applyPaneLayoutSnapshotOperations(t *testing.T, h *paneRearrangeHarness) {
441 t.Helper()
442 for _, operation := range []struct {
443 name string
444 pane layout.PaneID
445 direction layout.Direction
446 }{
447 {name: "consume singleton into vertical column", pane: "pane-1", direction: layout.Right},
448 {name: "expel vertical member as singleton column", pane: "pane-3", direction: layout.Right},
449 } {
450 err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
451 kind: daemonActionConsumeOrExpelPane, target: h.target(operation.pane), direction: operation.direction,
452 })
453 require.NoError(t, err, operation.name)
454 }
455}
456
457func assertConsumedAndExpelledColumns(t *testing.T, tree *layout.Tree) {
458 t.Helper()
459 require.NotNil(t, tree)
460 require.NotNil(t, tree.Root)
461 require.Equal(t, layout.Horizontal, tree.Root.Dir)
462 require.Len(t, tree.Root.Children, 3)
463 require.Equal(t, layout.Vertical, tree.Root.Children[0].Dir, "consumed panes must remain a vertical column")
464 require.Equal(t, layout.Leaf, tree.Root.Children[1].Kind, "expelled pane must remain a singleton column")
465 require.Equal(t, layout.Stack, tree.Root.Children[2].Kind, "stack column must remain present")
466 require.Equal(t, layout.PaneID("pane-5"), tree.Root.Children[2].Expanded, "stack expansion must be preserved")
467 require.Equal(t, layout.PaneID("pane-3"), tree.Focus, "expelled pane focus must be preserved")
468}
469
470func snapshotPaneStableIDs(panes []snapcodec.Pane) map[layout.PaneID]string {
471 stableIDs := make(map[layout.PaneID]string, len(panes))
472 for _, p := range panes {
473 stableIDs[p.ID] = p.StableID
474 }
475 return stableIDs
476}
477
478func captureRestoredPaneLayout(tb *tab) restoredPaneLayout {
479 tb.mu.Lock()
480 defer tb.mu.Unlock()
481 stableIDs := make(map[layout.PaneID]string, len(tb.panes))
482 for id, p := range tb.panes {
483 p.mu.Lock()
484 stableIDs[id] = p.stableID
485 p.mu.Unlock()
486 }
487 return restoredPaneLayout{tree: tb.tree.Clone(), tabStableID: tb.stableID, paneStableIDs: stableIDs}
488}
489
490func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
491 snapshot := restoreAcceptanceSession(t, "restored")
492 generation := acceptanceGeneration(t, snapshot, 9)
493 repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}}
494 committed := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)}
495 record := domain.CatalogueRecord{
496 Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd",
497 CreatedAt: int64(snapshot.CreatedAt), UpdatedAt: 81, LastUsedSeq: 17,
498 TabNames: []string{"before"}, Committed: &committed,
499 }
500 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
501
502 pty, release := newBlockingPTY(t)
503 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
504 coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil)
505 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
506 WithSnapshotRepository(repository)(d)
507 WithRecoveryCoordinator(coordinator)(d)
508 t.Cleanup(func() { release(); d.sessWg.Wait() })
509
510 d.restoreIncrementalSnapshots(context.Background())
511 d.mu.Lock()
512 restored := d.findByNameLocked(snapshot.Name)
513 d.mu.Unlock()
514 require.NotNil(t, restored)
515 require.NoError(t, d.renameTab(restored, restored.tabs[0], "after"))
516
517 updates := catalogue.MetadataUpdates()
518 require.Len(t, updates, 1, "the authoritative Catalogue port must receive restored-session metadata updates")
519 require.Equal(t, record.IncarnationID, updates[0].IncarnationID)
520 updated, ok, err := catalogue.Record(record.Name)
521 require.NoError(t, err)
522 require.True(t, ok)
523 require.Equal(t, record.IncarnationID, updated.IncarnationID)
524 require.Equal(t, record.Committed, updated.Committed)
525 require.Equal(t, record.DegradedReason, updated.DegradedReason)
526 require.Equal(t, []string{"after"}, updated.TabNames)
527
528 startSnapshotEncodeWorker(t, d)
529 markSnapshotDirty(restored)
530 require.True(t, d.scheduleSnapshot(restored))
531 awaitSnapshotIdle(t, restored)
532 d.snapshots.noticeMu.Lock()
533 failure := d.snapshots.activeFailureSignature
534 d.snapshots.noticeMu.Unlock()
535 require.Empty(t, failure)
536 require.Len(t, repository.publishes, 1)
537 require.Equal(t, record.Committed, repository.publishes[0].ParentCheckpoint)
538 published, ok, err := catalogue.Record(record.Name)
539 require.NoError(t, err)
540 require.True(t, ok)
541 require.Equal(t, uint64(10), published.Committed.Generation)
542}
543
544func TestRestorePaneTerminalRequiresCompleteRecoveryAndInstallsAtomically(t *testing.T) {
545 base := restoreAcceptanceSession(t, "restored").Tabs[0].Panes[0]
546 for _, tt := range []struct {
547 name string
548 mutate func(*snapcodec.Pane)
549 }{
550 {name: "missing tail", mutate: func(snap *snapcodec.Pane) { snap.Tail = nil }},
551 {name: "missing transcript", mutate: func(snap *snapcodec.Pane) { snap.Transcript = nil }},
552 {name: "invalid transcript", mutate: func(snap *snapcodec.Pane) { snap.Transcript = []byte("invalid") }},
553 } {
554 t.Run(tt.name, func(t *testing.T) {
555 snap := base
556 tt.mutate(&snap)
557 p := newPane("pane", nil, domain.Size{Cols: 293, Rows: 4})
558 p.screen.Write([]byte("old-frame"))
559 require.NoError(t, p.history.Append(testRow("old-history"), vt.LineBound{End: len("old-history")}))
560 oldScreen, oldHistory := p.screen, p.history
561
562 err := restorePaneTerminal(p, snap)
563
564 require.Error(t, err)
565 require.Same(t, oldScreen, p.screen)
566 require.Same(t, oldHistory, p.history)
567 require.Equal(t, 'o', p.screen.Cell(0, 0).Rune, "a failed restore must never install a partial or old persisted frame")
568 require.Equal(t, "old-history", cellsString(p.history.View().Row(0)))
569 })
570 }
571}
572
573func snapshotHistoryTexts(view vt.HistoryView) []string {
574 rows := make([]string, view.Len())
575 for i := range rows {
576 rows[i] = strings.TrimRight(cellsString(view.Row(i)), " ")
577 }
578 return rows
579}
580
581func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {
582 snapshot := restoreAcceptanceSession(t, "fallback")
583 valid := acceptanceGeneration(t, snapshot, 3)
584
585 for _, mutate := range []struct {
586 name string
587 fn func(*ports.SnapshotGeneration)
588 }{
589 {"missing", func(g *ports.SnapshotGeneration) {
590 for digest := range g.Objects {
591 delete(g.Objects, digest)
592 break
593 }
594 }},
595 {"wrong role after digest verification", func(g *ports.SnapshotGeneration) {
596 manifest, err := snapcodec.UnmarshalManifest(g.Manifest)
597 require.NoError(t, err)
598 tailRef := manifest.Tabs[0].Panes[0].Tail
599 _, payload, err := snapcodec.UnmarshalObject(g.Objects[tailRef.Digest])
600 require.NoError(t, err)
601 replacement, err := snapcodec.MarshalObject(snapcodec.RecoveryTranscript, payload)
602 require.NoError(t, err)
603 manifest.Tabs[0].Panes[0].Tail.Digest = replacement.Digest
604 manifest.Tabs[0].Panes[0].Tail.Size = uint32(len(replacement.Data))
605 g.Manifest, err = snapcodec.MarshalManifest(manifest)
606 require.NoError(t, err)
607 delete(g.Objects, tailRef.Digest)
608 g.Objects[replacement.Digest] = replacement.Data
609 }},
610 {"manifest size mismatch", func(g *ports.SnapshotGeneration) {
611 manifest, err := snapcodec.UnmarshalManifest(g.Manifest)
612 require.NoError(t, err)
613 manifest.Tabs[0].Panes[0].Tail.Size++
614 g.Manifest, err = snapcodec.MarshalManifest(manifest)
615 require.NoError(t, err)
616 }},
617 } {
618 t.Run(mutate.name, func(t *testing.T) {
619 generation := cloneAcceptanceGeneration(valid)
620 mutate.fn(&generation)
621 _, err := sessionFromGeneration(generation)
622 require.Error(t, err)
623 })
624 }
625}
626
627// TestRestoreIncrementalPayloadBudget bounds the inflated payloads of one
628// generation: compressed objects must not restore past the budget.
629func TestRestoreIncrementalPayloadBudget(t *testing.T) {
630 generation := acceptanceGeneration(t, restoreAcceptanceSession(t, "budget"), 3)
631 decoded, err := sessionFromGeneration(generation)
632 require.NoError(t, err)
633 total := 0
634 for _, tab := range decoded.Tabs {
635 for _, pane := range tab.Panes {
636 for _, chunk := range pane.SealedChunks {
637 total += len(chunk)
638 }
639 total += len(pane.Tail) + len(pane.Transcript)
640 }
641 }
642 require.Positive(t, total)
643
644 for _, tt := range []struct {
645 name string
646 budget int
647 wantErr bool
648 }{
649 {name: "exact budget", budget: total},
650 {name: "one byte short", budget: total - 1, wantErr: true},
651 } {
652 t.Run(tt.name, func(t *testing.T) {
653 _, err := sessionFromGenerationBudget(cloneAcceptanceGeneration(generation), tt.budget)
654 if tt.wantErr {
655 require.ErrorContains(t, err, "generation payload too large")
656 return
657 }
658 require.NoError(t, err)
659 })
660 }
661}