detached c38f440

test(client): cover remote link-dead reconnect toast

commit
c38f4400244046200ba385269a9decd19e9e8f9c
author
brice · · 2026-07-06 21:22 UTC
parent
7035bab
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +94 -0
M internal/usecase/client/reconnect_toast_test.go +94 -0
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "context"
66 "errors"
7+ "fmt"
78 "io"
89 "log/slog"
910 "strings"
@@ -75,6 +76,54 @@ type reconnectToastRecv struct {
7576 err error
7677 }
7778
79+type reconnectToastSequenceDialer struct {
80+ transports []ports.Transport
81+ calls int
82+}
83+
84+func (d *reconnectToastSequenceDialer) Dial(context.Context) (ports.Transport, error) {
85+ if d.calls >= len(d.transports) {
86+ return nil, io.EOF
87+ }
88+ tr := d.transports[d.calls]
89+ d.calls++
90+ return tr, nil
91+}
92+
93+type reconnectToastRecordingTransport struct {
94+ recvs []reconnectToastRecv
95+ sends []ports.Frame
96+ closed bool
97+}
98+
99+func (t *reconnectToastRecordingTransport) Send(f ports.Frame) error {
100+ t.sends = append(t.sends, f)
101+ return nil
102+}
103+
104+func (t *reconnectToastRecordingTransport) Recv() (ports.Frame, error) {
105+ if len(t.recvs) == 0 {
106+ return ports.Frame{}, io.EOF
107+ }
108+ recv := t.recvs[0]
109+ t.recvs = t.recvs[1:]
110+ return recv.frame, recv.err
111+}
112+
113+func (t *reconnectToastRecordingTransport) Close() error {
114+ t.closed = true
115+ return nil
116+}
117+
118+func reconnectToastHelloFromSend(t *testing.T, tr *reconnectToastRecordingTransport) ports.Hello {
119+ t.Helper()
120+ require.NotEmpty(t, tr.sends)
121+ require.Equal(t, ports.MsgHello, tr.sends[0].Type)
122+ hello, err := ports.UnmarshalHello(tr.sends[0].Payload)
123+ require.NoError(t, err)
124+ return hello
125+}
126+
78127 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
79128 var out bytes.Buffer
80129 size := domain.Size{Cols: 80, Rows: 24}
@@ -132,6 +181,51 @@ func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
132181 require.True(t, <-done)
133182 }
134183
184+func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
185+ linkDead := errors.New("remote link dead")
186+ wrappedLinkDead := errors.Join(fmt.Errorf("remote transport receive failed: %w", linkDead))
187+ oldSleep := reconnectSleep
188+ oldSleepWithResize := reconnectSleepWithResize
189+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
190+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
191+ return true
192+ }
193+ defer func() {
194+ reconnectSleep = oldSleep
195+ reconnectSleepWithResize = oldSleepWithResize
196+ }()
197+
198+ term := newReconnectToastTerminalHarness(t)
199+ defer term.closeInput()
200+ tr1 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
201+ {frame: reconnectToastWelcome(44)},
202+ {err: wrappedLinkDead},
203+ }}
204+ tr2 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
205+ {frame: reconnectToastWelcome(55)},
206+ {frame: reconnectToastDetach(ports.ReasonDetach)},
207+ }}
208+ dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
209+
210+ err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
211+ require.NoError(t, err)
212+ require.True(t, tr1.closed)
213+ require.True(t, tr2.closed)
214+ require.Equal(t, 2, dialer.calls)
215+
216+ firstHello := reconnectToastHelloFromSend(t, tr1)
217+ resumeHello := reconnectToastHelloFromSend(t, tr2)
218+ require.Equal(t, ports.IntentAttach, firstHello.Intent)
219+ require.Zero(t, firstHello.ResumeToken)
220+ require.Equal(t, ports.IntentResume, resumeHello.Intent)
221+ require.Equal(t, uint64(44), resumeHello.ResumeToken)
222+ require.Equal(t, firstHello.ClientID, resumeHello.ClientID)
223+
224+ out := term.out.String()
225+ require.Contains(t, out, reconnectToastMessage)
226+ require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
227+}
228+
135229 func TestRemoteReconnectToastLifecycle(t *testing.T) {
136230 tests := []struct {
137231 name string
@@ -4,6 +4,7 @@ import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6 "errors"6 "errors"
7+ "fmt"
7 "io"8 "io"
8 "log/slog"9 "log/slog"
9 "strings"10 "strings"
@@ -75,6 +76,54 @@ type reconnectToastRecv struct {
75 err error76 err error
76 }77 }
77 78
79+type reconnectToastSequenceDialer struct {
80+ transports []ports.Transport
81+ calls int
82+}
83+
84+func (d *reconnectToastSequenceDialer) Dial(context.Context) (ports.Transport, error) {
85+ if d.calls >= len(d.transports) {
86+ return nil, io.EOF
87+ }
88+ tr := d.transports[d.calls]
89+ d.calls++
90+ return tr, nil
91+}
92+
93+type reconnectToastRecordingTransport struct {
94+ recvs []reconnectToastRecv
95+ sends []ports.Frame
96+ closed bool
97+}
98+
99+func (t *reconnectToastRecordingTransport) Send(f ports.Frame) error {
100+ t.sends = append(t.sends, f)
101+ return nil
102+}
103+
104+func (t *reconnectToastRecordingTransport) Recv() (ports.Frame, error) {
105+ if len(t.recvs) == 0 {
106+ return ports.Frame{}, io.EOF
107+ }
108+ recv := t.recvs[0]
109+ t.recvs = t.recvs[1:]
110+ return recv.frame, recv.err
111+}
112+
113+func (t *reconnectToastRecordingTransport) Close() error {
114+ t.closed = true
115+ return nil
116+}
117+
118+func reconnectToastHelloFromSend(t *testing.T, tr *reconnectToastRecordingTransport) ports.Hello {
119+ t.Helper()
120+ require.NotEmpty(t, tr.sends)
121+ require.Equal(t, ports.MsgHello, tr.sends[0].Type)
122+ hello, err := ports.UnmarshalHello(tr.sends[0].Payload)
123+ require.NoError(t, err)
124+ return hello
125+}
126+
78 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {127 func TestReconnectToastDrawAndClearHelpers(t *testing.T) {
79 var out bytes.Buffer128 var out bytes.Buffer
80 size := domain.Size{Cols: 80, Rows: 24}129 size := domain.Size{Cols: 80, Rows: 24}
@@ -132,6 +181,51 @@ func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) {
132 require.True(t, <-done)181 require.True(t, <-done)
133 }182 }
134 183
184+func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) {
185+ linkDead := errors.New("remote link dead")
186+ wrappedLinkDead := errors.Join(fmt.Errorf("remote transport receive failed: %w", linkDead))
187+ oldSleep := reconnectSleep
188+ oldSleepWithResize := reconnectSleepWithResize
189+ reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
190+ reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
191+ return true
192+ }
193+ defer func() {
194+ reconnectSleep = oldSleep
195+ reconnectSleepWithResize = oldSleepWithResize
196+ }()
197+
198+ term := newReconnectToastTerminalHarness(t)
199+ defer term.closeInput()
200+ tr1 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
201+ {frame: reconnectToastWelcome(44)},
202+ {err: wrappedLinkDead},
203+ }}
204+ tr2 := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{
205+ {frame: reconnectToastWelcome(55)},
206+ {frame: reconnectToastDetach(ports.ReasonDetach)},
207+ }}
208+ dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
209+
210+ err := Run(context.Background(), dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler))
211+ require.NoError(t, err)
212+ require.True(t, tr1.closed)
213+ require.True(t, tr2.closed)
214+ require.Equal(t, 2, dialer.calls)
215+
216+ firstHello := reconnectToastHelloFromSend(t, tr1)
217+ resumeHello := reconnectToastHelloFromSend(t, tr2)
218+ require.Equal(t, ports.IntentAttach, firstHello.Intent)
219+ require.Zero(t, firstHello.ResumeToken)
220+ require.Equal(t, ports.IntentResume, resumeHello.Intent)
221+ require.Equal(t, uint64(44), resumeHello.ResumeToken)
222+ require.Equal(t, firstHello.ClientID, resumeHello.ClientID)
223+
224+ out := term.out.String()
225+ require.Contains(t, out, reconnectToastMessage)
226+ require.Contains(t, out, strings.Repeat(" ", reconnectToastBounds(term.size).Width))
227+}
228+
135 func TestRemoteReconnectToastLifecycle(t *testing.T) {229 func TestRemoteReconnectToastLifecycle(t *testing.T) {
136 tests := []struct {230 tests := []struct {
137 name string231 name string

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