v0.7.0 4c16dd8

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}

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