v0.2.0 80c61e3

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/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}

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