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