1//go:build linux
2
3package app
4
5import (
6 "context"
7 "sync"
8 "testing"
9 "time"
10
11 "github.com/bnema/vev/internal/adapters/brokeripc"
12 "github.com/bnema/vev/internal/adapters/clock"
13 "github.com/bnema/vev/internal/adapters/uidriver"
14 "github.com/bnema/vev/internal/adapters/uiterm"
15 "github.com/bnema/vev/internal/domain"
16 "github.com/bnema/vev/internal/ports"
17 "github.com/bnema/vev/internal/protocol"
18 "github.com/bnema/vev/internal/usecase/client"
19 "github.com/stretchr/testify/require"
20)
21
22// UI-driver broker fixture (Plan 001 P7 UI-driver slice 2).
23//
24// The fixture owns every resource a UI-driver test family must provision and tear
25// down: one private offline root, one real broker sandbox process, the daemon it
26// observes, and every driver run started over the fixture's connector. Nothing
27// here reaches the production runtime or state directories; the production roots
28// are redirected into private temporary directories and asserted untouched.
29//
30// Ownership is explicit and one-directional: the fixture stops the runs *it*
31// started before the broker and daemon it created are torn down. A run's EOF is a
32// detach — it closes that run's logical stream and service only — so the fixture
33// never interprets a driver's exit as proof the shared daemon went away, and the
34// daemon stays alive for the next run.
35
36// uiDriverBrokerFixture is the shared private broker, daemon, and run registry of
37// one UI-driver test family.
38type uiDriverBrokerFixture struct {
39 broker offlineClientFixture
40 // prodRuntime and prodState are the redirected production roots the fixture
41 // asserts untouched.
42 prodRuntime string
43 prodState string
44
45 mu sync.Mutex
46 runs []*uiDriverFixtureRun
47 closed bool
48}
49
50// uiDriverFixtureRun is one UI-driver run the fixture owns: its JSONL stream, its
51// virtual terminal, its UI service, its cancellation, and the channel reporting
52// its result.
53type uiDriverFixtureRun struct {
54 stream *uidriverTestStream
55 terminal *uiterm.Terminal
56 ui *client.UI
57 cancel context.CancelFunc
58 done chan error
59}
60
61// startUIDriverBrokerFixture provisions one private broker sandbox with its own
62// daemon and registers the teardown immediately, so a failure part-way through
63// provisioning still cleans up exactly what was created.
64func startUIDriverBrokerFixture(t *testing.T) *uiDriverBrokerFixture {
65 t.Helper()
66 broker := startOfflineClientFixture(t)
67 fixture := &uiDriverBrokerFixture{broker: broker, prodRuntime: broker.prodRuntime, prodState: broker.prodState}
68 t.Cleanup(fixture.stopRuns)
69 return fixture
70}
71
72// Connector returns the broker IPC connector of this fixture's private offline
73// broker. It is the only transport authority a sandbox harness is given: it
74// starts no production broker and never falls back to the production XDG layout.
75func (f *uiDriverBrokerFixture) Connector() ports.BrokerConnector {
76 return brokeripc.NewConnector(f.broker.socket, brokeripc.Config{}).Navigator()
77}
78
79// Streams reports one event per logical stream the fixture broker admitted.
80func (f *uiDriverBrokerFixture) Streams() <-chan struct{} { return f.broker.streams }
81
82// StartDriver launches the no-argument driver: one ephemeral local creation, the
83// product default of every frontend.
84func (f *uiDriverBrokerFixture) StartDriver(t *testing.T) *uiDriverFixtureRun {
85 t.Helper()
86 return f.startDriver(t, localEphemeralNavigation(), nil)
87}
88
89// StartPickerDriver launches the `--picker` driver: no initial navigation at
90// all, so it reaches ready without opening a logical stream.
91func (f *uiDriverBrokerFixture) StartPickerDriver(t *testing.T) *uiDriverFixtureRun {
92 t.Helper()
93 return f.startDriver(t, client.InitialNavigation{}, nil)
94}
95
96// StartNamedDriver launches the `--session NAME` driver: one named local
97// creation.
98func (f *uiDriverBrokerFixture) StartNamedDriver(t *testing.T, name string) *uiDriverFixtureRun {
99 t.Helper()
100 navigation, resolver, err := terminalBrokerNavigation(protocol.IntentNew, name, "")
101 require.NoError(t, err)
102 return f.startDriver(t, navigation, resolver)
103}
104
105// StartRefusedTargetDriver launches a driver whose resolved target carries a
106// lifecycle the broker has never committed, so the run refuses it locally and
107// stays in the picker.
108func (f *uiDriverBrokerFixture) StartRefusedTargetDriver(t *testing.T) *uiDriverFixtureRun {
109 t.Helper()
110 var unknown domain.SessionLifecycleID
111 unknown[0], unknown[1] = 0xEE, 0x11
112 target := protocol.ExactSessionTarget{LifecycleID: unknown, SessionName: "absent"}
113 require.NoError(t, target.Validate())
114 navigation := client.InitialNavigation{
115 Kind: client.InitialNavigationAttachExact,
116 Epoch: f.epoch(t),
117 Destination: ports.BrokerEndpointFence{Local: true},
118 Target: target,
119 }
120 require.NoError(t, navigation.Validate())
121 return f.startDriver(t, navigation, nil)
122}
123
124// startDriver composes and registers one harness run over the fixture's private
125// broker.
126func (f *uiDriverBrokerFixture) startDriver(t *testing.T, navigation client.InitialNavigation, resolver client.InitialNavigationResolver) *uiDriverFixtureRun {
127 t.Helper()
128 ctx, cancel := context.WithCancel(t.Context())
129 terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, "")
130 require.NoError(t, err)
131 ui := client.NewUI(terminal, clock.New())
132 run := &uiDriverFixtureRun{
133 stream: newUIDriverTestStream(), terminal: terminal, ui: ui,
134 cancel: cancel, done: make(chan error, 1),
135 }
136 f.mu.Lock()
137 f.runs = append(f.runs, run)
138 f.mu.Unlock()
139 go func() {
140 run.done <- runOfflineUIDriverClient(ctx, offlineUIDriverHarness{
141 socket: f.broker.socket,
142 terminal: terminal,
143 ui: ui,
144 log: discardLog(),
145 navigation: navigation,
146 resolver: resolver,
147 stream: run.stream,
148 })
149 }()
150 return run
151}
152
153// localSessionNames returns the local session names the fixture broker currently
154// publishes. It is the session-scoped proof of what a driver did: unlike the
155// logical-stream signal, it is unaffected by the broker's own observation dials.
156func (f *uiDriverBrokerFixture) localSessionNames(t *testing.T) []string {
157 t.Helper()
158 ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait)
159 defer cancel()
160 service, err := brokeripc.NewConnector(f.broker.socket, brokeripc.Config{}).Connect(ctx)
161 require.NoError(t, err)
162 defer service.Close()
163 f.waitEpoch(t, service)
164 observation, ok := localBrokerObservation(service.Snapshot())
165 if !ok {
166 return nil
167 }
168 names := make([]string, 0, len(observation.Sessions))
169 for _, session := range observation.Sessions {
170 names = append(names, session.Name)
171 }
172 return names
173}
174
175// epoch returns the fixture broker's current committed epoch.
176func (f *uiDriverBrokerFixture) epoch(t *testing.T) ports.BrokerEpoch {
177 t.Helper()
178 ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait)
179 defer cancel()
180 service, err := brokeripc.NewConnector(f.broker.socket, brokeripc.Config{}).Connect(ctx)
181 require.NoError(t, err)
182 defer service.Close()
183 return f.waitEpoch(t, service)
184}
185
186// waitEpoch waits for one broker connection's first committed publication and
187// returns its epoch.
188func (f *uiDriverBrokerFixture) waitEpoch(t *testing.T, service ports.BrokerService) ports.BrokerEpoch {
189 t.Helper()
190 sub, err := service.Subscribe()
191 require.NoError(t, err)
192 defer sub.Close()
193 deadline := time.NewTimer(brokerTestWait)
194 defer deadline.Stop()
195 for {
196 if snapshot := service.Snapshot(); snapshot.Epoch != 0 {
197 return snapshot.Epoch
198 }
199 select {
200 case <-sub.Changed():
201 case <-service.Done():
202 t.Fatalf("broker connection ended before publishing a snapshot: %v", service.Err())
203 case <-deadline.C:
204 t.Fatal("the fixture broker never published a snapshot")
205 }
206 }
207}
208
209// Ready reads this run's single discovery response.
210func (r *uiDriverFixtureRun) Ready(t *testing.T) uidriver.Ready {
211 t.Helper()
212 return r.stream.awaitReady(t)
213}
214
215// Terminal returns the run's virtual terminal.
216func (r *uiDriverFixtureRun) Terminal() *uiterm.Terminal { return r.terminal }
217
218// EOF closes this run's JSONL stream and joins it. Closing twice is safe: the
219// stream owns its work exactly once, so a duplicate EOF can never end another
220// run.
221func (r *uiDriverFixtureRun) EOF(t *testing.T) {
222 t.Helper()
223 require.NoError(t, r.stream.Close())
224 select {
225 case err := <-r.done:
226 require.NoError(t, ignoreContextCancellation(err))
227 case <-time.After(brokerTestWait):
228 t.Fatal("the UI driver did not stop after its stream closed")
229 }
230}
231
232// capture performs one capture over the run's own JSONL stream.
233func (r *uiDriverFixtureRun) capture(t *testing.T, attachment string) map[string]any {
234 t.Helper()
235 return r.stream.awaitCapture(t, attachment)
236}
237
238// awaitAttachedSnapshot waits on the UI owner's broadcast until one coherent
239// attached publication carries the expected target, a usable generation, and
240// the expected rendered content. Assertions must use the returned snapshot.
241func (r *uiDriverFixtureRun) awaitAttachedSnapshot(t *testing.T, attachment, session, text string) ports.UISnapshot {
242 t.Helper()
243 ctx, cancel := context.WithTimeout(t.Context(), brokerTestWait)
244 defer cancel()
245 status := ports.UIStatusAttached
246 // The public UI-driver session predicate is an exact lifecycle fence: a
247 // session name by itself is intentionally invalid. Creation does not know
248 // that lifecycle until the attached publication, so wait on the other
249 // committed predicates and assert the requested creation name afterward.
250 expect := ports.UIExpect{Status: &status, TextContains: &text}
251 _, err := r.ui.Wait(ctx, ports.UIWaitRequest{Attachment: attachment, Expect: expect, Timeout: brokerTestWait})
252 require.NoError(t, err, "the UI driver never published the expected attached snapshot")
253 snapshot, err := r.ui.Capture(attachment)
254 require.NoError(t, err)
255 require.Equal(t, ports.UIStatusAttached, snapshot.Context.Status)
256 require.NotZero(t, snapshot.Context.Generation)
257 require.NotEqual(t, domain.SessionLifecycleID{}, snapshot.Context.Route.Target.LifecycleID)
258 if session != "" {
259 require.Equal(t, session, snapshot.Context.Route.Target.SessionName)
260 }
261 require.Contains(t, offlineSnapshotText(snapshot), text)
262 return snapshot
263}
264
265// keys sends one key batch at the given generation and returns its action status.
266// An accepted batch whose boundary was lost is reported as outcome_unknown, which
267// is an honest terminal status and not a refusal.
268func (r *uiDriverFixtureRun) keys(t *testing.T, attachment string, generation uint64, values ...string) ports.UIActionStatus {
269 t.Helper()
270 id := r.stream.nextID()
271 require.NoError(t, r.stream.writeRequest(map[string]any{
272 "version": 1, "id": id, "op": "keys", "attachment": attachment,
273 "generation": generation, "keys": values,
274 }))
275 envelope := r.stream.awaitEnvelope(t, id)
276 if envelope.Error != nil {
277 require.True(t, envelope.Error.Accepted, "the driver refused a key batch: %+v", envelope.Error)
278 require.Equal(t, ports.UIErrOutcomeUnknown, envelope.Error.Code, "the key batch failed for another reason: %+v", envelope.Error)
279 return ports.UIActionOutcomeUnknown
280 }
281 var result struct {
282 Status ports.UIActionStatus `json:"status"`
283 }
284 require.NoError(t, remarshal(envelope.Result, &result))
285 return result.Status
286}
287
288// presentationStatus returns the run's currently published presentation status.
289func (r *uiDriverFixtureRun) presentationStatus(t *testing.T, attachment string) ports.UIPresentationStatus {
290 t.Helper()
291 snapshot, err := r.ui.Capture(attachment)
292 require.NoError(t, err)
293 return snapshot.Context.Status
294}
295
296// text sends one typed batch at the given generation and returns its action
297// status.
298func (r *uiDriverFixtureRun) text(t *testing.T, attachment string, generation uint64, value string) ports.UIActionStatus {
299 t.Helper()
300 id := r.stream.nextID()
301 require.NoError(t, r.stream.writeRequest(map[string]any{
302 "version": 1, "id": id, "op": "text", "attachment": attachment,
303 "generation": generation, "text": value,
304 }))
305 envelope := r.stream.awaitEnvelope(t, id)
306 require.Nil(t, envelope.Error, "the driver refused a typed batch: %+v", envelope.Error)
307 var result struct {
308 Status ports.UIActionStatus `json:"status"`
309 }
310 require.NoError(t, remarshal(envelope.Result, &result))
311 return result.Status
312}
313
314// stopRuns ends every run the fixture started, before the broker and daemon it
315// created are torn down.
316func (f *uiDriverBrokerFixture) stopRuns() {
317 if f == nil {
318 return
319 }
320 f.mu.Lock()
321 if f.closed {
322 f.mu.Unlock()
323 return
324 }
325 f.closed = true
326 runs := append([]*uiDriverFixtureRun(nil), f.runs...)
327 f.mu.Unlock()
328 for _, run := range runs {
329 _ = run.stream.Close()
330 run.cancel()
331 run.terminal.Close()
332 select {
333 case <-run.done:
334 case <-time.After(brokerTestWait):
335 }
336 }
337}