test(daemon): integrate sync visibility transitions
- commit
- 96e9b63e09cd688077d4fe0a2081dc8f0793a4c5
- author
- brice · · 2026-07-11 19:20 UTC
- parent
- 92fca30
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
1 file · +133 -0
M internal/usecase/daemon/render_test.go +133 -0
| @@ -110,6 +110,139 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |||
| 110 | 110 | // stubClock returns timers whose channel never fires, so a scheduler under it | |
| 111 | 111 | // blocks in its debounce loop until the session context is cancelled. Used by | |
| 112 | 112 | ||
| 113 | + | // channelPTY drives ptyReader through generated PTY mocks. A processed token is | |
| 114 | + | // published only when ptyReader asks for its next read, which proves the | |
| 115 | + | // preceding chunk completed its parser and coordinator path without sleeping. | |
| 116 | + | type channelPTYStep struct { | |
| 117 | + | data []byte | |
| 118 | + | err error | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func newChannelPTY(t *testing.T) (*portsmocks.MockPTY, chan<- channelPTYStep, <-chan struct{}) { | |
| 122 | + | t.Helper() | |
| 123 | + | pty := portsmocks.NewMockPTY(t) | |
| 124 | + | steps := make(chan channelPTYStep) | |
| 125 | + | processed := make(chan struct{}, 8) | |
| 126 | + | read := false | |
| 127 | + | pty.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |
| 128 | + | if read { | |
| 129 | + | processed <- struct{}{} | |
| 130 | + | } | |
| 131 | + | read = true | |
| 132 | + | step := <-steps | |
| 133 | + | return copy(buf, step.data), step.err | |
| 134 | + | }).Maybe() | |
| 135 | + | pty.EXPECT().Close().Return(nil).Maybe() | |
| 136 | + | return pty, steps, processed | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func awaitPTYReadProcessed(t *testing.T, processed <-chan struct{}) { | |
| 140 | + | t.Helper() | |
| 141 | + | <-processed | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func awaitOutputFrameWithoutSleep(t *testing.T, sends <-chan ports.Frame) ports.Frame { | |
| 145 | + | t.Helper() | |
| 146 | + | frame := <-sends | |
| 147 | + | require.Equal(t, ports.MsgOutput, frame.Type) | |
| 148 | + | return frame | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func drainCoordinatorTimers(clock *coordinatorMockClock) []*coordinatorMockTimer { | |
| 152 | + | var timers []*coordinatorMockTimer | |
| 153 | + | for { | |
| 154 | + | select { | |
| 155 | + | case timer := <-clock.timers: | |
| 156 | + | timers = append(timers, timer) | |
| 157 | + | default: | |
| 158 | + | return timers | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func fireCoordinatorTimer(t *testing.T, timers []*coordinatorMockTimer, duration time.Duration) { | |
| 164 | + | t.Helper() | |
| 165 | + | for _, timer := range timers { | |
| 166 | + | if timer.duration == duration { | |
| 167 | + | timer.ch <- time.Time{} | |
| 168 | + | return | |
| 169 | + | } | |
| 170 | + | } | |
| 171 | + | t.Fatalf("coordinator did not arm %s timer", duration) | |
| 172 | + | } | |
| 173 | + | ||
| 174 | + | func TestPTYReaderSyncVisibilityTransitions(t *testing.T) { | |
| 175 | + | t.Run("inactive synchronized pane activates only after complete urgent frame", func(t *testing.T) { | |
| 176 | + | inactivePTY, inactiveSteps, inactiveProcessed := newChannelPTY(t) | |
| 177 | + | activePTY, _, _ := newChannelPTY(t) | |
| 178 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, activePTY, inactivePTY) | |
| 179 | + | clock := newCoordinatorMockClock(t, 8) | |
| 180 | + | d.clock = clock.clock | |
| 181 | + | d.attachCoordinator(sess, nil, ac) | |
| 182 | + | ||
| 183 | + | d.sessWg.Add(1) | |
| 184 | + | go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane()) | |
| 185 | + | inactiveSteps <- channelPTYStep{data: []byte("\x1b[?2026hpartial")} | |
| 186 | + | awaitPTYReadProcessed(t, inactiveProcessed) | |
| 187 | + | ||
| 188 | + | sess.mu.Lock() | |
| 189 | + | sess.active = 1 | |
| 190 | + | sess.mu.Unlock() | |
| 191 | + | d.invalidateRender(sess, ac, true, "sync activation") | |
| 192 | + | fireCoordinatorTimer(t, drainCoordinatorTimers(clock), urgentRenderDeadline) | |
| 193 | + | requireNoCoordinatorOutputFrame(t, sends) | |
| 194 | + | ||
| 195 | + | inactiveSteps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")} | |
| 196 | + | awaitPTYReadProcessed(t, inactiveProcessed) | |
| 197 | + | frame := awaitOutputFrameWithoutSleep(t, sends) | |
| 198 | + | output, err := ports.UnmarshalOutput(frame.Payload) | |
| 199 | + | require.NoError(t, err) | |
| 200 | + | require.Contains(t, string(output.Data), "partial complete") | |
| 201 | + | requireNoCoordinatorOutputFrame(t, sends) | |
| 202 | + | ||
| 203 | + | inactiveSteps <- channelPTYStep{err: io.EOF} | |
| 204 | + | d.sessWg.Wait() | |
| 205 | + | }) | |
| 206 | + | ||
| 207 | + | t.Run("hidden synchronized pane cannot stall newly active output or flush late", func(t *testing.T) { | |
| 208 | + | oldPTY, oldSteps, oldProcessed := newChannelPTY(t) | |
| 209 | + | newPTY, newSteps, newProcessed := newChannelPTY(t) | |
| 210 | + | parkedPTY, _, _ := newChannelPTY(t) | |
| 211 | + | // Keep one unopened tab so reader EOF cleanup cannot tear down the | |
| 212 | + | // session and introduce unrelated detach-notification timers. | |
| 213 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, oldPTY, newPTY, parkedPTY) | |
| 214 | + | clock := newCoordinatorMockClock(t, 8) | |
| 215 | + | d.clock = clock.clock | |
| 216 | + | d.attachCoordinator(sess, nil, ac) | |
| 217 | + | ||
| 218 | + | d.sessWg.Add(2) | |
| 219 | + | go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane()) | |
| 220 | + | go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane()) | |
| 221 | + | oldSteps <- channelPTYStep{data: []byte("\x1b[?2026hold partial")} | |
| 222 | + | awaitPTYReadProcessed(t, oldProcessed) | |
| 223 | + | ||
| 224 | + | sess.mu.Lock() | |
| 225 | + | sess.active = 1 | |
| 226 | + | sess.mu.Unlock() | |
| 227 | + | newSteps <- channelPTYStep{data: []byte("newly active")} | |
| 228 | + | awaitPTYReadProcessed(t, newProcessed) | |
| 229 | + | fireCoordinatorTimer(t, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |
| 230 | + | frame := awaitOutputFrameWithoutSleep(t, sends) | |
| 231 | + | output, err := ports.UnmarshalOutput(frame.Payload) | |
| 232 | + | require.NoError(t, err) | |
| 233 | + | require.Contains(t, string(output.Data), "newly active") | |
| 234 | + | require.NotContains(t, string(output.Data), "old partial") | |
| 235 | + | ||
| 236 | + | oldSteps <- channelPTYStep{data: []byte("\x1b[?2026l")} | |
| 237 | + | awaitPTYReadProcessed(t, oldProcessed) | |
| 238 | + | requireNoCoordinatorOutputFrame(t, sends) | |
| 239 | + | ||
| 240 | + | oldSteps <- channelPTYStep{err: io.EOF} | |
| 241 | + | newSteps <- channelPTYStep{err: io.EOF} | |
| 242 | + | d.sessWg.Wait() | |
| 243 | + | }) | |
| 244 | + | } | |
| 245 | + | ||
| 113 | 246 | func TestClearNonRenderablePaneDamage(t *testing.T) { | |
| 114 | 247 | newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) { | |
| 115 | 248 | t.Helper() | |
| @@ -110,6 +110,139 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) { | |||||
| 110 | // stubClock returns timers whose channel never fires, so a scheduler under it | 110 | // stubClock returns timers whose channel never fires, so a scheduler under it | ||
| 111 | // blocks in its debounce loop until the session context is cancelled. Used by | 111 | // blocks in its debounce loop until the session context is cancelled. Used by | ||
| 112 | 112 | ||||
| 113 | + | // channelPTY drives ptyReader through generated PTY mocks. A processed token is | |||
| 114 | + | // published only when ptyReader asks for its next read, which proves the | |||
| 115 | + | // preceding chunk completed its parser and coordinator path without sleeping. | |||
| 116 | + | type channelPTYStep struct { | |||
| 117 | + | data []byte | |||
| 118 | + | err error | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func newChannelPTY(t *testing.T) (*portsmocks.MockPTY, chan<- channelPTYStep, <-chan struct{}) { | |||
| 122 | + | t.Helper() | |||
| 123 | + | pty := portsmocks.NewMockPTY(t) | |||
| 124 | + | steps := make(chan channelPTYStep) | |||
| 125 | + | processed := make(chan struct{}, 8) | |||
| 126 | + | read := false | |||
| 127 | + | pty.EXPECT().Read(mock.Anything).RunAndReturn(func(buf []byte) (int, error) { | |||
| 128 | + | if read { | |||
| 129 | + | processed <- struct{}{} | |||
| 130 | + | } | |||
| 131 | + | read = true | |||
| 132 | + | step := <-steps | |||
| 133 | + | return copy(buf, step.data), step.err | |||
| 134 | + | }).Maybe() | |||
| 135 | + | pty.EXPECT().Close().Return(nil).Maybe() | |||
| 136 | + | return pty, steps, processed | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func awaitPTYReadProcessed(t *testing.T, processed <-chan struct{}) { | |||
| 140 | + | t.Helper() | |||
| 141 | + | <-processed | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func awaitOutputFrameWithoutSleep(t *testing.T, sends <-chan ports.Frame) ports.Frame { | |||
| 145 | + | t.Helper() | |||
| 146 | + | frame := <-sends | |||
| 147 | + | require.Equal(t, ports.MsgOutput, frame.Type) | |||
| 148 | + | return frame | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func drainCoordinatorTimers(clock *coordinatorMockClock) []*coordinatorMockTimer { | |||
| 152 | + | var timers []*coordinatorMockTimer | |||
| 153 | + | for { | |||
| 154 | + | select { | |||
| 155 | + | case timer := <-clock.timers: | |||
| 156 | + | timers = append(timers, timer) | |||
| 157 | + | default: | |||
| 158 | + | return timers | |||
| 159 | + | } | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func fireCoordinatorTimer(t *testing.T, timers []*coordinatorMockTimer, duration time.Duration) { | |||
| 164 | + | t.Helper() | |||
| 165 | + | for _, timer := range timers { | |||
| 166 | + | if timer.duration == duration { | |||
| 167 | + | timer.ch <- time.Time{} | |||
| 168 | + | return | |||
| 169 | + | } | |||
| 170 | + | } | |||
| 171 | + | t.Fatalf("coordinator did not arm %s timer", duration) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func TestPTYReaderSyncVisibilityTransitions(t *testing.T) { | |||
| 175 | + | t.Run("inactive synchronized pane activates only after complete urgent frame", func(t *testing.T) { | |||
| 176 | + | inactivePTY, inactiveSteps, inactiveProcessed := newChannelPTY(t) | |||
| 177 | + | activePTY, _, _ := newChannelPTY(t) | |||
| 178 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, activePTY, inactivePTY) | |||
| 179 | + | clock := newCoordinatorMockClock(t, 8) | |||
| 180 | + | d.clock = clock.clock | |||
| 181 | + | d.attachCoordinator(sess, nil, ac) | |||
| 182 | + | ||||
| 183 | + | d.sessWg.Add(1) | |||
| 184 | + | go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane()) | |||
| 185 | + | inactiveSteps <- channelPTYStep{data: []byte("\x1b[?2026hpartial")} | |||
| 186 | + | awaitPTYReadProcessed(t, inactiveProcessed) | |||
| 187 | + | ||||
| 188 | + | sess.mu.Lock() | |||
| 189 | + | sess.active = 1 | |||
| 190 | + | sess.mu.Unlock() | |||
| 191 | + | d.invalidateRender(sess, ac, true, "sync activation") | |||
| 192 | + | fireCoordinatorTimer(t, drainCoordinatorTimers(clock), urgentRenderDeadline) | |||
| 193 | + | requireNoCoordinatorOutputFrame(t, sends) | |||
| 194 | + | ||||
| 195 | + | inactiveSteps <- channelPTYStep{data: []byte(" complete\x1b[?2026l")} | |||
| 196 | + | awaitPTYReadProcessed(t, inactiveProcessed) | |||
| 197 | + | frame := awaitOutputFrameWithoutSleep(t, sends) | |||
| 198 | + | output, err := ports.UnmarshalOutput(frame.Payload) | |||
| 199 | + | require.NoError(t, err) | |||
| 200 | + | require.Contains(t, string(output.Data), "partial complete") | |||
| 201 | + | requireNoCoordinatorOutputFrame(t, sends) | |||
| 202 | + | ||||
| 203 | + | inactiveSteps <- channelPTYStep{err: io.EOF} | |||
| 204 | + | d.sessWg.Wait() | |||
| 205 | + | }) | |||
| 206 | + | ||||
| 207 | + | t.Run("hidden synchronized pane cannot stall newly active output or flush late", func(t *testing.T) { | |||
| 208 | + | oldPTY, oldSteps, oldProcessed := newChannelPTY(t) | |||
| 209 | + | newPTY, newSteps, newProcessed := newChannelPTY(t) | |||
| 210 | + | parkedPTY, _, _ := newChannelPTY(t) | |||
| 211 | + | // Keep one unopened tab so reader EOF cleanup cannot tear down the | |||
| 212 | + | // session and introduce unrelated detach-notification timers. | |||
| 213 | + | d, sess, ac, sends := newManualSessionWithPTYs(t, oldPTY, newPTY, parkedPTY) | |||
| 214 | + | clock := newCoordinatorMockClock(t, 8) | |||
| 215 | + | d.clock = clock.clock | |||
| 216 | + | d.attachCoordinator(sess, nil, ac) | |||
| 217 | + | ||||
| 218 | + | d.sessWg.Add(2) | |||
| 219 | + | go d.ptyReader(sess, sess.tabs[0], sess.tabs[0].focusedPane()) | |||
| 220 | + | go d.ptyReader(sess, sess.tabs[1], sess.tabs[1].focusedPane()) | |||
| 221 | + | oldSteps <- channelPTYStep{data: []byte("\x1b[?2026hold partial")} | |||
| 222 | + | awaitPTYReadProcessed(t, oldProcessed) | |||
| 223 | + | ||||
| 224 | + | sess.mu.Lock() | |||
| 225 | + | sess.active = 1 | |||
| 226 | + | sess.mu.Unlock() | |||
| 227 | + | newSteps <- channelPTYStep{data: []byte("newly active")} | |||
| 228 | + | awaitPTYReadProcessed(t, newProcessed) | |||
| 229 | + | fireCoordinatorTimer(t, drainCoordinatorTimers(clock), minOutputRenderDeadline) | |||
| 230 | + | frame := awaitOutputFrameWithoutSleep(t, sends) | |||
| 231 | + | output, err := ports.UnmarshalOutput(frame.Payload) | |||
| 232 | + | require.NoError(t, err) | |||
| 233 | + | require.Contains(t, string(output.Data), "newly active") | |||
| 234 | + | require.NotContains(t, string(output.Data), "old partial") | |||
| 235 | + | ||||
| 236 | + | oldSteps <- channelPTYStep{data: []byte("\x1b[?2026l")} | |||
| 237 | + | awaitPTYReadProcessed(t, oldProcessed) | |||
| 238 | + | requireNoCoordinatorOutputFrame(t, sends) | |||
| 239 | + | ||||
| 240 | + | oldSteps <- channelPTYStep{err: io.EOF} | |||
| 241 | + | newSteps <- channelPTYStep{err: io.EOF} | |||
| 242 | + | d.sessWg.Wait() | |||
| 243 | + | }) | |||
| 244 | + | } | |||
| 245 | + | ||||
| 113 | func TestClearNonRenderablePaneDamage(t *testing.T) { | 246 | func TestClearNonRenderablePaneDamage(t *testing.T) { | ||
| 114 | newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) { | 247 | newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) { | ||
| 115 | t.Helper() | 248 | t.Helper() | ||