test(daemon): cover DEC alternate exit timing
- commit
- f3c04699663293fffe10836a98b99c3550980d2c
- author
- brice · · 2026-07-13 05:58 UTC
- parent
- 07cd4c8
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +80 -44
M internal/usecase/daemon/render_test.go +80 -44
| @@ -255,53 +255,89 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) { | |||
| 255 | 255 | } | |
| 256 | 256 | ||
| 257 | 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) | |
| 258 | + | for _, tc := range []struct { | |
| 259 | + | name string | |
| 260 | + | exitBeforeACK bool | |
| 261 | + | exitSuffix string | |
| 262 | + | overlay bool | |
| 263 | + | }{ | |
| 264 | + | {name: "exit waits for local ACK"}, | |
| 265 | + | {name: "exit arrives before local ACK", exitBeforeACK: true}, | |
| 266 | + | {name: "prompt bytes follow exit before local ACK", exitBeforeACK: true, exitSuffix: "PROMPT> "}, | |
| 267 | + | {name: "palette overlay with bars", overlay: true}, | |
| 268 | + | } { | |
| 269 | + | t.Run(tc.name, func(t *testing.T) { | |
| 270 | + | pty, steps, processed := newChannelPTY(t) | |
| 271 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |
| 272 | + | // A one-frame local output window forces the replay to exercise both | |
| 273 | + | // ACK orderings without relying on elapsed time. | |
| 274 | + | ac.output.maxOutstanding = 1 | |
| 275 | + | clock := newCoordinatorMockClock(t, 8) | |
| 276 | + | d.clock = clock.clock | |
| 277 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |
| 278 | + | client := vt.NewScreen(80, 25) | |
| 279 | + | pane := sess.tabs[0].focusedPane() | |
| 280 | + | ||
| 281 | + | d.sessWg.Add(1) | |
| 282 | + | go d.ptyReader(sess, sess.tabs[0], pane) | |
| 283 | + | t.Cleanup(func() { | |
| 284 | + | steps <- channelPTYStep{err: io.EOF} | |
| 285 | + | d.sessWg.Wait() | |
| 286 | + | }) | |
| 267 | 287 | ||
| 268 | - | d.sessWg.Add(1) | |
| 269 | - | go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane()) | |
| 288 | + | replay := func(data []byte, deadline time.Duration) ports.Output { | |
| 289 | + | t.Helper() | |
| 290 | + | steps <- channelPTYStep{data: data} | |
| 291 | + | awaitPTYReadProcessed(t, processed) | |
| 292 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), deadline) | |
| 293 | + | return mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)) | |
| 294 | + | } | |
| 295 | + | acknowledge := func(out ports.Output) { | |
| 296 | + | ac.ackOutputState(out.NewStateNum) | |
| 297 | + | rc.notifyAck() | |
| 298 | + | } | |
| 270 | 299 | ||
| 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 | |
| 300 | + | if tc.overlay { | |
| 301 | + | d.enterPalette(sess, ac) | |
| 302 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline) | |
| 303 | + | acknowledge(mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))) | |
| 304 | + | } | |
| 305 | + | acknowledge(replay([]byte("PRIMARY"), minOutputRenderDeadline)) | |
| 306 | + | primary := client.Frame.Clone() | |
| 307 | + | ||
| 308 | + | // The reported PTY stream is deliberately split into enter, body, and | |
| 309 | + | // exit reads. MATRIX distinguishes alternate cells in the replayed | |
| 310 | + | // terminal model from all primary, chrome, and overlay cells. | |
| 311 | + | acknowledge(replay([]byte("\x1b[?1049h"), minOutputRenderDeadline)) | |
| 312 | + | alternate := replay([]byte(strings.Repeat("MATRIX", 300)), minOutputRenderDeadline) | |
| 313 | + | pane.mu.Lock() | |
| 314 | + | require.Contains(t, strings.Join(frameRows(pane.screen.Frame), "\n"), "MATRIX", "fixture must make alternate cells visible to the production renderer") | |
| 315 | + | pane.mu.Unlock() | |
| 316 | + | ||
| 317 | + | exitData := []byte("\x1b[?1049l" + tc.exitSuffix) | |
| 318 | + | if tc.exitBeforeACK { | |
| 319 | + | steps <- channelPTYStep{data: exitData} | |
| 320 | + | awaitPTYReadProcessed(t, processed) | |
| 321 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |
| 322 | + | requireNoCoordinatorOutputFrame(t, sends) | |
| 323 | + | acknowledge(alternate) | |
| 324 | + | } else { | |
| 325 | + | acknowledge(alternate) | |
| 326 | + | steps <- channelPTYStep{data: exitData} | |
| 327 | + | awaitPTYReadProcessed(t, processed) | |
| 328 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |
| 329 | + | } | |
| 330 | + | exit := mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)) | |
| 331 | + | require.NotEmpty(t, exit.Data, "the first post-exit output must actively remove alternate cells") | |
| 332 | + | rendered := strings.Join(frameRows(client.Frame), "\n") | |
| 333 | + | require.NotContains(t, rendered, "MATRIX", "the first post-exit frame must not retain alternate cells") | |
| 334 | + | if tc.exitSuffix == "" { | |
| 335 | + | require.Equal(t, frameRows(primary), frameRows(client.Frame), "the first post-exit output must restore the primary screen") | |
| 336 | + | } else { | |
| 337 | + | require.Contains(t, rendered, tc.exitSuffix, "bytes immediately after DEC 1049 exit must appear in the first post-exit frame") | |
| 338 | + | } | |
| 339 | + | }) | |
| 283 | 340 | } | |
| 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 | 341 | } | |
| 306 | 342 | ||
| 307 | 343 | func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) { | |
| @@ -255,53 +255,89 @@ func TestPTYReaderSyncVisibilityTransitions(t *testing.T) { | |||||
| 255 | } | 255 | } | ||
| 256 | 256 | ||||
| 257 | func TestPTYReaderRestoresPrimaryScreenImmediatelyAfterDEC1049Exit(t *testing.T) { | 257 | func TestPTYReaderRestoresPrimaryScreenImmediatelyAfterDEC1049Exit(t *testing.T) { | ||
| 258 | - | pty, steps, processed := newChannelPTY(t) | 258 | + | for _, tc := range []struct { |
| 259 | - | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | 259 | + | name string |
| 260 | - | // A one-frame local output window forces the replay to exercise the ACK | 260 | + | exitBeforeACK bool |
| 261 | - | // edge between each visible terminal state without relying on elapsed time. | 261 | + | exitSuffix string |
| 262 | - | ac.output.maxOutstanding = 1 | 262 | + | overlay bool |
| 263 | - | clock := newCoordinatorMockClock(t, 8) | 263 | + | }{ |
| 264 | - | d.clock = clock.clock | 264 | + | {name: "exit waits for local ACK"}, |
| 265 | - | rc := d.attachCoordinator(sess, nil, ac, true) | 265 | + | {name: "exit arrives before local ACK", exitBeforeACK: true}, |
| 266 | - | client := vt.NewScreen(80, 25) | 266 | + | {name: "prompt bytes follow exit before local ACK", exitBeforeACK: true, exitSuffix: "PROMPT> "}, |
| 267 | + | {name: "palette overlay with bars", overlay: true}, | |||
| 268 | + | } { | |||
| 269 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 270 | + | pty, steps, processed := newChannelPTY(t) | |||
| 271 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, pty) | |||
| 272 | + | // A one-frame local output window forces the replay to exercise both | |||
| 273 | + | // ACK orderings without relying on elapsed time. | |||
| 274 | + | ac.output.maxOutstanding = 1 | |||
| 275 | + | clock := newCoordinatorMockClock(t, 8) | |||
| 276 | + | d.clock = clock.clock | |||
| 277 | + | rc := d.attachCoordinator(sess, nil, ac, true) | |||
| 278 | + | client := vt.NewScreen(80, 25) | |||
| 279 | + | pane := sess.tabs[0].focusedPane() | |||
| 280 | + | ||||
| 281 | + | d.sessWg.Add(1) | |||
| 282 | + | go d.ptyReader(sess, sess.tabs[0], pane) | |||
| 283 | + | t.Cleanup(func() { | |||
| 284 | + | steps <- channelPTYStep{err: io.EOF} | |||
| 285 | + | d.sessWg.Wait() | |||
| 286 | + | }) | |||
| 267 | 287 | ||||
| 268 | - | d.sessWg.Add(1) | 288 | + | replay := func(data []byte, deadline time.Duration) ports.Output { |
| 269 | - | go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane()) | 289 | + | t.Helper() |
| 290 | + | steps <- channelPTYStep{data: data} | |||
| 291 | + | awaitPTYReadProcessed(t, processed) | |||
| 292 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), deadline) | |||
| 293 | + | return mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)) | |||
| 294 | + | } | |||
| 295 | + | acknowledge := func(out ports.Output) { | |||
| 296 | + | ac.ackOutputState(out.NewStateNum) | |||
| 297 | + | rc.notifyAck() | |||
| 298 | + | } | |||
| 270 | 299 | ||||
| 271 | - | replay := func(data []byte, acknowledge bool) ports.Output { | 300 | + | if tc.overlay { |
| 272 | - | t.Helper() | 301 | + | d.enterPalette(sess, ac) |
| 273 | - | steps <- channelPTYStep{data: data} | 302 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), urgentRenderDeadline) |
| 274 | - | awaitPTYReadProcessed(t, processed) | 303 | + | acknowledge(mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends))) |
| 275 | - | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline) | 304 | + | } |
| 276 | - | frame := awaitOutputFrameWithoutSleep(t, sends) | 305 | + | acknowledge(replay([]byte("PRIMARY"), minOutputRenderDeadline)) |
| 277 | - | out := mustApplyOutput(t, client, frame) | 306 | + | primary := client.Frame.Clone() |
| 278 | - | if acknowledge { | 307 | + | |
| 279 | - | ac.ackOutputState(out.NewStateNum) | 308 | + | // The reported PTY stream is deliberately split into enter, body, and |
| 280 | - | rc.notifyAck() | 309 | + | // exit reads. MATRIX distinguishes alternate cells in the replayed |
| 281 | - | } | 310 | + | // terminal model from all primary, chrome, and overlay cells. |
| 282 | - | return out | 311 | + | acknowledge(replay([]byte("\x1b[?1049h"), minOutputRenderDeadline)) |
| 312 | + | alternate := replay([]byte(strings.Repeat("MATRIX", 300)), minOutputRenderDeadline) | |||
| 313 | + | pane.mu.Lock() | |||
| 314 | + | require.Contains(t, strings.Join(frameRows(pane.screen.Frame), "\n"), "MATRIX", "fixture must make alternate cells visible to the production renderer") | |||
| 315 | + | pane.mu.Unlock() | |||
| 316 | + | ||||
| 317 | + | exitData := []byte("\x1b[?1049l" + tc.exitSuffix) | |||
| 318 | + | if tc.exitBeforeACK { | |||
| 319 | + | steps <- channelPTYStep{data: exitData} | |||
| 320 | + | awaitPTYReadProcessed(t, processed) | |||
| 321 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |||
| 322 | + | requireNoCoordinatorOutputFrame(t, sends) | |||
| 323 | + | acknowledge(alternate) | |||
| 324 | + | } else { | |||
| 325 | + | acknowledge(alternate) | |||
| 326 | + | steps <- channelPTYStep{data: exitData} | |||
| 327 | + | awaitPTYReadProcessed(t, processed) | |||
| 328 | + | fireCoordinatorTimer(t, rc, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |||
| 329 | + | } | |||
| 330 | + | exit := mustApplyOutput(t, client, awaitOutputFrameWithoutSleep(t, sends)) | |||
| 331 | + | require.NotEmpty(t, exit.Data, "the first post-exit output must actively remove alternate cells") | |||
| 332 | + | rendered := strings.Join(frameRows(client.Frame), "\n") | |||
| 333 | + | require.NotContains(t, rendered, "MATRIX", "the first post-exit frame must not retain alternate cells") | |||
| 334 | + | if tc.exitSuffix == "" { | |||
| 335 | + | require.Equal(t, frameRows(primary), frameRows(client.Frame), "the first post-exit output must restore the primary screen") | |||
| 336 | + | } else { | |||
| 337 | + | require.Contains(t, rendered, tc.exitSuffix, "bytes immediately after DEC 1049 exit must appear in the first post-exit frame") | |||
| 338 | + | } | |||
| 339 | + | }) | |||
| 283 | } | 340 | } | ||
| 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 | } | 341 | } | ||
| 306 | 342 | ||||
| 307 | func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) { | 343 | func TestPTYReaderRepublishesSynchronizedCompletionAfterAttachmentLifecycle(t *testing.T) { | ||