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}

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