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/ports"
18 portsmocks "github.com/bnema/vev/internal/ports/mocks"
19 "github.com/bnema/vev/internal/usecase/layout"
20 recoveryusecase "github.com/bnema/vev/internal/usecase/recovery"
21 snapcodec "github.com/bnema/vev/internal/usecase/snapshot"
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, MaxCells: 8 * 293, 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.True(t, restored.terminal.TrueColor, "restored sessions must retain startup terminal capability for future panes")
236 require.Equal(t, 0, testAttachmentTabIndex(restored))
237 require.Equal(t, "/snapshot/cwd", restored.cwd)
238 require.Len(t, restored.tabs, 1)
239 tab := restored.tabs[0]
240 require.Equal(t, 293, tab.size.Cols)
241 require.Equal(t, layout.PaneID("pane-7"), tab.tree.Focus)
242 pane := tab.panes[layout.PaneID("pane-7")]
243 pane.mu.Lock()
244 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"}
245 require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "bounded history must precede primary and active alternate transcript rows")
246 for y := range pane.screen.Frame.Height {
247 for x := range pane.screen.Frame.Width {
248 require.Truef(t, pane.screen.Frame.At(x, y).Equal(renderer.BlankCell()), "restored frame cell (%d,%d) must be blank", x, y)
249 }
250 }
251 require.Equal(t, 7, pane.history.Len())
252 require.Equal(t, 7*293, pane.history.Cells())
253 require.Equal(t, defaultScrollbackRows, pane.history.Cap())
254 require.Equal(t, defaultScrollbackCells, pane.history.CellCap())
255 pane.screen.Write([]byte("new-output"))
256 pane.screen.Write([]byte("\x1b[?1049h\x1b[?1049l"))
257 require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "new output and redraw must not remove recovered history")
258 pane.mu.Unlock()
259
260 startSnapshotEncodeWorker(t, d)
261 markSnapshotDirty(restored)
262 require.True(t, d.scheduleSnapshot(restored))
263 awaitSnapshotClean(t, restored)
264 require.Len(t, repository.publishes, 1)
265 require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream")
266}
267
268type paneLayoutSnapshotFixture struct {
269 snapshot snapcodec.Session
270 tree *layout.Tree
271 tabStableID string
272 paneStableIDs map[layout.PaneID]string
273}
274
275type restoredPaneLayout struct {
276 tree *layout.Tree
277 tabStableID string
278 paneStableIDs map[layout.PaneID]string
279}
280
281func TestSnapshotCodecPreservesConsumedAndExpelledPaneLayout(t *testing.T) {
282 fixture := captureConsumedAndExpelledPaneLayout(t)
283 require.Len(t, fixture.snapshot.Tabs, 1)
284 decodedTab := fixture.snapshot.Tabs[0]
285
286 require.Equal(t, fixture.tabStableID, decodedTab.StableID)
287 require.Equal(t, layout.PaneID("pane-3"), decodedTab.Focus, "snapshot tab focus must be preserved")
288 assertConsumedAndExpelledColumns(t, decodedTab.Tree)
289 require.Equal(t, fixture.tree, decodedTab.Tree, "manifest codec must preserve the complete rearranged tree")
290 require.Equal(t, fixture.paneStableIDs, snapshotPaneStableIDs(decodedTab.Panes), "manifest codec must preserve every pane stable ID")
291}
292
293func TestSnapshotRuntimeRestorePreservesConsumedAndExpelledPaneLayout(t *testing.T) {
294 fixture := captureConsumedAndExpelledPaneLayout(t)
295 restoreDaemon := newTestDaemon(t, newFactory(t, &paneRearrangePTY{}), stubClock{})
296 restoredTabs, err := restoreDaemon.restoreSnapshotTabs(context.Background(), restoreDaemon.serveCtx, fixture.snapshot)
297 require.NoError(t, err)
298 defer closeRestoredTabs(restoredTabs)
299 require.Len(t, restoredTabs, 1)
300
301 restored := captureRestoredPaneLayout(restoredTabs[0])
302 require.Equal(t, fixture.tabStableID, restored.tabStableID)
303 assertConsumedAndExpelledColumns(t, restored.tree)
304 require.Equal(t, fixture.tree, restored.tree, "runtime restore must preserve the complete rearranged tree")
305 require.Equal(t, fixture.paneStableIDs, restored.paneStableIDs, "runtime restore must preserve every pane stable ID")
306}
307
308func TestSnapshotRestoreUsesDaemonTerminalCapabilities(t *testing.T) {
309 tests := []struct {
310 name string
311 baseEnv []string
312 wantTerm string
313 wantColorTerm string
314 notWantEntries []string
315 }{
316 {
317 name: "truecolor",
318 baseEnv: []string{
319 "TERM=xterm-kitty",
320 "COLORTERM=truecolor",
321 "XDG_RUNTIME_DIR=/run/user/1000",
322 "WAYLAND_DISPLAY=wayland-1",
323 },
324 wantTerm: "TERM=xterm-direct",
325 wantColorTerm: "COLORTERM=truecolor",
326 notWantEntries: []string{"TERM=xterm-kitty"},
327 },
328 {
329 name: "256-color fallback",
330 baseEnv: []string{
331 "TERM=xterm-256color",
332 "COLORTERM=old",
333 "XDG_RUNTIME_DIR=/run/user/1000",
334 "WAYLAND_DISPLAY=wayland-1",
335 },
336 wantTerm: "TERM=xterm-256color",
337 notWantEntries: []string{"COLORTERM=old", "COLORTERM=truecolor"},
338 },
339 }
340
341 for _, tt := range tests {
342 t.Run(tt.name, func(t *testing.T) {
343 snapshot := restoreAcceptanceSession(t, "restored")
344 pty, release := newBlockingPTY(t)
345 defer release()
346
347 var gotEnv []string
348 factory := portsmocks.NewMockPTYFactory(t)
349 factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
350 RunAndReturn(func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Size) (ports.PTY, error) {
351 gotEnv = append([]string(nil), env...)
352 return pty, nil
353 }).Once()
354
355 d := newTestDaemon(t, factory, stubClock{})
356 d.baseEnv = tt.baseEnv
357 tabs, err := d.restoreSnapshotTabs(context.Background(), d.serveCtx, snapshot)
358 require.NoError(t, err)
359 defer closeRestoredTabs(tabs)
360
361 require.Contains(t, gotEnv, tt.wantTerm)
362 if tt.wantColorTerm != "" {
363 require.Contains(t, gotEnv, tt.wantColorTerm)
364 }
365 require.Contains(t, gotEnv, "XDG_RUNTIME_DIR=/run/user/1000")
366 require.Contains(t, gotEnv, "WAYLAND_DISPLAY=wayland-1")
367 for _, notWant := range tt.notWantEntries {
368 require.NotContains(t, gotEnv, notWant)
369 }
370 })
371 }
372}
373
374func captureConsumedAndExpelledPaneLayout(t *testing.T) paneLayoutSnapshotFixture {
375 t.Helper()
376 h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, paneLayoutSnapshotTree())
377 h.session.createdAt = 77
378 h.session.incarnation = domain.IncarnationID{7}
379 tabStableID := "tab-stable-rearranged"
380 paneStableIDs := map[layout.PaneID]string{
381 "pane-1": "pane-stable-1",
382 "pane-2": "pane-stable-2",
383 "pane-3": "pane-stable-3",
384 "pane-4": "pane-stable-4",
385 "pane-5": "pane-stable-5",
386 }
387 setPaneLayoutSnapshotIDs(h.tab, tabStableID, paneStableIDs)
388 applyPaneLayoutSnapshotOperations(t, h)
389
390 h.tab.mu.Lock()
391 wantTree := h.tab.tree.Clone()
392 h.tab.mu.Unlock()
393 capture, ok := h.daemon.captureSnapshotState(h.session, 7)
394 require.True(t, ok)
395 publication, err := h.daemon.incrementalPublication(capture)
396 require.NoError(t, err)
397 decoded, err := sessionFromGeneration(snapshotGeneration(publication))
398 require.NoError(t, err)
399
400 return paneLayoutSnapshotFixture{
401 snapshot: decoded,
402 tree: wantTree,
403 tabStableID: tabStableID,
404 paneStableIDs: paneStableIDs,
405 }
406}
407
408func paneLayoutSnapshotTree() *layout.Tree {
409 return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Weight: 9, Children: []*layout.Node{
410 {Kind: layout.Leaf, Leaf: "pane-1", Weight: 40},
411 {Kind: layout.Split, Dir: layout.Vertical, Weight: 80, Children: []*layout.Node{
412 {Kind: layout.Leaf, Leaf: "pane-2", Weight: 10},
413 {Kind: layout.Leaf, Leaf: "pane-3", Weight: 20},
414 }},
415 {Kind: layout.Stack, Weight: 40, Expanded: "pane-5", Children: []*layout.Node{
416 {Kind: layout.Leaf, Leaf: "pane-4", Weight: 7},
417 {Kind: layout.Leaf, Leaf: "pane-5", Weight: 3},
418 }},
419 }}, Focus: "pane-4"}
420}
421
422func setPaneLayoutSnapshotIDs(tb *tab, tabStableID string, paneStableIDs map[layout.PaneID]string) {
423 tb.mu.Lock()
424 defer tb.mu.Unlock()
425 tb.stableID = tabStableID
426 tb.nextPaneID = 6
427 for id, stableID := range paneStableIDs {
428 tb.panes[id].mu.Lock()
429 tb.panes[id].stableID = stableID
430 tb.panes[id].mu.Unlock()
431 }
432}
433
434func applyPaneLayoutSnapshotOperations(t *testing.T, h *paneRearrangeHarness) {
435 t.Helper()
436 for _, operation := range []struct {
437 name string
438 pane layout.PaneID
439 direction layout.Direction
440 }{
441 {name: "consume singleton into vertical column", pane: "pane-1", direction: layout.Right},
442 {name: "expel vertical member as singleton column", pane: "pane-3", direction: layout.Right},
443 } {
444 err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{
445 kind: daemonActionConsumeOrExpelPane, target: h.target(operation.pane), direction: operation.direction,
446 })
447 require.NoError(t, err, operation.name)
448 }
449}
450
451func assertConsumedAndExpelledColumns(t *testing.T, tree *layout.Tree) {
452 t.Helper()
453 require.NotNil(t, tree)
454 require.NotNil(t, tree.Root)
455 require.Equal(t, layout.Horizontal, tree.Root.Dir)
456 require.Len(t, tree.Root.Children, 3)
457 require.Equal(t, layout.Vertical, tree.Root.Children[0].Dir, "consumed panes must remain a vertical column")
458 require.Equal(t, layout.Leaf, tree.Root.Children[1].Kind, "expelled pane must remain a singleton column")
459 require.Equal(t, layout.Stack, tree.Root.Children[2].Kind, "stack column must remain present")
460 require.Equal(t, layout.PaneID("pane-5"), tree.Root.Children[2].Expanded, "stack expansion must be preserved")
461 require.Equal(t, layout.PaneID("pane-3"), tree.Focus, "expelled pane focus must be preserved")
462}
463
464func snapshotPaneStableIDs(panes []snapcodec.Pane) map[layout.PaneID]string {
465 stableIDs := make(map[layout.PaneID]string, len(panes))
466 for _, p := range panes {
467 stableIDs[p.ID] = p.StableID
468 }
469 return stableIDs
470}
471
472func captureRestoredPaneLayout(tb *tab) restoredPaneLayout {
473 tb.mu.Lock()
474 defer tb.mu.Unlock()
475 stableIDs := make(map[layout.PaneID]string, len(tb.panes))
476 for id, p := range tb.panes {
477 p.mu.Lock()
478 stableIDs[id] = p.stableID
479 p.mu.Unlock()
480 }
481 return restoredPaneLayout{tree: tb.tree.Clone(), tabStableID: tb.stableID, paneStableIDs: stableIDs}
482}
483
484func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) {
485 snapshot := restoreAcceptanceSession(t, "restored")
486 generation := acceptanceGeneration(t, snapshot, 9)
487 repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}}
488 committed := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)}
489 record := domain.CatalogueRecord{
490 Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd",
491 CreatedAt: int64(snapshot.CreatedAt), UpdatedAt: 81, LastUsedSeq: 17,
492 TabNames: []string{"before"}, Committed: &committed,
493 }
494 catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record})
495
496 pty, release := newBlockingPTY(t)
497 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
498 coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil)
499 WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d)
500 WithSnapshotRepository(repository)(d)
501 WithRecoveryCoordinator(coordinator)(d)
502 t.Cleanup(func() { release(); d.sessWg.Wait() })
503
504 d.restoreIncrementalSnapshots(context.Background())
505 d.mu.Lock()
506 restored := d.findByNameLocked(snapshot.Name)
507 d.mu.Unlock()
508 require.NotNil(t, restored)
509 require.NoError(t, d.renameTab(restored, restored.tabs[0], "after"))
510
511 updates := catalogue.MetadataUpdates()
512 require.Len(t, updates, 1, "the authoritative Catalogue port must receive restored-session metadata updates")
513 require.Equal(t, record.IncarnationID, updates[0].IncarnationID)
514 updated, ok, err := catalogue.Record(record.Name)
515 require.NoError(t, err)
516 require.True(t, ok)
517 require.Equal(t, record.IncarnationID, updated.IncarnationID)
518 require.Equal(t, record.Committed, updated.Committed)
519 require.Equal(t, record.DegradedReason, updated.DegradedReason)
520 require.Equal(t, []string{"after"}, updated.TabNames)
521
522 startSnapshotEncodeWorker(t, d)
523 markSnapshotDirty(restored)
524 require.True(t, d.scheduleSnapshot(restored))
525 awaitSnapshotIdle(t, restored)
526 d.snapshotNoticeMu.Lock()
527 failure := d.snapshotActiveFailureSignature
528 d.snapshotNoticeMu.Unlock()
529 require.Empty(t, failure)
530 require.Len(t, repository.publishes, 1)
531 require.Equal(t, record.Committed, repository.publishes[0].ParentCheckpoint)
532 published, ok, err := catalogue.Record(record.Name)
533 require.NoError(t, err)
534 require.True(t, ok)
535 require.Equal(t, uint64(10), published.Committed.Generation)
536}
537
538func TestRestorePaneTerminalRequiresCompleteRecoveryAndInstallsAtomically(t *testing.T) {
539 base := restoreAcceptanceSession(t, "restored").Tabs[0].Panes[0]
540 for _, tt := range []struct {
541 name string
542 mutate func(*snapcodec.Pane)
543 }{
544 {name: "missing tail", mutate: func(snap *snapcodec.Pane) { snap.Tail = nil }},
545 {name: "missing transcript", mutate: func(snap *snapcodec.Pane) { snap.Transcript = nil }},
546 {name: "invalid transcript", mutate: func(snap *snapcodec.Pane) { snap.Transcript = []byte("invalid") }},
547 } {
548 t.Run(tt.name, func(t *testing.T) {
549 snap := base
550 tt.mutate(&snap)
551 p := newPane("pane", nil, domain.Size{Cols: 293, Rows: 4})
552 p.screen.Write([]byte("old-frame"))
553 require.NoError(t, p.history.Append(testRow("old-history"), vt.LineBound{End: len("old-history")}))
554 oldScreen, oldHistory := p.screen, p.history
555
556 err := restorePaneTerminal(p, snap)
557
558 require.Error(t, err)
559 require.Same(t, oldScreen, p.screen)
560 require.Same(t, oldHistory, p.history)
561 require.Equal(t, 'o', p.screen.Frame.At(0, 0).Rune, "a failed restore must never install a partial or old persisted frame")
562 require.Equal(t, "old-history", cellsString(p.history.View().Row(0)))
563 })
564 }
565}
566
567func snapshotHistoryTexts(view vt.HistoryView) []string {
568 rows := make([]string, view.Len())
569 for i := range rows {
570 rows[i] = strings.TrimRight(cellsString(view.Row(i)), " ")
571 }
572 return rows
573}
574
575func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) {
576 snapshot := restoreAcceptanceSession(t, "fallback")
577 valid := acceptanceGeneration(t, snapshot, 3)
578
579 for _, mutate := range []struct {
580 name string
581 fn func(*ports.SnapshotGeneration)
582 }{
583 {"missing", func(g *ports.SnapshotGeneration) {
584 for digest := range g.Objects {
585 delete(g.Objects, digest)
586 break
587 }
588 }},
589 {"wrong role after digest verification", func(g *ports.SnapshotGeneration) {
590 manifest, err := snapcodec.UnmarshalManifest(g.Manifest)
591 require.NoError(t, err)
592 tailRef := manifest.Tabs[0].Panes[0].Tail
593 _, payload, err := snapcodec.UnmarshalObject(g.Objects[tailRef.Digest])
594 require.NoError(t, err)
595 replacement, err := snapcodec.MarshalObject(snapcodec.RecoveryTranscript, payload)
596 require.NoError(t, err)
597 manifest.Tabs[0].Panes[0].Tail.Digest = replacement.Digest
598 manifest.Tabs[0].Panes[0].Tail.Size = uint32(len(replacement.Data))
599 g.Manifest, err = snapcodec.MarshalManifest(manifest)
600 require.NoError(t, err)
601 delete(g.Objects, tailRef.Digest)
602 g.Objects[replacement.Digest] = replacement.Data
603 }},
604 {"wrong digest", func(g *ports.SnapshotGeneration) {
605 for digest, data := range g.Objects {
606 kind, payload, err := snapcodec.UnmarshalObject(data)
607 require.NoError(t, err)
608 payload = append([]byte(nil), payload...)
609 payload[len(payload)-1] ^= 1
610 replacement, err := snapcodec.MarshalObject(kind, payload)
611 require.NoError(t, err)
612 require.Equal(t, len(data), len(replacement.Data))
613 g.Objects[digest] = replacement.Data
614 break
615 }
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}