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}

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