detached ece7682

ui_driver_acceptance_test.go RawHistoryFolder

1//go:build linux
2
3package app
4
5import (
6	"context"
7	"testing"
8	"time"
9
10	"github.com/bnema/vev/internal/adapters/clock"
11	"github.com/bnema/vev/internal/adapters/uiterm"
12	"github.com/bnema/vev/internal/domain"
13	"github.com/bnema/vev/internal/ports"
14	portsmocks "github.com/bnema/vev/internal/ports/mocks"
15	"github.com/bnema/vev/internal/protocol"
16	"github.com/bnema/vev/internal/usecase/client"
17	"github.com/stretchr/testify/mock"
18	"github.com/stretchr/testify/require"
19)
20
21// startScriptedUIDriverRun launches one driver run over a scripted broker
22// service, waits for its discovery response, and then waits for the run's single
23// logical stream admission.
24func startScriptedUIDriverRun(t *testing.T, service *terminalCompositionService, navigation client.InitialNavigation, resolver client.InitialNavigationResolver) *uiDriverFixtureRun {
25	t.Helper()
26	ctx, cancel := context.WithCancel(t.Context())
27	terminal, err := uiterm.New(ctx, domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, "")
28	require.NoError(t, err)
29	ui := client.NewUI(terminal, clock.New())
30	stream := newUIDriverTestStream()
31	run := &uiDriverFixtureRun{stream: stream, terminal: terminal, ui: ui, cancel: cancel, done: make(chan error, 1)}
32	t.Cleanup(func() {
33		_ = stream.Close()
34		cancel()
35		terminal.Close()
36		select {
37		case <-run.done:
38		case <-time.After(brokerTestWait):
39			t.Error("the scripted UI driver did not stop")
40		}
41	})
42	connector := portsmocks.NewMockBrokerConnector(t)
43	connector.EXPECT().Connect(mock.Anything).Return(service, nil).Maybe()
44	go func() {
45		run.done <- runUIDriverClient(ctx, brokerClientConfig{
46			Connector:                connector,
47			Terminal:                 terminal,
48			Clock:                    clock.New(),
49			UI:                       ui,
50			InitialNavigation:        navigation,
51			ResolveInitialNavigation: resolver,
52			AttachmentEnvironment:    terminalAttachmentEnvironment(),
53			SessionEnvironment:       client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI},
54		}, stream)
55	}()
56	run.Ready(t)
57	require.Eventually(t, func() bool { return len(service.openedRequests()) != 0 }, brokerTestWait, 5*time.Millisecond,
58		"the run never admitted its logical stream")
59	return run
60}
61
62func TestUIDriverUsesBrokerSupervisorAndDaemon(t *testing.T) {
63	fixture := startUIDriverBrokerFixture(t)
64	run := fixture.StartDriver(t)
65	ready := run.Ready(t)
66	require.NotEmpty(t, ready.Attachment)
67	require.True(t, ready.Control)
68	require.Zero(t, ready.Generation, "the discovery response never invents an actionable generation")
69
70	awaitLogicalStream(t, fixture.Streams())
71	attached := run.awaitAttachedSnapshot(t, ready.Attachment, "", "offline-ready")
72	generation := attached.Context.Generation
73	require.Contains(t, offlineSnapshotText(attached), "offline-ready")
74
75	// The driver service is usable before the attachment commits: capture works
76	// on the same JSONL connection while the presentation is not yet attached.
77	require.NotNil(t, run.capture(t, ready.Attachment))
78
79	// The CLI intent became a real broker navigation: the driver created an
80	// ephemeral local session through the shared client, and the terminal
81	// published its committed initial frame.
82	// A typed batch reaches the session through the normal client input owner,
83	// and the committed publication boundary is observable.
84	require.Equal(t, ports.UIActionProcessed, run.text(t, ready.Attachment, generation, "printf 'DRIVER_%s' OK"))
85
86	run.EOF(t)
87}
88
89// TestUIDriverCreationRoutes pins the two decided creation routes against a real
90// broker, pool, daemonmux, and daemon: the omitted name creates one
91// automatically numbered ephemeral local session, and `--session NAME` creates
92// exactly that named one. Each run's own committed publication is the proof,
93// so the test never races the broker's own observation cadence.
94func TestUIDriverCreationRoutes(t *testing.T) {
95	fixture := startUIDriverBrokerFixture(t)
96
97	ephemeral := fixture.StartDriver(t)
98	ephemeralReady := ephemeral.Ready(t)
99	awaitLogicalStream(t, fixture.Streams())
100	ephemeralAttached := ephemeral.awaitAttachedSnapshot(t, ephemeralReady.Attachment, "", "offline-ready")
101	ephemeralName := ephemeralAttached.Context.Route.Target.SessionName
102	require.NotEmpty(t, ephemeralName, "an ephemeral creation commits an allocated session name")
103	require.NotEqual(t, "work", ephemeralName)
104
105	namedRun := fixture.StartNamedDriver(t, "work")
106	namedReady := namedRun.Ready(t)
107	awaitLogicalStream(t, fixture.Streams())
108	namedAttached := namedRun.awaitAttachedSnapshot(t, namedReady.Attachment, "work", "offline-ready")
109	require.Equal(t, "work", namedAttached.Context.Route.Target.SessionName)
110}
111
112// TestUIDriverNamedAttachLetsTheDaemonResolve pins the attach route
113// deterministically against the scripted broker: `attach work` opens exactly
114// one named admission, the daemon commits the identity, and the run dials no
115// daemon itself.
116func TestUIDriverNamedAttachLetsTheDaemonResolve(t *testing.T) {
117	snapshot := terminalCompositionSnapshot([]string{"work"}, nil)
118	navigation, resolver, err := terminalBrokerNavigation(protocol.IntentAttach, "work", "")
119	require.NoError(t, err)
120	require.Nil(t, resolver, "a local name needs no catalogue resolution")
121	require.Equal(t, client.InitialNavigationAttachNamed, navigation.Kind)
122
123	service := newTerminalCompositionService(snapshot)
124	run := startScriptedUIDriverRun(t, service, navigation, resolver)
125	require.NotNil(t, run)
126
127	opened := service.openedRequests()
128	require.Len(t, opened, 1, "attach work opens exactly one broker stream")
129	request := opened[0]
130	require.NoError(t, request.Validate())
131	require.Equal(t, ports.BrokerAdmissionAttachNamed, request.Admission)
132	require.True(t, request.Local)
133	require.Equal(t, "work", request.Name)
134	require.Equal(t, protocol.ExactSessionTarget{}, request.Target)
135}
136
137// TestUIDriverRefusedTargetStaysPickerAndKeepsServing pins the local-refusal
138// contract: a target carrying a lifecycle the broker has never committed is
139// refused, the driver stays usable in the picker, and it never attaches.
140func TestUIDriverRefusedTargetStaysPickerAndKeepsServing(t *testing.T) {
141	fixture := startUIDriverBrokerFixture(t)
142	run := fixture.StartRefusedTargetDriver(t)
143	ready := run.Ready(t)
144	require.NotEmpty(t, ready.Attachment)
145	require.Zero(t, ready.Generation)
146	// The refused target is never attached and the driver keeps answering while
147	// it stays in the picker.
148	require.Never(t, func() bool {
149		return run.presentationStatus(t, ready.Attachment) == ports.UIStatusAttached
150	}, 500*time.Millisecond, 20*time.Millisecond, "a refused target never attaches")
151	require.NotNil(t, run.capture(t, ready.Attachment))
152	run.EOF(t)
153}
154
155// TestUIDriverDetachReturnsToPickerWithoutRecreating pins that an ended
156// attachment returns the driver to the picker in the same run: the process stays
157// alive and the one-shot initial navigation is not re-armed into a second
158// attachment. A re-armed intent would return the driver to Attached on its own,
159// so staying in the picker is the observable proof.
160func TestUIDriverDetachReturnsToPickerWithoutRecreating(t *testing.T) {
161	fixture := startUIDriverBrokerFixture(t)
162	run := fixture.StartDriver(t)
163	ready := run.Ready(t)
164	awaitLogicalStream(t, fixture.Streams())
165	attached := run.awaitAttachedSnapshot(t, ready.Attachment, "", "offline-ready")
166	generation := attached.Context.Generation
167
168	// Ctrl+D ends the shell: the daemon settles the session and the driver's
169	// attachment ends, so the run returns to its picker presentation. The action
170	// itself may be attributed to the committed boundary or reported as an
171	// unknown outcome; either way the input is not replayed.
172	status := run.keys(t, ready.Attachment, generation, "Ctrl+D")
173	require.Contains(t, []ports.UIActionStatus{ports.UIActionProcessed, ports.UIActionOutcomeUnknown}, status)
174	require.Eventually(t, func() bool {
175		return run.presentationStatus(t, ready.Attachment) != ports.UIStatusAttached
176	}, brokerTestWait, 20*time.Millisecond, "an ended attachment returns the driver to the picker")
177
178	require.Never(t, func() bool {
179		return run.presentationStatus(t, ready.Attachment) == ports.UIStatusAttached
180	}, 750*time.Millisecond, 20*time.Millisecond, "a consumed intent is never re-armed")
181	run.EOF(t)
182}
183
184// TestUIDriverNavigationReusesTheSharedTerminalTranslation pins that the driver
185// intents are exactly the shared closed initial-navigation union: `--picker`
186// opens nothing, an omitted name creates an ephemeral local session, a local
187// name creates it, and a remote target stays a single-shot resolver over the
188// committed publication (never an invented identity). The driver keeps no
189// navigation vocabulary of its own.
190func TestUIDriverNavigationReusesTheSharedTerminalTranslation(t *testing.T) {
191	picker, resolver, err := uiDriverNavigation(uiDriverOptions{picker: true}, time.Time{})
192	require.NoError(t, err)
193	require.Nil(t, resolver)
194	require.Equal(t, client.InitialNavigation{}, picker)
195	require.NoError(t, picker.Validate(), "the picker intent is the safe zero value")
196
197	local, resolver, err := uiDriverNavigation(uiDriverOptions{}, time.Time{})
198	require.NoError(t, err)
199	require.Nil(t, resolver)
200	require.Equal(t, client.InitialNavigationCreateEphemeral, local.Kind)
201	require.True(t, local.Destination.Local)
202
203	named, resolver, err := uiDriverNavigation(uiDriverOptions{session: "work"}, time.Time{})
204	require.NoError(t, err)
205	require.Nil(t, resolver)
206	require.Equal(t, client.InitialNavigationCreateNamed, named.Kind)
207	require.Equal(t, "work", named.Name)
208	require.True(t, named.Destination.Local)
209
210	requestedAt := time.Unix(2000, 0)
211	unobserved := ports.BrokerSnapshot{
212		Epoch:    terminalCompositionEpoch,
213		Revision: 1,
214		Daemons: []ports.BrokerDaemonObservation{{
215			Endpoint:      "user@example.com",
216			DisplayOrigin: "user@example.com",
217			Registration:  terminalCompositionRegistration("user@example.com"),
218		}},
219	}
220	observedAt := func(at time.Time, sessions ...string) ports.BrokerSnapshot {
221		return ports.BrokerSnapshot{
222			Epoch:    terminalCompositionEpoch,
223			Revision: 1,
224			Daemons: []ports.BrokerDaemonObservation{{
225				Endpoint:       "user@example.com",
226				DisplayOrigin:  "user@example.com",
227				Registration:   terminalCompositionRegistration("user@example.com"),
228				InventoryKnown: true,
229				LastSuccess:    at,
230				Sessions:       terminalCompositionSessions(sessions),
231			}},
232		}
233	}
234	remoteSnapshot := func(sessions ...string) ports.BrokerSnapshot {
235		return observedAt(requestedAt.Add(time.Second), sessions...)
236	}
237	tests := []struct {
238		name     string
239		options  uiDriverOptions
240		snapshot ports.BrokerSnapshot
241		wantKind client.InitialNavigationKind
242		wantName string
243		wantErr  error
244	}{
245		{name: "remote named before first inventory waits", options: uiDriverOptions{remote: "user@example.com", session: "work"}, snapshot: unobserved, wantErr: client.ErrInitialNavigationNotObserved},
246		{name: "absence in an inventory older than the invocation waits", options: uiDriverOptions{remote: "user@example.com", session: "fresh"}, snapshot: observedAt(requestedAt.Add(-time.Second), "work"), wantErr: client.ErrInitialNavigationNotObserved},
247		{name: "presence in an older inventory attaches", options: uiDriverOptions{remote: "user@example.com", session: "work"}, snapshot: observedAt(requestedAt.Add(-time.Second), "work"), wantKind: client.InitialNavigationAttachExact, wantName: "work"},
248		{name: "remote ephemeral", options: uiDriverOptions{remote: "user@example.com"}, snapshot: remoteSnapshot("work"), wantKind: client.InitialNavigationCreateEphemeral},
249		{name: "remote named existing attaches exact lifecycle", options: uiDriverOptions{remote: "user@example.com", session: "work"}, snapshot: remoteSnapshot("other", "work"), wantKind: client.InitialNavigationAttachExact, wantName: "work"},
250		{name: "remote named missing creates", options: uiDriverOptions{remote: "user@example.com", session: "fresh"}, snapshot: remoteSnapshot("work"), wantKind: client.InitialNavigationCreateNamed, wantName: "fresh"},
251		{name: "remote named on empty inventory creates", options: uiDriverOptions{remote: "user@example.com", session: "fresh"}, snapshot: remoteSnapshot(), wantKind: client.InitialNavigationCreateNamed, wantName: "fresh"},
252	}
253	for _, tt := range tests {
254		t.Run(tt.name, func(t *testing.T) {
255			navigation, resolver, err := uiDriverNavigation(tt.options, requestedAt)
256			require.NoError(t, err)
257			require.Equal(t, client.InitialNavigation{}, navigation, "a remote target is never decided before the connection")
258			require.NotNil(t, resolver, "a remote target resolves against the committed publication")
259
260			resolved, resolveErr := resolver(tt.snapshot)
261			if tt.wantErr != nil {
262				require.ErrorIs(t, resolveErr, tt.wantErr)
263				var pending client.InitialNavigationNotObserved
264				require.ErrorAs(t, resolveErr, &pending)
265				require.Equal(t, "user@example.com", pending.Endpoint, "the wait names the endpoint to reconcile")
266				require.Equal(t, client.InitialNavigationCreateNamed, pending.Fallback.Kind, "an unobservable host still falls back to creation")
267				require.Equal(t, tt.options.session, pending.Fallback.Name)
268				require.NoError(t, pending.Fallback.Validate())
269				return
270			}
271			require.NoError(t, resolveErr)
272			require.NoError(t, resolved.Validate())
273			require.False(t, resolved.Destination.Local)
274			require.Equal(t, tt.snapshot.Daemons[0].Registration, resolved.Destination.Registration)
275			require.Equal(t, tt.wantKind, resolved.Kind)
276			switch tt.wantKind {
277			case client.InitialNavigationAttachExact:
278				want, ok := brokerObservationExactTarget(tt.snapshot.Daemons[0], tt.wantName)
279				require.True(t, ok)
280				require.Equal(t, want, resolved.Target, "an existing name attaches its exact lifecycle, never a same-name creation")
281			case client.InitialNavigationCreateNamed:
282				require.Equal(t, tt.wantName, resolved.Name)
283			}
284		})
285	}
286
287	// A target the committed publication does not carry is refused, never
288	// created implicitly.
289	_, resolver, err = uiDriverNavigation(uiDriverOptions{remote: "user@elsewhere.test"}, requestedAt)
290	require.NoError(t, err)
291	_, err = resolver(ports.BrokerSnapshot{Epoch: terminalCompositionEpoch, Revision: 1})
292	require.Error(t, err)
293}

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