package daemon import ( "context" "errors" "math" "strings" "sync" "testing" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" vt "github.com/bnema/vev-vt" renderer "github.com/bnema/vev-vt/ansi" "github.com/bnema/vev/internal/domain" "github.com/bnema/vev/internal/domain/layout" "github.com/bnema/vev/internal/ports" portsmocks "github.com/bnema/vev/internal/ports/mocks" snapcodec "github.com/bnema/vev/internal/snapshotcodec" recoveryusecase "github.com/bnema/vev/internal/usecase/recovery" ) type snapshotAcceptanceRepository struct { noOpSnapshotRepository mu sync.Mutex names []string generations map[string]ports.SnapshotGeneration publishErr error listErr error loadErr error publishes []ports.SnapshotPublication loadMutate func(ports.SnapshotGeneration, ports.SnapshotPublication) ports.SnapshotGeneration } func (r *snapshotAcceptanceRepository) Publish(ctx context.Context, p ports.SnapshotPublication) error { if err := ctx.Err(); err != nil { return err } r.mu.Lock() defer r.mu.Unlock() r.publishes = append(r.publishes, p) if r.publishErr != nil { return r.publishErr } r.generations[p.Name] = cloneAcceptanceGeneration(snapshotGeneration(p)) found := false for _, name := range r.names { found = found || name == p.Name } if !found { r.names = append(r.names, p.Name) } return nil } func (r *snapshotAcceptanceRepository) List(ctx context.Context) ([]string, error) { if err := ctx.Err(); err != nil { return nil, err } r.mu.Lock() defer r.mu.Unlock() if r.listErr != nil { return nil, r.listErr } return append([]string(nil), r.names...), nil } func (r *snapshotAcceptanceRepository) Load(ctx context.Context, name string) (ports.SnapshotGeneration, error) { if err := ctx.Err(); err != nil { return ports.SnapshotGeneration{}, err } r.mu.Lock() defer r.mu.Unlock() if r.loadErr != nil { return ports.SnapshotGeneration{}, r.loadErr } generation, ok := r.generations[name] if !ok { return ports.SnapshotGeneration{}, errors.New("missing generation") } generation = cloneAcceptanceGeneration(generation) if r.loadMutate != nil && len(r.publishes) > 0 { generation = r.loadMutate(generation, r.publishes[len(r.publishes)-1]) } return cloneAcceptanceGeneration(generation), nil } func (r *snapshotAcceptanceRepository) LoadCheckpoint(ctx context.Context, id domain.IncarnationID, name string, ref ports.CheckpointRef) (ports.SnapshotGeneration, error) { generation, err := r.Load(ctx, name) if err != nil { return ports.SnapshotGeneration{}, err } if generation.IncarnationID != id || generation.Generation != ref.Generation || snapcodec.ManifestDigest(generation.Manifest) != ref.ManifestDigest { return ports.SnapshotGeneration{}, errors.New("checkpoint unavailable") } return generation, nil } func TestSnapshotAcceptanceRepositoryLoadMutationDoesNotChangeStoredGeneration(t *testing.T) { digest := ports.SnapshotDigest{1} stored := ports.SnapshotGeneration{ Name: "work", Manifest: []byte("manifest"), Objects: map[ports.SnapshotDigest][]byte{digest: []byte("object")}, } repository := &snapshotAcceptanceRepository{ generations: map[string]ports.SnapshotGeneration{"work": stored}, publishes: []ports.SnapshotPublication{{Name: "work"}}, loadMutate: func(g ports.SnapshotGeneration, _ ports.SnapshotPublication) ports.SnapshotGeneration { g.Manifest[0] = 'M' g.Objects[digest][0] = 'O' delete(g.Objects, digest) return g }, } _, err := repository.Load(context.Background(), "work") require.NoError(t, err) require.Equal(t, []byte("manifest"), repository.generations["work"].Manifest) require.Equal(t, []byte("object"), repository.generations["work"].Objects[digest]) } func cloneAcceptanceGeneration(g ports.SnapshotGeneration) ports.SnapshotGeneration { out := g out.Manifest = append([]byte(nil), g.Manifest...) out.Objects = make(map[ports.SnapshotDigest][]byte, len(g.Objects)) for digest, data := range g.Objects { out.Objects[digest] = append([]byte(nil), data...) } return out } func restoreAcceptanceSession(t *testing.T, name string) snapcodec.Session { t.Helper() history := vt.NewHistory(vt.HistoryConfig{MaxRows: 8, MaxBytes: 8 * 293 * 32, ChunkRows: 2}) for _, ch := range []rune{'a', 'b', 'c', 'd', 'e'} { row := make([]renderer.Cell, 293) for i := range row { row[i] = renderer.Cell{Rune: ch} } appendHistoryRow(t, history, row) } view := history.SnapshotView() sealed := make([][]byte, view.ChunkCount()) for i := range sealed { var err error sealed[i], err = vt.MarshalHistoryChunk(view.Chunk(i)) require.NoError(t, err) } tail, err := vt.MarshalHistoryTail(view) require.NoError(t, err) screen := vt.NewScreen(293, 4) screen.Write([]byte("primary-visible")) screen.Write([]byte("\x1b[?1049h")) screen.Write([]byte("alternate-active")) transcript, err := screen.RecoveryTranscriptSnapshot().Marshal() require.NoError(t, err) paneID := layout.PaneID("pane-7") return snapcodec.Session{ Name: name, CreatedAt: 77, Active: 0, Tabs: []snapcodec.Tab{{ StableID: "tab-stable", Cols: 293, Rows: 4, NextPaneID: 8, Focus: paneID, Tree: &layout.Tree{Root: layout.NewLeaf(paneID), Focus: paneID}, Panes: []snapcodec.Pane{{ ID: paneID, StableID: "pane-stable", Cwd: "/snapshot/cwd", SealedChunks: sealed, Tail: tail, Transcript: transcript, Process: &snapcodec.Process{Argv: []string{"sleep", "99"}, Strategy: "argv"}, }}, }}, } } func acceptanceGeneration(t *testing.T, snapshot snapcodec.Session, generation uint64) ports.SnapshotGeneration { t.Helper() publication, err := acceptancePublication(snapshot) require.NoError(t, err) manifest, err := snapcodec.UnmarshalManifest(publication.Manifest) require.NoError(t, err) manifest.Generation = generation encoded, err := snapcodec.MarshalManifest(manifest) require.NoError(t, err) publication.Generation = generation publication.Manifest = encoded return cloneAcceptanceGeneration(snapshotGeneration(publication)) } func TestValidateRestoreSessionSnapshot(t *testing.T) { valid := restoreAcceptanceSession(t, "restored") for _, test := range []struct { name string snap snapcodec.Session }{ {"valid", valid}, {"empty name", func() snapcodec.Session { snap := valid; snap.Name = ""; return snap }()}, {"active without tabs", func() snapcodec.Session { snap := valid; snap.Tabs = nil; snap.Active = 1; return snap }()}, {"active beyond tabs", func() snapcodec.Session { snap := valid; snap.Active = 1; return snap }()}, {"created at overflow", func() snapcodec.Session { snap := valid; snap.CreatedAt = math.MaxInt64 + 1; return snap }()}, } { t.Run(test.name, func(t *testing.T) { err := validateRestoreSessionSnapshot(test.snap) if test.name == "valid" { require.NoError(t, err) return } require.Error(t, err) }) } } func TestRestoreIncrementalGenerationAcceptance(t *testing.T) { snapshot := restoreAcceptanceSession(t, "restored") generation := acceptanceGeneration(t, snapshot, 9) repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}} pty, release := newBlockingPTY(t) d := newTestDaemon(t, newFactory(t, pty), stubClock{}) d.baseEnv = []string{"TERM=xterm-kitty", "COLORTERM=truecolor"} checkpoint := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)} record := domain.CatalogueRecord{Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), Committed: &checkpoint} catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}) WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d) WithSnapshotRepository(repository)(d) WithRecoveryCoordinator(recoveryusecase.NewCoordinator(catalogue, repository, nil))(d) t.Cleanup(func() { release(); d.sessWg.Wait() }) d.restoreIncrementalSnapshots(context.Background()) d.mu.Lock() restored := d.findByNameLocked(snapshot.Name) d.mu.Unlock() require.NotNil(t, restored) require.False(t, restored.snapDirty.Load(), "a loaded generation must begin clean") require.Equal(t, uint64(9), restored.snapshotPublishedGeneration, "the loaded manifest is the repository generation head") require.Equal(t, 0, testAttachmentTabIndex(restored)) require.Equal(t, "/snapshot/cwd", restored.cwd) require.Len(t, restored.tabs, 1) tab := restored.tabs[0] require.Equal(t, 293, tab.size.Cols) require.Equal(t, layout.PaneID("pane-7"), tab.tree.Focus) pane := tab.panes[layout.PaneID("pane-7")] pane.mu.Lock() 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"} require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "bounded history must precede primary and active alternate transcript rows") for y := range pane.screen.Rows() { for x := range pane.screen.Columns() { require.Truef(t, pane.screen.Cell(x, y).Equal(renderer.BlankCell()), "restored frame cell (%d,%d) must be blank", x, y) } } require.Equal(t, 7, pane.history.Len()) cells := 0 view := pane.history.View() for y := range view.Len() { cells += view.RowWidth(y) } require.Equal(t, 7*293, cells) require.Equal(t, defaultScrollbackRows, pane.history.Cap()) require.Equal(t, defaultScrollbackBytes, pane.history.ByteCap()) require.LessOrEqual(t, pane.history.LogicalBytes(), pane.history.ByteCap()) pane.screen.Write([]byte("new-output")) pane.screen.Write([]byte("\x1b[?1049h\x1b[?1049l")) require.Equal(t, wantHistory, snapshotHistoryTexts(pane.history.View()), "new output and redraw must not remove recovered history") pane.mu.Unlock() startSnapshotEncodeWorker(t, d) markSnapshotDirty(restored) require.True(t, d.scheduleSnapshot(restored)) awaitSnapshotClean(t, restored) require.Len(t, repository.publishes, 1) require.Equal(t, uint64(10), repository.publishes[0].Generation, "a restored session must continue the concrete repository generation stream") } type paneLayoutSnapshotFixture struct { snapshot snapcodec.Session tree *layout.Tree tabStableID string paneStableIDs map[layout.PaneID]string } type restoredPaneLayout struct { tree *layout.Tree tabStableID string paneStableIDs map[layout.PaneID]string } func TestSnapshotCodecPreservesConsumedAndExpelledPaneLayout(t *testing.T) { fixture := captureConsumedAndExpelledPaneLayout(t) require.Len(t, fixture.snapshot.Tabs, 1) decodedTab := fixture.snapshot.Tabs[0] require.Equal(t, fixture.tabStableID, decodedTab.StableID) require.Equal(t, layout.PaneID("pane-3"), decodedTab.Focus, "snapshot tab focus must be preserved") assertConsumedAndExpelledColumns(t, decodedTab.Tree) require.Equal(t, fixture.tree, decodedTab.Tree, "manifest codec must preserve the complete rearranged tree") require.Equal(t, fixture.paneStableIDs, snapshotPaneStableIDs(decodedTab.Panes), "manifest codec must preserve every pane stable ID") } func TestSnapshotRuntimeRestorePreservesConsumedAndExpelledPaneLayout(t *testing.T) { fixture := captureConsumedAndExpelledPaneLayout(t) restoreDaemon := newTestDaemon(t, newFactory(t, &paneRearrangePTY{}), stubClock{}) restoredTabs, err := restoreDaemon.restoreSnapshotTabs(context.Background(), restoreDaemon.serveCtx, fixture.snapshot) require.NoError(t, err) defer closeRestoredTabs(restoredTabs) require.Len(t, restoredTabs, 1) restored := captureRestoredPaneLayout(restoredTabs[0]) require.Equal(t, fixture.tabStableID, restored.tabStableID) assertConsumedAndExpelledColumns(t, restored.tree) require.Equal(t, fixture.tree, restored.tree, "runtime restore must preserve the complete rearranged tree") require.Equal(t, fixture.paneStableIDs, restored.paneStableIDs, "runtime restore must preserve every pane stable ID") } func TestSnapshotRestoreUsesDaemonTerminalCapabilities(t *testing.T) { tests := []struct { name string baseEnv []string wantTerm string wantColorTerm string notWantEntries []string }{ { name: "truecolor", baseEnv: []string{ "TERM=xterm-kitty", "COLORTERM=truecolor", "XDG_RUNTIME_DIR=/run/user/1000", "WAYLAND_DISPLAY=wayland-1", }, wantTerm: "TERM=xterm-256color", wantColorTerm: "COLORTERM=truecolor", notWantEntries: []string{"TERM=xterm-kitty"}, }, { name: "256-color fallback", baseEnv: []string{ "TERM=xterm-256color", "COLORTERM=old", "XDG_RUNTIME_DIR=/run/user/1000", "WAYLAND_DISPLAY=wayland-1", }, wantTerm: "TERM=xterm-256color", wantColorTerm: "COLORTERM=truecolor", notWantEntries: []string{"COLORTERM=old"}, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { snapshot := restoreAcceptanceSession(t, "restored") pty, release := newBlockingPTY(t) defer release() var gotEnv []string factory := portsmocks.NewMockPTYFactory(t) factory.EXPECT().Open(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). RunAndReturn(func(_ context.Context, _ string, _ []string, env []string, _ string, _ domain.Geometry) (ports.PTY, error) { gotEnv = append([]string(nil), env...) return pty, nil }).Once() d := newTestDaemon(t, factory, stubClock{}) d.baseEnv = tt.baseEnv tabs, err := d.restoreSnapshotTabs(context.Background(), d.serveCtx, snapshot) require.NoError(t, err) defer closeRestoredTabs(tabs) require.Contains(t, gotEnv, tt.wantTerm) if tt.wantColorTerm != "" { require.Contains(t, gotEnv, tt.wantColorTerm) } require.Contains(t, gotEnv, "XDG_RUNTIME_DIR=/run/user/1000") require.Contains(t, gotEnv, "WAYLAND_DISPLAY=wayland-1") for _, notWant := range tt.notWantEntries { require.NotContains(t, gotEnv, notWant) } }) } } func captureConsumedAndExpelledPaneLayout(t *testing.T) paneLayoutSnapshotFixture { t.Helper() h := newPaneRearrangeHarness(t, domain.Size{Cols: 200, Rows: 30}, paneLayoutSnapshotTree()) h.session.createdAt = 77 h.session.incarnation = domain.IncarnationID{7} tabStableID := "tab-stable-rearranged" paneStableIDs := map[layout.PaneID]string{ "pane-1": "pane-stable-1", "pane-2": "pane-stable-2", "pane-3": "pane-stable-3", "pane-4": "pane-stable-4", "pane-5": "pane-stable-5", } setPaneLayoutSnapshotIDs(h.tab, tabStableID, paneStableIDs) applyPaneLayoutSnapshotOperations(t, h) h.tab.mu.Lock() wantTree := h.tab.tree.Clone() h.tab.mu.Unlock() capture, ok := h.daemon.captureSnapshotState(h.session, 7) require.True(t, ok) publication, err := h.daemon.incrementalPublication(capture) require.NoError(t, err) decoded, err := sessionFromGeneration(snapshotGeneration(publication)) require.NoError(t, err) return paneLayoutSnapshotFixture{ snapshot: decoded, tree: wantTree, tabStableID: tabStableID, paneStableIDs: paneStableIDs, } } func paneLayoutSnapshotTree() *layout.Tree { return &layout.Tree{Root: &layout.Node{Kind: layout.Split, Dir: layout.Horizontal, Weight: 9, Children: []*layout.Node{ {Kind: layout.Leaf, Leaf: "pane-1", Weight: 40}, {Kind: layout.Split, Dir: layout.Vertical, Weight: 80, Children: []*layout.Node{ {Kind: layout.Leaf, Leaf: "pane-2", Weight: 10}, {Kind: layout.Leaf, Leaf: "pane-3", Weight: 20}, }}, {Kind: layout.Stack, Weight: 40, Expanded: "pane-5", Children: []*layout.Node{ {Kind: layout.Leaf, Leaf: "pane-4", Weight: 7}, {Kind: layout.Leaf, Leaf: "pane-5", Weight: 3}, }}, }}, Focus: "pane-4"} } func setPaneLayoutSnapshotIDs(tb *tab, tabStableID string, paneStableIDs map[layout.PaneID]string) { tb.mu.Lock() defer tb.mu.Unlock() tb.stableID = tabStableID tb.nextPaneID = 6 for id, stableID := range paneStableIDs { tb.panes[id].mu.Lock() tb.panes[id].stableID = stableID tb.panes[id].mu.Unlock() } } func applyPaneLayoutSnapshotOperations(t *testing.T, h *paneRearrangeHarness) { t.Helper() for _, operation := range []struct { name string pane layout.PaneID direction layout.Direction }{ {name: "consume singleton into vertical column", pane: "pane-1", direction: layout.Right}, {name: "expel vertical member as singleton column", pane: "pane-3", direction: layout.Right}, } { err := (daemonActions{d: h.daemon}).Run(daemonActionRequest{ kind: daemonActionConsumeOrExpelPane, target: h.target(operation.pane), direction: operation.direction, }) require.NoError(t, err, operation.name) } } func assertConsumedAndExpelledColumns(t *testing.T, tree *layout.Tree) { t.Helper() require.NotNil(t, tree) require.NotNil(t, tree.Root) require.Equal(t, layout.Horizontal, tree.Root.Dir) require.Len(t, tree.Root.Children, 3) require.Equal(t, layout.Vertical, tree.Root.Children[0].Dir, "consumed panes must remain a vertical column") require.Equal(t, layout.Leaf, tree.Root.Children[1].Kind, "expelled pane must remain a singleton column") require.Equal(t, layout.Stack, tree.Root.Children[2].Kind, "stack column must remain present") require.Equal(t, layout.PaneID("pane-5"), tree.Root.Children[2].Expanded, "stack expansion must be preserved") require.Equal(t, layout.PaneID("pane-3"), tree.Focus, "expelled pane focus must be preserved") } func snapshotPaneStableIDs(panes []snapcodec.Pane) map[layout.PaneID]string { stableIDs := make(map[layout.PaneID]string, len(panes)) for _, p := range panes { stableIDs[p.ID] = p.StableID } return stableIDs } func captureRestoredPaneLayout(tb *tab) restoredPaneLayout { tb.mu.Lock() defer tb.mu.Unlock() stableIDs := make(map[layout.PaneID]string, len(tb.panes)) for id, p := range tb.panes { p.mu.Lock() stableIDs[id] = p.stableID p.mu.Unlock() } return restoredPaneLayout{tree: tb.tree.Clone(), tabStableID: tb.stableID, paneStableIDs: stableIDs} } func TestRestoredSessionMetadataUpdatePreservesCheckpointLineage(t *testing.T) { snapshot := restoreAcceptanceSession(t, "restored") generation := acceptanceGeneration(t, snapshot, 9) repository := &snapshotAcceptanceRepository{names: []string{snapshot.Name}, generations: map[string]ports.SnapshotGeneration{snapshot.Name: generation}} committed := domain.CheckpointRef{Generation: generation.Generation, ManifestDigest: snapcodec.ManifestDigest(generation.Manifest)} record := domain.CatalogueRecord{ Name: snapshot.Name, IncarnationID: generation.IncarnationID, Cwd: "/snapshot/cwd", CreatedAt: int64(snapshot.CreatedAt), UpdatedAt: 81, LastUsedSeq: 17, TabNames: []string{"before"}, Committed: &committed, } catalogue := newDurableRecoveryCatalogue([]domain.CatalogueRecord{record}) pty, release := newBlockingPTY(t) d := newTestDaemon(t, newFactory(t, pty), stubClock{}) coordinator := recoveryusecase.NewCoordinator(catalogue, repository, nil) WithCatalogue(catalogue, []domain.CatalogueRecord{record})(d) WithSnapshotRepository(repository)(d) WithRecoveryCoordinator(coordinator)(d) t.Cleanup(func() { release(); d.sessWg.Wait() }) d.restoreIncrementalSnapshots(context.Background()) d.mu.Lock() restored := d.findByNameLocked(snapshot.Name) d.mu.Unlock() require.NotNil(t, restored) require.NoError(t, d.renameTab(restored, restored.tabs[0], "after")) updates := catalogue.MetadataUpdates() require.Len(t, updates, 1, "the authoritative Catalogue port must receive restored-session metadata updates") require.Equal(t, record.IncarnationID, updates[0].IncarnationID) updated, ok, err := catalogue.Record(record.Name) require.NoError(t, err) require.True(t, ok) require.Equal(t, record.IncarnationID, updated.IncarnationID) require.Equal(t, record.Committed, updated.Committed) require.Equal(t, record.DegradedReason, updated.DegradedReason) require.Equal(t, []string{"after"}, updated.TabNames) startSnapshotEncodeWorker(t, d) markSnapshotDirty(restored) require.True(t, d.scheduleSnapshot(restored)) awaitSnapshotIdle(t, restored) d.snapshots.noticeMu.Lock() failure := d.snapshots.activeFailureSignature d.snapshots.noticeMu.Unlock() require.Empty(t, failure) require.Len(t, repository.publishes, 1) require.Equal(t, record.Committed, repository.publishes[0].ParentCheckpoint) published, ok, err := catalogue.Record(record.Name) require.NoError(t, err) require.True(t, ok) require.Equal(t, uint64(10), published.Committed.Generation) } func TestRestorePaneTerminalRequiresCompleteRecoveryAndInstallsAtomically(t *testing.T) { base := restoreAcceptanceSession(t, "restored").Tabs[0].Panes[0] for _, tt := range []struct { name string mutate func(*snapcodec.Pane) }{ {name: "missing tail", mutate: func(snap *snapcodec.Pane) { snap.Tail = nil }}, {name: "missing transcript", mutate: func(snap *snapcodec.Pane) { snap.Transcript = nil }}, {name: "invalid transcript", mutate: func(snap *snapcodec.Pane) { snap.Transcript = []byte("invalid") }}, } { t.Run(tt.name, func(t *testing.T) { snap := base tt.mutate(&snap) p := newPane("pane", nil, domain.Size{Cols: 293, Rows: 4}) p.screen.Write([]byte("old-frame")) require.NoError(t, p.history.Append(testRow("old-history"), vt.LineBound{End: len("old-history")})) oldScreen, oldHistory := p.screen, p.history err := restorePaneTerminal(p, snap) require.Error(t, err) require.Same(t, oldScreen, p.screen) require.Same(t, oldHistory, p.history) require.Equal(t, 'o', p.screen.Cell(0, 0).Rune, "a failed restore must never install a partial or old persisted frame") require.Equal(t, "old-history", cellsString(p.history.View().Row(0))) }) } } func snapshotHistoryTexts(view vt.HistoryView) []string { rows := make([]string, view.Len()) for i := range rows { rows[i] = strings.TrimRight(cellsString(view.Row(i)), " ") } return rows } func TestRestoreIncrementalFallbackAndInvalidObjectMappings(t *testing.T) { snapshot := restoreAcceptanceSession(t, "fallback") valid := acceptanceGeneration(t, snapshot, 3) for _, mutate := range []struct { name string fn func(*ports.SnapshotGeneration) }{ {"missing", func(g *ports.SnapshotGeneration) { for digest := range g.Objects { delete(g.Objects, digest) break } }}, {"wrong role after digest verification", func(g *ports.SnapshotGeneration) { manifest, err := snapcodec.UnmarshalManifest(g.Manifest) require.NoError(t, err) tailRef := manifest.Tabs[0].Panes[0].Tail _, payload, err := snapcodec.UnmarshalObject(g.Objects[tailRef.Digest]) require.NoError(t, err) replacement, err := snapcodec.MarshalObject(snapcodec.RecoveryTranscript, payload) require.NoError(t, err) manifest.Tabs[0].Panes[0].Tail.Digest = replacement.Digest manifest.Tabs[0].Panes[0].Tail.Size = uint32(len(replacement.Data)) g.Manifest, err = snapcodec.MarshalManifest(manifest) require.NoError(t, err) delete(g.Objects, tailRef.Digest) g.Objects[replacement.Digest] = replacement.Data }}, {"manifest size mismatch", func(g *ports.SnapshotGeneration) { manifest, err := snapcodec.UnmarshalManifest(g.Manifest) require.NoError(t, err) manifest.Tabs[0].Panes[0].Tail.Size++ g.Manifest, err = snapcodec.MarshalManifest(manifest) require.NoError(t, err) }}, } { t.Run(mutate.name, func(t *testing.T) { generation := cloneAcceptanceGeneration(valid) mutate.fn(&generation) _, err := sessionFromGeneration(generation) require.Error(t, err) }) } }