detached f3c0469

test(daemon): cover DEC alternate exit timing

commit
f3c04699663293fffe10836a98b99c3550980d2c
author
brice · · 2026-07-13 05:58 UTC
parent
07cd4c8
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
255255 }
256256
257257 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+ })
267287
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+ }
270299
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+ })
283340 }
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()
305341 }
306342
307343 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 ACK260+ exitBeforeACK bool
261- // edge between each visible terminal state without relying on elapsed time.261+ exitSuffix string
262- ac.output.maxOutstanding = 1262+ overlay bool
263- clock := newCoordinatorMockClock(t, 8)263+ }{
264- d.clock = clock.clock264+ {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 out311+ 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) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close