detached ece7682

ui_driver_fixture_test.go RawHistoryFolder

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}

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