detached 0a42b0f

test(floating): reproduce premature popup exit

commit
0a42b0f415bba275348d337c32addfd3dfeb6beb
author
brice · · 2026-07-13 09:57 UTC
parent
88f2a08
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +133 -0
M internal/usecase/daemon/floating_open_test.go +133 -0
@@ -14,6 +14,139 @@ import (
1414 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1515 )
1616
17+type contextAwareFloatingPTY struct {
18+ ctx context.Context
19+ readerStarted chan struct{}
20+ closed chan struct{}
21+ readerOnce sync.Once
22+ closeOnce sync.Once
23+ closeCount int
24+}
25+
26+func newContextAwareFloatingPTY() *contextAwareFloatingPTY {
27+ return &contextAwareFloatingPTY{
28+ readerStarted: make(chan struct{}),
29+ closed: make(chan struct{}),
30+ }
31+}
32+
33+func (p *contextAwareFloatingPTY) Read([]byte) (int, error) {
34+ p.readerOnce.Do(func() { close(p.readerStarted) })
35+ <-p.ctx.Done()
36+ return 0, p.ctx.Err()
37+}
38+
39+func (*contextAwareFloatingPTY) Write(b []byte) (int, error) { return len(b), nil }
40+func (*contextAwareFloatingPTY) Resize(domain.Size) error { return nil }
41+func (*contextAwareFloatingPTY) Pid() int { return 1 }
42+func (*contextAwareFloatingPTY) ForegroundPgid() (int, error) {
43+ return 1, nil
44+}
45+
46+func (p *contextAwareFloatingPTY) Close() error {
47+ p.closeOnce.Do(func() {
48+ p.closeCount++
49+ close(p.closed)
50+ })
51+ return nil
52+}
53+
54+type contextAwareFloatingFactory struct {
55+ pty *contextAwareFloatingPTY
56+ opened chan context.Context
57+}
58+
59+func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
60+ f.pty.ctx = ctx
61+ f.opened <- ctx
62+ return f.pty, nil
63+}
64+
65+func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
66+ teardowns := []struct {
67+ name string
68+ teardown func(*Daemon, *session, *tab)
69+ }{
70+ {
71+ name: "floating pane",
72+ teardown: func(d *Daemon, _ *session, tb *tab) {
73+ d.teardownFloating(tb, nil)
74+ },
75+ },
76+ {
77+ name: "tab",
78+ teardown: func(_ *Daemon, _ *session, tb *tab) {
79+ tb.closeAllPanes()
80+ },
81+ },
82+ {
83+ name: "session",
84+ teardown: func(d *Daemon, sess *session, _ *tab) {
85+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
86+ },
87+ },
88+ }
89+ for _, tt := range teardowns {
90+ t.Run(tt.name, func(t *testing.T) {
91+ pty := newContextAwareFloatingPTY()
92+ factory := &contextAwareFloatingFactory{pty: pty, opened: make(chan context.Context, 1)}
93+ d := newTestDaemon(t, factory, stubClock{})
94+ tb := newFloatingTestTab(t)
95+ tabCtx, cancelTab := context.WithCancel(t.Context())
96+ tb.ctx, tb.cancel = tabCtx, cancelTab
97+ sessCtx, cancelSession := context.WithCancel(t.Context())
98+ sess := &session{id: "context-" + domain.SessionID(tt.name), name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession}
99+ d.mu.Lock()
100+ d.sessions[sess.id] = sess
101+ d.mu.Unlock()
102+ tb.mu.Lock()
103+ generation := tb.beginFloatingWarmLocked(true)
104+ tb.mu.Unlock()
105+
106+ d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
107+ sessionName: "work",
108+ size: domain.Size{Cols: 20, Rows: 8},
109+ geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
110+ paneStableID: "p_floating",
111+ parentCtx: tabCtx,
112+ }, generation, nil)
113+
114+ openCtx := <-factory.opened
115+ require.NoError(t, openCtx.Err(), "the PTY context must outlive the launch worker")
116+ tb.mu.Lock()
117+ floating := tb.floating.pane
118+ tb.mu.Unlock()
119+ require.NotNil(t, floating)
120+ sess.mu.Lock()
121+ sess.client = &attachedClient{}
122+ sess.mu.Unlock()
123+ require.True(t, d.paneRenderable(sess, tb, floating), "installed visible popup must remain renderable")
124+ sess.mu.Lock()
125+ sess.client = nil
126+ sess.mu.Unlock()
127+ select {
128+ case <-pty.readerStarted:
129+ case <-time.After(time.Second):
130+ t.Fatal("floating reader did not start")
131+ }
132+
133+ tt.teardown(d, sess, tb)
134+ select {
135+ case <-openCtx.Done():
136+ case <-time.After(time.Second):
137+ t.Fatal("floating PTY context was not cancelled by teardown")
138+ }
139+ select {
140+ case <-pty.closed:
141+ case <-time.After(time.Second):
142+ t.Fatal("floating PTY was not closed by teardown")
143+ }
144+ d.sessWg.Wait()
145+ require.Equal(t, 1, pty.closeCount, "floating PTY must close exactly once")
146+ })
147+ }
148+}
149+
17150 func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testing.T) {
18151 factory := portsmocks.NewMockPTYFactory(t)
19152 opened := make(chan struct{})
@@ -14,6 +14,139 @@ import (
14 portsmocks "github.com/bnema/vev/internal/ports/mocks"14 portsmocks "github.com/bnema/vev/internal/ports/mocks"
15 )15 )
16 16
17+type contextAwareFloatingPTY struct {
18+ ctx context.Context
19+ readerStarted chan struct{}
20+ closed chan struct{}
21+ readerOnce sync.Once
22+ closeOnce sync.Once
23+ closeCount int
24+}
25+
26+func newContextAwareFloatingPTY() *contextAwareFloatingPTY {
27+ return &contextAwareFloatingPTY{
28+ readerStarted: make(chan struct{}),
29+ closed: make(chan struct{}),
30+ }
31+}
32+
33+func (p *contextAwareFloatingPTY) Read([]byte) (int, error) {
34+ p.readerOnce.Do(func() { close(p.readerStarted) })
35+ <-p.ctx.Done()
36+ return 0, p.ctx.Err()
37+}
38+
39+func (*contextAwareFloatingPTY) Write(b []byte) (int, error) { return len(b), nil }
40+func (*contextAwareFloatingPTY) Resize(domain.Size) error { return nil }
41+func (*contextAwareFloatingPTY) Pid() int { return 1 }
42+func (*contextAwareFloatingPTY) ForegroundPgid() (int, error) {
43+ return 1, nil
44+}
45+
46+func (p *contextAwareFloatingPTY) Close() error {
47+ p.closeOnce.Do(func() {
48+ p.closeCount++
49+ close(p.closed)
50+ })
51+ return nil
52+}
53+
54+type contextAwareFloatingFactory struct {
55+ pty *contextAwareFloatingPTY
56+ opened chan context.Context
57+}
58+
59+func (f *contextAwareFloatingFactory) Open(ctx context.Context, _ string, _ []string, _ []string, _ string, _ domain.Size) (ports.PTY, error) {
60+ f.pty.ctx = ctx
61+ f.opened <- ctx
62+ return f.pty, nil
63+}
64+
65+func TestFloatingSuccessfulOpenTransfersContextOwnershipToPane(t *testing.T) {
66+ teardowns := []struct {
67+ name string
68+ teardown func(*Daemon, *session, *tab)
69+ }{
70+ {
71+ name: "floating pane",
72+ teardown: func(d *Daemon, _ *session, tb *tab) {
73+ d.teardownFloating(tb, nil)
74+ },
75+ },
76+ {
77+ name: "tab",
78+ teardown: func(_ *Daemon, _ *session, tb *tab) {
79+ tb.closeAllPanes()
80+ },
81+ },
82+ {
83+ name: "session",
84+ teardown: func(d *Daemon, sess *session, _ *tab) {
85+ require.NoError(t, d.killSession(sess, ports.ReasonSessionKilled, false))
86+ },
87+ },
88+ }
89+ for _, tt := range teardowns {
90+ t.Run(tt.name, func(t *testing.T) {
91+ pty := newContextAwareFloatingPTY()
92+ factory := &contextAwareFloatingFactory{pty: pty, opened: make(chan context.Context, 1)}
93+ d := newTestDaemon(t, factory, stubClock{})
94+ tb := newFloatingTestTab(t)
95+ tabCtx, cancelTab := context.WithCancel(t.Context())
96+ tb.ctx, tb.cancel = tabCtx, cancelTab
97+ sessCtx, cancelSession := context.WithCancel(t.Context())
98+ sess := &session{id: "context-" + domain.SessionID(tt.name), name: "work", tabs: []*tab{tb}, ctx: sessCtx, cancel: cancelSession}
99+ d.mu.Lock()
100+ d.sessions[sess.id] = sess
101+ d.mu.Unlock()
102+ tb.mu.Lock()
103+ generation := tb.beginFloatingWarmLocked(true)
104+ tb.mu.Unlock()
105+
106+ d.openAndInstallFloating(sess, tb, floatingLaunchSpec{
107+ sessionName: "work",
108+ size: domain.Size{Cols: 20, Rows: 8},
109+ geometry: floatingGeometry{Inner: domain.Rect{Width: 20, Height: 8}},
110+ paneStableID: "p_floating",
111+ parentCtx: tabCtx,
112+ }, generation, nil)
113+
114+ openCtx := <-factory.opened
115+ require.NoError(t, openCtx.Err(), "the PTY context must outlive the launch worker")
116+ tb.mu.Lock()
117+ floating := tb.floating.pane
118+ tb.mu.Unlock()
119+ require.NotNil(t, floating)
120+ sess.mu.Lock()
121+ sess.client = &attachedClient{}
122+ sess.mu.Unlock()
123+ require.True(t, d.paneRenderable(sess, tb, floating), "installed visible popup must remain renderable")
124+ sess.mu.Lock()
125+ sess.client = nil
126+ sess.mu.Unlock()
127+ select {
128+ case <-pty.readerStarted:
129+ case <-time.After(time.Second):
130+ t.Fatal("floating reader did not start")
131+ }
132+
133+ tt.teardown(d, sess, tb)
134+ select {
135+ case <-openCtx.Done():
136+ case <-time.After(time.Second):
137+ t.Fatal("floating PTY context was not cancelled by teardown")
138+ }
139+ select {
140+ case <-pty.closed:
141+ case <-time.After(time.Second):
142+ t.Fatal("floating PTY was not closed by teardown")
143+ }
144+ d.sessWg.Wait()
145+ require.Equal(t, 1, pty.closeCount, "floating PTY must close exactly once")
146+ })
147+ }
148+}
149+
17 func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testing.T) {150 func TestFloatingOpenCancellationBoundsSessionTeardownAndClosesLatePTY(t *testing.T) {
18 factory := portsmocks.NewMockPTYFactory(t)151 factory := portsmocks.NewMockPTYFactory(t)
19 opened := make(chan struct{})152 opened := make(chan struct{})

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