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}