test(client): cover remote link-dead reconnect toast
- commit
- c38f4400244046200ba385269a9decd19e9e8f9c
- author
- brice · · 2026-07-06 21:22 UTC
- parent
- 7035bab
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | 6 | "errors" | |
| 7 | + | "fmt" | |
| 7 | 8 | "io" | |
| 8 | 9 | "log/slog" | |
| 9 | 10 | "strings" | |
| @@ -75,6 +76,54 @@ type reconnectToastRecv struct { | |||
| 75 | 76 | 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 | 127 | func TestReconnectToastDrawAndClearHelpers(t *testing.T) { | |
| 79 | 128 | var out bytes.Buffer | |
| 80 | 129 | size := domain.Size{Cols: 80, Rows: 24} | |
| @@ -132,6 +181,51 @@ func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) { | |||
| 132 | 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 | 229 | func TestRemoteReconnectToastLifecycle(t *testing.T) { | |
| 136 | 230 | tests := []struct { | |
| 137 | 231 | 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 error | 76 | 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.Buffer | 128 | 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 string | 231 | name string | ||