test(client): use port mocks for reconnect toast tests
- commit
- 77ed6d6ba0903877023ba8787715dd019b9b7fda
- author
- brice · · 2026-07-06 01:29 UTC
- parent
- 25fad49
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 ( | |||
| 7 | 7 | "io" | |
| 8 | 8 | "log/slog" | |
| 9 | 9 | "strings" | |
| 10 | - | "sync/atomic" | |
| 11 | 10 | "testing" | |
| 12 | 11 | "time" | |
| 13 | 12 | ||
| 13 | + | "github.com/stretchr/testify/mock" | |
| 14 | 14 | "github.com/stretchr/testify/require" | |
| 15 | 15 | ||
| 16 | 16 | "github.com/bnema/vev/internal/domain" | |
| 17 | 17 | "github.com/bnema/vev/internal/ports" | |
| 18 | + | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 18 | 19 | "github.com/bnema/vev/pkg/renderer" | |
| 19 | 20 | ) | |
| 20 | 21 | ||
| 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 | |
| 24 | 49 | } | |
| 25 | 50 | ||
| 26 | - | func newReconnectToastClock() *reconnectToastClock { | |
| 27 | - | return &reconnectToastClock{created: make(chan struct{})} | |
| 28 | - | } | |
| 51 | + | func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() } | |
| 29 | 52 | ||
| 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})} | |
| 35 | 55 | } | |
| 36 | 56 | ||
| 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})} | |
| 47 | 59 | } | |
| 48 | 60 | ||
| 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() | |
| 53 | 67 | } | |
| 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 | |
| 60 | 71 | } | |
| 61 | 72 | ||
| 62 | 73 | type reconnectToastRecv struct { | |
| @@ -64,60 +75,6 @@ type reconnectToastRecv struct { | |||
| 64 | 75 | 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 | 78 | func TestReconnectToastDrawAndClearHelpers(t *testing.T) { | |
| 122 | 79 | var out bytes.Buffer | |
| 123 | 80 | size := domain.Size{Cols: 80, Rows: 24} | |
| @@ -151,7 +108,12 @@ func displayWidth(s string) int { | |||
| 151 | 108 | } | |
| 152 | 109 | ||
| 153 | 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 | 117 | resizeCh := make(chan domain.Size, 2) | |
| 156 | 118 | resizeCh <- domain.Size{Cols: 100, Rows: 30} | |
| 157 | 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 | 129 | require.Equal(t, domain.Size{Cols: 100, Rows: 30}, <-got) | |
| 169 | 130 | require.Equal(t, domain.Size{Cols: 120, Rows: 40}, <-got) | |
| 170 | - | clk.timer.fire() | |
| 131 | + | timerCh <- time.Time{} | |
| 171 | 132 | require.True(t, <-done) | |
| 172 | 133 | } | |
| 173 | 134 | ||
| 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 | + | }, | |
| 210 | 183 | } | |
| 211 | - | defer func() { | |
| 212 | - | reconnectSleep = oldSleep | |
| 213 | - | reconnectSleepWithResize = oldSleepWithResize | |
| 214 | - | }() | |
| 215 | 184 | ||
| 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 | + | }) | |
| 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 | } | |
| @@ -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 *reconnectToastTimer | 23 | + | 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)) - 1 | 62 | + | t.Helper() |
| 51 | - | if i >= len(d.trs) { | 63 | + | tr := portsmocks.NewMockTransport(t) |
| 52 | - | return nil, io.EOF | 64 | + | 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], nil | 68 | + | 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 error | 75 | 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.Buffer | 79 | 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 := reconnectSleep | 136 | + | tests := []struct { |
| 176 | - | oldSleepWithResize := reconnectSleepWithResize | 137 | + | 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 true | 140 | + | wantErr func(t *testing.T, err error) |
| 180 | - | } | 141 | + | wantBlank bool |
| 181 | - | defer func() { | 142 | + | }{ |
| 182 | - | reconnectSleep = oldSleep | 143 | + | { |
| 183 | - | reconnectSleepWithResize = oldSleepWithResize | 144 | + | 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 := reconnectSleep | 161 | + | sleep: func(cancel context.CancelFunc) bool { |
| 201 | - | oldSleepWithResize := reconnectSleepWithResize | 162 | + | 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 false | 166 | + | 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 false | 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 | + | }, | |||
| 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 := reconnectSleep | 198 | + | |
| 230 | - | oldSleepWithResize := reconnectSleepWithResize | 199 | + | 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 true | 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 | + | }) | |||
| 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 | } | ||