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}