pane_lifetime_test.go RawHistoryFolder

1package daemon
2
3import (
4	"context"
5	"errors"
6	"io"
7	"sync"
8	"sync/atomic"
9	"testing"
10	"time"
11
12	"github.com/stretchr/testify/require"
13
14	"github.com/bnema/vev/internal/domain"
15	"github.com/bnema/vev/internal/domain/layout"
16	"github.com/bnema/vev/internal/ports"
17)
18
19type lifetimePTY struct {
20	ctx       context.Context
21	closed    chan struct{}
22	closeOnce sync.Once
23	closes    atomic.Int32
24}
25
26func newLifetimePTY() *lifetimePTY {
27	return &lifetimePTY{closed: make(chan struct{})}
28}
29
30func (p *lifetimePTY) Read([]byte) (int, error) {
31	select {
32	case <-p.ctx.Done():
33		return 0, p.ctx.Err()
34	case <-p.closed:
35		return 0, io.EOF
36	}
37}
38
39func (*lifetimePTY) Write(b []byte) (int, error)  { return len(b), nil }
40func (*lifetimePTY) Resize(domain.Geometry) error { return nil }
41func (*lifetimePTY) Pid() int                     { return 1 }
42func (*lifetimePTY) ForegroundPgid() (int, error) {
43	return 1, nil
44}
45func (p *lifetimePTY) Close() error {
46	p.closes.Add(1)
47	p.closeOnce.Do(func() { close(p.closed) })
48	return nil
49}
50
51type lifetimePTYFactory struct {
52	mu       sync.Mutex
53	ptys     []*lifetimePTY
54	contexts []context.Context
55}
56
57func (f *lifetimePTYFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
58	p := newLifetimePTY()
59	p.ctx = ctx
60	f.mu.Lock()
61	f.ptys = append(f.ptys, p)
62	f.contexts = append(f.contexts, ctx)
63	f.mu.Unlock()
64	return p, nil
65}
66
67func TestPaneOpenErrorsReleaseReturnedPTYWithoutPublication(t *testing.T) {
68	type openCase struct {
69		name     string
70		wantCode domain.NoticeCode
71		run      func(*testing.T, *Daemon) ([]*tab, func(), error)
72	}
73	tests := []openCase{
74		{
75			name:     "initial pane",
76			wantCode: domain.NoticeSessionSpawn,
77			run: func(_ *testing.T, d *Daemon) ([]*tab, func(), error) {
78				sess, err := createSessionForTest(d, "work", true, "/tmp", domain.Size{Cols: 80, Rows: 24}, terminalEnv{}, nil)
79				if sess != nil {
80					return sess.tabs, func() { closeTabs(sess.tabs) }, err
81				}
82				return nil, d.serveCancel, err
83			},
84		},
85		{
86			name:     "created tab",
87			wantCode: domain.NoticeTabSpawn,
88			run: func(t *testing.T, d *Daemon) ([]*tab, func(), error) {
89				sessCtx, cancelSession := context.WithCancel(t.Context())
90				base := newTab(newQuietPTY(), domain.Size{Cols: 80, Rows: 22})
91				base.ctx, base.cancel = context.WithCancel(sessCtx)
92				sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, ctx: sessCtx, cancel: cancelSession, tabs: []*tab{base}}
93				d.mu.Lock()
94				d.sessions[sess.id] = sess
95				d.mu.Unlock()
96				err := d.createTab(sess, domain.Size{Cols: 80, Rows: 24})
97				return sess.tabs, func() {
98					cancelSession()
99					closeTabs(sess.tabs)
100				}, err
101			},
102		},
103		{
104			name:     "split pane",
105			wantCode: domain.NoticePaneSpawn,
106			run: func(t *testing.T, d *Daemon) ([]*tab, func(), error) {
107				sessCtx, cancelSession := context.WithCancel(t.Context())
108				tb := newTab(newQuietPTY(), domain.Size{Cols: 41, Rows: 10})
109				tb.ctx, tb.cancel = context.WithCancel(sessCtx)
110				sess := &session{sessionCore: sessionCore{id: "work", name: "work"}, cwd: "/tmp", ctx: sessCtx, cancel: cancelSession, tabs: []*tab{tb}}
111				_, err := d.splitPaneAt(sess, tb, tb.focusedPane(), layout.Right)
112				return []*tab{tb}, func() {
113					cancelSession()
114					tb.closeAllPanes()
115				}, err
116			},
117		},
118		{
119			name: "restored pane",
120			run: func(t *testing.T, d *Daemon) ([]*tab, func(), error) {
121				restoreCtx, cancelRestore := context.WithCancel(t.Context())
122				sessCtx, cancelSession := context.WithCancel(t.Context())
123				tabs, err := d.restoreSnapshotTabs(restoreCtx, sessCtx, restoreAcceptanceSession(t, "work"))
124				return tabs, func() {
125					cancelRestore()
126					cancelSession()
127					closeRestoredTabs(tabs)
128				}, err
129			},
130		},
131	}
132
133	for _, tt := range tests {
134		t.Run(tt.name, func(t *testing.T) {
135			cause := errors.New("open failed")
136			pty := newFailedOpenPTY()
137			cancelCallbacks := atomic.Int32{}
138			cancelled := make(chan struct{})
139			openFactory := paneErrorFactory{
140				pty: pty,
141				err: cause,
142				onOpen: func(ctx context.Context) {
143					pty.ctx = ctx
144					context.AfterFunc(ctx, func() {
145						cancelCallbacks.Add(1)
146						close(cancelled)
147					})
148				},
149			}
150			d := newTestDaemon(t, &openFactory, stubClock{})
151
152			tabs, cleanup, err := tt.run(t, d)
153			require.ErrorIs(t, err, cause)
154			if tt.wantCode != 0 {
155				var userErr *domain.UserError
156				require.ErrorAs(t, err, &userErr)
157				require.Equal(t, tt.wantCode, userErr.Code)
158			}
159			select {
160			case <-cancelled:
161			case <-time.After(time.Second):
162				t.Fatal("failed pane Open process context was not cancelled")
163			}
164
165			for _, tb := range tabs {
166				if tb == nil {
167					continue
168				}
169				tb.mu.Lock()
170				floating := tb.floating.pane
171				var floatingPTY ports.PTY
172				if floating != nil {
173					floating.mu.Lock()
174					floatingPTY = floating.pty
175					floating.mu.Unlock()
176				}
177				publishedFailedPTY := false
178				for _, published := range tb.panes {
179					publishedFailedPTY = publishedFailedPTY || published.pty == pty
180				}
181				tb.mu.Unlock()
182				require.NotEqual(t, pty, floatingPTY)
183				require.False(t, publishedFailedPTY, "failed Open must not publish a pane or owner")
184			}
185			require.Zero(t, pty.readerStarts.Load(), "failed Open must not start a reader")
186			require.Equal(t, int32(1), pty.closes.Load(), "failed Open PTY must close exactly once")
187			require.Equal(t, int32(1), cancelCallbacks.Load(), "failed Open context must cancel exactly once")
188
189			cleanup()
190			require.Equal(t, int32(1), pty.closes.Load(), "later teardown must not close an unpublished PTY again")
191			require.Equal(t, int32(1), cancelCallbacks.Load(), "later teardown must not recancel an unpublished process")
192		})
193	}
194}
195
196type paneErrorFactory struct {
197	pty    ports.PTY
198	err    error
199	onOpen func(context.Context)
200}
201
202func (f *paneErrorFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Geometry) (ports.PTY, error) {
203	if f.onOpen != nil {
204		f.onOpen(ctx)
205	}
206	return f.pty, f.err
207}

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