ui_driver_client_test.go RawHistoryFolder

1//go:build linux
2
3package app
4
5import (
6	"context"
7	"encoding/json"
8	"errors"
9	"io"
10	"path/filepath"
11	"sync"
12	"testing"
13	"time"
14
15	"github.com/bnema/vev/internal/adapters/clock"
16	"github.com/bnema/vev/internal/adapters/uidriver"
17	"github.com/bnema/vev/internal/adapters/uiterm"
18	"github.com/bnema/vev/internal/domain"
19	"github.com/bnema/vev/internal/ports"
20	portsmocks "github.com/bnema/vev/internal/ports/mocks"
21	"github.com/bnema/vev/internal/usecase/client"
22	"github.com/stretchr/testify/mock"
23	"github.com/stretchr/testify/require"
24)
25
26// UI-driver JSONL composition tests (Plan 001 P7 UI-driver slice 2).
27//
28// The driver service is one shared composition: runUIDriverClient launches the
29// shared broker client exactly once, serves the JSONL protocol over one stream,
30// and joins both workers on EOF or cancellation. These tests drive it directly,
31// so every assertion holds for the production `--ui-driver` entry point and for
32// the sandbox harness that delegates to the same function.
33
34// failingBrokerConnector returns a connector whose every attempt fails, so a test can
35// drive the non-fatal broker path without any transport.
36func failingBrokerConnector(t *testing.T, err error) ports.BrokerConnector {
37	connector := portsmocks.NewMockBrokerConnector(t)
38	connector.EXPECT().Connect(mock.Anything).Return(nil, err).Maybe()
39	return connector
40}
41
42// uidriverTestStream is an owned bidirectional JSONL stream over two private
43// pipes: the driver reads requests from the request pipe and writes responses to
44// the response pipe, exactly as a socket would. Closing it is the driver's EOF,
45// and it owns its work exactly once so a duplicate EOF can never end another run.
46// Request IDs are allocated here, so every caller shares one monotonic sequence.
47//
48// One decoder goroutine owns the single read lifetime of the response pipe and
49// feeds whole envelopes into a channel, so a slow assertion can never make a
50// decoder buffer and silently drop the next response's bytes.
51type uidriverTestStream struct {
52	requests   *io.PipeReader
53	requestsW  *io.PipeWriter
54	responses  *io.PipeReader
55	responsesW *io.PipeWriter
56
57	decoded   sync.Once
58	results   chan driverTestEnvelope
59	closeOnce sync.Once
60	closeErr  error
61
62	idMu chan struct{}
63	next uint64
64}
65
66// driverTestEnvelope is one JSON Lines response envelope, exactly as the driver
67// service writes it.
68type driverTestEnvelope struct {
69	Version int             `json:"version"`
70	ID      uint64          `json:"id"`
71	Result  json.RawMessage `json:"result"`
72	Error   *struct {
73		Code     ports.UIErrorCode `json:"code"`
74		Accepted bool              `json:"accepted"`
75		ActionID uint64            `json:"action_id,omitempty"`
76	} `json:"error,omitempty"`
77}
78
79func newUIDriverTestStream() *uidriverTestStream {
80	requests, requestsW := io.Pipe()
81	responses, responsesW := io.Pipe()
82	stream := &uidriverTestStream{
83		requests: requests, requestsW: requestsW, responses: responses, responsesW: responsesW,
84		results: make(chan driverTestEnvelope, 8), idMu: make(chan struct{}, 1),
85	}
86	stream.idMu <- struct{}{}
87	return stream
88}
89
90func (s *uidriverTestStream) Read(data []byte) (int, error)  { return s.requests.Read(data) }
91func (s *uidriverTestStream) Write(data []byte) (int, error) { return s.responsesW.Write(data) }
92func (s *uidriverTestStream) Close() error {
93	s.closeOnce.Do(func() {
94		closeErr := s.requestsW.Close()
95		if err := s.responsesW.Close(); closeErr == nil {
96			closeErr = err
97		}
98		s.closeErr = closeErr
99	})
100	return s.closeErr
101}
102
103// awaitReady reads the single discovery response of one driver stream.
104func (s *uidriverTestStream) awaitReady(t *testing.T) uidriver.Ready {
105	t.Helper()
106	envelope := s.awaitEnvelope(t, 0)
107	var ready uidriver.Ready
108	require.NoError(t, remarshal(envelope.Result, &ready))
109	return ready
110}
111
112// awaitCapture sends one capture request and returns its result.
113func (s *uidriverTestStream) awaitCapture(t *testing.T, attachment string) map[string]any {
114	t.Helper()
115	id := s.nextID()
116	require.NoError(t, s.writeRequest(map[string]any{
117		"version": 1, "id": id, "op": "capture", "attachment": attachment,
118	}))
119	envelope := s.awaitEnvelope(t, id)
120	require.Nil(t, envelope.Error)
121	var capture map[string]any
122	require.NoError(t, remarshal(envelope.Result, &capture))
123	return capture
124}
125
126// nextID allocates the next monotonically increasing JSONL request id of this
127// stream.
128func (s *uidriverTestStream) nextID() uint64 {
129	<-s.idMu
130	s.next++
131	id := s.next
132	s.idMu <- struct{}{}
133	return id
134}
135
136func (s *uidriverTestStream) writeRequest(request map[string]any) error {
137	data, err := json.Marshal(request)
138	if err != nil {
139		return err
140	}
141	_, err = s.requestsW.Write(append(data, '\n'))
142	return err
143}
144
145// awaitEnvelope returns the next response envelope, bounded so a driver that
146// stops answering fails the test instead of blocking it.
147func (s *uidriverTestStream) awaitEnvelope(t *testing.T, wantID uint64) driverTestEnvelope {
148	t.Helper()
149	s.decoded.Do(func() {
150		go func() {
151			defer close(s.results)
152			decoder := json.NewDecoder(s.responses)
153			for {
154				var envelope driverTestEnvelope
155				if err := decoder.Decode(&envelope); err != nil {
156					return
157				}
158				s.results <- envelope
159			}
160		}()
161	})
162	select {
163	case envelope, ok := <-s.results:
164		require.True(t, ok, "the driver stream ended before response %d", wantID)
165		require.Equal(t, wantID, envelope.ID)
166		return envelope
167	case <-time.After(brokerTestWait):
168		t.Fatal("the driver stream produced no response")
169		return driverTestEnvelope{}
170	}
171}
172
173func remarshal(value any, target any) error {
174	data, err := json.Marshal(value)
175	if err != nil {
176		return err
177	}
178	return json.Unmarshal(data, target)
179}
180
181// TestProductionBrokerConnectorIsLazy pins that constructing the production
182// connector performs no I/O at all: the connect-or-spawn step runs inside
183// Connect, so an absent or unreachable broker is an ordinary attempt failure
184// under the supervisor's cadence instead of a fatal startup error.
185func TestProductionBrokerConnectorIsLazy(t *testing.T) {
186	startupErr := errors.New("broker not reachable")
187	calls := 0
188	previous := connectProductionClientBroker
189	connectProductionClientBroker = func(context.Context) (ports.BrokerService, error) {
190		calls++
191		return nil, startupErr
192	}
193	t.Cleanup(func() { connectProductionClientBroker = previous })
194
195	connector := newProductionBrokerConnector()
196	require.NotNil(t, connector)
197	require.Zero(t, calls, "construction must not connect")
198
199	_, err := connector.Connect(context.Background())
200	require.ErrorIs(t, err, startupErr)
201	require.Equal(t, 1, calls)
202
203	// Every attempt, the first included, is a fresh connect-or-spawn.
204	_, err = connector.Connect(context.Background())
205	require.ErrorIs(t, err, startupErr)
206	require.Equal(t, 2, calls)
207}
208
209// TestUIDriverClientReadyOnBrokerFailureKeepsServing pins the non-fatal broker
210// contract of the shared JSONL composition: a broker that can never be reached
211// neither prevents the discovery response nor ends the service, and the driver
212// keeps answering capture while it is stuck in the picker.
213func TestUIDriverClientReadyOnBrokerFailureKeepsServing(t *testing.T) {
214	startCtx := t.Context()
215	terminal, err := uiterm.New(startCtx, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, "")
216	require.NoError(t, err)
217	defer terminal.Close()
218	clk := clock.New()
219	ui := client.NewUI(terminal, clk)
220
221	ctx, cancel := context.WithCancel(context.Background())
222	defer cancel()
223	stream := newUIDriverTestStream()
224	done := make(chan error, 1)
225	go func() {
226		done <- runUIDriverClient(ctx, brokerClientConfig{
227			Connector:             failingBrokerConnector(t, errors.New("broker not reachable")),
228			Terminal:              terminal,
229			Clock:                 clk,
230			UI:                    ui,
231			AttachmentEnvironment: terminalAttachmentEnvironment(),
232			SessionEnvironment:    client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI},
233		}, stream)
234	}()
235
236	ready := stream.awaitReady(t)
237	require.NotEmpty(t, ready.Attachment)
238	require.True(t, ready.Control)
239	require.Zero(t, ready.Generation, "no actionable generation before a committed attachment")
240	// A broker that can never be reached is a Picker, never an Attached
241	// presentation and never a legacy transition state.
242	require.Equal(t, ports.UIStatusPicker, ready.Status, "an unreachable broker is published as the picker")
243
244	capture := stream.awaitCapture(t, ready.Attachment)
245	require.NotNil(t, capture)
246	// The Picker publication carries the run's handle and no session metadata.
247	require.Equal(t, map[string]any{
248		"attachment": ready.Attachment, "generation": float64(0), "session": map[string]any{"lifecycle_id": "", "session_name": ""},
249		"focus": map[string]any{"tab_id": "", "pane_id": ""}, "output_epoch": float64(0), "output_state": float64(0),
250		"view_revision": float64(0), "view_publication": float64(0), "status": "picker",
251	}, capture["context"])
252
253	// The run is still alive: a failed broker attempt must not end Serve.
254	select {
255	case err := <-done:
256		t.Fatalf("the driver ended on a broker failure: %v", err)
257	case <-time.After(150 * time.Millisecond):
258	}
259
260	require.NoError(t, stream.Close())
261	select {
262	case err := <-done:
263		require.NoError(t, ignoreContextCancellation(err))
264	case <-time.After(brokerTestWait):
265		t.Fatal("the driver did not stop after its stream closed")
266	}
267}
268
269// TestUIDriverClientRejectsMissingCompositionInputs pins the composition
270// contract: the shared JSONL function requires a UI, a terminal, a clock, and a
271// stream, and never invents one.
272func TestUIDriverClientRejectsMissingCompositionInputs(t *testing.T) {
273	clk := clock.New()
274	stream := newUIDriverTestStream()
275	defer func() { _ = stream.Close() }()
276	require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Connector: failingBrokerConnector(t, errors.New("unused"))}, stream))
277	require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Clock: clk, UI: client.NewUI(nil, clk)}, stream))
278	require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Clock: clk, UI: client.NewUI(nil, clk), Terminal: nil}, stream))
279	require.Error(t, runUIDriverClient(t.Context(), brokerClientConfig{Clock: clk, UI: client.NewUI(nil, clk)}, nil))
280}
281
282// TestUIDriverSandboxHarnessUsesItsOwnConnector pins the sandbox boundary: the
283// hidden harness reaches sessions only through the broker IPC connector of its
284// own offline root, and never through the production connect-or-spawn.
285func TestUIDriverSandboxHarnessUsesItsOwnConnector(t *testing.T) {
286	productionConnects := 0
287	previousConnect := connectProductionClientBroker
288	connectProductionClientBroker = func(context.Context) (ports.BrokerService, error) {
289		productionConnects++
290		return nil, errors.New("the sandbox harness must never reach the production broker")
291	}
292	t.Cleanup(func() { connectProductionClientBroker = previousConnect })
293
294	fixture := startUIDriverBrokerFixture(t)
295	run := fixture.StartDriver(t)
296	ready := run.Ready(t)
297	require.NotEmpty(t, ready.Attachment)
298	awaitLogicalStream(t, fixture.Streams())
299	attached := run.awaitAttachedSnapshot(t, ready.Attachment, "", "offline-ready")
300	require.Contains(t, offlineSnapshotText(attached), "offline-ready")
301	require.Zero(t, productionConnects, "the sandbox harness keeps its own connector")
302
303	run.EOF(t)
304}
305
306// TestUIDriverSandboxHarnessPickerOpensNothing pins the sandbox picker start: no
307// initial navigation at all, so the harness reaches ready without creating a
308// session or attaching anything.
309func TestUIDriverSandboxHarnessPickerOpensNothing(t *testing.T) {
310	fixture := startUIDriverBrokerFixture(t)
311	run := fixture.StartPickerDriver(t)
312	ready := run.Ready(t)
313	require.NotEmpty(t, ready.Attachment)
314	require.Zero(t, ready.Generation)
315	// A picker start opens nothing: the driver stays usable while the broker
316	// publishes no session for it.
317	require.NotNil(t, run.capture(t, ready.Attachment))
318	require.Empty(t, fixture.localSessionNames(t), "a picker start creates no session")
319	run.EOF(t)
320}
321
322// TestUIDriverClientKeepsSharedDaemonOnEOF pins the EOF contract: closing a
323// driver's JSONL stream ends only that run. The daemon and the broker stay alive,
324// so a second driver reaches the same session afterwards.
325func TestUIDriverClientKeepsSharedDaemonOnEOF(t *testing.T) {
326	fixture := startUIDriverBrokerFixture(t)
327	first := fixture.StartDriver(t)
328	firstReady := first.Ready(t)
329	awaitLogicalStream(t, fixture.Streams())
330	first.awaitAttachedSnapshot(t, firstReady.Attachment, "", "offline-ready")
331	first.EOF(t)
332
333	// The shared daemon outlives the driver: a fresh driver reaches the same
334	// session through the same fixture broker.
335	second := fixture.StartDriver(t)
336	secondReady := second.Ready(t)
337	require.NotEqual(t, firstReady.Attachment, secondReady.Attachment)
338	awaitLogicalStream(t, fixture.Streams())
339	second.awaitAttachedSnapshot(t, secondReady.Attachment, "", "offline-ready")
340	second.EOF(t)
341}
342
343// TestUIDriverFixtureUsesIsolatedProductionBroker pins the fixture isolation:
344// the driver reaches the broker within its private production XDG roots.
345func TestUIDriverFixtureUsesIsolatedProductionBroker(t *testing.T) {
346	fixture := startUIDriverBrokerFixture(t)
347	require.DirExists(t, filepath.Join(fixture.prodRuntime, "vev", "broker"))
348	require.FileExists(t, filepath.Join(fixture.prodState, "vev", "broker", "state", "state.json"))
349	connector := fixture.Connector()
350	require.NotNil(t, connector)
351	service, err := connector.Connect(t.Context())
352	require.NoError(t, err)
353	require.NoError(t, service.Close())
354}

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