snapshot_restore_test.go RawHistoryFolder

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}

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