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 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}

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