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 environment; a test
537 // drives the broker composition with it unset.
538 t.Setenv("VEV", "")
539 service := newTerminalCompositionService(snapshot)
540 service.daemonSessions = daemonSessions
541 terminal := newTerminalCompositionTerminal()
542 previousConnect := connectProductionClientBroker
543 previousTerminal := terminalForAttach
544 previousCallbacks := terminalBrokerCallbacks
545 connectProductionClientBroker = func(context.Context) (ports.BrokerService, error) {
546 return service, nil
547 }
548 terminalForAttach = func() ports.Terminal { return terminal }
549
550 run := &terminalCompositionRun{
551 service: service,
552 terminal: terminal,
553 states: make(chan client.State, 64),
554 notices: make(chan client.LifecycleNotice, 16),
555 failures: make(chan error, 16),
556 finished: make(chan struct{}),
557 }
558 terminalBrokerCallbacks = func() terminalBrokerSeams {
559 return terminalBrokerSeams{
560 OnState: func(state client.State) {
561 select {
562 case run.states <- state:
563 default:
564 }
565 },
566 OnLifecycle: func(notice client.LifecycleNotice) {
567 select {
568 case run.notices <- notice:
569 default:
570 }
571 },
572 OnFailure: func(err error) {
573 select {
574 case run.failures <- err:
575 default:
576 }
577 },
578 }
579 }
580 t.Cleanup(func() {
581 connectProductionClientBroker = previousConnect
582 terminalForAttach = previousTerminal
583 terminalBrokerCallbacks = previousCallbacks
584 })
585
586 ctx, cancel := context.WithCancel(context.Background())
587 run.cancel = cancel
588 go func() {
589 err := runAttach(ctx, intent, name, remoteTarget)
590 run.runMu.Lock()
591 run.runErr = err
592 run.runMu.Unlock()
593 close(run.finished)
594 }()
595 t.Cleanup(func() {
596 cancel()
597 select {
598 case <-run.finished:
599 case <-time.After(brokerTestWait):
600 t.Error("terminal client did not stop")
601 }
602 })
603 return run
604}
605
606// awaitTerminalCompositionAttachment waits for the supervisor to present an
607// attached state and returns it.
608func awaitTerminalCompositionAttachment(t *testing.T, run *terminalCompositionRun) client.State {
609 t.Helper()
610 deadline := time.NewTimer(brokerTestWait)
611 defer deadline.Stop()
612 for {
613 select {
614 case state := <-run.states:
615 if state.Presentation == client.PresentAttached {
616 return state
617 }
618 case <-run.finished:
619 t.Fatalf("terminal client ended before attaching: %v", run.wait(t))
620 case <-deadline.C:
621 t.Fatal("terminal client never attached")
622 }
623 }
624}
625
626// awaitTerminalCompositionNotice waits for one bounded lifecycle notice.
627func awaitTerminalCompositionNotice(t *testing.T, run *terminalCompositionRun) client.LifecycleNotice {
628 t.Helper()
629 select {
630 case notice := <-run.notices:
631 return notice
632 case <-time.After(brokerTestWait):
633 t.Fatal("terminal client reported no lifecycle notice")
634 return client.LifecycleNotice{}
635 }
636}
637
638func TestTerminalNavigationTranslationClosesTheIntent(t *testing.T) {
639 tests := []struct {
640 name string
641 intent uint8
642 session string
643 remote string
644 wantKind client.InitialNavigationKind
645 wantLocal bool
646 wantName string
647 wantResolver bool
648 wantError bool
649 }{
650 {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, wantKind: client.InitialNavigationCreateEphemeral, wantLocal: true},
651 {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", wantKind: client.InitialNavigationCreateNamed, wantLocal: true, wantName: "scratch"},
652 {name: "attach names a local session for the daemon to resolve", intent: protocol.IntentAttach, session: "work", wantKind: client.InitialNavigationAttachNamed, wantLocal: true, wantName: "work"},
653 {name: "remote attach resolves the remote registration", intent: protocol.IntentAttach, session: "work", remote: "user@example.test", wantResolver: true},
654 {name: "remote new resolves a named remote creation", intent: protocol.IntentNew, session: "work", remote: "user@example.test", wantResolver: true},
655 {name: "remote ephemeral attach resolves a remote creation", intent: protocol.IntentEphemeral, remote: "user@example.test", wantResolver: true},
656 {name: "invalid local name is refused", intent: protocol.IntentNew, session: "bad name", wantError: true},
657 {name: "invalid remote target is refused", intent: protocol.IntentAttach, session: "work", remote: "bad target", wantError: true},
658 }
659 for _, tc := range tests {
660 t.Run(tc.name, func(t *testing.T) {
661 navigation, resolver, err := terminalBrokerNavigation(tc.intent, tc.session, tc.remote)
662 if tc.wantError {
663 require.Error(t, err)
664 require.Nil(t, resolver)
665 require.Equal(t, client.InitialNavigation{}, navigation)
666 return
667 }
668 require.NoError(t, err)
669 if tc.wantResolver {
670 require.NotNil(t, resolver)
671 require.Equal(t, client.InitialNavigation{}, navigation)
672 return
673 }
674 require.Nil(t, resolver)
675 require.Equal(t, tc.wantKind, navigation.Kind)
676 require.Equal(t, tc.wantLocal, navigation.Destination.Local)
677 require.Equal(t, tc.wantName, navigation.Name)
678 require.NoError(t, navigation.Validate())
679 })
680 }
681}
682
683// TestTerminalCompositionOpensTheExactBrokerStream pins the ordinary terminal
684// entry point to the broker: each parsed CLI target resolves to exactly one
685// admission on one broker logical stream, and the composition dials no daemon.
686func TestTerminalCompositionOpensTheExactBrokerStream(t *testing.T) {
687 localSnapshot := func() ports.BrokerSnapshot {
688 return terminalCompositionSnapshot([]string{"work"}, nil)
689 }
690 remoteSnapshot := func() ports.BrokerSnapshot {
691 return terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}})
692 }
693 tests := []struct {
694 name string
695 intent uint8
696 session string
697 remote string
698 snapshot ports.BrokerSnapshot
699 daemonSessions []string
700 wantAdmission ports.BrokerStreamAdmission
701 wantLocal bool
702 wantName string
703 wantHello uint8
704 }{
705 {name: "no arguments creates a local ephemeral session", intent: protocol.IntentEphemeral, snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateEphemeral, wantLocal: true, wantHello: protocol.IntentEphemeral},
706 {name: "new creates a local named session", intent: protocol.IntentNew, session: "scratch", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionCreateNamed, wantLocal: true, wantName: "scratch", wantHello: protocol.IntentNew},
707 {name: "attach names the local session", intent: protocol.IntentAttach, session: "work", snapshot: localSnapshot(), wantAdmission: ports.BrokerAdmissionAttachNamed, wantLocal: true, wantName: "work", wantHello: protocol.IntentAttach},
708 {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},
709 {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},
710 }
711 for _, tc := range tests {
712 t.Run(tc.name, func(t *testing.T) {
713 run := startTerminalCompositionWithDaemon(t, tc.intent, tc.session, tc.remote, tc.snapshot, tc.daemonSessions)
714 awaitTerminalCompositionAttachment(t, run)
715 opened := run.service.openedRequests()
716 require.Len(t, opened, 1, "one CLI target opens exactly one broker stream")
717 request := opened[0]
718 require.NoError(t, request.Validate())
719 require.Equal(t, terminalCompositionEpoch, request.Epoch)
720 require.Equal(t, ports.BrokerStreamAttachment, request.Purpose)
721 require.Equal(t, tc.wantAdmission, request.Admission)
722 require.Equal(t, tc.wantLocal, request.Local)
723 require.Equal(t, tc.wantName, request.Name)
724 require.Equal(t, ports.BrokerConnectionID{4}, request.Connection)
725 require.NotZero(t, request.Stream)
726 require.NotEmpty(t, request.Policy.Transport)
727 require.Equal(t, ports.BrokerDaemonStartIfNeeded, request.StartMode)
728 if !tc.wantLocal {
729 require.Equal(t, "user@example.test", request.Endpoint)
730 require.Equal(t, terminalCompositionRegistration("user@example.test"), request.Registration)
731 }
732 if tc.wantAdmission == ports.BrokerAdmissionExact {
733 require.NoError(t, request.Target.Validate())
734 require.Equal(t, tc.session, request.Target.SessionName)
735 }
736 // The same intent reaches the typed session handshake, so the
737 // broker admission and the daemon Hello can never disagree.
738 hello := run.service.streamHello(t)
739 require.Equal(t, tc.wantHello, hello.Intent)
740 require.Equal(t, tc.session, hello.Name)
741 require.Equal(t, tc.remote != "", hello.Remote)
742 if tc.wantAdmission == ports.BrokerAdmissionExact {
743 require.NotNil(t, hello.ExactTarget)
744 require.Equal(t, request.Target, *hello.ExactTarget)
745 }
746 if tc.wantAdmission == ports.BrokerAdmissionAttachNamed {
747 require.Nil(t, hello.ExactTarget, "a named attach leaves identity to the daemon")
748 controls := run.service.openedControls()
749 require.Len(t, controls, 1, "the preflight asks the daemon once")
750 require.Equal(t, ports.BrokerDaemonStartIfNeeded, controls[0].StartMode, "the preflight may start a stopped daemon")
751 }
752 })
753 }
754}
755
756// terminalCompositionUnobservedLocal is the publication a freshly started
757// broker commits right after `kill --all`: the local daemon is known but its
758// sessions are not observed yet, while the daemon itself restores them.
759func terminalCompositionUnobservedLocal() ports.BrokerSnapshot {
760 snapshot := terminalCompositionSnapshot(nil, nil)
761 snapshot.Daemons[0].InventoryKnown = false
762 return snapshot
763}
764
765// TestTerminalCompositionPublishesRealSessionOutput proves an admitted
766// attachment is a committed publication: the daemon's initial full frame reached
767// the terminal through a committed UI output transaction, and the supervisor
768// presented attached only after that commit.
769func TestTerminalCompositionPublishesRealSessionOutput(t *testing.T) {
770 run := startTerminalComposition(t, protocol.IntentEphemeral, "", "", terminalCompositionSnapshot([]string{"work"}, nil))
771 awaitTerminalCompositionAttachment(t, run)
772 require.Contains(t, run.terminal.written(), "terminal-composition-ready")
773 // The initial publication is committed before the attached marker, so its
774 // recorded boundary status is the pre-attachment Connecting presentation.
775 require.Contains(t, run.terminal.publishes(), ports.UIStatusConnecting)
776 // The attached presentation is published just after the attached state, on
777 // the attachment goroutine, so wait for it instead of racing it.
778 require.Eventually(t, func() bool {
779 return slices.Contains(run.terminal.publishes(), ports.UIStatusAttached)
780 }, brokerTestWait, 5*time.Millisecond)
781 _, _, restores := run.terminalCounts()
782 require.Zero(t, restores)
783}
784
785func (r *terminalCompositionRun) terminalCounts() (int, int, int) {
786 r.terminal.mu.Lock()
787 defer r.terminal.mu.Unlock()
788 return len(r.terminal.out), r.terminal.flushes, r.terminal.restores
789}
790
791// TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker pins the
792// failure contract: a target the committed catalogue does not carry is a local
793// refusal. No stream is opened, the supervisor reports a bounded
794// selection-unavailable notice, and the process stays in the picker instead of
795// attaching a fallback or creating implicitly.
796func TestTerminalCompositionRefusedTargetOpensNothingAndKeepsThePicker(t *testing.T) {
797 tests := []struct {
798 name string
799 intent uint8
800 session string
801 remote string
802 snapshot ports.BrokerSnapshot
803 }{
804 {name: "local daemon absent", intent: protocol.IntentEphemeral, snapshot: ports.BrokerSnapshot{Epoch: terminalCompositionEpoch, Revision: 1}},
805 {name: "unconfigured remote host", intent: protocol.IntentAttach, session: "work", remote: "user@elsewhere.test", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)},
806 }
807 for _, tc := range tests {
808 t.Run(tc.name, func(t *testing.T) {
809 run := startTerminalComposition(t, tc.intent, tc.session, tc.remote, tc.snapshot)
810 require.Equal(t, client.LifecycleNoticeSelectionUnavailable, awaitTerminalCompositionNotice(t, run).Kind)
811 // A local refusal is surfaced once, with the bounded text the supervisor
812 // classifies; the resolver's own sentinel is deliberately not propagated
813 // past that taxonomy, so only the presence and the classification are
814 // asserted.
815 select {
816 case failure := <-run.failures:
817 require.Error(t, failure)
818 case <-time.After(brokerTestWait):
819 t.Fatal("the refused target reported no failure")
820 }
821 require.Empty(t, run.service.openedRequests(), "a refused target never opens a stream")
822 // The pending navigation presents Connecting, never the picker
823 // first; the refusal then returns to the picker and never attaches.
824 last := client.PresentConnecting
825 for {
826 select {
827 case state := <-run.states:
828 require.NotEqual(t, client.PresentAttached, state.Presentation, "a refusal never attaches")
829 last = state.Presentation
830 case <-time.After(100 * time.Millisecond):
831 require.Equal(t, client.PresentPicker, last, "a refusal ends on the picker")
832 return
833 }
834 }
835 })
836 }
837}
838
839// TestTerminalCompositionDaemonRefusesMissingName pins that existence is the
840// daemon's decision: a declined (non-interactive) create prompt keeps the
841// named attach, the daemon answers ErrNoSuchSession, and the client reports
842// that refusal without attaching or creating.
843func TestTerminalCompositionDaemonRefusesMissingName(t *testing.T) {
844 tests := []struct {
845 name string
846 remote string
847 snapshot ports.BrokerSnapshot
848 }{
849 {name: "local", snapshot: terminalCompositionSnapshot([]string{"work"}, nil)},
850 {name: "remote", remote: "user@example.test", snapshot: terminalCompositionSnapshot(nil, map[string][]string{"user@example.test": {"work"}})},
851 }
852 for _, tc := range tests {
853 t.Run(tc.name, func(t *testing.T) {
854 withAttachInteractiveConsole(t, false)
855 run := startTerminalComposition(t, protocol.IntentAttach, "gone", tc.remote, tc.snapshot)
856 require.Equal(t, client.LifecycleNoticeDestinationFailed, awaitTerminalCompositionNotice(t, run).Kind)
857 select {
858 case failure := <-run.failures:
859 var protocolErr *client.ProtocolError
860 require.ErrorAs(t, failure, &protocolErr)
861 require.Equal(t, protocol.ErrNoSuchSession, protocolErr.Code)
862 case <-time.After(brokerTestWait):
863 t.Fatal("the daemon refusal was not reported")
864 }
865 opened := run.service.openedRequests()
866 require.Len(t, opened, 1)
867 require.Equal(t, ports.BrokerAdmissionAttachNamed, opened[0].Admission, "a missing name is never created implicitly")
868 })
869 }
870}
871
872// TestTerminalCompositionClosesRawModeOnTerminalEnd pins that the shared
873// composition restores the terminal exactly once when the run ends, however the
874// attachment settled.
875func TestTerminalCompositionClosesRawModeOnTerminalEnd(t *testing.T) {
876 run := startTerminalComposition(t, protocol.IntentEphemeral, "", "", terminalCompositionSnapshot([]string{"work"}, nil))
877 awaitTerminalCompositionAttachment(t, run)
878 run.cancel()
879 require.NoError(t, ignoreContextCancellation(run.wait(t)))
880 _, _, restores := run.terminalCounts()
881 require.Equal(t, 1, restores, "raw mode is restored exactly once")
882}
883
884// TestTerminalCompositionCapturesSessionEnvironmentDefensively proves the test
885// terminal composition keeps its process snapshot defensive: it captures
886// env/Cwd, mutates the process seam after construction, and the supervisor
887// still creates without failure.
888func TestTerminalCompositionCapturesSessionEnvironmentDefensively(t *testing.T) {
889 t.Setenv("VEV_TEST_SESSION_ENV", "composition-value")
890 snapshot := terminalSessionEnvironment()
891 require.Equal(t, client.SessionEnvironmentLocalCLI, snapshot.Provenance)
892 require.NoError(t, snapshot.Validate())
893 require.Contains(t, snapshot.Env, "VEV_TEST_SESSION_ENV=composition-value")
894 // Mutate the process seam after capture: the snapshot must keep the
895 // captured value rather than aliasing live process state.
896 require.NoError(t, os.Setenv("VEV_TEST_SESSION_ENV", "mutated-after-capture"))
897 require.Contains(t, snapshot.Env, "VEV_TEST_SESSION_ENV=composition-value")
898 require.NotContains(t, snapshot.Env, "VEV_TEST_SESSION_ENV=mutated-after-capture")
899 clone := snapshot.Clone()
900 // Mutate the clone's slice after capture: the supervisor creation must
901 // not fail and the source slice stays intact, so the snapshot is a copy
902 // rather than a live alias of the process environment.
903 for i := range clone.Env {
904 clone.Env[i] = "MUTATED=x"
905 }
906 require.NotEqual(t, snapshot.Env, clone.Env)
907 servicesnapshot := terminalCompositionSnapshot([]string{"work"}, nil)
908 service := newTerminalCompositionService(servicesnapshot)
909 terminal := newTerminalCompositionTerminal()
910 navigation := localEphemeralNavigation()
911 connector := portsmocks.NewMockBrokerConnector(t)
912 connector.EXPECT().Connect(mock.Anything).Return(service, nil).Maybe()
913 _, err := client.NewSupervisor(client.SupervisorConfig{
914 Connector: connector,
915 Terminal: terminal,
916 Clock: clock.New(),
917 // The picker is omitted: creation alone proves the snapshot does not
918 // fail supervisor validation.
919 InitialNavigation: navigation,
920 SessionEnvironment: snapshot,
921 })
922 require.NoError(t, err)
923}
924
925// TestTerminalCompositionReportsBrokerFailureInThePicker pins that a broker the
926// composition cannot connect to (or start) is no longer a fatal startup error:
927// the run enters raw mode, stays in the picker, and reports the typed failure
928// without opening any stream. It remains live until the terminal ends.
929func TestTerminalCompositionReportsBrokerFailureInThePicker(t *testing.T) {
930 t.Setenv("VEV", "")
931 startupErr := errors.New("broker not reachable")
932 previousConnect := connectProductionClientBroker
933 terminal := newTerminalCompositionTerminal()
934 previousTerminal := terminalForAttach
935 connectProductionClientBroker = func(context.Context) (ports.BrokerService, error) { return nil, startupErr }
936 terminalForAttach = func() ports.Terminal { return terminal }
937 t.Cleanup(func() {
938 connectProductionClientBroker = previousConnect
939 terminalForAttach = previousTerminal
940 })
941
942 ctx, cancel := context.WithCancel(context.Background())
943 done := make(chan error, 1)
944 go func() { done <- runAttach(ctx, protocol.IntentEphemeral, "", "") }()
945
946 // A broker failure is surfaced, not fatal, and no stream is opened.
947 require.Eventually(t, func() bool {
948 for _, status := range terminal.publishes() {
949 if status == ports.UIStatusConnecting || status == ports.UIStatusAttached {
950 return false
951 }
952 }
953 return true
954 }, 200*time.Millisecond, 10*time.Millisecond)
955 select {
956 case err := <-done:
957 t.Fatalf("the composition ended on a broker failure: %v", err)
958 case <-time.After(200 * time.Millisecond):
959 }
960 _, _, restores := terminalCounts(terminal)
961 require.Zero(t, restores, "the run is still live, so raw mode is not yet restored")
962
963 cancel()
964 require.NoError(t, ignoreContextCancellation(<-done))
965 _, _, restores = terminalCounts(terminal)
966 require.Equal(t, 1, restores, "the cancelled run restores raw mode exactly once")
967}
968
969// TestRunAttachNestedSessionBehavior pins the nested-session product contract of
970// the terminal path: an explicit `new <name>` inside an existing session still
971// creates the session without attaching, and every other intent is refused
972// without reaching the broker.
973func TestRunAttachNestedSessionBehavior(t *testing.T) {
974 t.Setenv("VEV", "outer")
975 connectCalls := 0
976 previousConnect := connectProductionClientBroker
977 previousCreate := createDetachedTerminalSession
978 var createdName string
979 connectProductionClientBroker = func(context.Context) (ports.BrokerService, error) {
980 connectCalls++
981 return nil, errors.New("the broker must not be reached")
982 }
983 createDetachedTerminalSession = func(_ context.Context, name string) error {
984 createdName = name
985 return nil
986 }
987 t.Cleanup(func() {
988 connectProductionClientBroker = previousConnect
989 createDetachedTerminalSession = previousCreate
990 })
991
992 require.NoError(t, runAttach(context.Background(), protocol.IntentNew, "scratch", ""))
993 require.Equal(t, "scratch", createdName)
994
995 for _, intent := range []uint8{protocol.IntentEphemeral, protocol.IntentAttach} {
996 err := runAttach(context.Background(), intent, "scratch", "")
997 require.ErrorContains(t, err, "sessions should be nested with care")
998 }
999 require.Zero(t, connectCalls, "a nested invocation never connects to the broker")
1000}
1001
1002func terminalCounts(terminal *terminalCompositionTerminal) (int, int, int) {
1003 terminal.mu.Lock()
1004 defer terminal.mu.Unlock()
1005 return len(terminal.out), terminal.flushes, terminal.restores
1006}
1007
1008// TestTerminalCompositionUsesTheBrokerConnector pins the production terminal
1009// path source: runAttach composes the shared broker client over the process'
1010// production connector and keeps no direct-dialer attach composition, attach
1011// preflight, or client host registry. The old pre-cutover symbols are removed
1012// outright, so this fails if any is reintroduced as a bypass.
1013func TestTerminalCompositionUsesTheBrokerConnector(t *testing.T) {
1014 sources := loadAppSources(t, false)
1015 body := sources["attach.go"]
1016 require.Contains(t, body, "runBrokerClient(ctx, brokerClientConfig{")
1017 require.Contains(t, body, "newProductionBrokerConnector()")
1018 body += sources["sessions.go"] + sources["run.go"]
1019 for _, forbidden := range []string{
1020 "runAttachWithDeps", "runAttachDeps", "localDaemonDialer", "dialOnlyLocalDialer",
1021 "defaultLocalDialer", "resolveMissingSessionAttach", "newClientHostRegistry",
1022 "listSessionsWithDialer", "clientEndpointFactory",
1023 } {
1024 require.NotContains(t, body, forbidden, "the terminal composition must not keep %q", forbidden)
1025 }
1026}
1027
1028// TestTerminalExactAttachAcceptedByRealDaemon is the end-to-end regression for
1029// the exact-attach wire shape: against the real broker and a real daemon, the
1030// Hello the client supervisor builds for an exact admission (Intent=attach,
1031// Name equal to the exact target's session name, ExactTarget set) is accepted
1032// and answered with a committed Welcome. It fails if the strict Hello contract
1033// and the worker disagree about the name.
1034func TestTerminalExactAttachAcceptedByRealDaemon(t *testing.T) {
1035 fixture := startOfflineClientFixture(t)
1036 ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait)
1037 defer cancel()
1038 service, err := brokeripc.NewConnector(fixture.socket, brokeripc.Config{}).Connect(ctx)
1039 require.NoError(t, err)
1040 defer service.Close()
1041 require.NotZero(t, awaitTerminalCompositionEpoch(t, service))
1042
1043 createID, err := service.NextStreamID()
1044 require.NoError(t, err)
1045 create, err := service.OpenStream(ctx, ports.BrokerOpenStreamRequest{
1046 Purpose: ports.BrokerStreamAttachment, Stream: createID, Local: true, Policy: fixture.policy,
1047 Admission: ports.BrokerAdmissionCreateNamed, Name: "attach-exact", StartMode: ports.BrokerDaemonStartIfNeeded,
1048 })
1049 require.NoError(t, err)
1050 require.NoError(t, create.SendClient(protocol.Hello{Version: protocol.Version, Intent: protocol.IntentNew, Name: "attach-exact", Size: domain.Size{Cols: 80, Rows: 24}}))
1051 target := receiveOfflineWelcome(t, create).CommittedIdentity.Target
1052 require.NoError(t, create.Close())
1053
1054 exactID, err := service.NextStreamID()
1055 require.NoError(t, err)
1056 exact, err := service.OpenStream(ctx, ports.BrokerOpenStreamRequest{
1057 Purpose: ports.BrokerStreamAttachment, Stream: exactID, Local: true, Policy: fixture.policy,
1058 Admission: ports.BrokerAdmissionExact, Target: target, StartMode: ports.BrokerDaemonStartIfNeeded,
1059 })
1060 require.NoError(t, err)
1061 defer exact.Close()
1062
1063 // The exact Hello shape the client supervisor's worker builds for this
1064 // request; it must satisfy the protocol and be accepted by the daemon.
1065 hello := protocol.Hello{
1066 Version: protocol.Version, Intent: protocol.IntentAttach, Name: target.SessionName,
1067 ExactTarget: &target, Size: domain.Size{Cols: 80, Rows: 24},
1068 }
1069 require.NoError(t, protocol.ValidateHello(hello))
1070 require.NoError(t, exact.SendClient(hello))
1071 require.Equal(t, "attach-exact", receiveOfflineWelcome(t, exact).SessionName)
1072}
1073
1074// TestTerminalCompositionExactAttachReportsMissingLocalInventory documents the
1075// composition's behavior when the committed broker publication carries no local
1076// session inventory: an exact attach is refused locally as a bounded selection
1077// notice, opens no stream, and never invents a creation or a same-name
1078// fallback. It pins the honest refusal so a future broker-side local catalogue
1079// is the only thing that can make this navigation succeed.
1080func TestTerminalCompositionExactAttachReportsMissingLocalInventory(t *testing.T) {
1081 fixture := startOfflineClientFixture(t)
1082 ctx, cancel := context.WithTimeout(context.Background(), brokerTestWait)
1083 defer cancel()
1084
1085 terminal := newTerminalCompositionTerminal()
1086 notices := make(chan client.LifecycleNotice, 16)
1087 failures := make(chan error, 16)
1088 var lifecycleID domain.SessionLifecycleID
1089 lifecycleID[0], lifecycleID[1] = 3, 0x5a
1090 navigation := client.InitialNavigation{
1091 Kind: client.InitialNavigationAttachExact,
1092 Epoch: terminalCompositionEpoch,
1093 Destination: ports.BrokerEndpointFence{Local: true},
1094 Target: protocol.ExactSessionTarget{LifecycleID: lifecycleID, SessionName: "work"},
1095 }
1096 require.NoError(t, navigation.Validate())
1097
1098 done := make(chan error, 1)
1099 go func() {
1100 done <- runBrokerClient(ctx, brokerClientConfig{
1101 Connector: brokeripc.NewConnector(fixture.socket, brokeripc.Config{}).Navigator(),
1102 Terminal: terminal,
1103 InitialNavigation: navigation,
1104 SessionEnvironment: client.SessionEnvironment{Provenance: client.SessionEnvironmentLocalCLI},
1105 OnFailure: func(err error) {
1106 select {
1107 case failures <- err:
1108 default:
1109 }
1110 },
1111 OnLifecycle: func(notice client.LifecycleNotice) {
1112 select {
1113 case notices <- notice:
1114 default:
1115 }
1116 },
1117 })
1118 }()
1119
1120 select {
1121 case notice := <-notices:
1122 require.Equal(t, client.LifecycleNoticeSelectionUnavailable, notice.Kind)
1123 case <-time.After(brokerTestWait):
1124 t.Fatal("the exact attach reported no lifecycle notice")
1125 }
1126 select {
1127 case failure := <-failures:
1128 require.Error(t, failure)
1129 case <-time.After(brokerTestWait):
1130 t.Fatal("the exact attach reported no failure")
1131 }
1132 // The refused navigation never writes a session frame and never attaches:
1133 // it presents Connecting while pending and ends on the picker.
1134 publishes := terminal.publishes()
1135 for _, status := range publishes {
1136 require.NotEqual(t, ports.UIStatusAttached, status, "a refused navigation never attaches")
1137 }
1138 require.NotEmpty(t, publishes)
1139 require.Equal(t, ports.UIStatusPicker, publishes[len(publishes)-1], "a refused navigation ends on the picker")
1140 cancel()
1141 require.NoError(t, ignoreContextCancellation(<-done))
1142}
1143
1144// awaitTerminalCompositionEpoch waits for one connection's first committed
1145// publication and returns its epoch.
1146func awaitTerminalCompositionEpoch(t *testing.T, service ports.BrokerService) ports.BrokerEpoch {
1147 t.Helper()
1148 sub, err := service.Subscribe()
1149 require.NoError(t, err)
1150 defer sub.Close()
1151 deadline := time.NewTimer(brokerTestWait)
1152 defer deadline.Stop()
1153 for {
1154 if snapshot := service.Snapshot(); snapshot.Epoch != 0 {
1155 return snapshot.Epoch
1156 }
1157 select {
1158 case <-sub.Changed():
1159 case <-service.Done():
1160 t.Fatalf("broker connection ended before publishing a snapshot: %v", service.Err())
1161 case <-deadline.C:
1162 t.Fatal("broker never published a snapshot")
1163 }
1164 }
1165}
1166
1167func TestDetectTerminalColor(t *testing.T) {
1168 tests := []struct {
1169 name string
1170 term string
1171 colorTerm string
1172 env []string
1173 want terminalcap.ColorCapabilities
1174 }{
1175 {name: "kitty environment keeps heuristic truecolor", term: "xterm-kitty", env: []string{"KITTY_WINDOW_ID=1"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceHeuristic}},
1176 {name: "explicit COLORTERM overrides env", term: "xterm-256color", colorTerm: "truecolor", env: []string{"COLORTERM="}, want: terminalcap.ColorCapabilities{Mode: terminalcap.TrueColor, Source: terminalcap.SourceDeclared}},
1177 {name: "explicit TERM overrides env", term: "xterm-256color", env: []string{"TERM=foot-direct"}, want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}},
1178 {name: "unknown terminal is indexed", term: "unknown", want: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256}},
1179 }
1180 for _, tt := range tests {
1181 t.Run(tt.name, func(t *testing.T) {
1182 require.Equal(t, tt.want, detectTerminalColor(tt.term, tt.colorTerm, tt.env))
1183 })
1184 }
1185}
1186
1187func TestResolveColorOverride(t *testing.T) {
1188 detected := terminalcap.ColorCapabilities{Mode: terminalcap.ANSI256, Source: terminalcap.SourceDeclared}
1189 forced := func(mode terminalcap.ColorMode) terminalcap.ColorCapabilities {
1190 return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced}
1191 }
1192 tests := []struct {
1193 name string
1194 env string
1195 cfg domain.TerminalConfig
1196 want terminalcap.ColorCapabilities
1197 wantWarn bool
1198 }{
1199 {name: "nothing set uses detection", want: detected},
1200 {name: "config auto uses detection", cfg: domain.TerminalConfig{}, want: detected},
1201 {name: "config forces mode", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16)},
1202 {name: "config forces truecolor", cfg: domain.TerminalConfig{Colors: terminalcap.TrueColor, ColorsSet: true}, want: forced(terminalcap.TrueColor)},
1203 {name: "env forces mode", env: "mono", want: forced(terminalcap.Monochrome)},
1204 {name: "env is case-insensitive and trimmed", env: " TrueColor ", want: forced(terminalcap.TrueColor)},
1205 {name: "env wins over config", env: "256", cfg: domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, want: forced(terminalcap.ANSI256)},
1206 {name: "env auto overrides config with detection", env: "auto", cfg: domain.TerminalConfig{Colors: terminalcap.Monochrome, ColorsSet: true}, want: detected},
1207 {name: "blank env falls through to config", env: " ", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, want: forced(terminalcap.ANSI16)},
1208 {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},
1209 {name: "invalid env falls back to detection with a warning", env: "24bit", want: detected, wantWarn: true},
1210 }
1211 for _, tt := range tests {
1212 t.Run(tt.name, func(t *testing.T) {
1213 var logs bytes.Buffer
1214 log := slog.New(slog.NewTextHandler(&logs, nil))
1215 require.Equal(t, tt.want, resolveColorOverride(detected, tt.env, tt.cfg, log))
1216 if tt.wantWarn {
1217 require.Contains(t, logs.String(), "VEV_COLORS")
1218 } else {
1219 require.Empty(t, logs.String())
1220 }
1221 })
1222 }
1223 t.Run("nil logger tolerates invalid env", func(t *testing.T) {
1224 require.Equal(t, detected, resolveColorOverride(detected, "rainbow", domain.TerminalConfig{}, nil))
1225 })
1226}
1227
1228func TestTerminalAttachmentEnvironmentColor(t *testing.T) {
1229 forced := func(mode terminalcap.ColorMode) terminalcap.ColorCapabilities {
1230 return terminalcap.ColorCapabilities{Mode: mode, Source: terminalcap.SourceForced}
1231 }
1232 tests := []struct {
1233 name string
1234 term string
1235 colors string
1236 cfg domain.TerminalConfig
1237 wantUser terminalcap.ColorCapabilities
1238 }{
1239 {name: "dumb process terminal", term: "dumb", wantUser: terminalcap.ColorCapabilities{Mode: terminalcap.Monochrome, Source: terminalcap.SourceDeclared}},
1240 {name: "linux console", term: "linux", wantUser: terminalcap.ColorCapabilities{Mode: terminalcap.ANSI16, Source: terminalcap.SourceDeclared}},
1241 {name: "env overrides process terminal", term: "dumb", colors: "truecolor", wantUser: forced(terminalcap.TrueColor)},
1242 {name: "config overrides process terminal", term: "xterm-256color", cfg: domain.TerminalConfig{Colors: terminalcap.ANSI16, ColorsSet: true}, wantUser: forced(terminalcap.ANSI16)},
1243 }
1244 for _, tt := range tests {
1245 t.Run(tt.name, func(t *testing.T) {
1246 t.Setenv("TERM", tt.term)
1247 t.Setenv("COLORTERM", "")
1248 t.Setenv("KITTY_WINDOW_ID", "")
1249 t.Setenv("KITTY_PID", "")
1250 t.Setenv("KITTY_LISTEN_ON", "")
1251 t.Setenv(envColors, tt.colors)
1252
1253 require.Equal(t, tt.wantUser, userTerminalAttachmentEnvironment(nil, tt.cfg).Color)
1254 // Web and headless UI-driver clients render into RGB VT mirrors and
1255 // ignore both the gateway process terminal and the user override.
1256 virtual := virtualTerminalAttachmentEnvironment()
1257 require.Equal(t, forced(terminalcap.TrueColor), virtual.Color)
1258 require.False(t, virtual.ProbeTerminal)
1259 })
1260 }
1261}