detached 96e9b63

test(daemon): integrate sync visibility transitions

commit
96e9b63e09cd688077d4fe0a2081dc8f0793a4c5
author
brice · · 2026-07-11 19:20 UTC
parent
92fca30
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
110110 // stubClock returns timers whose channel never fires, so a scheduler under it
111111 // blocks in its debounce loop until the session context is cancelled. Used by
112112
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+
113246 func TestClearNonRenderablePaneDamage(t *testing.T) {
114247 newFixture := func(t *testing.T) (*Daemon, *session, *tab, *pane, chan ports.Frame) {
115248 t.Helper()
@@ -110,6 +110,139 @@ func TestPaletteBackdropProductionRenderAndDismissal(t *testing.T) {
110 // stubClock returns timers whose channel never fires, so a scheduler under it110 // 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 by111 // 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()

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