1//go:build linux
2
3package app
4
5import (
6 "bytes"
7 "context"
8 "errors"
9 "io"
10 "log/slog"
11 "os"
12 "slices"
13 "sync"
14 "testing"
15 "time"
16
17 "github.com/stretchr/testify/mock"
18 "github.com/stretchr/testify/require"
19
20 "github.com/bnema/vev/internal/adapters/brokeripc"
21 "github.com/bnema/vev/internal/adapters/clock"
22 "github.com/bnema/vev/internal/domain"
23 "github.com/bnema/vev/internal/domain/terminalcap"
24 "github.com/bnema/vev/internal/ports"
25 portsmocks "github.com/bnema/vev/internal/ports/mocks"
26 "github.com/bnema/vev/internal/protocol"
27 "github.com/bnema/vev/internal/protocol/catalogue"
28 "github.com/bnema/vev/internal/usecase/client"
29)
30
31// Terminal composition tests (Plan 001 P7.4a).
32//
33// runAttach is the ordinary terminal entry point. These tests drive it through
34// the composition-root seams only: connectProductionClientBroker supplies one
35// scripted broker connection and terminalForAttach supplies a recording
36// terminal, so the whole client supervisor runs without a production broker
37// process, a daemon dialer, or a controlling terminal. Every assertion is about
38// the composition contract: which broker stream the parsed CLI target resolves
39// to, that a refused target opens nothing, and that an admitted attachment
40// commits a real session publication to the terminal.
41
42const (
43 terminalCompositionEpoch = ports.BrokerEpoch(41)
44 terminalCompositionGeneration = domain.RemoteGeneration(3)
45)
46
47func terminalCompositionPolicy() ports.BrokerPolicy {
48 return ports.BrokerPolicy{
49 ProtocolVersion: protocol.Version,
50 CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion,
51 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
52 Transport: "unix-mux",
53 Trust: "same-user",
54 Launch: "explicit",
55 Isolation: "per-user",
56 }
57}
58
59func terminalCompositionRegistration(endpoint string) domain.RemoteRegistration {
60 var incarnation [16]byte
61 for i := range incarnation {
62 incarnation[i] = byte(i + 1)
63 }
64 return domain.RemoteRegistration{Endpoint: endpoint, Incarnation: incarnation, Generation: terminalCompositionGeneration}
65}
66
67// terminalCompositionSnapshot publishes the local daemon plus the given remote
68// endpoints, each carrying exactly the sessions named.
69func terminalCompositionSnapshot(localSessions []string, remotes map[string][]string) ports.BrokerSnapshot {
70 policy := terminalCompositionPolicy()
71 observations := []ports.BrokerDaemonObservation{{
72 Local: true,
73 DisplayOrigin: "local",
74 Policy: policy,
75 Identity: "local-daemon",
76 Incarnation: ports.BrokerDaemonIncarnation{9},
77 ProtocolVersion: protocol.Version,
78 Availability: domain.RemoteAvailabilityReachable,
79 LastSuccess: time.Now(),
80 InventoryKnown: true,
81 Sessions: terminalCompositionSessions(localSessions),
82 }}
83 endpoints := make([]string, 0, len(remotes))
84 for endpoint := range remotes {
85 endpoints = append(endpoints, endpoint)
86 }
87 sortStrings(endpoints)
88 for _, endpoint := range endpoints {
89 observations = append(observations, ports.BrokerDaemonObservation{
90 Endpoint: endpoint,
91 DisplayOrigin: endpoint,
92 Policy: policy,
93 Registration: terminalCompositionRegistration(endpoint),
94 Identity: ports.BrokerDaemonIdentity("identity-" + endpoint),
95 Incarnation: ports.BrokerDaemonIncarnation{7},
96 ProtocolVersion: protocol.Version,
97 Availability: domain.RemoteAvailabilityReachable,
98 LastSuccess: time.Now(),
99 InventoryKnown: true,
100 Sessions: terminalCompositionSessions(remotes[endpoint]),
101 })
102 }
103 return ports.BrokerSnapshot{Epoch: terminalCompositionEpoch, Revision: 1, Daemons: observations}
104}
105
106func terminalCompositionSessions(names []string) []catalogue.RemoteCatalogSession {
107 sessions := make([]catalogue.RemoteCatalogSession, 0, len(names))
108 for i, name := range names {
109 var lifecycle domain.SessionLifecycleID
110 lifecycle[0] = byte(i + 3)
111 lifecycle[1] = 0x5a
112 sessions = append(sessions, catalogue.RemoteCatalogSession{
113 LifecycleID: lifecycle, Name: name, State: catalogue.RemoteCatalogSessionUp,
114 })
115 }
116 return sessions
117}
118
119func sortStrings(values []string) {
120 for i := 1; i < len(values); i++ {
121 for j := i; j > 0 && values[j] < values[j-1]; j-- {
122 values[j], values[j-1] = values[j-1], values[j]
123 }
124 }
125}
126
127// terminalCompositionService is the scripted broker service one terminal client
128// adopts. It records every opened stream request, serves one committed
129// publication, and answers each admitted attachment with a Welcome and one
130// initial full session frame so the supervisor reaches its attached
131// presentation.
132type terminalCompositionService struct {
133 mu sync.Mutex
134 snapshot ports.BrokerSnapshot
135 // daemonSessions, when set, is what the local daemon owns regardless of
136 // what the broker catalogue has observed.
137 daemonSessions []string
138 closed bool
139 next ports.BrokerStreamID
140 opened []ports.BrokerOpenStreamRequest
141 streams []*terminalCompositionStream
142 done chan struct{}
143 changed chan struct{}
144}
145
146func newTerminalCompositionService(snapshot ports.BrokerSnapshot) *terminalCompositionService {
147 return &terminalCompositionService{
148 snapshot: snapshot,
149 done: make(chan struct{}),
150 changed: make(chan struct{}, 1),
151 }
152}
153
154func (s *terminalCompositionService) ConnectionID() ports.BrokerConnectionID {
155 return ports.BrokerConnectionID{4}
156}
157
158func (s *terminalCompositionService) NextStreamID() (ports.BrokerStreamID, error) {
159 s.mu.Lock()
160 defer s.mu.Unlock()
161 if s.closed {
162 return 0, ports.BrokerAdmissionClosed
163 }
164 s.next++
165 return s.next, nil
166}
167
168func (s *terminalCompositionService) Done() <-chan struct{} { return s.done }
169func (s *terminalCompositionService) Err() error { return nil }
170
171func (s *terminalCompositionService) Snapshot() ports.BrokerSnapshot { return s.snapshot.Clone() }
172
173func (s *terminalCompositionService) Subscribe() (ports.BrokerSubscription, error) {
174 return terminalCompositionSubscription{changed: s.changed}, nil
175}
176
177func (s *terminalCompositionService) SubscribePreview(ports.BrokerPreviewRequest) (ports.BrokerPreviewSubscription, error) {
178 return nil, errors.New("terminal composition service does not support preview")
179}
180
181func (s *terminalCompositionService) OpenStream(_ context.Context, request ports.BrokerOpenStreamRequest) (ports.BrokerLogicalConnection, error) {
182 s.mu.Lock()
183 if s.closed {
184 s.mu.Unlock()
185 return nil, ports.BrokerAdmissionClosed
186 }
187 s.opened = append(s.opened, request)
188 sessions := terminalCompositionDaemonSessions(s.snapshot, request)
189 if request.Local && s.daemonSessions != nil {
190 sessions = terminalCompositionSessions(s.daemonSessions)
191 }
192 stream := newTerminalCompositionStream(request, sessions)
193 s.streams = append(s.streams, stream)
194 s.mu.Unlock()
195 return stream, nil
196}
197
198func (s *terminalCompositionService) CloseStream(ports.BrokerConnectionID, ports.BrokerStreamID) error {
199 return nil
200}
201
202func (s *terminalCompositionService) AddHost(context.Context, string, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
203 return domain.RemoteRegistration{}, nil
204}
205
206func (s *terminalCompositionService) RemoveHost(context.Context, domain.RemoteRegistration) (bool, error) {
207 return false, nil
208}
209
210func (s *terminalCompositionService) UpdateHostPolicy(context.Context, domain.RemoteRegistration, ports.BrokerPolicy) (domain.RemoteRegistration, error) {
211 return domain.RemoteRegistration{}, nil
212}
213
214func (s *terminalCompositionService) RequestReconcile(string) {}
215
216func (s *terminalCompositionService) Close() error {
217 s.mu.Lock()
218 defer s.mu.Unlock()
219 if !s.closed {
220 s.closed = true
221 close(s.done)
222 }
223 return nil
224}
225
226// openedRequests returns the attachment streams opened; the preflight's
227// control listings are reported by openedControls.
228func (s *terminalCompositionService) openedRequests() []ports.BrokerOpenStreamRequest {
229 return s.openedWithPurpose(ports.BrokerStreamAttachment)
230}
231
232func (s *terminalCompositionService) openedControls() []ports.BrokerOpenStreamRequest {
233 return s.openedWithPurpose(ports.BrokerStreamControl)
234}
235
236func (s *terminalCompositionService) openedWithPurpose(purpose ports.BrokerStreamPurpose) []ports.BrokerOpenStreamRequest {
237 s.mu.Lock()
238 defer s.mu.Unlock()
239 var out []ports.BrokerOpenStreamRequest
240 for _, request := range s.opened {
241 if request.Purpose == purpose {
242 out = append(out, request)
243 }
244 }
245 return out
246}
247
248// streamHello returns the Hello the supervisor sent on the single admitted
249// stream, proving the composition carried the CLI intent through to the typed
250// session handshake.
251func (s *terminalCompositionService) streamHello(t *testing.T) protocol.Hello {
252 t.Helper()
253 s.mu.Lock()
254 var streams []*terminalCompositionStream
255 for _, stream := range s.streams {
256 if stream.request.Purpose == ports.BrokerStreamAttachment {
257 streams = append(streams, stream)
258 }
259 }
260 s.mu.Unlock()
261 require.Len(t, streams, 1)
262 for _, message := range streams[0].sentMessages() {
263 if hello, ok := message.(protocol.Hello); ok {
264 return hello
265 }
266 }
267 t.Fatal("the composed client never sent a session Hello")
268 return protocol.Hello{}
269}
270
271type terminalCompositionSubscription struct{ changed chan struct{} }
272
273func (s terminalCompositionSubscription) Changed() <-chan struct{} { return s.changed }
274func (s terminalCompositionSubscription) Close() {}
275
276// terminalCompositionStream answers one admitted attachment with a Welcome and
277// one initial full session frame carrying real terminal bytes, so a test can
278// prove the committed publication reached the terminal.
279type terminalCompositionStream struct {
280 request ports.BrokerOpenStreamRequest
281
282 mu sync.Mutex
283 sent []protocol.ClientMessage
284 queue []protocol.ServerMessage
285 closed bool
286 done chan struct{}
287}
288
289// terminalCompositionDaemonSessions is the session list the scripted
290// destination daemon owns: the sessions its observation publishes.
291func terminalCompositionDaemonSessions(snapshot ports.BrokerSnapshot, request ports.BrokerOpenStreamRequest) []catalogue.RemoteCatalogSession {
292 for _, observation := range snapshot.Daemons {
293 if observation.Local == request.Local && (request.Local || observation.Endpoint == request.Endpoint) {
294 return observation.Sessions
295 }
296 }
297 return nil
298}
299
300func newTerminalCompositionStream(request ports.BrokerOpenStreamRequest, sessions []catalogue.RemoteCatalogSession) *terminalCompositionStream {
301 stream := &terminalCompositionStream{request: request, done: make(chan struct{})}
302 if request.Purpose == ports.BrokerStreamControl {
303 // The scripted daemon answers a List with its own sessions.
304 infos := make([]protocol.SessionInfo, 0, len(sessions))
305 for _, session := range sessions {
306 infos = append(infos, protocol.SessionInfo{Name: session.Name})
307 }
308 stream.queue = append(stream.queue, protocol.Sessions{Sessions: infos})
309 return stream
310 }
311 if request.Admission == ports.BrokerAdmissionAttachNamed && !slices.ContainsFunc(sessions, func(session catalogue.RemoteCatalogSession) bool { return session.Name == request.Name }) {
312 // Like the real daemon, an unknown name is refused, never created.
313 stream.queue = append(stream.queue, protocol.ErrorMsg{Code: protocol.ErrNoSuchSession, Text: "no such resumable session: " + request.Name})
314 return stream
315 }
316 target := terminalCompositionCommittedTarget(request)
317 stream.queue = append(stream.queue, protocol.Welcome{
318 SessionID: "sess", SessionName: request.Name,
319 CommittedIdentity: &protocol.CommittedRouteIdentity{Target: target},
320 })
321 stream.queue = append(stream.queue, protocol.Output{
322 Epoch: 1, Full: true, New: 1,
323 Size: domain.Size{Cols: 80, Rows: 24},
324 Context: &protocol.ViewContext{
325 Publication: 1,
326 Route: protocol.CommittedRouteIdentity{Target: target},
327 TabID: domain.TabStableID("tab-1"),
328 FocusedPaneID: domain.PaneStableID("pane-1"),
329 },
330 Data: []byte("terminal-composition-ready\r\n"),
331 })
332 return stream
333}
334
335// terminalCompositionCommittedTarget is the exact identity the scripted daemon
336// commits for one admitted request. An ephemeral creation has no requested name,
337// so the scripted daemon allocates one exactly as the real daemon does.
338func terminalCompositionCommittedTarget(request ports.BrokerOpenStreamRequest) protocol.ExactSessionTarget {
339 if request.Admission == ports.BrokerAdmissionExact {
340 return request.Target
341 }
342 name := request.Name
343 if name == "" {
344 name = "1"
345 }
346 var lifecycle domain.SessionLifecycleID
347 lifecycle[0] = 0x11
348 return protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: name}
349}
350
351func (s *terminalCompositionStream) SendClient(message protocol.ClientMessage) error {
352 s.mu.Lock()
353 defer s.mu.Unlock()
354 s.sent = append(s.sent, message)
355 return nil
356}
357
358func (s *terminalCompositionStream) ReceiveServer() (protocol.ServerMessage, error) {
359 s.mu.Lock()
360 if len(s.queue) != 0 {
361 message := s.queue[0]
362 s.queue = s.queue[1:]
363 s.mu.Unlock()
364 return message, nil
365 }
366 s.mu.Unlock()
367 <-s.done
368 return nil, io.EOF
369}
370
371func (s *terminalCompositionStream) Capabilities() protocol.ConnectionCapabilities {
372 return protocol.ConnectionCapabilities{}
373}
374
375func (s *terminalCompositionStream) LinkState() ports.LinkState { return ports.LinkStateConnected }
376func (s *terminalCompositionStream) LinkEvents() <-chan ports.LinkEvent {
377 return nil
378}
379
380func (s *terminalCompositionStream) Done() <-chan struct{} { return s.done }
381func (s *terminalCompositionStream) Err() error { return nil }
382
383func (s *terminalCompositionStream) Close() error {
384 s.mu.Lock()
385 defer s.mu.Unlock()
386 if !s.closed {
387 s.closed = true
388 close(s.done)
389 }
390 return nil
391}
392
393func (s *terminalCompositionStream) sentMessages() []protocol.ClientMessage {
394 s.mu.Lock()
395 defer s.mu.Unlock()
396 return append([]protocol.ClientMessage(nil), s.sent...)
397}
398
399// terminalCompositionTerminal is the terminal double: it records written bytes,
400// flushes, raw-mode entries/restores, and every UI publication so a test can
401// prove a committed attachment published a real frame.
402type terminalCompositionTerminal struct {
403 mu sync.Mutex
404 out []byte
405 flushes int
406 restores int
407 statuses []ports.UIPresentationStatus
408 changes chan struct{}
409 input *terminalCompositionInput
410}
411
412func newTerminalCompositionTerminal() *terminalCompositionTerminal {
413 return &terminalCompositionTerminal{changes: make(chan struct{}), input: newTerminalCompositionInput()}
414}
415
416// terminalCompositionInput is the supervisor's single terminal reader. It
417// yields bytes only when a test pushes them and reaches EOF when it closes, so
418// a client with no pushed input stays live until the test ends the run.
419type terminalCompositionInput struct {
420 chunks chan []byte
421}
422
423func newTerminalCompositionInput() *terminalCompositionInput {
424 return &terminalCompositionInput{chunks: make(chan []byte)}
425}
426
427func (i *terminalCompositionInput) Read(buffer []byte) (int, error) {
428 chunk, ok := <-i.chunks
429 if !ok {
430 return 0, io.EOF
431 }
432 return copy(buffer, chunk), nil
433}
434
435func (t *terminalCompositionTerminal) EnableKittyKeyboard(int) error { return nil }
436
437func (t *terminalCompositionTerminal) EnterRaw() (func() error, error) {
438 return func() error {
439 t.mu.Lock()
440 t.restores++
441 t.mu.Unlock()
442 return nil
443 }, nil
444}
445
446func (t *terminalCompositionTerminal) Geometry() (domain.Geometry, error) {
447 return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil
448}
449
450func (t *terminalCompositionTerminal) ResizeEvents() <-chan domain.Geometry { return nil }
451
452// In blocks until the run ends: the supervisor owns exactly one terminal read
453// lifetime, and an immediately-empty reader would look like a terminal EOF and
454// end an otherwise live client.
455func (t *terminalCompositionTerminal) In() io.Reader { return t.input }
456
457func (t *terminalCompositionTerminal) Out() io.Writer { return t }
458
459func (t *terminalCompositionTerminal) Write(data []byte) (int, error) {
460 t.mu.Lock()
461 defer t.mu.Unlock()
462 t.out = append(t.out, data...)
463 return len(data), nil
464}
465
466func (t *terminalCompositionTerminal) Flush() error {
467 t.mu.Lock()
468 defer t.mu.Unlock()
469 t.flushes++
470 return nil
471}
472
473func (t *terminalCompositionTerminal) BeginOutput(ports.UIContext) {}
474func (t *terminalCompositionTerminal) EndOutput(bool) {}
475
476func (t *terminalCompositionTerminal) PublishContext(context ports.UIContext) error {
477 t.mu.Lock()
478 defer t.mu.Unlock()
479 t.statuses = append(t.statuses, context.Status)
480 return nil
481}
482
483func (t *terminalCompositionTerminal) written() string {
484 t.mu.Lock()
485 defer t.mu.Unlock()
486 return string(t.out)
487}
488
489func (t *terminalCompositionTerminal) publishes() []ports.UIPresentationStatus {
490 t.mu.Lock()
491 defer t.mu.Unlock()
492 return append([]ports.UIPresentationStatus(nil), t.statuses...)
493}
494
495// startTerminalComposition drives the real runAttach entry point over one
496// scripted broker service and recording terminal. It substitutes only the two
497// composition seams (connect-or-spawn and the terminal) plus the callback seam,
498// so every assertion holds for the production terminal path itself and not for
499// a parallel test harness.
500type terminalCompositionRun struct {
501 service *terminalCompositionService
502 terminal *terminalCompositionTerminal
503 states chan client.State
504 notices chan client.LifecycleNotice
505 failures chan error
506 finished chan struct{}
507 runMu sync.Mutex
508 runErr error
509 cancel context.CancelFunc
510}
511
512// wait returns the run's terminal error. It is idempotent: every caller waits on
513// the same closed channel, so a failure observed by one helper never consumes the
514// result another helper needs.
515func (r *terminalCompositionRun) wait(t *testing.T) error {
516 t.Helper()
517 select {
518 case <-r.finished:
519 case <-time.After(brokerTestWait):
520 t.Fatal("terminal client did not stop")
521 }
522 r.runMu.Lock()
523 defer r.runMu.Unlock()
524 return r.runErr
525}
526
527func startTerminalComposition(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot) *terminalCompositionRun {
528 t.Helper()
529 return startTerminalCompositionWithDaemon(t, intent, name, remoteTarget, snapshot, nil)
530}
531
532// startTerminalCompositionWithDaemon is startTerminalComposition whose local
533// daemon owns daemonSessions even when the catalogue has not observed them.
534func startTerminalCompositionWithDaemon(t *testing.T, intent uint8, name, remoteTarget string, snapshot ports.BrokerSnapshot, daemonSessions []string) *terminalCompositionRun {
535 t.Helper()
536 // The ordinary terminal path reads the nested-session and remote-transport
537 // environment; a test drives the broker composition with both unset.
538 t.Setenv("VEV", "")
539 t.Setenv(envRemoteTransport, "")
540 service := newTerminalCompositionService(snapshot)
541 service.daemonSessions = daemonSessions
542 terminal := newTerminalCompositionTerminal()
543 previousConnect := connectProductionClientBroker
544 previousTerminal := terminalForAttach
545 previousCallbacks := terminalBrokerCallbacks
546 connectProductionClientBroker = func(context.Context) (ports.BrokerService, error) {
547 return service, nil
548 }
549 terminalForAttach = func() ports.Terminal { return terminal }
550
551 run := &terminalCompositionRun{
552 service: service,
553 terminal: terminal,
554 states: make(chan client.State, 64),
555 notices: make(chan client.LifecycleNotice, 16),
556 failures: make(chan error, 16),
557 finished: make(chan struct{}),
558 }
559 terminalBrokerCallbacks = func() terminalBrokerSeams {
560 return terminalBrokerSeams{
561 OnState: func(state client.State) {
562 select {
563 case run.states <- state:
564 default:
565 }
566 },
567 OnLifecycle: func(notice client.LifecycleNotice) {
568 select {
569 case run.notices <- notice:
570 default:
571 }
572 },
573 OnFailure: func(err error) {
574 select {
575 case run.failures <- err:
576 default:
577 }
578 },
579 }
580 }
581 t.Cleanup(func() {
582 connectProductionClientBroker = previousConnect
583 terminalForAttach = previousTerminal
584 terminalBrokerCallbacks = previousCallbacks
585 })
586
587 ctx, cancel := context.WithCancel(context.Background())
588 run.cancel = cancel
589 go func() {
590 err := runAttach(ctx, intent, name, remoteTarget)
591 run.runMu.Lock()
592 run.runErr = err
593 run.runMu.Unlock()
594 close(run.finished)
595 }()
596 t.Cleanup(func() {
597 cancel()
598 select {
599 case <-run.finished:
600 case <-time.After(brokerTestWait):
601 t.Error("terminal client did not stop")
602 }
603 })
604 return run
605}
606
607// awaitTerminalCompositionAttachment waits for the supervisor to present an
608// attached state and returns it.
609func awaitTerminalCompositionAttachment(t *testing.T, run *terminalCompositionRun) client.State {
610 t.Helper()
611 deadline := time.NewTimer(brokerTestWait)
612 defer deadline.Stop()
613 for {
614 select {
615 case state := <-run.states:
616 if state.Presentation == client.PresentAttached {
617 return state
618 }
619 case <-run.finished:
620 t.Fatalf("terminal client ended before attaching: %v", run.wait(t))
621 case <-deadline.C:
622 t.Fatal("terminal client never attached")
623 }
624 }
625}
626
627// awaitTerminalCompositionNotice waits for one bounded lifecycle notice.
628func awaitTerminalCompositionNotice(t *testing.T, run *terminalCompositionRun) client.LifecycleNotice {
629 t.Helper()
630 select {
631 case notice := <-run.notices:
632 return notice
633 case <-time.After(brokerTestWait):
634 t.Fatal("terminal client reported no lifecycle notice")
635 return client.LifecycleNotice{}
636 }
637}
638
639func TestTerminalNavigationTranslationClosesTheIntent(t *testing.T) {
640 tests := []struct {
641 name string
642 intent uint8
643 session string
644 remote string
645 wantKind client.InitialNavigationKind
646 wantLocal bool
647 wantName string
648 wantResolver bool
649 wantError bool
650 }{
651 {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, wantKind: client.InitialNavigationCreateEphemeral, wantLocal: true},
652 {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", wantKind: client.InitialNavigationCreateNamed, wantLocal: true, wantName: "scratch"},
653 {name: "attach names a local session for the daemon to resolve", intent: protocol.IntentAttach, session: "work", wantKind: client.InitialNavigationAttachNamed, wantLocal: true, wantName: "work"},
654 {name: "remote attach resolves the remote registration", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", wantResolver: true},
655 {name: "remote new resolves a named remote creation", intent: protocol.IntentNew, session: "work", remote: "user@example.test", wantResolver: true},
656 {name: "remote ephemeral attach resolves a remote creation", intent: protocol.IntentEphemeral, remote: "user@example.test", wantResolver: true},
657 {name: "invalid local name is refused", intent: protocol.IntentNew, session: "bad name", wantError: true},
658 {name: "invalid remote target is refused", intent: protocol.IntentAttach, session: "work", remote: "bad target", wantError: true},
659 }
660 for _, tc := range tests {
661 t.Run(tc.name, func(t *testing.T) {
662 navigation, resolver, err := terminalBrokerNavigation(tc.intent, tc.session, tc.remote)
663 if tc.wantError {
664 require.Error(t, err)
665 require.Nil(t, resolver)
666 require.Equal(t, client.InitialNavigation{}, navigation)
667 return
668 }
669 require.NoError(t, err)
670 if tc.wantResolver {
671 require.NotNil(t, resolver)
672 require.Equal(t, client.InitialNavigation{}, navigation)
673 return
674 }
675 require.Nil(t, resolver)
676 require.Equal(t, tc.wantKind, navigation.Kind)
677 require.Equal(t, tc.wantLocal, navigation.Destination.Local)
678 require.Equal(t, tc.wantName, navigation.Name)
679 require.NoError(t, navigation.Validate())
680 })
681 }
682}
683
684// TestTerminalCompositionOpensTheExactBrokerStream pins the ordinary terminal
685// entry point to the broker: each parsed CLI target resolves to exactly one
686// admission on one broker logical stream, and the composition dials no daemon.
687func TestTerminalCompositionOpensTheExactBrokerStream(t *testing.T) {
688 localSnapshot := func() ports.BrokerSnapshot {
689 return terminalCompositionSnapshot([]string{"work"}, nil)
690 }
691 remoteSnapshot := func() ports.BrokerSnapshot {
692 return terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}})
693 }
694 tests := []struct {
695 name string
696 intent uint8
697 session string
698 remote string
699 snapshot ports.BrokerSnapshot
700 daemonSessions []string
701 wantAdmission ports.BrokerStreamAdmission
702 wantLocal bool
703 wantName string
704 wantHello uint8
705 }{
706 {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateEphemeral, wantLocal: true, wantHello: protocol.IntentEphemeral},
707 {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateNamed, wantLocal: true, wantName: "scratch", wantHello: protocol.IntentNew},
708 {name: "attach names the local session", intent: protocol.IntentAttach, session: "work", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionAttachNamed, wantLocal: true, wantName: "work", wantHello: protocol.IntentAttach},
709 {name: "remote attach names the remote session", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", snapshot: remoteSnapshot(), wantAdmission: ports.BrokerAdmissionAttachNamed, wantName: "work", wantHello: protocol.IntentAttach},
710 {name: "attach reaches a session the catalogue has not observed yet", intent: protocol.IntentAttach, session: "work", snapshot: terminalCompositionUnobservedLocal(), daemonSessions: []string{"work"}, wantAdmission: ports.BrokerAdmissionAttachNamed, wantLocal: true, wantName: "work", wantHello: protocol.IntentAttach},
711 }
712 for _, tc := range tests {
713 t.Run(tc.name, func(t *testing.T) {
714 run := startTerminalCompositionWithDaemon(t, tc.intent, tc.session, tc.remote, tc.snapshot, tc.daemonSessions)
715 awaitTerminalCompositionAttachment(t, run)
716 opened := run.service.openedRequests()
717 require.Len(t, opened, 1, "one CLI target opens exactly one broker stream")
718 request := opened[0]
719 require.NoError(t, request.Validate())
720 require.Equal(t, terminalCompositionEpoch, request.Epoch)
721 require.Equal(t, ports.BrokerStreamAttachment, request.Purpose)
722 require.Equal(t, tc.wantAdmission, request.Admission)
723 require.Equal(t, tc.wantLocal, request.Local)
724 require.Equal(t, tc.wantName, request.Name)
725 require.Equal(t, ports.BrokerConnectionID{4}, request.Connection)
726 require.NotZero(t, request.Stream)
727 require.NotEmpty(t, request.Policy.Transport)
728 require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode)
729 if !tc.wantLocal {
730 require.Equal(t, "user@example.test", request.Endpoint)
731 require.Equal(t, terminalCompositionRegistration("user@example.test"), request.Registration)
732 }
733 if tc.wantAdmission == ports.BrokerAdmissionExact {
734 require.NoError(t, request.Target.Validate())
735 require.Equal(t, tc.session, request.Target.SessionName)
736 }
737 // The same intent reaches the typed session handshake, so the
738 // broker admission and the daemon Hello can never disagree.
739 hello := run.service.streamHello(t)
740 require.Equal(t, tc.wantHello, hello.Intent)
741 require.Equal(t, tc.session, hello.Name)
742 require.Equal(t, tc.remote != "", hello.Remote)
743 if tc.wantAdmission == ports.BrokerAdmissionExact {
744 require.NotNil(t, hello.ExactTarget)
745 require.Equal(t, request.Target, *hello.ExactTarget)
746 }
747 if tc.wantAdmission == ports.BrokerAdmissionAttachNamed {
748 require.Nil(t, hello.ExactTarget, "a named attach leaves identity to the daemon")
749 controls := run.service.openedControls()
750 require.Len(t, controls, 1, "the preflight asks the daemon once")
751 require.Equal(t, ports.BrokerDaemonStartIfNeeded, controls[0].StartMode, "the preflight may start a stopped daemon")
752 }
753 })
754 }
755}
756
757// terminalCompositionUnobservedLocal is the publication a freshly started
758// broker commits right after `kill --all`: the local daemon is known but its
759// sessions are not observed yet, while the daemon itself restores them.
760func terminalCompositionUnobservedLocal() ports.BrokerSnapshot {
761 snapshot := terminalCompositionSnapshot(nil, nil)
762 snapshot.Daemons[0].InventoryKnown = false
763 return snapshot
764}
765
766// TestTerminalCompositionPublishesRealSessionOutput proves an admitted
767// attachment is a committed publication: the daemon's initial full frame reached
768// the terminal through a committed UI output transaction, and the supervisor
769// presented attached only after that commit.
770func TestTerminalCompositionPublishesRealSessionOutput(t *testing.T) {
771 run := startTerminalComposition(t, protocol.IntentEphemeral, "", "", terminalCompositionSnapshot([]string{"work"}, nil))
772 awaitTerminalCompositionAttachment(t, run)
773 require.Contains(t, run.terminal.written(), "terminal-composition-ready")
774 // The initial publication is committed before the attached marker, so its
775 // recorded boundary status is the pre-attachment Connecting presentation.
776 require.Contains(t, run.terminal.publishes(), ports.UIStatusConnecting)
777 // The attached presentation is published just after the attached state, on
778 // the attachment goroutine, so wait for it instead of racing it.
779 require.Eventually(t, func() bool {
780 return slices.Contains(run.terminal.publishes(), ports.UIStatusAttached)
781 }, brokerTestWait, 5*time.Millisecond)
782 _, _, restores := run.terminalCounts()
783 require.Zero(t, restores)
784}
785
786func (r *terminalCompositionRun) terminalCounts() (int, int, int) {
787 r.terminal.mu.Lock()
788 defer r.terminal.mu.Unlock()
789 return len(r.terminal.out), r.terminal.flushes, r.terminal.restores
790}
791
792// TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker pins the
793// failure contract: a target the committed catalogue does not carry is a local
794// refusal. No stream is opened, the supervisor reports a bounded
795// selection-unavailable notice, and the process stays in the picker instead of
796// attaching a fallback or creating implicitly.
797func TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker(t *testing.T) {
798 tests := []struct {
799 name string
800 intent uint8
801 session string
802 remote string
803 snapshot ports.BrokerSnapshot
804 }{
805 {name: "local daemon absent", intent: protocol.IntentEphemeral, snapshot: ports.BrokerSnapshot{Epoch: terminalCompositionEpoch, Revision: 1}},
806 {name: "unconfigured remote host", intent: protocol.IntentAttach, session: "work", remote: "user@elsewhere.test", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)},
807 }
808 for _, tc := range tests {
809 t.Run(tc.name, func(t *testing.T) {
810 run := startTerminalComposition(t, tc.intent, tc.session, tc.remote, tc.snapshot)
811 require.Equal(t, client.LifecycleNoticeSelectionUnavailable, awaitTerminalCompositionNotice(t, run).Kind)
812 // A local refusal is surfaced once, with the bounded text the supervisor
813 // classifies; the resolver's own sentinel is deliberately not propagated
814 // past that taxonomy, so only the presence and the classification are
815 // asserted.
816 select {
817 case failure := <-run.failures:
818 require.Error(t, failure)
819 case <-time.After(brokerTestWait):
820 t.Fatal("the refused target reported no failure")
821 }
822 require.Empty(t, run.service.openedRequests(), "a refused target never opens a stream")
823 // The pending navigation presents Connecting, never the picker
824 // first; the refusal then returns to the picker and never attaches.
825 last := client.PresentConnecting
826 for {
827 select {
828 case state := <-run.states:
829 require.NotEqual(t, client.PresentAttached, state.Presentation, "a refusal never attaches")
830 last = state.Presentation
831 case <-time.After(100 * time.Millisecond):
832 require.Equal(t, client.PresentPicker, last, "a refusal ends on the picker")
833 return
834 }
835 }
836 })
837 }
838}
839
840// TestTerminalCompositionDaemonRefusesMissingName pins that existence is the
841// daemon's decision: a declined (non-interactive) create prompt keeps the
842// named attach, the daemon answers ErrNoSuchSession, and the client reports
843// that refusal without attaching or creating.
844func TestTerminalCompositionDaemonRefusesMissingName(t *testing.T) {
845 tests := []struct {
846 name string
847 remote string
848 snapshot ports.BrokerSnapshot
849 }{
850 {name: "local", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)},
851 {name: "remote", remote: "user@example.test", snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}})},
852 }
853 for _, tc := range tests {
854 t.Run(tc.name, func(t *testing.T) {
855 withAttachInteractiveConsole(t, false)
856 run := startTerminalComposition(t, protocol.IntentAttach, "gone", tc.remote, tc.snapshot)
857 require.Equal(t, client.LifecycleNoticeDestinationFailed, awaitTerminalCompositionNotice(t, run).Kind)
858 select {
859 case failure := <-run.failures:
860 var protocolErr *client.ProtocolError
861 require.ErrorAs(t, failure, &protocolErr)
862 require.Equal(t, protocol.ErrNoSuchSession, protocolErr.Code)
863 case <-time.After(brokerTestWait):
864 t.Fatal("the daemon refusal was not reported")
865 }
866 opened := run.service.openedRequests()
867 require.Len(t, opened, 1)
868 require.Equal(t, ports.BrokerAdmissionAttachNamed, opened[0].Admission, "a missing name is never created implicitly")
869 })
870 }
871}
872
873// TestTerminalCompositionClosesRawModeOnTerminalEnd pins that the shared
874// composition restores the terminal exactly once when the run ends, however the
875// attachment settled.
876func TestTerminalCompositionClosesRawModeOnTerminalEnd(t *testing.T) {
877 run := startTerminalComposition(t, protocol.IntentEphemeral, "", "", terminalCompositionSnapshot([]string{"work"}, nil))
878 awaitTerminalCompositionAttachment(t, run)
879 run.cancel()
880 require.NoError(t, ignoreContextCancellation(run.wait(t)))
881 _, _, restores := run.terminalCounts()
882 require.Equal(t, 1, restores, "raw mode is restored exactly once")
883}
884
885// TestTerminalCompositionCapturesSessionEnvironmentDefensively proves the test
886// terminal composition keeps its process snapshot defensive: it captures
887// env/Cwd, mutates the process seam after construction, and the supervisor
888// still creates without failure.
889func TestTerminalCompositionCapturesSessionEnvironmentDefensively(t *testing.T) {
890 t.Setenv("VEV_TEST_SESSION_ENV", "composition-value")
891 snapshot := terminalSessionEnvironment()
892 require.Equal(t, client.SessionEnvironmentLocalCLI, snapshot.Provenance)
893 require.NoError(t, snapshot.Validate())
894 require.Contains(t, snapshot.Env, "VEV_TEST_SESSION_ENV=composition-value")
895 // Mutate the process seam after capture: the snapshot must keep the
896 // captured value rather than aliasing live process state.
897 require.NoError(t, os.Setenv("VEV_TEST_SESSION_ENV", "mutated-after-capture"))
898 require.Contains(t, snapshot.Env, "VEV_TEST_SESSION_ENV=composition-value")
899 require.NotContains(t, snapshot.Env, "VEV_TEST_SESSION_ENV=mutated-after-capture")
900 clone := snapshot.Clone()
901 // Mutate the clone's slice after capture: the supervisor creation must
902 // not fail and the source slice stays intact, so the snapshot is a copy
903 // rather than a live alias of the process environment.
904 for i := range clone.Env {
905 clone.Env[i] = "MUTATED=x"
906 }
907 require.NotEqual(t, snapshot.Env, clone.Env)
908 servicesnapshot := terminalCompositionSnapshot([]string{"work"}, nil)
909 service := newTerminalCompositionService(servicesnapshot)
910 terminal := newTerminalCompositionTerminal()
911 navigation := localEphemeralNavigation()
912 connector := portsmocks.NewMockBrokerConnector(t)
913 connector.EXPECT().Connect(mock.Anything).Return(service, nil).Maybe()
914 _, err := client.NewSupervisor(client.SupervisorConfig{
915 Connector: connector,
916 Terminal: terminal,
917 Clock: clock.New(),
918 // The picker is omitted: creation alone proves the snapshot does not
919 // fail supervisor validation.
920 InitialNavigation: navigation,
921 SessionEnvironment: snapshot,
922 })
923 require.NoError(t, err)
924}
925
926// TestTerminalCompositionReportsBrokerFailureInThePicker pins that a broker the
927// composition cannot connect to (or start) is no longer a fatal startup error:
928// the run enters raw mode, stays in the picker, and reports the typed failure
929// without opening any stream. It remains live until the terminal ends.
930func TestTerminalCompositionReportsBrokerFailureInThePicker(t *testing.T) {
931 t.Setenv("VEV", "")
932 t.Setenv(envRemoteTransport, "")
933 startupErr := errors.New("broker not reachable")
934 previousConnect := connectProductionClientBroker
935 terminal := newTerminalCompositionTerminal()
936 previousTerminal := terminalForAttach
937 connectProductionClientBroker = func(context.Context) (ports.BrokerService, error) { return nil, startupErr }
938 terminalForAttach = func() ports.Terminal { return terminal }
939 t.Cleanup(func() {
940 connectProductionClientBroker = previousConnect
941 terminalForAttach = previousTerminal
942 })
943
944 ctx, cancel := context.WithCancel(context.Background())
945 done := make(chan error, 1)
946 go func() { done <- runAttach(ctx, protocol.IntentEphemeral, "", "") }()
947
948 // A broker failure is surfaced, not fatal, and no stream is opened.
949 require.Eventually(t, func() bool {
950 for _, status := range terminal.publishes() {
951 if status == ports.UIStatusConnecting || status == ports.UIStatusAttached {
952 return false
953 }
954 }
955 return true
956 }, 200*time.Millisecond, 10*time.Millisecond)
957 select {
958 case err := <-done:
959 t.Fatalf("the composition ended on a broker failure: %v", err)
960 case <-time.After(200 * time.Millisecond):
961 }
962 _, _, restores := terminalCounts(terminal)
963 require.Zero(t, restores, "the run is still live, so raw mode is not yet restored")
964
965 cancel()
966 require.NoError(t, ignoreContextCancellation(<-done))
967 _, _, restores = terminalCounts(terminal)
968 require.Equal(t, 1, restores, "the cancelled run restores raw mode exactly once")
969}
970
971// TestRunAttachNestedSessionBehavior pins the nested-session product contract of
972// the terminal path: an explicit `new <name>` inside an existing session still
973// creates the session without attaching, and every other intent is refused
974// without reaching the broker.
975func TestRunAttachNestedSessionBehavior(t *testing.T) {
976 t.Setenv("VEV", "outer")
977 t.Setenv(envRemoteTransport, "")
978 connectCalls := 0
979 previousConnect := connectProductionClientBroker
980 previousCreate := createDetachedTerminalSession
981 var createdName string
982 connectProductionClientBroker = func(context.Context) (ports.BrokerService, error) {
983 connectCalls++
984 return nil, errors.New("the broker must not be reached")
985 }
986 createDetachedTerminalSession = func(_ context.Context, name string) error {
987 createdName = name
988 return nil
989 }
990 t.Cleanup(func() {
991 connectProductionClientBroker = previousConnect
992 createDetachedTerminalSession = previousCreate
993 })
994
995 require.NoError(t, runAttach(context.Background(), protocol.IntentNew, "scratch", ""))
996 require.Equal(t, "scratch", createdName)
997
998 for _, intent := range []uint8{protocol.IntentEphemeral, protocol.IntentAttach} {
999 err := runAttach(context.Background(), intent, "scratch", "")
1000 require.ErrorContains(t, err, "sessions should be nested with care")
1001 }
1002 require.Zero(t, connectCalls, "a nested invocation never connects to the broker")
1003}
1004
1005func terminalCounts(terminal *terminalCompositionTerminal) (int, int, int) {
1006 terminal.mu.Lock()
1007 defer terminal.mu.Unlock()
1008 return len(terminal.out), terminal.flushes, terminal.restores
1009}
1010
1011// TestTerminalCompositionUsesTheBrokerConnector pins the production terminal
1012// path source: runAttach composes the shared broker client over the process'
1013// production connector and keeps no direct-dialer attach composition, attach
1014// preflight, or client host registry. The old pre-cutover symbols are removed
1015// outright, so this fails if any is reintroduced as a bypass.
1016func TestTerminalCompositionUsesTheBrokerConnector(t *testing.T) {
1017 sources := loadAppSources(t, false)
1018 body := sources["run.go"]
1019 require.Contains(t, body, "runBrokerClient(ctx, brokerClientConfig{")
1020 require.Contains(t, body, "newProductionBrokerConnector()")
1021 for _, forbidden := range []string{
1022 "runAttachWithDeps", "runAttachDeps", "localDaemonDialer", "dialOnlyLocalDialer",
1023 "defaultLocalDialer", "resolveMissingSessionAttach", "newClientHostRegistry",
1024 "listSessionsWithDialer", "clientEndpointFactory",
1025 } {
1026 require.NotContains(t, body, forbidden, "the terminal composition must not keep %q", forbidden)
1027 }
1028}
1029
1030// TestTerminalExactAttachAcceptedByRealDaemon is the end-to-end regression for
1031// the exact-attach wire shape: against the real broker and a real daemon, the
1032// Hello the client supervisor builds for an exact admission (Intent=attach,
1033// Name equal to the exact target's session name, ExactTarget set) is accepted
1034// and answered with a committed Welcome. It fails if the strict Hello contract
1035// and the worker disagree about the name.
1036func TestTerminalExactAttachAcceptedByRealDaemon(t *testing.T) {
1037 fixture := startOfflineClientFixture(t)
1038 ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait)
1039 defer cancel()
1040 service, err := brokeripc.NewConnector(fixture.socket, brokeripc.Config{}).Connect(ctx)
1041 require.NoError(t, err)
1042 defer service.Close()
1043 require.NotZero(t, awaitTerminalCompositionEpoch(t, service))
1044
1045 createID, err := service.NextStreamID()
1046 require.NoError(t, err)
1047 create, err := service.OpenStream(ctx, ports.BrokerOpenStreamRequest{
1048 Purpose: ports.BrokerStreamAttachment, Stream: createID, Local: true, Policy: fixture.policy,
1049 Admission: ports.BrokerAdmissionCreateNamed, Name: "attach-exact", StartMode: ports.BrokerDaemonStartIfNeeded,
1050 })
1051 require.NoError(t, err)
1052 require.NoError(t, create.SendClient(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "attach-exact", Size: domain.Size{Cols: 80, Rows: 24}}))
1053 target := receiveOfflineWelcome(t, create).CommittedIdentity.Target
1054 require.NoError(t, create.Close())
1055
1056 exactID, err := service.NextStreamID()
1057 require.NoError(t, err)
1058 exact, err := service.OpenStream(ctx, ports.BrokerOpenStreamRequest{
1059 Purpose: ports.BrokerStreamAttachment, Stream: exactID, Local: true, Policy: fixture.policy,
1060 Admission: ports.BrokerAdmissionExact, Target: target, StartMode: ports.BrokerDaemonStartIfNeeded,
1061 })
1062 require.NoError(t, err)
1063 defer exact.Close()
1064
1065 // The exact Hello shape the client supervisor's worker builds for this
1066 // request; it must satisfy the protocol and be accepted by the daemon.
1067 hello := protocol.Hello{
1068 Version: protocol.Version, Intent: protocol.IntentAttach, Name: target.SessionName,
1069 ExactTarget: &target, Size: domain.Size{Cols: 80, Rows: 24},
1070 }
1071 require.NoError(t, protocol.ValidateHello(hello))
1072 require.NoError(t, exact.SendClient(hello))
1073 require.Equal(t, "attach-exact", receiveOfflineWelcome(t, exact).SessionName)
1074}
1075
1076// TestTerminalCompositionExactAttachReportsMissingLocalInventory documents the
1077// composition's behavior when the committed broker publication carries no local
1078// session inventory: an exact attach is refused locally as a bounded selection
1079// notice, opens no stream, and never invents a creation or a same-name
1080// fallback. It pins the honest refusal so a future broker-side local catalogue
1081// is the only thing that can make this navigation succeed.
1082func TestTerminalCompositionExactAttachReportsMissingLocalInventory(t *testing.T) {
1083 fixture := startOfflineClientFixture(t)
1084 ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait)
1085 defer cancel()
1086
1087 terminal := newTerminalCompositionTerminal()
1088 notices := make(chan client.LifecycleNotice, 16)
1089 failures := make(chan error, 16)
1090 var lifecycleID domain.SessionLifecycleID
1091 lifecycleID[0], lifecycleID[1] = 3, 0x5a
1092 navigation := client.InitialNavigation{
1093 Kind: client.InitialNavigationAttachExact,
1094 Epoch: terminalCompositionEpoch,
1095 Destination: ports.BrokerEndpointFence{Local: true},
1096 Target: protocol.ExactSessionTarget{LifecycleID: lifecycleID, SessionName: "work"},
1097 }
1098 require.NoError(t, navigation.Validate())
1099
1100 done := make(chan error, 1)
1101 go func() {
1102 done <- runBrokerClient(ctx, brokerClientConfig{
1103 Connector: brokeripc.NewConnector(fixture.socket, brokeripc.Config{}).Navigator(),
1104 Terminal: terminal,
1105 InitialNavigation: navigation,
1106 SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI},
1107 OnFailure: func(err error) {
1108 select {
1109 case failures <- err:
1110 default:
1111 }
1112 },
1113 OnLifecycle: func(notice client.LifecycleNotice) {
1114 select {
1115 case notices <- notice:
1116 default:
1117 }
1118 },
1119 })
1120 }()
1121
1122 select {
1123 case notice := <-notices:
1124 require.Equal(t, client.LifecycleNoticeSelectionUnavailable, notice.Kind)
1125 case <-time.After(brokerTestWait):
1126 t.Fatal("the exact attach reported no lifecycle notice")
1127 }
1128 select {
1129 case failure := <-failures:
1130 require.Error(t, failure)
1131 case <-time.After(brokerTestWait):
1132 t.Fatal("the exact attach reported no failure")
1133 }
1134 // The refused navigation never writes a session frame and never attaches:
1135 // it presents Connecting while pending and ends on the picker.
1136 publishes := terminal.publishes()
1137 for _, status := range publishes {
1138 require.NotEqual(t, ports.UIStatusAttached, status, "a refused navigation never attaches")
1139 }
1140 require.NotEmpty(t, publishes)
1141 require.Equal(t, ports.UIStatusPicker, publishes[len(publishes)-1], "a refused navigation ends on the picker")
1142 cancel()
1143 require.NoError(t, ignoreContextCancellation(<-done))
1144}
1145
1146// awaitTerminalCompositionEpoch waits for one connection's first committed
1147// publication and returns its epoch.
1148func awaitTerminalCompositionEpoch(t *testing.T, service ports.BrokerService) ports.BrokerEpoch {
1149 t.Helper()
1150 sub, err := service.Subscribe()
1151 require.NoError(t, err)
1152 defer sub.Close()
1153 deadline := time.NewTimer(brokerTestWait)
1154 defer deadline.Stop()
1155 for {
1156 if snapshot := service.Snapshot(); snapshot.Epoch != 0 {
1157 return snapshot.Epoch
1158 }
1159 select {
1160 case <-sub.Changed():
1161 case <-service.Done():
1162 t.Fatalf("broker connection ended before publishing a snapshot: %v", service.Err())
1163 case <-deadline.C:
1164 t.Fatal("broker never published a snapshot")
1165 }
1166 }
1167}
1168
1169func TestDetectTerminalColor(t *testing.T) {
1170 tests := []struct {
1171 name string
1172 term string
1173 colorTerm string
1174 env []string
1175 want terminalcap.ColorCapabilities
1176 }{
1177 {name: "kitty environment keeps heuristic truecolor", term: "xterm-kitty", env: []string{"KITTY_WINDOW_ID=1"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceHeuristic}},
1178 {name: "explicit COLORTERM overrides env", term: "xterm-256color", colorTerm: "truecolor", env: []string{"COLORTERM="}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}},
1179 {name: "explicit TERM overrides env", term: "xterm-256color", env: []string{"TERM=foot-direct"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}},
1180 {name: "unknown terminal is indexed", term: "unknown", want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}},
1181 }
1182 for _, tt := range tests {
1183 t.Run(tt.name, func(t *testing.T) {
1184 require.Equal(t, tt.want, detectTerminalColor(tt.term, tt.colorTerm, tt.env))
1185 })
1186 }
1187}
1188
1189func TestResolveColorOverride(t *testing.T) {
1190 detected := terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}
1191 forced := func(mode terminalcap.ColorMode) terminalcap.ColorCapabilities {
1192 return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced}
1193 }
1194 tests := []struct {
1195 name string
1196 env string
1197 cfg domain.TerminalConfig
1198 want terminalcap.ColorCapabilities
1199 wantWarn bool
1200 }{
1201 {name: "nothing set uses detection", want: detected},
1202 {name: "config auto uses detection", cfg: domain.TerminalConfig{}, want: detected},
1203 {name: "config forces mode", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16)},
1204 {name: "config forces truecolor", cfg: domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, want: forced(terminalcap.TrueColor)},
1205 {name: "env forces mode", env: "mono", want: forced(terminalcap.Monochrome)},
1206 {name: "env is case-insensitive and trimmed", env: " TrueColor ", want: forced(terminalcap.TrueColor)},
1207 {name: "env wins over config", env: "256", cfg: domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, want: forced(terminalcap.ANSI256)},
1208 {name: "env auto overrides config with detection", env: "auto", cfg: domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, want: detected},
1209 {name: "blank env falls through to config", env: " ", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16)},
1210 {name: "invalid env falls back to config with a warning", env: "rainbow", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16), wantWarn: true},
1211 {name: "invalid env falls back to detection with a warning", env: "24bit", want: detected, wantWarn: true},
1212 }
1213 for _, tt := range tests {
1214 t.Run(tt.name, func(t *testing.T) {
1215 var logs bytes.Buffer
1216 log := slog.New(slog.NewTextHandler(&logs, nil))
1217 require.Equal(t, tt.want, resolveColorOverride(detected, tt.env, tt.cfg, log))
1218 if tt.wantWarn {
1219 require.Contains(t, logs.String(), "VEV_COLORS")
1220 } else {
1221 require.Empty(t, logs.String())
1222 }
1223 })
1224 }
1225 t.Run("nil logger tolerates invalid env", func(t *testing.T) {
1226 require.Equal(t, detected, resolveColorOverride(detected, "rainbow", domain.TerminalConfig{}, nil))
1227 })
1228}
1229
1230func TestTerminalAttachmentEnvironmentColor(t *testing.T) {
1231 forced := func(mode terminalcap.ColorMode) terminalcap.ColorCapabilities {
1232 return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced}
1233 }
1234 tests := []struct {
1235 name string
1236 term string
1237 colors string
1238 cfg domain.TerminalConfig
1239 wantUser terminalcap.ColorCapabilities
1240 }{
1241 {name: "dumb process terminal", term: "dumb", wantUser: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}},
1242 {name: "linux console", term: "linux", wantUser: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}},
1243 {name: "env overrides process terminal", term: "dumb", colors: "truecolor", wantUser: forced(terminalcap.TrueColor)},
1244 {name: "config overrides process terminal", term: "xterm-256color", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, wantUser: forced(terminalcap.ANSI16)},
1245 }
1246 for _, tt := range tests {
1247 t.Run(tt.name, func(t *testing.T) {
1248 t.Setenv("TERM", tt.term)
1249 t.Setenv("COLORTERM", "")
1250 t.Setenv("KITTY_WINDOW_ID", "")
1251 t.Setenv("KITTY_PID", "")
1252 t.Setenv("KITTY_LISTEN_ON", "")
1253 t.Setenv(envColors, tt.colors)
1254
1255 require.Equal(t, tt.wantUser, userTerminalAttachmentEnvironment(nil, tt.cfg).Color)
1256 // Web and headless UI-driver clients render into RGB VT mirrors and
1257 // ignore both the gateway process terminal and the user override.
1258 virtual := virtualTerminalAttachmentEnvironment()
1259 require.Equal(t, forced(terminalcap.TrueColor), virtual.Color)
1260 require.False(t, virtual.ProbeTerminal)
1261 })
1262 }
1263}