test(daemon): reproduce stale alternate screen exit
- commit
- 07cd4c870e160b6a14d665abda8b2c523345d19b
- author
- brice · · 2026-07-13 05:53 UTC
- parent
- a3882e7
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +50 -0
M internal/usecase/daemon/render_test.go +50 -0
| @@ -254,6 +254,56 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) { | |||
| 254 | 254 | }) | |
| 255 | 255 | } | |
| 256 | 256 | ||
| 257 | + | func TestPTYReaderRestoresPrimaryScreenImmediatelyAfterDEC1049Exit(t *testing.T) { | |
| 258 | + | pty, steps, processed := newChannelPTY(t) | |
| 259 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |
| 260 | + | // A one-frame local output window forces the replay to exercise the ACK | |
| 261 | + | // edge between each visible terminal state without relying on elapsed time. | |
| 262 | + | ac.output.maxOutstanding = 1 | |
| 263 | + | clock := newCoordinatorMockClock(t, 8) | |
| 264 | + | d.clock = clock.clock | |
| 265 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 266 | + | client := vt.NewScreen(80, 25) | |
| 267 | + | ||
| 268 | + | d.sessWg.Add(1) | |
| 269 | + | go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane()) | |
| 270 | + | ||
| 271 | + | replay := func(data []byte, acknowledge bool) ports.Output { | |
| 272 | + | t.Helper() | |
| 273 | + | steps <- channelPTYStep{data: data} | |
| 274 | + | awaitPTYReadProcessed(t, processed) | |
| 275 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |
| 276 | + | frame := awaitOutputFrameWithoutSleep(t, sends) | |
| 277 | + | out := mustApplyOutput(t, client, frame) | |
| 278 | + | if acknowledge { | |
| 279 | + | ac.ackOutputState(out.NewStateNum) | |
| 280 | + | rc.notifyAck() | |
| 281 | + | } | |
| 282 | + | return out | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | replay([]byte("PRIMARY"), true) | |
| 286 | + | primary := client.Frame.Clone() | |
| 287 | + | ||
| 288 | + | // This is the PTY byte sequence emitted by the reported command: primary | |
| 289 | + | // content, a dense DEC alternate screen, then an immediate DEC restore. | |
| 290 | + | alternate := replay(append([]byte("\x1b[?1049h"), []byte(strings.Repeat("X", 80*23))...), false) | |
| 291 | + | require.Equal(t, 'X', client.Frame.At(79, 23).Rune, "fixture must make alternate cells client-visible") | |
| 292 | + | ||
| 293 | + | steps <- channelPTYStep{data: []byte("\x1b[?1049l")} | |
| 294 | + | awaitPTYReadProcessed(t, processed) | |
| 295 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |
| 296 | + | requireNoCoordinatorOutputFrame(t, sends) | |
| 297 | + | ac.ackOutputState(alternate.NewStateNum) | |
| 298 | + | rc.notifyAck() | |
| 299 | + | exit := mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)) | |
| 300 | + | require.NotEmpty(t, exit.Data, "the first post-exit output must actively remove alternate cells") | |
| 301 | + | require.Equal(t, frameRows(primary), frameRows(client.Frame), "the first output after DEC 1049 exit must restore primary cells and erase every alternate glyph") | |
| 302 | + | ||
| 303 | + | steps <- channelPTYStep{err: io.EOF} | |
| 304 | + | d.sessWg.Wait() | |
| 305 | + | } | |
| 306 | + | ||
| 257 | 307 | func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) { | |
| 258 | 308 | t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) { | |
| 259 | 309 | pty, steps, processed := newChannelPTY(t) | |
| @@ -254,6 +254,56 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) { | |||||
| 254 | }) | 254 | }) | ||
| 255 | } | 255 | } | ||
| 256 | 256 | ||||
| 257 | + | func TestPTYReaderRestoresPrimaryScreenImmediatelyAfterDEC1049Exit(t *testing.T) { | |||
| 258 | + | pty, steps, processed := newChannelPTY(t) | |||
| 259 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |||
| 260 | + | // A one-frame local output window forces the replay to exercise the ACK | |||
| 261 | + | // edge between each visible terminal state without relying on elapsed time. | |||
| 262 | + | ac.output.maxOutstanding = 1 | |||
| 263 | + | clock := newCoordinatorMockClock(t, 8) | |||
| 264 | + | d.clock = clock.clock | |||
| 265 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |||
| 266 | + | client := vt.NewScreen(80, 25) | |||
| 267 | + | ||||
| 268 | + | d.sessWg.Add(1) | |||
| 269 | + | go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane()) | |||
| 270 | + | ||||
| 271 | + | replay := func(data []byte, acknowledge bool) ports.Output { | |||
| 272 | + | t.Helper() | |||
| 273 | + | steps <- channelPTYStep{data: data} | |||
| 274 | + | awaitPTYReadProcessed(t, processed) | |||
| 275 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |||
| 276 | + | frame := awaitOutputFrameWithoutSleep(t, sends) | |||
| 277 | + | out := mustApplyOutput(t, client, frame) | |||
| 278 | + | if acknowledge { | |||
| 279 | + | ac.ackOutputState(out.NewStateNum) | |||
| 280 | + | rc.notifyAck() | |||
| 281 | + | } | |||
| 282 | + | return out | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | replay([]byte("PRIMARY"), true) | |||
| 286 | + | primary := client.Frame.Clone() | |||
| 287 | + | ||||
| 288 | + | // This is the PTY byte sequence emitted by the reported command: primary | |||
| 289 | + | // content, a dense DEC alternate screen, then an immediate DEC restore. | |||
| 290 | + | alternate := replay(append([]byte("\x1b[?1049h"), []byte(strings.Repeat("X", 80*23))...), false) | |||
| 291 | + | require.Equal(t, 'X', client.Frame.At(79, 23).Rune, "fixture must make alternate cells client-visible") | |||
| 292 | + | ||||
| 293 | + | steps <- channelPTYStep{data: []byte("\x1b[?1049l")} | |||
| 294 | + | awaitPTYReadProcessed(t, processed) | |||
| 295 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |||
| 296 | + | requireNoCoordinatorOutputFrame(t, sends) | |||
| 297 | + | ac.ackOutputState(alternate.NewStateNum) | |||
| 298 | + | rc.notifyAck() | |||
| 299 | + | exit := mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)) | |||
| 300 | + | require.NotEmpty(t, exit.Data, "the first post-exit output must actively remove alternate cells") | |||
| 301 | + | require.Equal(t, frameRows(primary), frameRows(client.Frame), "the first output after DEC 1049 exit must restore primary cells and erase every alternate glyph") | |||
| 302 | + | ||||
| 303 | + | steps <- channelPTYStep{err: io.EOF} | |||
| 304 | + | d.sessWg.Wait() | |||
| 305 | + | } | |||
| 306 | + | ||||
| 257 | func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) { | 307 | func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) { | ||
| 258 | t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) { | 308 | t.Run("detached target completes cross-session preview without later PTY output", func(t *testing.T) { | ||
| 259 | pty, steps, processed := newChannelPTY(t) | 309 | pty, steps, processed := newChannelPTY(t) | ||