detached 77ed6d6

test(client): use port mocks for reconnect toast tests

commit
77ed6d6ba0903877023ba8787715dd019b9b7fda
author
brice · · 2026-07-06 01:29 UTC
parent
25fad49
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +125 -164
M internal/usecase/client/reconnect_toast_test.go +125 -164
@@ -7,56 +7,67 @@ import (
77 "io"
88 "log/slog"
99 "strings"
10- "sync/atomic"
1110 "testing"
1211 "time"
1312
13+ "github.com/stretchr/testify/mock"
1414 "github.com/stretchr/testify/require"
1515
1616 "github.com/bnema/vev/internal/domain"
1717 "github.com/bnema/vev/internal/ports"
18+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
1819 "github.com/bnema/vev/pkg/renderer"
1920 )
2021
21-type reconnectToastClock struct {
22- timer *reconnectToastTimer
23- created chan struct{}
22+type reconnectToastTerminalHarness struct {
23+ term *portsmocks.MockTerminal
24+ in *io.PipeReader
25+ inWriter *io.PipeWriter
26+ out bytes.Buffer
27+ size domain.Size
28+ resizeCh chan domain.Size
29+}
30+
31+func newReconnectToastTerminalHarness(t *testing.T) *reconnectToastTerminalHarness {
32+ t.Helper()
33+ in, inWriter := io.Pipe()
34+ h := &reconnectToastTerminalHarness{
35+ term: portsmocks.NewMockTerminal(t),
36+ in: in,
37+ inWriter: inWriter,
38+ size: domain.Size{Cols: 80, Rows: 24},
39+ resizeCh: make(chan domain.Size),
40+ }
41+ h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
42+ h.term.EXPECT().Size().Return(h.size, nil).Maybe()
43+ h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()
44+ h.term.EXPECT().QueryColors().Return(nil).Maybe()
45+ h.term.EXPECT().In().Return(h.in).Maybe()
46+ h.term.EXPECT().Out().Return(&h.out).Maybe()
47+ h.term.EXPECT().Flush().Return(nil).Maybe()
48+ return h
2449 }
2550
26-func newReconnectToastClock() *reconnectToastClock {
27- return &reconnectToastClock{created: make(chan struct{})}
28-}
51+func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() }
2952
30-func (*reconnectToastClock) Now() time.Time { return time.Time{} }
31-func (c *reconnectToastClock) NewTimer(time.Duration) ports.Timer {
32- c.timer = &reconnectToastTimer{ch: make(chan time.Time, 1)}
33- close(c.created)
34- return c.timer
53+func reconnectToastWelcome(token uint64) ports.Frame {
54+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s1", ResumeToken: token, Capabilities: ports.CapabilityResume})}
3555 }
3656
37-type reconnectToastTimer struct{ ch chan time.Time }
38-
39-func (t *reconnectToastTimer) C() <-chan time.Time { return t.ch }
40-func (t *reconnectToastTimer) Reset(time.Duration) bool { return true }
41-func (t *reconnectToastTimer) Stop() bool { return true }
42-func (t *reconnectToastTimer) fire() { t.ch <- time.Time{} }
43-
44-type reconnectToastDialer struct {
45- trs []ports.Transport
46- calls atomic.Int32
57+func reconnectToastDetach(reason uint8) ports.Frame {
58+ return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})}
4759 }
4860
49-func (d *reconnectToastDialer) Dial(context.Context) (ports.Transport, error) {
50- i := int(d.calls.Add(1)) - 1
51- if i >= len(d.trs) {
52- return nil, io.EOF
61+func mockReconnectTransport(t *testing.T, recvs ...reconnectToastRecv) *portsmocks.MockTransport {
62+ t.Helper()
63+ tr := portsmocks.NewMockTransport(t)
64+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
65+ for _, recv := range recvs {
66+ tr.EXPECT().Recv().Return(recv.frame, recv.err).Once()
5367 }
54- return d.trs[i], nil
55-}
56-
57-type reconnectToastTransport struct {
58- recvs []reconnectToastRecv
59- sends []ports.Frame
68+ tr.EXPECT().Recv().Return(ports.Frame{}, io.EOF).Maybe()
69+ tr.EXPECT().Close().Return(nil).Maybe()
70+ return tr
6071 }
6172
6273 type reconnectToastRecv struct {
@@ -64,60 +75,6 @@ type reconnectToastRecv struct {
6475 err error
6576 }
6677
67-func (t *reconnectToastTransport) Send(f ports.Frame) error {
68- t.sends = append(t.sends, f)
69- return nil
70-}
71-
72-func (t *reconnectToastTransport) Recv() (ports.Frame, error) {
73- if len(t.recvs) == 0 {
74- return ports.Frame{}, io.EOF
75- }
76- it := t.recvs[0]
77- t.recvs = t.recvs[1:]
78- return it.frame, it.err
79-}
80-
81-func (t *reconnectToastTransport) Close() error { return nil }
82-
83-type reconnectToastTerminal struct {
84- in *blockingReconnectToastReader
85- out bytes.Buffer
86- size domain.Size
87- rawCount atomic.Int32
88-}
89-
90-func newReconnectToastTerminal() *reconnectToastTerminal {
91- return &reconnectToastTerminal{in: &blockingReconnectToastReader{done: make(chan struct{})}, size: domain.Size{Cols: 80, Rows: 24}}
92-}
93-
94-func (t *reconnectToastTerminal) EnterRaw() (func() error, error) {
95- t.rawCount.Add(1)
96- return func() error { return nil }, nil
97-}
98-func (t *reconnectToastTerminal) Size() (domain.Size, error) { return t.size, nil }
99-func (t *reconnectToastTerminal) ResizeEvents() <-chan domain.Size { return make(chan domain.Size) }
100-func (t *reconnectToastTerminal) QueryColors() error { return nil }
101-func (t *reconnectToastTerminal) In() io.Reader { return t.in }
102-func (t *reconnectToastTerminal) Out() io.Writer { return &t.out }
103-func (t *reconnectToastTerminal) Flush() error { return nil }
104-
105-type blockingReconnectToastReader struct{ done chan struct{} }
106-
107-func (r *blockingReconnectToastReader) Read([]byte) (int, error) {
108- <-r.done
109- return 0, io.EOF
110-}
111-func (r *blockingReconnectToastReader) unblock() { close(r.done) }
112-
113-func reconnectToastWelcome(token uint64) ports.Frame {
114- return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s1", ResumeToken: token, Capabilities: ports.CapabilityResume})}
115-}
116-
117-func reconnectToastDetach(reason uint8) ports.Frame {
118- return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})}
119-}
120-
12178 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
12279 var out bytes.Buffer
12380 size := domain.Size{Cols: 80, Rows: 24}
@@ -151,7 +108,12 @@ func displayWidth(s string) int {
151108 }
152109
153110 func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
154- clk := newReconnectToastClock()
111+ clk := portsmocks.NewMockClock(t)
112+ timer := portsmocks.NewMockTimer(t)
113+ timerCh := make(chan time.Time, 1)
114+ clk.EXPECT().NewTimer(time.Hour).Return(timer).Once()
115+ timer.EXPECT().C().Return((<-chan time.Time)(timerCh)).Maybe()
116+ timer.EXPECT().Stop().Return(true).Once()
155117 resizeCh := make(chan domain.Size, 2)
156118 resizeCh <- domain.Size{Cols: 100, Rows: 30}
157119 resizeCh <- domain.Size{Cols: 120, Rows: 40}
@@ -164,89 +126,88 @@ func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
164126 })
165127 }()
166128
167- <-clk.created
168129 require.Equal(t, domain.Size{Cols: 100, Rows: 30}, <-got)
169130 require.Equal(t, domain.Size{Cols: 120, Rows: 40}, <-got)
170- clk.timer.fire()
131+ timerCh <- time.Time{}
171132 require.True(t, <-done)
172133 }
173134
174-func TestRemoteReconnectToastClearsOnSuccessfulReconnect(t *testing.T) {
175- oldSleep := reconnectSleep
176- oldSleepWithResize := reconnectSleepWithResize
177- reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
178- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
179- return true
180- }
181- defer func() {
182- reconnectSleep = oldSleep
183- reconnectSleepWithResize = oldSleepWithResize
184- }()
185-
186- term := newReconnectToastTerminal()
187- defer term.in.unblock()
188- tr1 := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(11)}, {err: io.EOF}}}
189- tr2 := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(22)}, {frame: reconnectToastDetach(ports.ReasonDetach)}}}
190- dialer := &reconnectToastDialer{trs: []ports.Transport{tr1, tr2}}
191-
192- err := Run(context.Background(), dialer, term, newReconnectToastClock(), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
193- require.NoError(t, err)
194- out := term.out.String()
195- require.Contains(t, out, reconnectToastMessage)
196- require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
197-}
198-
199-func TestRemoteReconnectToastClearsOnCancellation(t *testing.T) {
200- oldSleep := reconnectSleep
201- oldSleepWithResize := reconnectSleepWithResize
202- ctx, cancel := context.WithCancel(context.Background())
203- reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool {
204- cancel()
205- return false
206- }
207- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
208- cancel()
209- return false
135+func TestRemoteReconnectToastLifecycle(t *testing.T) {
136+ tests := []struct {
137+ name string
138+ configure func(t *testing.T, dialer *portsmocks.MockDialer)
139+ sleep func(cancel context.CancelFunc) bool
140+ wantErr func(t *testing.T, err error)
141+ wantBlank bool
142+ }{
143+ {
144+ name: "clears on successful reconnect",
145+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
146+ tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
147+ tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)})
148+ dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()
149+ dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()
150+ },
151+ sleep: func(context.CancelFunc) bool { return true },
152+ wantErr: func(t *testing.T, err error) { require.NoError(t, err) },
153+ wantBlank: true,
154+ },
155+ {
156+ name: "clears on cancellation",
157+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
158+ tr := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
159+ dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
160+ },
161+ sleep: func(cancel context.CancelFunc) bool {
162+ cancel()
163+ return false
164+ },
165+ wantErr: func(t *testing.T, err error) { require.ErrorIs(t, err, context.Canceled) },
166+ wantBlank: true,
167+ },
168+ {
169+ name: "clears on final exit",
170+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
171+ tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
172+ tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonSessionKilled)})
173+ dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()
174+ dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()
175+ },
176+ sleep: func(context.CancelFunc) bool { return true },
177+ wantErr: func(t *testing.T, err error) {
178+ var detached *DetachedError
179+ require.True(t, errors.As(err, &detached))
180+ },
181+ wantBlank: true,
182+ },
210183 }
211- defer func() {
212- reconnectSleep = oldSleep
213- reconnectSleepWithResize = oldSleepWithResize
214- }()
215184
216- term := newReconnectToastTerminal()
217- defer term.in.unblock()
218- tr := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(11)}, {err: io.EOF}}}
219- dialer := &reconnectToastDialer{trs: []ports.Transport{tr}}
220-
221- err := Run(ctx, dialer, term, newReconnectToastClock(), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
222- require.ErrorIs(t, err, context.Canceled)
223- out := term.out.String()
224- require.Contains(t, out, reconnectToastMessage)
225- require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
226-}
227-
228-func TestRemoteReconnectToastClearsOnFinalExit(t *testing.T) {
229- oldSleep := reconnectSleep
230- oldSleepWithResize := reconnectSleepWithResize
231- reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
232- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
233- return true
185+ for _, tt := range tests {
186+ t.Run(tt.name, func(t *testing.T) {
187+ oldSleep := reconnectSleep
188+ oldSleepWithResize := reconnectSleepWithResize
189+ ctx, cancel := context.WithCancel(context.Background())
190+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return tt.sleep(cancel) }
191+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
192+ return tt.sleep(cancel)
193+ }
194+ defer func() {
195+ reconnectSleep = oldSleep
196+ reconnectSleepWithResize = oldSleepWithResize
197+ }()
198+
199+ term := newReconnectToastTerminalHarness(t)
200+ defer term.closeInput()
201+ dialer := portsmocks.NewMockDialer(t)
202+ tt.configure(t, dialer)
203+
204+ err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
205+ tt.wantErr(t, err)
206+ out := term.out.String()
207+ require.Contains(t, out, reconnectToastMessage)
208+ if tt.wantBlank {
209+ require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
210+ }
211+ })
234212 }
235- defer func() {
236- reconnectSleep = oldSleep
237- reconnectSleepWithResize = oldSleepWithResize
238- }()
239-
240- term := newReconnectToastTerminal()
241- defer term.in.unblock()
242- tr1 := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(11)}, {err: io.EOF}}}
243- tr2 := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(22)}, {frame: reconnectToastDetach(ports.ReasonSessionKilled)}}}
244- dialer := &reconnectToastDialer{trs: []ports.Transport{tr1, tr2}}
245-
246- err := Run(context.Background(), dialer, term, newReconnectToastClock(), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
247- var detached *DetachedError
248- require.True(t, errors.As(err, &detached))
249- out := term.out.String()
250- require.Contains(t, out, reconnectToastMessage)
251- require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
252213 }
@@ -7,56 +7,67 @@ import (
7 "io"7 "io"
8 "log/slog"8 "log/slog"
9 "strings"9 "strings"
10- "sync/atomic"
11 "testing"10 "testing"
12 "time"11 "time"
13 12
13+ "github.com/stretchr/testify/mock"
14 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15 15
16 "github.com/bnema/vev/internal/domain"16 "github.com/bnema/vev/internal/domain"
17 "github.com/bnema/vev/internal/ports"17 "github.com/bnema/vev/internal/ports"
18+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
18 "github.com/bnema/vev/pkg/renderer"19 "github.com/bnema/vev/pkg/renderer"
19 )20 )
20 21
21-type reconnectToastClock struct {22+type reconnectToastTerminalHarness struct {
22- timer *reconnectToastTimer23+ term *portsmocks.MockTerminal
23- created chan struct{}24+ in *io.PipeReader
25+ inWriter *io.PipeWriter
26+ out bytes.Buffer
27+ size domain.Size
28+ resizeCh chan domain.Size
29+}
30+
31+func newReconnectToastTerminalHarness(t *testing.T) *reconnectToastTerminalHarness {
32+ t.Helper()
33+ in, inWriter := io.Pipe()
34+ h := &reconnectToastTerminalHarness{
35+ term: portsmocks.NewMockTerminal(t),
36+ in: in,
37+ inWriter: inWriter,
38+ size: domain.Size{Cols: 80, Rows: 24},
39+ resizeCh: make(chan domain.Size),
40+ }
41+ h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
42+ h.term.EXPECT().Size().Return(h.size, nil).Maybe()
43+ h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()
44+ h.term.EXPECT().QueryColors().Return(nil).Maybe()
45+ h.term.EXPECT().In().Return(h.in).Maybe()
46+ h.term.EXPECT().Out().Return(&h.out).Maybe()
47+ h.term.EXPECT().Flush().Return(nil).Maybe()
48+ return h
24 }49 }
25 50
26-func newReconnectToastClock() *reconnectToastClock {51+func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() }
27- return &reconnectToastClock{created: make(chan struct{})}
28-}
29 52
30-func (*reconnectToastClock) Now() time.Time { return time.Time{} }53+func reconnectToastWelcome(token uint64) ports.Frame {
31-func (c *reconnectToastClock) NewTimer(time.Duration) ports.Timer {54+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s1", ResumeToken: token, Capabilities: ports.CapabilityResume})}
32- c.timer = &reconnectToastTimer{ch: make(chan time.Time, 1)}
33- close(c.created)
34- return c.timer
35 }55 }
36 56
37-type reconnectToastTimer struct{ ch chan time.Time }57+func reconnectToastDetach(reason uint8) ports.Frame {
38-58+ return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})}
39-func (t *reconnectToastTimer) C() <-chan time.Time { return t.ch }
40-func (t *reconnectToastTimer) Reset(time.Duration) bool { return true }
41-func (t *reconnectToastTimer) Stop() bool { return true }
42-func (t *reconnectToastTimer) fire() { t.ch <- time.Time{} }
43-
44-type reconnectToastDialer struct {
45- trs []ports.Transport
46- calls atomic.Int32
47 }59 }
48 60
49-func (d *reconnectToastDialer) Dial(context.Context) (ports.Transport, error) {61+func mockReconnectTransport(t *testing.T, recvs ...reconnectToastRecv) *portsmocks.MockTransport {
50- i := int(d.calls.Add(1)) - 162+ t.Helper()
51- if i >= len(d.trs) {63+ tr := portsmocks.NewMockTransport(t)
52- return nil, io.EOF64+ tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
65+ for _, recv := range recvs {
66+ tr.EXPECT().Recv().Return(recv.frame, recv.err).Once()
53 }67 }
54- return d.trs[i], nil68+ tr.EXPECT().Recv().Return(ports.Frame{}, io.EOF).Maybe()
55-}69+ tr.EXPECT().Close().Return(nil).Maybe()
56-70+ return tr
57-type reconnectToastTransport struct {
58- recvs []reconnectToastRecv
59- sends []ports.Frame
60 }71 }
61 72
62 type reconnectToastRecv struct {73 type reconnectToastRecv struct {
@@ -64,60 +75,6 @@ type reconnectToastRecv struct {
64 err error75 err error
65 }76 }
66 77
67-func (t *reconnectToastTransport) Send(f ports.Frame) error {
68- t.sends = append(t.sends, f)
69- return nil
70-}
71-
72-func (t *reconnectToastTransport) Recv() (ports.Frame, error) {
73- if len(t.recvs) == 0 {
74- return ports.Frame{}, io.EOF
75- }
76- it := t.recvs[0]
77- t.recvs = t.recvs[1:]
78- return it.frame, it.err
79-}
80-
81-func (t *reconnectToastTransport) Close() error { return nil }
82-
83-type reconnectToastTerminal struct {
84- in *blockingReconnectToastReader
85- out bytes.Buffer
86- size domain.Size
87- rawCount atomic.Int32
88-}
89-
90-func newReconnectToastTerminal() *reconnectToastTerminal {
91- return &reconnectToastTerminal{in: &blockingReconnectToastReader{done: make(chan struct{})}, size: domain.Size{Cols: 80, Rows: 24}}
92-}
93-
94-func (t *reconnectToastTerminal) EnterRaw() (func() error, error) {
95- t.rawCount.Add(1)
96- return func() error { return nil }, nil
97-}
98-func (t *reconnectToastTerminal) Size() (domain.Size, error) { return t.size, nil }
99-func (t *reconnectToastTerminal) ResizeEvents() <-chan domain.Size { return make(chan domain.Size) }
100-func (t *reconnectToastTerminal) QueryColors() error { return nil }
101-func (t *reconnectToastTerminal) In() io.Reader { return t.in }
102-func (t *reconnectToastTerminal) Out() io.Writer { return &t.out }
103-func (t *reconnectToastTerminal) Flush() error { return nil }
104-
105-type blockingReconnectToastReader struct{ done chan struct{} }
106-
107-func (r *blockingReconnectToastReader) Read([]byte) (int, error) {
108- <-r.done
109- return 0, io.EOF
110-}
111-func (r *blockingReconnectToastReader) unblock() { close(r.done) }
112-
113-func reconnectToastWelcome(token uint64) ports.Frame {
114- return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s1", ResumeToken: token, Capabilities: ports.CapabilityResume})}
115-}
116-
117-func reconnectToastDetach(reason uint8) ports.Frame {
118- return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: reason})}
119-}
120-
121 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {78 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
122 var out bytes.Buffer79 var out bytes.Buffer
123 size := domain.Size{Cols: 80, Rows: 24}80 size := domain.Size{Cols: 80, Rows: 24}
@@ -151,7 +108,12 @@ func displayWidth(s string) int {
151 }108 }
152 109
153 func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {110 func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
154- clk := newReconnectToastClock()111+ clk := portsmocks.NewMockClock(t)
112+ timer := portsmocks.NewMockTimer(t)
113+ timerCh := make(chan time.Time, 1)
114+ clk.EXPECT().NewTimer(time.Hour).Return(timer).Once()
115+ timer.EXPECT().C().Return((<-chan time.Time)(timerCh)).Maybe()
116+ timer.EXPECT().Stop().Return(true).Once()
155 resizeCh := make(chan domain.Size, 2)117 resizeCh := make(chan domain.Size, 2)
156 resizeCh <- domain.Size{Cols: 100, Rows: 30}118 resizeCh <- domain.Size{Cols: 100, Rows: 30}
157 resizeCh <- domain.Size{Cols: 120, Rows: 40}119 resizeCh <- domain.Size{Cols: 120, Rows: 40}
@@ -164,89 +126,88 @@ func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
164 })126 })
165 }()127 }()
166 128
167- <-clk.created
168 require.Equal(t, domain.Size{Cols: 100, Rows: 30}, <-got)129 require.Equal(t, domain.Size{Cols: 100, Rows: 30}, <-got)
169 require.Equal(t, domain.Size{Cols: 120, Rows: 40}, <-got)130 require.Equal(t, domain.Size{Cols: 120, Rows: 40}, <-got)
170- clk.timer.fire()131+ timerCh <- time.Time{}
171 require.True(t, <-done)132 require.True(t, <-done)
172 }133 }
173 134
174-func TestRemoteReconnectToastClearsOnSuccessfulReconnect(t *testing.T) {135+func TestRemoteReconnectToastLifecycle(t *testing.T) {
175- oldSleep := reconnectSleep136+ tests := []struct {
176- oldSleepWithResize := reconnectSleepWithResize137+ name string
177- reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }138+ configure func(t *testing.T, dialer *portsmocks.MockDialer)
178- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {139+ sleep func(cancel context.CancelFunc) bool
179- return true140+ wantErr func(t *testing.T, err error)
180- }141+ wantBlank bool
181- defer func() {142+ }{
182- reconnectSleep = oldSleep143+ {
183- reconnectSleepWithResize = oldSleepWithResize144+ name: "clears on successful reconnect",
184- }()145+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
185-146+ tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
186- term := newReconnectToastTerminal()147+ tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonDetach)})
187- defer term.in.unblock()148+ dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()
188- tr1 := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(11)}, {err: io.EOF}}}149+ dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()
189- tr2 := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(22)}, {frame: reconnectToastDetach(ports.ReasonDetach)}}}150+ },
190- dialer := &reconnectToastDialer{trs: []ports.Transport{tr1, tr2}}151+ sleep: func(context.CancelFunc) bool { return true },
191-152+ wantErr: func(t *testing.T, err error) { require.NoError(t, err) },
192- err := Run(context.Background(), dialer, term, newReconnectToastClock(), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))153+ wantBlank: true,
193- require.NoError(t, err)154+ },
194- out := term.out.String()155+ {
195- require.Contains(t, out, reconnectToastMessage)156+ name: "clears on cancellation",
196- require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))157+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
197-}158+ tr := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
198-159+ dialer.EXPECT().Dial(mock.Anything).Return(tr, nil).Once()
199-func TestRemoteReconnectToastClearsOnCancellation(t *testing.T) {160+ },
200- oldSleep := reconnectSleep161+ sleep: func(cancel context.CancelFunc) bool {
201- oldSleepWithResize := reconnectSleepWithResize162+ cancel()
202- ctx, cancel := context.WithCancel(context.Background())163+ return false
203- reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool {164+ },
204- cancel()165+ wantErr: func(t *testing.T, err error) { require.ErrorIs(t, err, context.Canceled) },
205- return false166+ wantBlank: true,
206- }167+ },
207- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {168+ {
208- cancel()169+ name: "clears on final exit",
209- return false170+ configure: func(t *testing.T, dialer *portsmocks.MockDialer) {
171+ tr1 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(11)}, reconnectToastRecv{err: io.EOF})
172+ tr2 := mockReconnectTransport(t, reconnectToastRecv{frame: reconnectToastWelcome(22)}, reconnectToastRecv{frame: reconnectToastDetach(ports.ReasonSessionKilled)})
173+ dialer.EXPECT().Dial(mock.Anything).Return(tr1, nil).Once()
174+ dialer.EXPECT().Dial(mock.Anything).Return(tr2, nil).Once()
175+ },
176+ sleep: func(context.CancelFunc) bool { return true },
177+ wantErr: func(t *testing.T, err error) {
178+ var detached *DetachedError
179+ require.True(t, errors.As(err, &detached))
180+ },
181+ wantBlank: true,
182+ },
210 }183 }
211- defer func() {
212- reconnectSleep = oldSleep
213- reconnectSleepWithResize = oldSleepWithResize
214- }()
215 184
216- term := newReconnectToastTerminal()185+ for _, tt := range tests {
217- defer term.in.unblock()186+ t.Run(tt.name, func(t *testing.T) {
218- tr := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(11)}, {err: io.EOF}}}187+ oldSleep := reconnectSleep
219- dialer := &reconnectToastDialer{trs: []ports.Transport{tr}}188+ oldSleepWithResize := reconnectSleepWithResize
220-189+ ctx, cancel := context.WithCancel(context.Background())
221- err := Run(ctx, dialer, term, newReconnectToastClock(), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))190+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return tt.sleep(cancel) }
222- require.ErrorIs(t, err, context.Canceled)191+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
223- out := term.out.String()192+ return tt.sleep(cancel)
224- require.Contains(t, out, reconnectToastMessage)193+ }
225- require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))194+ defer func() {
226-}195+ reconnectSleep = oldSleep
227-196+ reconnectSleepWithResize = oldSleepWithResize
228-func TestRemoteReconnectToastClearsOnFinalExit(t *testing.T) {197+ }()
229- oldSleep := reconnectSleep198+
230- oldSleepWithResize := reconnectSleepWithResize199+ term := newReconnectToastTerminalHarness(t)
231- reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }200+ defer term.closeInput()
232- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {201+ dialer := portsmocks.NewMockDialer(t)
233- return true202+ tt.configure(t, dialer)
203+
204+ err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
205+ tt.wantErr(t, err)
206+ out := term.out.String()
207+ require.Contains(t, out, reconnectToastMessage)
208+ if tt.wantBlank {
209+ require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
210+ }
211+ })
234 }212 }
235- defer func() {
236- reconnectSleep = oldSleep
237- reconnectSleepWithResize = oldSleepWithResize
238- }()
239-
240- term := newReconnectToastTerminal()
241- defer term.in.unblock()
242- tr1 := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(11)}, {err: io.EOF}}}
243- tr2 := &reconnectToastTransport{recvs: []reconnectToastRecv{{frame: reconnectToastWelcome(22)}, {frame: reconnectToastDetach(ports.ReasonSessionKilled)}}}
244- dialer := &reconnectToastDialer{trs: []ports.Transport{tr1, tr2}}
245-
246- err := Run(context.Background(), dialer, term, newReconnectToastClock(), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
247- var detached *DetachedError
248- require.True(t, errors.As(err, &detached))
249- out := term.out.String()
250- require.Contains(t, out, reconnectToastMessage)
251- require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
252 }213 }

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