detached 97d6a05

test(daemon): cover streamed preview watches

commit
97d6a0558635752bbb680a78a8ba5ae3bbe61a20
author
brice · · 2026-09-23 01:18 UTC
parent
9a0770d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +451 -0
A internal/usecase/daemon/remote_preview_watch_test.go +451 -0
@@ -0,0 +1,451 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "strings"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/internal/protocol"
19+)
20+
21+// --- fake clock: distinguishes the watch's own interval/revalidate timers
22+// from the boundedControlSend timeout timer, so a test can drive the watch
23+// loop deterministically without ever needing the send-bound timer to fire.
24+
25+type previewTimer struct {
26+ mock *portsmocks.MockTimer
27+ ch chan time.Time
28+ duration time.Duration
29+}
30+
31+func (pt *previewTimer) fire() { pt.ch <- time.Time{} }
32+
33+type previewClockHarness struct {
34+ clock *portsmocks.MockClock
35+ pending chan *previewTimer
36+}
37+
38+func newPreviewClockHarness(t *testing.T) *previewClockHarness {
39+ t.Helper()
40+ h := &previewClockHarness{clock: portsmocks.NewMockClock(t), pending: make(chan *previewTimer, 64)}
41+ h.clock.EXPECT().Now().Return(time.Time{}).Maybe()
42+ h.clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer {
43+ pt := &previewTimer{mock: portsmocks.NewMockTimer(t), ch: make(chan time.Time, 1), duration: d}
44+ pt.mock.EXPECT().C().Maybe().Return((<-chan time.Time)(pt.ch))
45+ pt.mock.EXPECT().Stop().Maybe().Return(true)
46+ // boundedControlSend races this timer against an already-completing
47+ // SendServer call on every frame send; the test never needs to fire it.
48+ if d != detachNotifyTimeout {
49+ h.pending <- pt
50+ }
51+ return pt.mock
52+ }).Maybe()
53+ return h
54+}
55+
56+func (h *previewClockHarness) awaitTimer(t *testing.T, want time.Duration) *previewTimer {
57+ t.Helper()
58+ select {
59+ case pt := <-h.pending:
60+ require.Equal(t, want, pt.duration)
61+ return pt
62+ case <-time.After(2 * time.Second):
63+ t.Fatalf("timed out waiting for a %s watch timer", want)
64+ return nil
65+ }
66+}
67+
68+// --- fake connection: ReceiveClient must block until the test simulates
69+// either a client message/EOF or a server-side Close. A generated
70+// portsmocks.MockServerConnection still backs SendServer/Close so frame
71+// assertions decode typed protocol.ServerMessage values directly, but the
72+// blocking-until-signaled ReceiveClient is hand-rolled: expressing it through
73+// mock.Call plumbing would need the same channel underneath anyway.
74+
75+type clientRecvResult struct {
76+ msg protocol.ClientMessage
77+ err error
78+}
79+
80+type previewConnHarness struct {
81+ tr *portsmocks.MockServerConnection
82+ frames chan protocol.RemotePreview
83+ recv chan clientRecvResult
84+ closed chan struct{}
85+ closeOnce sync.Once
86+}
87+
88+func newPreviewConnHarness(t *testing.T) *previewConnHarness {
89+ t.Helper()
90+ h := &previewConnHarness{
91+ tr: portsmocks.NewMockServerConnection(t),
92+ frames: make(chan protocol.RemotePreview, 16),
93+ recv: make(chan clientRecvResult, 1),
94+ closed: make(chan struct{}),
95+ }
96+ h.tr.EXPECT().ReceiveClient().RunAndReturn(func() (protocol.ClientMessage, error) {
97+ select {
98+ case r := <-h.recv:
99+ return r.msg, r.err
100+ case <-h.closed:
101+ return nil, io.EOF
102+ }
103+ }).Maybe()
104+ h.tr.EXPECT().SendServer(mock.Anything).RunAndReturn(func(message protocol.ServerMessage) error {
105+ preview, ok := message.(protocol.RemotePreview)
106+ if !ok {
107+ return fmt.Errorf("unexpected server message %T", message)
108+ }
109+ h.frames <- preview
110+ return nil
111+ }).Maybe()
112+ h.tr.EXPECT().Close().RunAndReturn(func() error {
113+ h.closeOnce.Do(func() { close(h.closed) })
114+ return nil
115+ }).Maybe()
116+ return h
117+}
118+
119+// closeClient simulates the client itself sending a message or hanging up;
120+// this is distinct from the server-side Close the daemon issues on teardown.
121+func (h *previewConnHarness) closeClient(err error) {
122+ select {
123+ case h.recv <- clientRecvResult{err: err}:
124+ default:
125+ }
126+}
127+
128+func (h *previewConnHarness) nextFrame(t *testing.T) protocol.RemotePreview {
129+ t.Helper()
130+ select {
131+ case f := <-h.frames:
132+ return f
133+ case <-time.After(2 * time.Second):
134+ t.Fatal("timed out waiting for a preview frame")
135+ return protocol.RemotePreview{}
136+ }
137+}
138+
139+func (h *previewConnHarness) requireNoFrame(t *testing.T) {
140+ t.Helper()
141+ select {
142+ case f := <-h.frames:
143+ t.Fatalf("unexpected preview frame sent: %+v", f)
144+ case <-time.After(75 * time.Millisecond):
145+ }
146+}
147+
148+func (h *previewConnHarness) awaitClosed(t *testing.T) {
149+ t.Helper()
150+ select {
151+ case <-h.closed:
152+ case <-time.After(2 * time.Second):
153+ t.Fatal("transport was not closed")
154+ }
155+}
156+
157+// --- daemon/session fixture -------------------------------------------------
158+
159+// newPreviewSession builds a headless named session with one quiet-PTY tab per
160+// id, registers it in d.sessions the same way createSessionLocked would, and
161+// wires each tab/pane context like the package's other manual fixtures.
162+func newPreviewSession(t *testing.T, d *Daemon, tabIDs ...string) *session {
163+ t.Helper()
164+ lifecycle := newTestLifecycle(t)
165+ sctx, scancel := context.WithCancel(d.serveCtx)
166+ tabs := make([]*tab, 0, len(tabIDs))
167+ for i, id := range tabIDs {
168+ wctx, wcancel := context.WithCancel(sctx)
169+ tb := newTabWithStableID(id, fmt.Sprintf("pane-%d", i), newQuietPTY(), domain.Size{Cols: 80, Rows: 24})
170+ tb.ctx, tb.cancel = wctx, wcancel
171+ for _, p := range tb.panes {
172+ p.ctx, p.cancel = wctx, wcancel
173+ }
174+ tabs = append(tabs, tb)
175+ }
176+ sess := &session{
177+ sessionCore: sessionCore{id: "preview", name: "work", incarnation: lifecycle, attachments: map[*attachedClient]struct{}{}},
178+ ctx: sctx,
179+ cancel: scancel,
180+ tabs: tabs,
181+ }
182+ for _, tb := range tabs {
183+ publishTiledPaneOwners(sess, tb)
184+ }
185+ d.mu.Lock()
186+ d.sessions[sess.id] = sess
187+ d.mu.Unlock()
188+ t.Cleanup(scancel)
189+ return sess
190+}
191+
192+// writePreviewContent writes directly to the pane's VT screen under pane.mu,
193+// matching the lock captureRemotePreview takes around its own Snapshot call
194+// so a concurrently running watch never races the write under -race.
195+func writePreviewContent(t *testing.T, p *pane, seq string) {
196+ t.Helper()
197+ p.mu.Lock()
198+ defer p.mu.Unlock()
199+ p.screen.Write([]byte(seq))
200+}
201+
202+// previewFrameText flattens a frame's cells to a plain string for substring
203+// assertions; continuation cells (the right half of a wide rune) are skipped.
204+func previewFrameText(preview protocol.RemotePreview) string {
205+ var b strings.Builder
206+ for _, cell := range preview.Cells {
207+ if cell.Continuation {
208+ continue
209+ }
210+ if cell.Rune == 0 {
211+ b.WriteByte(' ')
212+ } else {
213+ b.WriteRune(cell.Rune)
214+ }
215+ }
216+ return b.String()
217+}
218+
219+func buildPreviewWatch(sess *session, tabID string, interval time.Duration) protocol.RemotePreviewWatch {
220+ return protocol.RemotePreviewWatch{
221+ Request: protocol.RemotePreviewRequest{
222+ Version: protocol.RemotePreviewSchemaVersion,
223+ Target: domain.RemoteSessionTarget{
224+ Endpoint: "local", DisplayOrigin: "local",
225+ LifecycleID: sess.incarnation, SessionName: sess.name, LiveTabID: domain.TabStableID(tabID),
226+ },
227+ Width: 20, Height: 5,
228+ },
229+ MinInterval: interval,
230+ }
231+}
232+
233+// --- watch harness -----------------------------------------------------------
234+
235+type previewWatchHarness struct {
236+ t *testing.T
237+ d *Daemon
238+ sess *session
239+ tabs []*tab
240+ clk *previewClockHarness
241+ conn *previewConnHarness
242+ done chan struct{}
243+}
244+
245+func newPreviewWatchHarness(t *testing.T, tabIDs ...string) *previewWatchHarness {
246+ t.Helper()
247+ clk := newPreviewClockHarness(t)
248+ d := newTestDaemonWithCleanup(t, nil, clk.clock, true)
249+ sess := newPreviewSession(t, d, tabIDs...)
250+ conn := newPreviewConnHarness(t)
251+ return &previewWatchHarness{t: t, d: d, sess: sess, tabs: sess.tabs, clk: clk, conn: conn}
252+}
253+
254+func (h *previewWatchHarness) start(watch protocol.RemotePreviewWatch) {
255+ h.t.Helper()
256+ h.done = make(chan struct{})
257+ go func() {
258+ h.d.serveRemotePreviewWatch(h.conn.tr, watch)
259+ close(h.done)
260+ }()
261+}
262+
263+func (h *previewWatchHarness) awaitDone(timeout time.Duration) {
264+ h.t.Helper()
265+ select {
266+ case <-h.done:
267+ case <-time.After(timeout):
268+ h.t.Fatal("serveRemotePreviewWatch did not return")
269+ }
270+}
271+
272+// stop ends a still-running watch through the daemon hard context, exactly
273+// as daemon shutdown would, and joins the goroutine.
274+func (h *previewWatchHarness) stop() {
275+ h.d.hardCancel()
276+ h.awaitDone(2 * time.Second)
277+}
278+
279+// --- tests -------------------------------------------------------------------
280+
281+func TestServeRemotePreviewWatchSendsFirstFrameImmediately(t *testing.T) {
282+ h := newPreviewWatchHarness(t, "tab-0")
283+ writePreviewContent(t, h.tabs[0].focusedPane(), "\x1b[H\x1b[2Jv1")
284+ h.start(buildPreviewWatch(h.sess, "tab-0", 32*time.Millisecond))
285+
286+ frame := h.conn.nextFrame(t)
287+ require.Equal(t, protocol.RemotePreviewOK, frame.Status)
288+ require.Contains(t, previewFrameText(frame), "v1")
289+
290+ h.stop()
291+}
292+
293+func TestServeRemotePreviewWatchCoalescesBurstToOneFramePerInterval(t *testing.T) {
294+ h := newPreviewWatchHarness(t, "tab-0")
295+ pane := h.tabs[0].focusedPane()
296+ writePreviewContent(t, pane, "\x1b[H\x1b[2Jv1")
297+ interval := 32 * time.Millisecond
298+ h.start(buildPreviewWatch(h.sess, "tab-0", interval))
299+ first := h.conn.nextFrame(t)
300+ require.Contains(t, previewFrameText(first), "v1")
301+
302+ // A burst of writes inside one MinInterval window: only the state at the
303+ // next poll boundary should ever reach the client.
304+ writePreviewContent(t, pane, "\x1b[H\x1b[2Jv2")
305+ writePreviewContent(t, pane, "\x1b[H\x1b[2Jv3")
306+ writePreviewContent(t, pane, "\x1b[H\x1b[2Jv4")
307+
308+ h.clk.awaitTimer(t, interval).fire()
309+ h.clk.awaitTimer(t, remotePreviewRevalidate).fire()
310+
311+ second := h.conn.nextFrame(t)
312+ require.Contains(t, previewFrameText(second), "v4")
313+ h.conn.requireNoFrame(t)
314+
315+ h.stop()
316+}
317+
318+func TestServeRemotePreviewWatchSkipsResendWhenUnchanged(t *testing.T) {
319+ h := newPreviewWatchHarness(t, "tab-0")
320+ pane := h.tabs[0].focusedPane()
321+ writePreviewContent(t, pane, "\x1b[H\x1b[2Jsame")
322+ interval := 32 * time.Millisecond
323+ h.start(buildPreviewWatch(h.sess, "tab-0", interval))
324+ h.conn.nextFrame(t)
325+
326+ h.clk.awaitTimer(t, interval).fire()
327+ h.clk.awaitTimer(t, remotePreviewRevalidate).fire()
328+ // The next interval timer is armed only after the loop re-captured and
329+ // compared this pass, so seeing it proves the unchanged content was
330+ // already skipped rather than merely not-yet-checked.
331+ h.clk.awaitTimer(t, interval)
332+ h.conn.requireNoFrame(t)
333+
334+ h.stop()
335+}
336+
337+func TestServeRemotePreviewWatchIgnoresBackgroundTabOutput(t *testing.T) {
338+ h := newPreviewWatchHarness(t, "tab-0", "tab-1")
339+ watched := h.tabs[0].focusedPane()
340+ background := h.tabs[1].focusedPane()
341+ writePreviewContent(t, watched, "\x1b[H\x1b[2Jwatched")
342+ interval := 32 * time.Millisecond
343+ h.start(buildPreviewWatch(h.sess, "tab-0", interval))
344+ first := h.conn.nextFrame(t)
345+ require.Contains(t, previewFrameText(first), "watched")
346+
347+ writePreviewContent(t, background, "\x1b[H\x1b[2Jbg-output")
348+ h.clk.awaitTimer(t, interval).fire()
349+ h.clk.awaitTimer(t, remotePreviewRevalidate).fire()
350+ h.clk.awaitTimer(t, interval) // proves the pass already re-captured tab-0
351+ h.conn.requireNoFrame(t)
352+
353+ h.stop()
354+}
355+
356+func TestServeRemotePreviewWatchCapturesSelectedTab(t *testing.T) {
357+ h := newPreviewWatchHarness(t, "tab-0", "tab-1")
358+ writePreviewContent(t, h.tabs[0].focusedPane(), "\x1b[H\x1b[2Jactive")
359+ writePreviewContent(t, h.tabs[1].focusedPane(), "\x1b[H\x1b[2Jselected")
360+ h.start(buildPreviewWatch(h.sess, "tab-1", 32*time.Millisecond))
361+
362+ frame := h.conn.nextFrame(t)
363+ require.Equal(t, protocol.RemotePreviewOK, frame.Status)
364+ require.Equal(t, domain.TabStableID("tab-1"), frame.TabID)
365+ require.Contains(t, previewFrameText(frame), "selected")
366+ require.NotContains(t, previewFrameText(frame), "active")
367+
368+ h.stop()
369+}
370+
371+func TestServeRemotePreviewWatchSendsNoSuchTargetOnceWhenSessionKilled(t *testing.T) {
372+ h := newPreviewWatchHarness(t, "tab-0")
373+ writePreviewContent(t, h.tabs[0].focusedPane(), "\x1b[H\x1b[2Jv1")
374+ interval := 32 * time.Millisecond
375+ h.start(buildPreviewWatch(h.sess, "tab-0", interval))
376+ h.conn.nextFrame(t)
377+
378+ rc := attachmentRenderCoordinator(h.sess)
379+ require.NotNil(t, rc)
380+ require.True(t, rc.hasPreviewSubscribers())
381+
382+ // Simulate a kill through the same exact-identity lookup
383+ // remotePreviewSession uses, without driving the full kill workflow.
384+ h.d.mu.Lock()
385+ delete(h.d.sessions, h.sess.id)
386+ h.d.mu.Unlock()
387+
388+ h.clk.awaitTimer(t, interval).fire()
389+ h.clk.awaitTimer(t, remotePreviewRevalidate).fire()
390+
391+ frame := h.conn.nextFrame(t)
392+ require.Equal(t, protocol.RemotePreviewNoSuchTarget, frame.Status)
393+ h.conn.requireNoFrame(t)
394+ h.awaitDone(2 * time.Second)
395+ require.False(t, rc.hasPreviewSubscribers())
396+}
397+
398+func TestServeRemotePreviewWatchEndsOnClientClose(t *testing.T) {
399+ h := newPreviewWatchHarness(t, "tab-0")
400+ writePreviewContent(t, h.tabs[0].focusedPane(), "\x1b[H\x1b[2Jv1")
401+ h.start(buildPreviewWatch(h.sess, "tab-0", 32*time.Millisecond))
402+ h.conn.nextFrame(t)
403+
404+ rc := attachmentRenderCoordinator(h.sess)
405+ require.NotNil(t, rc)
406+ require.True(t, rc.hasPreviewSubscribers())
407+
408+ h.conn.closeClient(io.EOF)
409+ h.awaitDone(2 * time.Second)
410+ h.conn.requireNoFrame(t)
411+ require.False(t, rc.hasPreviewSubscribers())
412+}
413+
414+func TestServeRemotePreviewWatchRejectsInvalidWatch(t *testing.T) {
415+ tests := []struct {
416+ name string
417+ mutate func(*protocol.RemotePreviewWatch)
418+ }{
419+ {name: "interval below bound", mutate: func(w *protocol.RemotePreviewWatch) {
420+ w.MinInterval = protocol.RemotePreviewWatchMinInterval - time.Millisecond
421+ }},
422+ {name: "invalid request width", mutate: func(w *protocol.RemotePreviewWatch) {
423+ w.Request.Width = 0
424+ }},
425+ }
426+ for _, tt := range tests {
427+ t.Run(tt.name, func(t *testing.T) {
428+ h := newPreviewWatchHarness(t, "tab-0")
429+ watch := buildPreviewWatch(h.sess, "tab-0", 32*time.Millisecond)
430+ tt.mutate(&watch)
431+
432+ h.start(watch)
433+ frame := h.conn.nextFrame(t)
434+ require.Equal(t, protocol.RemotePreviewMalformed, frame.Status)
435+ h.conn.awaitClosed(t)
436+ h.awaitDone(2 * time.Second)
437+ })
438+ }
439+}
440+
441+func TestServeRemotePreviewWatchEndsOnHardContextCancel(t *testing.T) {
442+ h := newPreviewWatchHarness(t, "tab-0")
443+ writePreviewContent(t, h.tabs[0].focusedPane(), "\x1b[H\x1b[2Jv1")
444+ h.start(buildPreviewWatch(h.sess, "tab-0", 32*time.Millisecond))
445+ h.conn.nextFrame(t)
446+
447+ h.d.hardCancel()
448+ h.awaitDone(2 * time.Second)
449+ h.conn.requireNoFrame(t)
450+ h.conn.awaitClosed(t)
451+}
@@ -0,0 +1,451 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "strings"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/mock"
13+ "github.com/stretchr/testify/require"
14+
15+ "github.com/bnema/vev/internal/domain"
16+ "github.com/bnema/vev/internal/ports"
17+ portsmocks "github.com/bnema/vev/internal/ports/mocks"
18+ "github.com/bnema/vev/internal/protocol"
19+)
20+
21+// --- fake clock: distinguishes the watch's own interval/revalidate timers
22+// from the boundedControlSend timeout timer, so a test can drive the watch
23+// loop deterministically without ever needing the send-bound timer to fire.
24+
25+type previewTimer struct {
26+ mock *portsmocks.MockTimer
27+ ch chan time.Time
28+ duration time.Duration
29+}
30+
31+func (pt *previewTimer) fire() { pt.ch <- time.Time{} }
32+
33+type previewClockHarness struct {
34+ clock *portsmocks.MockClock
35+ pending chan *previewTimer
36+}
37+
38+func newPreviewClockHarness(t *testing.T) *previewClockHarness {
39+ t.Helper()
40+ h := &previewClockHarness{clock: portsmocks.NewMockClock(t), pending: make(chan *previewTimer, 64)}
41+ h.clock.EXPECT().Now().Return(time.Time{}).Maybe()
42+ h.clock.EXPECT().NewTimer(mock.Anything).RunAndReturn(func(d time.Duration) ports.Timer {
43+ pt := &previewTimer{mock: portsmocks.NewMockTimer(t), ch: make(chan time.Time, 1), duration: d}
44+ pt.mock.EXPECT().C().Maybe().Return((<-chan time.Time)(pt.ch))
45+ pt.mock.EXPECT().Stop().Maybe().Return(true)
46+ // boundedControlSend races this timer against an already-completing
47+ // SendServer call on every frame send; the test never needs to fire it.
48+ if d != detachNotifyTimeout {
49+ h.pending <- pt
50+ }
51+ return pt.mock
52+ }).Maybe()
53+ return h
54+}
55+
56+func (h *previewClockHarness) awaitTimer(t *testing.T, want time.Duration) *previewTimer {
57+ t.Helper()
58+ select {
59+ case pt := <-h.pending:
60+ require.Equal(t, want, pt.duration)
61+ return pt
62+ case <-time.After(2 * time.Second):
63+ t.Fatalf("timed out waiting for a %s watch timer", want)
64+ return nil
65+ }
66+}
67+
68+// --- fake connection: ReceiveClient must block until the test simulates
69+// either a client message/EOF or a server-side Close. A generated
70+// portsmocks.MockServerConnection still backs SendServer/Close so frame
71+// assertions decode typed protocol.ServerMessage values directly, but the
72+// blocking-until-signaled ReceiveClient is hand-rolled: expressing it through
73+// mock.Call plumbing would need the same channel underneath anyway.
74+
75+type clientRecvResult struct {
76+ msg protocol.ClientMessage
77+ err error
78+}
79+
80+type previewConnHarness struct {
81+ tr *portsmocks.MockServerConnection
82+ frames chan protocol.RemotePreview
83+ recv chan clientRecvResult
84+ closed chan struct{}
85+ closeOnce sync.Once
86+}
87+
88+func newPreviewConnHarness(t *testing.T) *previewConnHarness {
89+ t.Helper()
90+ h := &previewConnHarness{
91+ tr: portsmocks.NewMockServerConnection(t),
92+ frames: make(chan protocol.RemotePreview, 16),
93+ recv: make(chan clientRecvResult, 1),
94+ closed: make(chan struct{}),
95+ }
96+ h.tr.EXPECT().ReceiveClient().RunAndReturn(func() (protocol.ClientMessage, error) {
97+ select {
98+ case r := <-h.recv:
99+ return r.msg, r.err
100+ case <-h.closed:
101+ return nil, io.EOF
102+ }
103+ }).Maybe()
104+ h.tr.EXPECT().SendServer(mock.Anything).RunAndReturn(func(message protocol.ServerMessage) error {
105+ preview, ok := message.(protocol.RemotePreview)
106+ if !ok {
107+ return fmt.Errorf("unexpected server message %T", message)
108+ }
109+ h.frames <- preview
110+ return nil
111+ }).Maybe()
112+ h.tr.EXPECT().Close().RunAndReturn(func() error {
113+ h.closeOnce.Do(func() { close(h.closed) })
114+ return nil
115+ }).Maybe()
116+ return h
117+}
118+
119+// closeClient simulates the client itself sending a message or hanging up;
120+// this is distinct from the server-side Close the daemon issues on teardown.
121+func (h *previewConnHarness) closeClient(err error) {
122+ select {
123+ case h.recv <- clientRecvResult{err: err}:
124+ default:
125+ }
126+}
127+
128+func (h *previewConnHarness) nextFrame(t *testing.T) protocol.RemotePreview {
129+ t.Helper()
130+ select {
131+ case f := <-h.frames:
132+ return f
133+ case <-time.After(2 * time.Second):
134+ t.Fatal("timed out waiting for a preview frame")
135+ return protocol.RemotePreview{}
136+ }
137+}
138+
139+func (h *previewConnHarness) requireNoFrame(t *testing.T) {
140+ t.Helper()
141+ select {
142+ case f := <-h.frames:
143+ t.Fatalf("unexpected preview frame sent: %+v", f)
144+ case <-time.After(75 * time.Millisecond):
145+ }
146+}
147+
148+func (h *previewConnHarness) awaitClosed(t *testing.T) {
149+ t.Helper()
150+ select {
151+ case <-h.closed:
152+ case <-time.After(2 * time.Second):
153+ t.Fatal("transport was not closed")
154+ }
155+}
156+
157+// --- daemon/session fixture -------------------------------------------------
158+
159+// newPreviewSession builds a headless named session with one quiet-PTY tab per
160+// id, registers it in d.sessions the same way createSessionLocked would, and
161+// wires each tab/pane context like the package's other manual fixtures.
162+func newPreviewSession(t *testing.T, d *Daemon, tabIDs ...string) *session {
163+ t.Helper()
164+ lifecycle := newTestLifecycle(t)
165+ sctx, scancel := context.WithCancel(d.serveCtx)
166+ tabs := make([]*tab, 0, len(tabIDs))
167+ for i, id := range tabIDs {
168+ wctx, wcancel := context.WithCancel(sctx)
169+ tb := newTabWithStableID(id, fmt.Sprintf("pane-%d", i), newQuietPTY(), domain.Size{Cols: 80, Rows: 24})
170+ tb.ctx, tb.cancel = wctx, wcancel
171+ for _, p := range tb.panes {
172+ p.ctx, p.cancel = wctx, wcancel
173+ }
174+ tabs = append(tabs, tb)
175+ }
176+ sess := &session{
177+ sessionCore: sessionCore{id: "preview", name: "work", incarnation: lifecycle, attachments: map[*attachedClient]struct{}{}},
178+ ctx: sctx,
179+ cancel: scancel,
180+ tabs: tabs,
181+ }
182+ for _, tb := range tabs {
183+ publishTiledPaneOwners(sess, tb)
184+ }
185+ d.mu.Lock()
186+ d.sessions[sess.id] = sess
187+ d.mu.Unlock()
188+ t.Cleanup(scancel)
189+ return sess
190+}
191+
192+// writePreviewContent writes directly to the pane's VT screen under pane.mu,
193+// matching the lock captureRemotePreview takes around its own Snapshot call
194+// so a concurrently running watch never races the write under -race.
195+func writePreviewContent(t *testing.T, p *pane, seq string) {
196+ t.Helper()
197+ p.mu.Lock()
198+ defer p.mu.Unlock()
199+ p.screen.Write([]byte(seq))
200+}
201+
202+// previewFrameText flattens a frame's cells to a plain string for substring
203+// assertions; continuation cells (the right half of a wide rune) are skipped.
204+func previewFrameText(preview protocol.RemotePreview) string {
205+ var b strings.Builder
206+ for _, cell := range preview.Cells {
207+ if cell.Continuation {
208+ continue
209+ }
210+ if cell.Rune == 0 {
211+ b.WriteByte(' ')
212+ } else {
213+ b.WriteRune(cell.Rune)
214+ }
215+ }
216+ return b.String()
217+}
218+
219+func buildPreviewWatch(sess *session, tabID string, interval time.Duration) protocol.RemotePreviewWatch {
220+ return protocol.RemotePreviewWatch{
221+ Request: protocol.RemotePreviewRequest{
222+ Version: protocol.RemotePreviewSchemaVersion,
223+ Target: domain.RemoteSessionTarget{
224+ Endpoint: "local", DisplayOrigin: "local",
225+ LifecycleID: sess.incarnation, SessionName: sess.name, LiveTabID: domain.TabStableID(tabID),
226+ },
227+ Width: 20, Height: 5,
228+ },
229+ MinInterval: interval,
230+ }
231+}
232+
233+// --- watch harness -----------------------------------------------------------
234+
235+type previewWatchHarness struct {
236+ t *testing.T
237+ d *Daemon
238+ sess *session
239+ tabs []*tab
240+ clk *previewClockHarness
241+ conn *previewConnHarness
242+ done chan struct{}
243+}
244+
245+func newPreviewWatchHarness(t *testing.T, tabIDs ...string) *previewWatchHarness {
246+ t.Helper()
247+ clk := newPreviewClockHarness(t)
248+ d := newTestDaemonWithCleanup(t, nil, clk.clock, true)
249+ sess := newPreviewSession(t, d, tabIDs...)
250+ conn := newPreviewConnHarness(t)
251+ return &previewWatchHarness{t: t, d: d, sess: sess, tabs: sess.tabs, clk: clk, conn: conn}
252+}
253+
254+func (h *previewWatchHarness) start(watch protocol.RemotePreviewWatch) {
255+ h.t.Helper()
256+ h.done = make(chan struct{})
257+ go func() {
258+ h.d.serveRemotePreviewWatch(h.conn.tr, watch)
259+ close(h.done)
260+ }()
261+}
262+
263+func (h *previewWatchHarness) awaitDone(timeout time.Duration) {
264+ h.t.Helper()
265+ select {
266+ case <-h.done:
267+ case <-time.After(timeout):
268+ h.t.Fatal("serveRemotePreviewWatch did not return")
269+ }
270+}
271+
272+// stop ends a still-running watch through the daemon hard context, exactly
273+// as daemon shutdown would, and joins the goroutine.
274+func (h *previewWatchHarness) stop() {
275+ h.d.hardCancel()
276+ h.awaitDone(2 * time.Second)
277+}
278+
279+// --- tests -------------------------------------------------------------------
280+
281+func TestServeRemotePreviewWatchSendsFirstFrameImmediately(t *testing.T) {
282+ h := newPreviewWatchHarness(t, "tab-0")
283+ writePreviewContent(t, h.tabs[0].focusedPane(), "\x1b[H\x1b[2Jv1")
284+ h.start(buildPreviewWatch(h.sess, "tab-0", 32*time.Millisecond))
285+
286+ frame := h.conn.nextFrame(t)
287+ require.Equal(t, protocol.RemotePreviewOK, frame.Status)
288+ require.Contains(t, previewFrameText(frame), "v1")
289+
290+ h.stop()
291+}
292+
293+func TestServeRemotePreviewWatchCoalescesBurstToOneFramePerInterval(t *testing.T) {
294+ h := newPreviewWatchHarness(t, "tab-0")
295+ pane := h.tabs[0].focusedPane()
296+ writePreviewContent(t, pane, "\x1b[H\x1b[2Jv1")
297+ interval := 32 * time.Millisecond
298+ h.start(buildPreviewWatch(h.sess, "tab-0", interval))
299+ first := h.conn.nextFrame(t)
300+ require.Contains(t, previewFrameText(first), "v1")
301+
302+ // A burst of writes inside one MinInterval window: only the state at the
303+ // next poll boundary should ever reach the client.
304+ writePreviewContent(t, pane, "\x1b[H\x1b[2Jv2")
305+ writePreviewContent(t, pane, "\x1b[H\x1b[2Jv3")
306+ writePreviewContent(t, pane, "\x1b[H\x1b[2Jv4")
307+
308+ h.clk.awaitTimer(t, interval).fire()
309+ h.clk.awaitTimer(t, remotePreviewRevalidate).fire()
310+
311+ second := h.conn.nextFrame(t)
312+ require.Contains(t, previewFrameText(second), "v4")
313+ h.conn.requireNoFrame(t)
314+
315+ h.stop()
316+}
317+
318+func TestServeRemotePreviewWatchSkipsResendWhenUnchanged(t *testing.T) {
319+ h := newPreviewWatchHarness(t, "tab-0")
320+ pane := h.tabs[0].focusedPane()
321+ writePreviewContent(t, pane, "\x1b[H\x1b[2Jsame")
322+ interval := 32 * time.Millisecond
323+ h.start(buildPreviewWatch(h.sess, "tab-0", interval))
324+ h.conn.nextFrame(t)
325+
326+ h.clk.awaitTimer(t, interval).fire()
327+ h.clk.awaitTimer(t, remotePreviewRevalidate).fire()
328+ // The next interval timer is armed only after the loop re-captured and
329+ // compared this pass, so seeing it proves the unchanged content was
330+ // already skipped rather than merely not-yet-checked.
331+ h.clk.awaitTimer(t, interval)
332+ h.conn.requireNoFrame(t)
333+
334+ h.stop()
335+}
336+
337+func TestServeRemotePreviewWatchIgnoresBackgroundTabOutput(t *testing.T) {
338+ h := newPreviewWatchHarness(t, "tab-0", "tab-1")
339+ watched := h.tabs[0].focusedPane()
340+ background := h.tabs[1].focusedPane()
341+ writePreviewContent(t, watched, "\x1b[H\x1b[2Jwatched")
342+ interval := 32 * time.Millisecond
343+ h.start(buildPreviewWatch(h.sess, "tab-0", interval))
344+ first := h.conn.nextFrame(t)
345+ require.Contains(t, previewFrameText(first), "watched")
346+
347+ writePreviewContent(t, background, "\x1b[H\x1b[2Jbg-output")
348+ h.clk.awaitTimer(t, interval).fire()
349+ h.clk.awaitTimer(t, remotePreviewRevalidate).fire()
350+ h.clk.awaitTimer(t, interval) // proves the pass already re-captured tab-0
351+ h.conn.requireNoFrame(t)
352+
353+ h.stop()
354+}
355+
356+func TestServeRemotePreviewWatchCapturesSelectedTab(t *testing.T) {
357+ h := newPreviewWatchHarness(t, "tab-0", "tab-1")
358+ writePreviewContent(t, h.tabs[0].focusedPane(), "\x1b[H\x1b[2Jactive")
359+ writePreviewContent(t, h.tabs[1].focusedPane(), "\x1b[H\x1b[2Jselected")
360+ h.start(buildPreviewWatch(h.sess, "tab-1", 32*time.Millisecond))
361+
362+ frame := h.conn.nextFrame(t)
363+ require.Equal(t, protocol.RemotePreviewOK, frame.Status)
364+ require.Equal(t, domain.TabStableID("tab-1"), frame.TabID)
365+ require.Contains(t, previewFrameText(frame), "selected")
366+ require.NotContains(t, previewFrameText(frame), "active")
367+
368+ h.stop()
369+}
370+
371+func TestServeRemotePreviewWatchSendsNoSuchTargetOnceWhenSessionKilled(t *testing.T) {
372+ h := newPreviewWatchHarness(t, "tab-0")
373+ writePreviewContent(t, h.tabs[0].focusedPane(), "\x1b[H\x1b[2Jv1")
374+ interval := 32 * time.Millisecond
375+ h.start(buildPreviewWatch(h.sess, "tab-0", interval))
376+ h.conn.nextFrame(t)
377+
378+ rc := attachmentRenderCoordinator(h.sess)
379+ require.NotNil(t, rc)
380+ require.True(t, rc.hasPreviewSubscribers())
381+
382+ // Simulate a kill through the same exact-identity lookup
383+ // remotePreviewSession uses, without driving the full kill workflow.
384+ h.d.mu.Lock()
385+ delete(h.d.sessions, h.sess.id)
386+ h.d.mu.Unlock()
387+
388+ h.clk.awaitTimer(t, interval).fire()
389+ h.clk.awaitTimer(t, remotePreviewRevalidate).fire()
390+
391+ frame := h.conn.nextFrame(t)
392+ require.Equal(t, protocol.RemotePreviewNoSuchTarget, frame.Status)
393+ h.conn.requireNoFrame(t)
394+ h.awaitDone(2 * time.Second)
395+ require.False(t, rc.hasPreviewSubscribers())
396+}
397+
398+func TestServeRemotePreviewWatchEndsOnClientClose(t *testing.T) {
399+ h := newPreviewWatchHarness(t, "tab-0")
400+ writePreviewContent(t, h.tabs[0].focusedPane(), "\x1b[H\x1b[2Jv1")
401+ h.start(buildPreviewWatch(h.sess, "tab-0", 32*time.Millisecond))
402+ h.conn.nextFrame(t)
403+
404+ rc := attachmentRenderCoordinator(h.sess)
405+ require.NotNil(t, rc)
406+ require.True(t, rc.hasPreviewSubscribers())
407+
408+ h.conn.closeClient(io.EOF)
409+ h.awaitDone(2 * time.Second)
410+ h.conn.requireNoFrame(t)
411+ require.False(t, rc.hasPreviewSubscribers())
412+}
413+
414+func TestServeRemotePreviewWatchRejectsInvalidWatch(t *testing.T) {
415+ tests := []struct {
416+ name string
417+ mutate func(*protocol.RemotePreviewWatch)
418+ }{
419+ {name: "interval below bound", mutate: func(w *protocol.RemotePreviewWatch) {
420+ w.MinInterval = protocol.RemotePreviewWatchMinInterval - time.Millisecond
421+ }},
422+ {name: "invalid request width", mutate: func(w *protocol.RemotePreviewWatch) {
423+ w.Request.Width = 0
424+ }},
425+ }
426+ for _, tt := range tests {
427+ t.Run(tt.name, func(t *testing.T) {
428+ h := newPreviewWatchHarness(t, "tab-0")
429+ watch := buildPreviewWatch(h.sess, "tab-0", 32*time.Millisecond)
430+ tt.mutate(&watch)
431+
432+ h.start(watch)
433+ frame := h.conn.nextFrame(t)
434+ require.Equal(t, protocol.RemotePreviewMalformed, frame.Status)
435+ h.conn.awaitClosed(t)
436+ h.awaitDone(2 * time.Second)
437+ })
438+ }
439+}
440+
441+func TestServeRemotePreviewWatchEndsOnHardContextCancel(t *testing.T) {
442+ h := newPreviewWatchHarness(t, "tab-0")
443+ writePreviewContent(t, h.tabs[0].focusedPane(), "\x1b[H\x1b[2Jv1")
444+ h.start(buildPreviewWatch(h.sess, "tab-0", 32*time.Millisecond))
445+ h.conn.nextFrame(t)
446+
447+ h.d.hardCancel()
448+ h.awaitDone(2 * time.Second)
449+ h.conn.requireNoFrame(t)
450+ h.conn.awaitClosed(t)
451+}

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