Merge pull request #51 from bnema/fix/reconnect-toast-clear-bounds
fix(client): clear drawn reconnect toast bounds
3 files · +131 -12
M internal/usecase/client/client.go +5 -5
| @@ -160,17 +160,17 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||
| 160 | 160 | attemptName := name | |
| 161 | 161 | backoff := defaultReconnectBackoff.initial | |
| 162 | 162 | showingStatus := false | |
| 163 | - | var reconnectToastSize domain.Size | |
| 163 | + | var reconnectToastRect domain.Rect | |
| 164 | 164 | statusStage := reconnectStageOfflineRetrying | |
| 165 | 165 | redrawRemoteStatus := func(size domain.Size) { | |
| 166 | 166 | if !rawEntered || !remote { | |
| 167 | 167 | return | |
| 168 | 168 | } | |
| 169 | 169 | if showingStatus { | |
| 170 | - | _ = clearReconnectToast(term.Out(), reconnectToastSize) | |
| 170 | + | _ = clearReconnectToast(term.Out(), reconnectToastRect) | |
| 171 | 171 | } | |
| 172 | - | _ = drawReconnectToastStage(term.Out(), size, statusStage) | |
| 173 | - | reconnectToastSize = size | |
| 172 | + | drawnRect, _ := drawReconnectToastStage(term.Out(), size, statusStage) | |
| 173 | + | reconnectToastRect = drawnRect | |
| 174 | 174 | _ = term.Flush() | |
| 175 | 175 | showingStatus = true | |
| 176 | 176 | } | |
| @@ -207,7 +207,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||
| 207 | 207 | return | |
| 208 | 208 | } | |
| 209 | 209 | if remote { | |
| 210 | - | _ = clearReconnectToast(term.Out(), reconnectToastSize) | |
| 210 | + | _ = clearReconnectToast(term.Out(), reconnectToastRect) | |
| 211 | 211 | } else { | |
| 212 | 212 | _, _ = term.Out().Write([]byte(statusClear)) | |
| 213 | 213 | } | |
| @@ -160,17 +160,17 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||||
| 160 | attemptName := name | 160 | attemptName := name | ||
| 161 | backoff := defaultReconnectBackoff.initial | 161 | backoff := defaultReconnectBackoff.initial | ||
| 162 | showingStatus := false | 162 | showingStatus := false | ||
| 163 | - | var reconnectToastSize domain.Size | 163 | + | var reconnectToastRect domain.Rect |
| 164 | statusStage := reconnectStageOfflineRetrying | 164 | statusStage := reconnectStageOfflineRetrying | ||
| 165 | redrawRemoteStatus := func(size domain.Size) { | 165 | redrawRemoteStatus := func(size domain.Size) { | ||
| 166 | if !rawEntered || !remote { | 166 | if !rawEntered || !remote { | ||
| 167 | return | 167 | return | ||
| 168 | } | 168 | } | ||
| 169 | if showingStatus { | 169 | if showingStatus { | ||
| 170 | - | _ = clearReconnectToast(term.Out(), reconnectToastSize) | 170 | + | _ = clearReconnectToast(term.Out(), reconnectToastRect) |
| 171 | } | 171 | } | ||
| 172 | - | _ = drawReconnectToastStage(term.Out(), size, statusStage) | 172 | + | drawnRect, _ := drawReconnectToastStage(term.Out(), size, statusStage) |
| 173 | - | reconnectToastSize = size | 173 | + | reconnectToastRect = drawnRect |
| 174 | _ = term.Flush() | 174 | _ = term.Flush() | ||
| 175 | showingStatus = true | 175 | showingStatus = true | ||
| 176 | } | 176 | } | ||
| @@ -207,7 +207,7 @@ func Run(ctx context.Context, dialer ports.Dialer, term ports.Terminal, clk port | |||||
| 207 | return | 207 | return | ||
| 208 | } | 208 | } | ||
| 209 | if remote { | 209 | if remote { | ||
| 210 | - | _ = clearReconnectToast(term.Out(), reconnectToastSize) | 210 | + | _ = clearReconnectToast(term.Out(), reconnectToastRect) |
| 211 | } else { | 211 | } else { | ||
| 212 | _, _ = term.Out().Write([]byte(statusClear)) | 212 | _, _ = term.Out().Write([]byte(statusClear)) | ||
| 213 | } | 213 | } | ||
M internal/usecase/client/reconnect_toast.go +6 -6
| @@ -28,20 +28,20 @@ func reconnectStageMessage(stage reconnectStage) string { | |||
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | 30 | func drawReconnectToast(out io.Writer, size domain.Size) error { | |
| 31 | - | return drawReconnectToastStage(out, size, reconnectStageOfflineRetrying) | |
| 31 | + | _, err := drawReconnectToastStage(out, size, reconnectStageOfflineRetrying) | |
| 32 | + | return err | |
| 32 | 33 | } | |
| 33 | 34 | ||
| 34 | - | func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) error { | |
| 35 | + | func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) { | |
| 35 | 36 | message := reconnectStageMessage(stage) | |
| 36 | 37 | bounds := reconnectToastBoundsFor(size, message) | |
| 37 | 38 | if bounds.Width <= 0 || bounds.Height <= 0 { | |
| 38 | - | return nil | |
| 39 | + | return domain.Rect{}, nil | |
| 39 | 40 | } | |
| 40 | - | return writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message)) | |
| 41 | + | return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message)) | |
| 41 | 42 | } | |
| 42 | 43 | ||
| 43 | - | func clearReconnectToast(out io.Writer, size domain.Size) error { | |
| 44 | - | bounds := reconnectToastBounds(size) | |
| 44 | + | func clearReconnectToast(out io.Writer, bounds domain.Rect) error { | |
| 45 | 45 | if bounds.Width <= 0 || bounds.Height <= 0 { | |
| 46 | 46 | return nil | |
| 47 | 47 | } | |
| @@ -28,20 +28,20 @@ func reconnectStageMessage(stage reconnectStage) string { | |||||
| 28 | } | 28 | } | ||
| 29 | 29 | ||||
| 30 | func drawReconnectToast(out io.Writer, size domain.Size) error { | 30 | func drawReconnectToast(out io.Writer, size domain.Size) error { | ||
| 31 | - | return drawReconnectToastStage(out, size, reconnectStageOfflineRetrying) | 31 | + | _, err := drawReconnectToastStage(out, size, reconnectStageOfflineRetrying) |
| 32 | + | return err | |||
| 32 | } | 33 | } | ||
| 33 | 34 | ||||
| 34 | - | func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) error { | 35 | + | func drawReconnectToastStage(out io.Writer, size domain.Size, stage reconnectStage) (domain.Rect, error) { |
| 35 | message := reconnectStageMessage(stage) | 36 | message := reconnectStageMessage(stage) | ||
| 36 | bounds := reconnectToastBoundsFor(size, message) | 37 | bounds := reconnectToastBoundsFor(size, message) | ||
| 37 | if bounds.Width <= 0 || bounds.Height <= 0 { | 38 | if bounds.Width <= 0 || bounds.Height <= 0 { | ||
| 38 | - | return nil | 39 | + | return domain.Rect{}, nil |
| 39 | } | 40 | } | ||
| 40 | - | return writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message)) | 41 | + | return bounds, writeReconnectToast(out, bounds, reconnectToastLinesFor(bounds, message)) |
| 41 | } | 42 | } | ||
| 42 | 43 | ||||
| 43 | - | func clearReconnectToast(out io.Writer, size domain.Size) error { | 44 | + | func clearReconnectToast(out io.Writer, bounds domain.Rect) error { |
| 44 | - | bounds := reconnectToastBounds(size) | |||
| 45 | if bounds.Width <= 0 || bounds.Height <= 0 { | 45 | if bounds.Width <= 0 || bounds.Height <= 0 { | ||
| 46 | return nil | 46 | return nil | ||
| 47 | } | 47 | } | ||
M internal/usecase/client/reconnect_toast_test.go +120 -1
| @@ -49,6 +49,26 @@ func newReconnectToastTerminalHarness(t *testing.T) *reconnectToastTerminalHarne | |||
| 49 | 49 | return h | |
| 50 | 50 | } | |
| 51 | 51 | ||
| 52 | + | func newReconnectToastTerminalHarnessWithOutput(t *testing.T, out io.Writer) *reconnectToastTerminalHarness { | |
| 53 | + | t.Helper() | |
| 54 | + | in, inWriter := io.Pipe() | |
| 55 | + | h := &reconnectToastTerminalHarness{ | |
| 56 | + | term: portsmocks.NewMockTerminal(t), | |
| 57 | + | in: in, | |
| 58 | + | inWriter: inWriter, | |
| 59 | + | size: domain.Size{Cols: 80, Rows: 24}, | |
| 60 | + | resizeCh: make(chan domain.Size), | |
| 61 | + | } | |
| 62 | + | h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe() | |
| 63 | + | h.term.EXPECT().Size().Return(h.size, nil).Maybe() | |
| 64 | + | h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe() | |
| 65 | + | h.term.EXPECT().QueryColors().Return(nil).Maybe() | |
| 66 | + | h.term.EXPECT().In().Return(h.in).Maybe() | |
| 67 | + | h.term.EXPECT().Out().Return(out).Maybe() | |
| 68 | + | h.term.EXPECT().Flush().Return(nil).Maybe() | |
| 69 | + | return h | |
| 70 | + | } | |
| 71 | + | ||
| 52 | 72 | func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() } | |
| 53 | 73 | ||
| 54 | 74 | func reconnectToastWelcome(token uint64) ports.Frame { | |
| @@ -222,8 +242,8 @@ func TestReconnectToastDrawAndClearHelpers(t *testing.T) { | |||
| 222 | 242 | require.Contains(t, out.String(), reconnectToastMessage) | |
| 223 | 243 | require.Contains(t, out.String(), "\x1b[0m") | |
| 224 | 244 | ||
| 225 | - | require.NoError(t, clearReconnectToast(&out, size)) | |
| 226 | 245 | bounds := reconnectToastBounds(size) | |
| 246 | + | require.NoError(t, clearReconnectToast(&out, bounds)) | |
| 227 | 247 | require.Contains(t, out.String(), strings.Repeat(" ", bounds.Width)) | |
| 228 | 248 | } | |
| 229 | 249 | ||
| @@ -237,6 +257,61 @@ func TestReconnectToastLinesClampToBounds(t *testing.T) { | |||
| 237 | 257 | require.Contains(t, strings.Join(lines, "\n"), "…") | |
| 238 | 258 | } | |
| 239 | 259 | ||
| 260 | + | func TestReconnectToastStageTransitionsClearDrawnBounds(t *testing.T) { | |
| 261 | + | size := domain.Size{Cols: 80, Rows: 24} | |
| 262 | + | tests := []struct { | |
| 263 | + | name string | |
| 264 | + | stages []reconnectStage | |
| 265 | + | }{ | |
| 266 | + | { | |
| 267 | + | name: "degraded to probing to ssh to offline", | |
| 268 | + | stages: []reconnectStage{reconnectStageDegraded, reconnectStageProbingUDP, reconnectStageSSH, reconnectStageOfflineRetrying}, | |
| 269 | + | }, | |
| 270 | + | { | |
| 271 | + | name: "widest ssh to offline", | |
| 272 | + | stages: []reconnectStage{reconnectStageSSH, reconnectStageOfflineRetrying}, | |
| 273 | + | }, | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | for _, tt := range tests { | |
| 277 | + | t.Run(tt.name, func(t *testing.T) { | |
| 278 | + | var out bytes.Buffer | |
| 279 | + | previousBounds, err := drawReconnectToastStage(&out, size, tt.stages[0]) | |
| 280 | + | require.NoError(t, err) | |
| 281 | + | ||
| 282 | + | for _, stage := range tt.stages[1:] { | |
| 283 | + | out.Reset() | |
| 284 | + | require.NoError(t, clearReconnectToast(&out, previousBounds)) | |
| 285 | + | assertReconnectToastClearCoversBounds(t, out.String(), previousBounds) | |
| 286 | + | ||
| 287 | + | out.Reset() | |
| 288 | + | previousBounds, err = drawReconnectToastStage(&out, size, stage) | |
| 289 | + | require.NoError(t, err) | |
| 290 | + | } | |
| 291 | + | }) | |
| 292 | + | } | |
| 293 | + | } | |
| 294 | + | ||
| 295 | + | func TestReconnectToastClearAfterWidestStageErasesFullBorder(t *testing.T) { | |
| 296 | + | size := domain.Size{Cols: 80, Rows: 24} | |
| 297 | + | var out bytes.Buffer | |
| 298 | + | bounds, err := drawReconnectToastStage(&out, size, reconnectStageSSH) | |
| 299 | + | require.NoError(t, err) | |
| 300 | + | require.Greater(t, bounds.Width, reconnectToastBounds(size).Width) | |
| 301 | + | ||
| 302 | + | out.Reset() | |
| 303 | + | require.NoError(t, clearReconnectToast(&out, bounds)) | |
| 304 | + | assertReconnectToastClearCoversBounds(t, out.String(), bounds) | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | func assertReconnectToastClearCoversBounds(t *testing.T, output string, bounds domain.Rect) { | |
| 308 | + | t.Helper() | |
| 309 | + | blank := strings.Repeat(" ", bounds.Width) | |
| 310 | + | for row := range bounds.Height { | |
| 311 | + | require.Contains(t, output, fmt.Sprintf("\x1b[%d;%dH\x1b[0m%s", bounds.Y+row+1, bounds.X+1, blank)) | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | ||
| 240 | 315 | func displayWidth(s string) int { | |
| 241 | 316 | width := 0 | |
| 242 | 317 | for _, r := range s { | |
| @@ -270,6 +345,50 @@ func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) { | |||
| 270 | 345 | require.True(t, <-done) | |
| 271 | 346 | } | |
| 272 | 347 | ||
| 348 | + | type reconnectToastFailOnceWriter struct { | |
| 349 | + | bytes.Buffer | |
| 350 | + | needle string | |
| 351 | + | failed bool | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | func (w *reconnectToastFailOnceWriter) Write(p []byte) (int, error) { | |
| 355 | + | n, _ := w.Buffer.Write(p) | |
| 356 | + | if !w.failed && strings.Contains(string(p), w.needle) { | |
| 357 | + | w.failed = true | |
| 358 | + | return n, errors.New("reconnect toast write failed") | |
| 359 | + | } | |
| 360 | + | return n, nil | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | func TestRemoteReconnectToastClearsFailedDrawBounds(t *testing.T) { | |
| 364 | + | oldSleep := reconnectSleep | |
| 365 | + | oldSleepWithResize := reconnectSleepWithResize | |
| 366 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 367 | + | reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return false } | |
| 368 | + | reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool { | |
| 369 | + | cancel() | |
| 370 | + | return false | |
| 371 | + | } | |
| 372 | + | defer func() { | |
| 373 | + | reconnectSleep = oldSleep | |
| 374 | + | reconnectSleepWithResize = oldSleepWithResize | |
| 375 | + | }() | |
| 376 | + | ||
| 377 | + | out := &reconnectToastFailOnceWriter{needle: "reconnecting through SSH"} | |
| 378 | + | term := newReconnectToastTerminalHarnessWithOutput(t, out) | |
| 379 | + | defer term.closeInput() | |
| 380 | + | tr := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{ | |
| 381 | + | {frame: reconnectToastWelcome(44)}, | |
| 382 | + | {err: io.EOF}, | |
| 383 | + | }} | |
| 384 | + | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}} | |
| 385 | + | ||
| 386 | + | err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) | |
| 387 | + | require.ErrorIs(t, err, context.Canceled) | |
| 388 | + | require.True(t, out.failed) | |
| 389 | + | assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH))) | |
| 390 | + | } | |
| 391 | + | ||
| 273 | 392 | func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) { | |
| 274 | 393 | linkDead := errors.New("remote link dead") | |
| 275 | 394 | wrappedLinkDead := errors.Join( | |
| @@ -49,6 +49,26 @@ func newReconnectToastTerminalHarness(t *testing.T) *reconnectToastTerminalHarne | |||||
| 49 | return h | 49 | return h | ||
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | + | func newReconnectToastTerminalHarnessWithOutput(t *testing.T, out io.Writer) *reconnectToastTerminalHarness { | |||
| 53 | + | t.Helper() | |||
| 54 | + | in, inWriter := io.Pipe() | |||
| 55 | + | h := &reconnectToastTerminalHarness{ | |||
| 56 | + | term: portsmocks.NewMockTerminal(t), | |||
| 57 | + | in: in, | |||
| 58 | + | inWriter: inWriter, | |||
| 59 | + | size: domain.Size{Cols: 80, Rows: 24}, | |||
| 60 | + | resizeCh: make(chan domain.Size), | |||
| 61 | + | } | |||
| 62 | + | h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe() | |||
| 63 | + | h.term.EXPECT().Size().Return(h.size, nil).Maybe() | |||
| 64 | + | h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe() | |||
| 65 | + | h.term.EXPECT().QueryColors().Return(nil).Maybe() | |||
| 66 | + | h.term.EXPECT().In().Return(h.in).Maybe() | |||
| 67 | + | h.term.EXPECT().Out().Return(out).Maybe() | |||
| 68 | + | h.term.EXPECT().Flush().Return(nil).Maybe() | |||
| 69 | + | return h | |||
| 70 | + | } | |||
| 71 | + | ||||
| 52 | func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() } | 72 | func (h *reconnectToastTerminalHarness) closeInput() { _ = h.inWriter.Close() } | ||
| 53 | 73 | ||||
| 54 | func reconnectToastWelcome(token uint64) ports.Frame { | 74 | func reconnectToastWelcome(token uint64) ports.Frame { | ||
| @@ -222,8 +242,8 @@ func TestReconnectToastDrawAndClearHelpers(t *testing.T) { | |||||
| 222 | require.Contains(t, out.String(), reconnectToastMessage) | 242 | require.Contains(t, out.String(), reconnectToastMessage) | ||
| 223 | require.Contains(t, out.String(), "\x1b[0m") | 243 | require.Contains(t, out.String(), "\x1b[0m") | ||
| 224 | 244 | ||||
| 225 | - | require.NoError(t, clearReconnectToast(&out, size)) | |||
| 226 | bounds := reconnectToastBounds(size) | 245 | bounds := reconnectToastBounds(size) | ||
| 246 | + | require.NoError(t, clearReconnectToast(&out, bounds)) | |||
| 227 | require.Contains(t, out.String(), strings.Repeat(" ", bounds.Width)) | 247 | require.Contains(t, out.String(), strings.Repeat(" ", bounds.Width)) | ||
| 228 | } | 248 | } | ||
| 229 | 249 | ||||
| @@ -237,6 +257,61 @@ func TestReconnectToastLinesClampToBounds(t *testing.T) { | |||||
| 237 | require.Contains(t, strings.Join(lines, "\n"), "…") | 257 | require.Contains(t, strings.Join(lines, "\n"), "…") | ||
| 238 | } | 258 | } | ||
| 239 | 259 | ||||
| 260 | + | func TestReconnectToastStageTransitionsClearDrawnBounds(t *testing.T) { | |||
| 261 | + | size := domain.Size{Cols: 80, Rows: 24} | |||
| 262 | + | tests := []struct { | |||
| 263 | + | name string | |||
| 264 | + | stages []reconnectStage | |||
| 265 | + | }{ | |||
| 266 | + | { | |||
| 267 | + | name: "degraded to probing to ssh to offline", | |||
| 268 | + | stages: []reconnectStage{reconnectStageDegraded, reconnectStageProbingUDP, reconnectStageSSH, reconnectStageOfflineRetrying}, | |||
| 269 | + | }, | |||
| 270 | + | { | |||
| 271 | + | name: "widest ssh to offline", | |||
| 272 | + | stages: []reconnectStage{reconnectStageSSH, reconnectStageOfflineRetrying}, | |||
| 273 | + | }, | |||
| 274 | + | } | |||
| 275 | + | ||||
| 276 | + | for _, tt := range tests { | |||
| 277 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 278 | + | var out bytes.Buffer | |||
| 279 | + | previousBounds, err := drawReconnectToastStage(&out, size, tt.stages[0]) | |||
| 280 | + | require.NoError(t, err) | |||
| 281 | + | ||||
| 282 | + | for _, stage := range tt.stages[1:] { | |||
| 283 | + | out.Reset() | |||
| 284 | + | require.NoError(t, clearReconnectToast(&out, previousBounds)) | |||
| 285 | + | assertReconnectToastClearCoversBounds(t, out.String(), previousBounds) | |||
| 286 | + | ||||
| 287 | + | out.Reset() | |||
| 288 | + | previousBounds, err = drawReconnectToastStage(&out, size, stage) | |||
| 289 | + | require.NoError(t, err) | |||
| 290 | + | } | |||
| 291 | + | }) | |||
| 292 | + | } | |||
| 293 | + | } | |||
| 294 | + | ||||
| 295 | + | func TestReconnectToastClearAfterWidestStageErasesFullBorder(t *testing.T) { | |||
| 296 | + | size := domain.Size{Cols: 80, Rows: 24} | |||
| 297 | + | var out bytes.Buffer | |||
| 298 | + | bounds, err := drawReconnectToastStage(&out, size, reconnectStageSSH) | |||
| 299 | + | require.NoError(t, err) | |||
| 300 | + | require.Greater(t, bounds.Width, reconnectToastBounds(size).Width) | |||
| 301 | + | ||||
| 302 | + | out.Reset() | |||
| 303 | + | require.NoError(t, clearReconnectToast(&out, bounds)) | |||
| 304 | + | assertReconnectToastClearCoversBounds(t, out.String(), bounds) | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | func assertReconnectToastClearCoversBounds(t *testing.T, output string, bounds domain.Rect) { | |||
| 308 | + | t.Helper() | |||
| 309 | + | blank := strings.Repeat(" ", bounds.Width) | |||
| 310 | + | for row := range bounds.Height { | |||
| 311 | + | require.Contains(t, output, fmt.Sprintf("\x1b[%d;%dH\x1b[0m%s", bounds.Y+row+1, bounds.X+1, blank)) | |||
| 312 | + | } | |||
| 313 | + | } | |||
| 314 | + | ||||
| 240 | func displayWidth(s string) int { | 315 | func displayWidth(s string) int { | ||
| 241 | width := 0 | 316 | width := 0 | ||
| 242 | for _, r := range s { | 317 | for _, r := range s { | ||
| @@ -270,6 +345,50 @@ func TestReconnectSleepWithResizeEventsRedrawsUntilTimerFires(t *testing.T) { | |||||
| 270 | require.True(t, <-done) | 345 | require.True(t, <-done) | ||
| 271 | } | 346 | } | ||
| 272 | 347 | ||||
| 348 | + | type reconnectToastFailOnceWriter struct { | |||
| 349 | + | bytes.Buffer | |||
| 350 | + | needle string | |||
| 351 | + | failed bool | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | func (w *reconnectToastFailOnceWriter) Write(p []byte) (int, error) { | |||
| 355 | + | n, _ := w.Buffer.Write(p) | |||
| 356 | + | if !w.failed && strings.Contains(string(p), w.needle) { | |||
| 357 | + | w.failed = true | |||
| 358 | + | return n, errors.New("reconnect toast write failed") | |||
| 359 | + | } | |||
| 360 | + | return n, nil | |||
| 361 | + | } | |||
| 362 | + | ||||
| 363 | + | func TestRemoteReconnectToastClearsFailedDrawBounds(t *testing.T) { | |||
| 364 | + | oldSleep := reconnectSleep | |||
| 365 | + | oldSleepWithResize := reconnectSleepWithResize | |||
| 366 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 367 | + | reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return false } | |||
| 368 | + | reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool { | |||
| 369 | + | cancel() | |||
| 370 | + | return false | |||
| 371 | + | } | |||
| 372 | + | defer func() { | |||
| 373 | + | reconnectSleep = oldSleep | |||
| 374 | + | reconnectSleepWithResize = oldSleepWithResize | |||
| 375 | + | }() | |||
| 376 | + | ||||
| 377 | + | out := &reconnectToastFailOnceWriter{needle: "reconnecting through SSH"} | |||
| 378 | + | term := newReconnectToastTerminalHarnessWithOutput(t, out) | |||
| 379 | + | defer term.closeInput() | |||
| 380 | + | tr := &reconnectToastRecordingTransport{recvs: []reconnectToastRecv{ | |||
| 381 | + | {frame: reconnectToastWelcome(44)}, | |||
| 382 | + | {err: io.EOF}, | |||
| 383 | + | }} | |||
| 384 | + | dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr}} | |||
| 385 | + | ||||
| 386 | + | err := Run(ctx, dialer, term.term, portsmocks.NewMockClock(t), ports.IntentAttach, "main", true, nil, slog.New(slog.DiscardHandler)) | |||
| 387 | + | require.ErrorIs(t, err, context.Canceled) | |||
| 388 | + | require.True(t, out.failed) | |||
| 389 | + | assertReconnectToastClearCoversBounds(t, out.String(), reconnectToastBoundsFor(term.size, reconnectStageMessage(reconnectStageSSH))) | |||
| 390 | + | } | |||
| 391 | + | ||||
| 273 | func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) { | 392 | func TestRemoteReconnectToastLifecycleWithWrappedTransportError(t *testing.T) { | ||
| 274 | linkDead := errors.New("remote link dead") | 393 | linkDead := errors.New("remote link dead") | ||
| 275 | wrappedLinkDead := errors.Join( | 394 | wrappedLinkDead := errors.Join( | ||