broker_client_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close