broker_offline_client_render_test.go RawHistoryFolder
1package app
2
3import (
4 "bytes"
5 "errors"
6 "io"
7 "strings"
8 "sync"
9 "testing"
10 "time"
11
12 "github.com/stretchr/testify/require"
13
14 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/internal/domain/terminalcap"
16 "github.com/bnema/vev/internal/ports"
17 "github.com/bnema/vev/internal/protocol"
18 "github.com/bnema/vev/internal/protocol/catalogue"
19 "github.com/bnema/vev/internal/usecase/client"
20)
21
22// testBrokerClientRender builds the same *brokerClientPresentation the
23// production terminal composition renders through (see runBrokerClient's
24// presentation construction), so tests exercise the real Render method
25// instead of a test-only indirection.
26func testBrokerClientRender(terminal ports.Terminal, picker *client.Picker, onState func(client.State)) func(client.State) {
27 presentation := &brokerClientPresentation{terminal: terminal, picker: picker, onState: onState}
28 return presentation.Render
29}
30
31// The resize presentation fence at the composition seam. The supervisor asks
32// its renderer to repaint whenever a resize invalidation reaches a serialized
33// picker wait, and the production offline composition paints only while the
34// shared client.PickerPresentation fence admits the state. That fence is exactly
35// PresentPicker: an admitted attachment foreground owns the terminal writer
36// from Begin, before MarkAttached and the initial publication, so painting the
37// pre-attachment connecting transition would overwrite session output with
38// picker bytes. Attached and terminating presentations are refused for the same
39// reason.
40
41// offlineRenderTerminal is the composition's terminal double: a mutable
42// geometry and an output buffer, with the single resize channel the attachment
43// geometry collector would consume.
44type offlineRenderTerminal struct {
45 mu sync.Mutex
46 geometry domain.Geometry
47 out bytes.Buffer
48 flushes int
49}
50
51func newOfflineRenderTerminal(geometry domain.Geometry) *offlineRenderTerminal {
52 return &offlineRenderTerminal{geometry: geometry}
53}
54
55func (t *offlineRenderTerminal) EnableKittyKeyboard(int) error { return nil }
56
57func (t *offlineRenderTerminal) EnterRaw() (func() error, error) {
58 return func() error { return nil }, nil
59}
60
61func (t *offlineRenderTerminal) Geometry() (domain.Geometry, error) {
62 t.mu.Lock()
63 defer t.mu.Unlock()
64 return t.geometry, nil
65}
66
67func (t *offlineRenderTerminal) ResizeEvents() <-chan domain.Geometry {
68 ch := make(chan domain.Geometry)
69 close(ch)
70 return ch
71}
72
73func (t *offlineRenderTerminal) In() io.Reader { return strings.NewReader("") }
74
75func (t *offlineRenderTerminal) Out() io.Writer { return &offlineRenderTerminalWriter{terminal: t} }
76
77func (t *offlineRenderTerminal) Flush() error {
78 t.mu.Lock()
79 defer t.mu.Unlock()
80 t.flushes++
81 return nil
82}
83
84func (t *offlineRenderTerminal) resize(geometry domain.Geometry) {
85 t.mu.Lock()
86 t.geometry = geometry
87 t.mu.Unlock()
88}
89
90// seed writes bytes exactly as an admitted attachment foreground would, so a
91// test can prove the composition never paints over session output.
92func (t *offlineRenderTerminal) seed(data string) {
93 t.mu.Lock()
94 defer t.mu.Unlock()
95 t.out.WriteString(data)
96}
97
98// written returns the bytes the composition actually wrote and the flush count.
99func (t *offlineRenderTerminal) written() (string, int) {
100 t.mu.Lock()
101 defer t.mu.Unlock()
102 return t.out.String(), t.flushes
103}
104
105// offlineRenderTerminalWriter serializes writes through the terminal's own lock
106// so a test reads a consistent buffer, exactly as the composition's writer mutex
107// does in production.
108type offlineRenderTerminalWriter struct{ terminal *offlineRenderTerminal }
109
110func (w *offlineRenderTerminalWriter) Write(data []byte) (int, error) {
111 w.terminal.mu.Lock()
112 defer w.terminal.mu.Unlock()
113 return w.terminal.out.Write(data)
114}
115
116// offlineRenderPicker builds the production picker over one reachable local
117// daemon with a single session, so Render produces a real frame at any size.
118func offlineRenderPicker(t *testing.T) *client.Picker {
119 t.Helper()
120 picker := client.NewPicker(nil, 0, terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor})
121 picker.ApplySnapshot(ports.BrokerSnapshot{
122 Epoch: 1,
123 Revision: 1,
124 Daemons: []ports.BrokerDaemonObservation{{
125 Local: true,
126 DisplayOrigin: "local",
127 Policy: ports.BrokerPolicy{
128 ProtocolVersion: protocol.Version,
129 CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
130 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
131 Transport: "quic",
132 Trust: "offline-render-trust",
133 Launch: "offline-render-launch",
134 Isolation: "offline-render-isolation",
135 },
136 Identity: "local-daemon",
137 Incarnation: ports.BrokerDaemonIncarnation{1},
138 ProtocolVersion: protocol.Version,
139 Availability: domain.RemoteAvailabilityReachable,
140 LastSuccess: time.Now(),
141 InventoryKnown: true,
142 Sessions: []catalogue.RemoteCatalogSession{{
143 LifecycleID: domain.SessionLifecycleID{1},
144 Name: "offline-render-session",
145 State: catalogue.RemoteCatalogSessionUp,
146 }},
147 }},
148 })
149 return picker
150}
151
152// TestOfflineClientRenderRefusesNonPickerPresentations proves the composition
153// fence refuses every state the shared predicate refuses, even after a resize
154// moved the terminal to a new geometry. The buffer already holds the session
155// frame an admitted foreground wrote, so a connecting repaint would overwrite
156// it. If client.PickerPresentation admitted PresentConnecting, this test fails
157// on overpainted session output.
158func TestOfflineClientRenderRefusesNonPickerPresentations(t *testing.T) {
159 small := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}
160 large := domain.Geometry{Size: domain.Size{Cols: 132, Rows: 43}}
161 const session = "\x1b[Hsession output"
162
163 terminal := newOfflineRenderTerminal(small)
164 terminal.seed(session)
165 picker := offlineRenderPicker(t)
166
167 observed := make(chan client.State, 8)
168 render := testBrokerClientRender(terminal, picker, func(state client.State) {
169 select {
170 case observed <- state:
171 default:
172 }
173 })
174
175 terminal.resize(large)
176 for _, presentation := range []client.Presentation{client.PresentConnecting, client.PresentAttached, client.PresentTerminating} {
177 t.Run(presentation.String()+" is never painted over", func(t *testing.T) {
178 state := client.State{Presentation: presentation, Connectivity: client.ConnectivityReady, Generation: 1}
179 render(state)
180 written, flushes := terminal.written()
181 require.Equal(t, session, written, "%s must leave the session output byte for byte", presentation)
182 require.Zero(t, flushes, "%s must not flush the terminal", presentation)
183 select {
184 case seen := <-observed:
185 require.Equal(t, state, seen, "the composition observer still sees the state")
186 default:
187 t.Fatalf("the composition never reported the %s state", presentation)
188 }
189 })
190 }
191}
192
193// TestOfflineClientRenderFenceIsPickerPresentation pins the composition to the
194// shared predicate: it writes picker bytes for exactly the states
195// client.PickerPresentation admits, so the composition can never widen or
196// narrow the fence locally.
197func TestOfflineClientRenderFenceIsPickerPresentation(t *testing.T) {
198 small := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}
199 states := []client.State{
200 {Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1},
201 {Presentation: client.PresentConnecting, Connectivity: client.ConnectivityReady, Generation: 1},
202 {Presentation: client.PresentAttached, Connectivity: client.ConnectivityReady, Generation: 1},
203 {Presentation: client.PresentTerminating, Connectivity: client.ConnectivityDisconnected, Generation: 1},
204 }
205 for _, state := range states {
206 t.Run(state.Presentation.String(), func(t *testing.T) {
207 terminal := newOfflineRenderTerminal(small)
208 render := testBrokerClientRender(terminal, offlineRenderPicker(t), nil)
209
210 render(state)
211
212 written, flushes := terminal.written()
213 if client.PickerPresentation(state) {
214 require.NotEmpty(t, written, "a picker-visible state must paint the picker")
215 require.Equal(t, 1, flushes, "a picker-visible state flushes exactly once")
216 return
217 }
218 require.Empty(t, written, "a refused state must write nothing")
219 require.Zero(t, flushes, "a refused state must not flush")
220 })
221 }
222}
223
224// TestOfflineClientRenderPaintsPickerResize pins the picker-visible half of the
225// fence: a resize invalidation repaints the picker at the terminal's current
226// geometry, and the repaint appends after the previous frame instead of
227// rewriting it.
228func TestOfflineClientRenderPaintsPickerResize(t *testing.T) {
229 small := domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}
230 large := domain.Geometry{Size: domain.Size{Cols: 132, Rows: 43}}
231
232 terminal := newOfflineRenderTerminal(small)
233 picker := offlineRenderPicker(t)
234 render := testBrokerClientRender(terminal, picker, nil)
235
236 render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1})
237 initial, flushes := terminal.written()
238 require.NotEmpty(t, initial, "the initial picker paint writes the frame at the reported geometry")
239 require.Contains(t, initial, "offline-render-session")
240 require.Equal(t, 1, flushes)
241
242 terminal.resize(large)
243 render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1})
244 resized, flushes := terminal.written()
245 require.True(t, strings.HasPrefix(resized, initial), "the resize repaint appends after the initial frame, never rewrites it")
246 require.Greater(t, len(resized), len(initial), "the picker resize repaint writes the frame at the new geometry")
247 require.Contains(t, strings.TrimPrefix(resized, initial), "offline-render-session", "the appended repaint is the picker frame at the new size")
248 require.Equal(t, 2, flushes)
249}
250
251// transactionRecordingTerminal is a composition terminal that also records the
252// UI output transactions the picker paint commits, so a test can prove the
253// picker frame and its published context are one unattached state.
254type transactionRecordingTerminal struct {
255 *offlineRenderTerminal
256 mu sync.Mutex
257 contexts []ports.UIContext
258 beginDepth int
259 endSuccesses []bool
260}
261
262func newTransactionRecordingTerminal(geometry domain.Geometry) *transactionRecordingTerminal {
263 return &transactionRecordingTerminal{offlineRenderTerminal: newOfflineRenderTerminal(geometry)}
264}
265
266func (t *transactionRecordingTerminal) BeginOutput(context ports.UIContext) {
267 t.mu.Lock()
268 defer t.mu.Unlock()
269 t.contexts = append(t.contexts, context)
270 t.beginDepth++
271}
272
273func (t *transactionRecordingTerminal) EndOutput(success bool) {
274 t.mu.Lock()
275 defer t.mu.Unlock()
276 t.beginDepth--
277 t.endSuccesses = append(t.endSuccesses, success)
278}
279
280func (t *transactionRecordingTerminal) PublishContext(context ports.UIContext) error {
281 t.mu.Lock()
282 defer t.mu.Unlock()
283 t.contexts = append(t.contexts, context)
284 return nil
285}
286
287func (t *transactionRecordingTerminal) published() []ports.UIContext {
288 t.mu.Lock()
289 defer t.mu.Unlock()
290 return append([]ports.UIContext(nil), t.contexts...)
291}
292
293// TestOfflineClientPickerPaintCommitsPickerTransaction pins that the picker frame
294// is committed through the terminal's UI output transaction under the Picker
295// presentation: the published context carries the run's handle and status with
296// no session identity and no actionable generation, and the transaction
297// succeeds only after the frame was written and flushed.
298func TestOfflineClientPickerPaintCommitsPickerTransaction(t *testing.T) {
299 terminal := newTransactionRecordingTerminal(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
300 render := testBrokerClientRender(terminal, offlineRenderPicker(t), nil)
301
302 render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1})
303 written, _ := terminal.written()
304 require.NotEmpty(t, written, "the picker paint writes the frame")
305 published := terminal.published()
306 require.NotEmpty(t, published, "the picker paint commits a context through the UI transaction")
307 for _, context := range published {
308 require.Equal(t, ports.UIStatusPicker, context.Status)
309 require.Zero(t, context.Generation, "a Picker context never carries an actionable generation")
310 require.Equal(t, ports.UIContext{Status: ports.UIStatusPicker}, context, "a Picker context carries no session metadata")
311 }
312 require.Equal(t, 0, terminal.beginDepth, "the transaction is balanced")
313 require.Equal(t, []bool{true}, terminal.endSuccesses, "the frame committed after write and flush")
314}
315
316// TestBrokerClientFailureWritesOnlyNonRetryableErrors pins that a retryable
317// failure never writes a raw line: the retry notice already shows it, and a
318// line per attempt would scroll the painted screen and duplicate the notice.
319func TestBrokerClientFailureWritesOnlyNonRetryableErrors(t *testing.T) {
320 tests := []struct {
321 name string
322 connectivity client.Connectivity
323 wantLine bool
324 }{
325 {name: "retry wait stays silent", connectivity: client.ConnectivityRetryWait},
326 {name: "disconnected is reported", connectivity: client.ConnectivityDisconnected, wantLine: true},
327 }
328 for _, tt := range tests {
329 t.Run(tt.name, func(t *testing.T) {
330 terminal := newOfflineRenderTerminal(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
331 presentation := &brokerClientPresentation{terminal: terminal}
332 presentation.Failure(client.State{Connectivity: tt.connectivity}, errors.New("broker connection lost"))
333 written, _ := terminal.written()
334 require.Equal(t, tt.wantLine, strings.Contains(written, "broker connection lost"))
335 })
336 }
337}
338
339// TestBrokerClientConnectingNoticeIsDelayed pins that a fast switch never
340// flashes the Connecting notice: it paints only once Connecting has lasted
341// connectingNoticeDelay, and leaving Connecting restarts the delay.
342func TestBrokerClientConnectingNoticeIsDelayed(t *testing.T) {
343 attached := client.State{Presentation: client.PresentAttached, Connectivity: client.ConnectivityReady, Generation: 1}
344 tests := []struct {
345 name string
346 slow bool // a remote attach or a resume
347 steps []time.Duration // clock advance before each Connecting render
348 reset bool // leave Connecting before the last render
349 painted bool
350 }{
351 {name: "first render stays silent", steps: []time.Duration{0}},
352 {name: "a fast switch stays silent", steps: []time.Duration{0, connectingNoticeDelay - time.Millisecond}},
353 {name: "a slow switch shows the notice", steps: []time.Duration{0, connectingNoticeDelay}, painted: true},
354 {name: "leaving connecting restarts the delay", steps: []time.Duration{0, connectingNoticeDelay}, reset: true},
355 {name: "a remote attach or resume shows it at once", slow: true, steps: []time.Duration{0}, painted: true},
356 }
357 for _, tt := range tests {
358 t.Run(tt.name, func(t *testing.T) {
359 connecting := client.State{Presentation: client.PresentConnecting, Connectivity: client.ConnectivityReady, Generation: 1, SlowConnecting: tt.slow}
360 terminal := newOfflineRenderTerminal(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
361 clock := newSandboxClock()
362 presentation := &brokerClientPresentation{terminal: terminal, picker: offlineRenderPicker(t), clock: clock}
363 for i, step := range tt.steps {
364 clock.Advance(step)
365 if tt.reset && i == len(tt.steps)-1 {
366 presentation.Render(attached)
367 }
368 presentation.Render(connecting)
369 }
370 written, _ := terminal.written()
371 require.Equal(t, tt.painted, strings.Contains(written, "Connecting to session"))
372 })
373 }
374}
375
376// TestBrokerClientSpinnerTimer pins the spinner timer lifecycle: none while
377// the plain picker shows, its first tick exactly when a held-back Connecting
378// notice becomes due, and a fresh timer after a transition ends.
379func TestBrokerClientSpinnerTimer(t *testing.T) {
380 terminal := newOfflineRenderTerminal(domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}})
381 clock := newSandboxClock()
382 presentation := &brokerClientPresentation{terminal: terminal, picker: offlineRenderPicker(t), clock: clock}
383 connecting := client.State{Presentation: client.PresentConnecting, Connectivity: client.ConnectivityReady, Generation: 1}
384
385 presentation.Render(client.State{Presentation: client.PresentPicker, Connectivity: client.ConnectivityReady, Generation: 1})
386 require.Nil(t, presentation.Spinner(), "the plain picker has no spinner")
387
388 for range 2 {
389 presentation.Render(connecting)
390 tick := presentation.Spinner()
391 require.NotNil(t, tick)
392 clock.Advance(connectingNoticeDelay - time.Millisecond)
393 select {
394 case <-tick:
395 t.Fatal("the spinner ticked before the notice was due")
396 default:
397 }
398 clock.Advance(time.Millisecond)
399 select {
400 case <-tick:
401 default:
402 t.Fatal("the spinner never ticked when the notice became due")
403 }
404 presentation.AdvanceSpinner(connecting)
405 written, _ := terminal.written()
406 require.Contains(t, written, "Connecting to session")
407 presentation.StopSpinner()
408 presentation.Render(client.State{Presentation: client.PresentAttached, Connectivity: client.ConnectivityReady, Generation: 1})
409 require.Nil(t, presentation.Spinner(), "a finished transition leaves no spinner")
410 }
411}