v0.2.0 80c61e3

remote_final_acceptance_test.go RawHistoryFolder

1//go:build linux
2
3package app
4
5import (
6	"bytes"
7	"context"
8	"io"
9	"log/slog"
10	"net"
11	"sync"
12	"testing"
13
14	"github.com/bnema/vev/internal/adapters/sshstdio"
15	"github.com/bnema/vev/internal/domain"
16	"github.com/bnema/vev/internal/ports"
17	portsmocks "github.com/bnema/vev/internal/ports/mocks"
18	"github.com/bnema/vev/internal/usecase/client"
19	"github.com/stretchr/testify/require"
20)
21
22type acceptanceRemoteFactory struct {
23	mu      sync.Mutex
24	calls   []string
25	handoff map[string]ports.AttachTarget
26	outputs chan string
27}
28
29func (f *acceptanceRemoteFactory) DialerForRemote(target string, _ string, _ ports.RemoteTransportMode, _ *slog.Logger) (ports.Dialer, error) {
30	f.mu.Lock()
31	f.calls = append(f.calls, target)
32	targetHandoff := f.handoff[target]
33	f.mu.Unlock()
34	return acceptanceRemoteDialer{target: target, handoff: targetHandoff, outputs: f.outputs}, nil
35}
36
37type acceptanceRemoteDialer struct {
38	target  string
39	handoff ports.AttachTarget
40	outputs chan<- string
41}
42
43func (d acceptanceRemoteDialer) Dial(context.Context) (ports.Transport, error) {
44	clientConn, serverConn := net.Pipe()
45	clientTransport := sshstdio.NewTransport(clientConn, clientConn, clientConn.Close)
46	serverTransport := sshstdio.NewTransport(serverConn, serverConn, serverConn.Close)
47	go serveAcceptanceRemote(serverTransport, d.target, d.handoff, d.outputs)
48	return clientTransport, nil
49}
50
51func serveAcceptanceRemote(tr ports.Transport, target string, handoff ports.AttachTarget, outputs chan<- string) {
52	defer func() { _ = tr.Close() }()
53	welcomed := false
54	for {
55		frame, err := tr.Recv()
56		if err != nil {
57			return
58		}
59		if !welcomed {
60			if frame.Type != ports.MsgHello {
61				continue
62			}
63			if err := tr.Send(ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: target})}); err != nil {
64				return
65			}
66			welcomed = true
67			continue
68		}
69		if handoff.Endpoint != "" {
70			_ = tr.Send(ports.Frame{Type: ports.MsgAttachTarget, Payload: ports.MarshalAttachTarget(handoff)})
71			for {
72				if _, err := tr.Recv(); err != nil {
73					return
74				}
75			}
76		}
77		data := []byte("remote:" + target)
78		if outputs != nil {
79			outputs <- string(data)
80		}
81		payload, err := ports.MarshalOutput(ports.Output{
82			Epoch: 1, New: 1, Full: true, Size: domain.Size{Cols: 80, Rows: 24}, Data: data,
83		})
84		if err != nil {
85			return
86		}
87		if err := tr.Send(ports.Frame{Type: ports.MsgOutput, Payload: payload}); err != nil {
88			return
89		}
90		_ = tr.Send(ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})})
91		for {
92			if _, err := tr.Recv(); err != nil {
93				return
94			}
95		}
96	}
97}
98
99type acceptanceRemoteTerminal struct {
100	in  io.Reader
101	out bytes.Buffer
102}
103
104func (t *acceptanceRemoteTerminal) EnterRaw() (func() error, error) {
105	return func() error { return nil }, nil
106}
107func (*acceptanceRemoteTerminal) Size() (domain.Size, error) {
108	return domain.Size{Cols: 80, Rows: 24}, nil
109}
110func (*acceptanceRemoteTerminal) ResizeEvents() <-chan domain.Size { return nil }
111func (t *acceptanceRemoteTerminal) In() io.Reader                  { return t.in }
112func (t *acceptanceRemoteTerminal) Out() io.Writer                 { return &t.out }
113func (*acceptanceRemoteTerminal) Flush() error                     { return nil }
114
115func TestAcceptanceRemoteDirectAndPickerUseOnlyRemoteTransports(t *testing.T) {
116	outputs := make(chan string, 4)
117	factory := &acceptanceRemoteFactory{outputs: outputs}
118	hostStore := portsmocks.NewMockRemoteHostStore(t)
119	for _, host := range []string{"direct.example", "picker.example", "picked.example"} {
120		hostStore.EXPECT().Remember(host).Return(nil).Once()
121	}
122	localCalls := 0
123	terminals := make([]*acceptanceRemoteTerminal, 0, 3)
124	deps := runAttachDeps{
125		hostStore: hostStore,
126		localDialer: func() ports.Dialer {
127			localCalls++
128			return acceptanceRemoteDialer{}
129		},
130		remoteDialerFactory:     factory,
131		selectedRemoteTransport: string(ports.RemoteTransportStdio),
132		runClient: func(ctx context.Context, deps client.Dependencies, request client.AttachRequest) error {
133			term := &acceptanceRemoteTerminal{in: bytes.NewReader(nil)}
134			terminals = append(terminals, term)
135			deps.Terminal = term
136			deps.Logger = slog.New(slog.DiscardHandler)
137			deps.Remote = true
138			result := client.NewRunner(deps).Run(ctx, request)
139			if request.SessionName == "work" {
140				require.NoError(t, result, "the client handoff completes before the test injects the composition-root handoff")
141				return &client.AttachTargetError{Target: ports.AttachTarget{Endpoint: "picked.example", Session: "picked", Intent: ports.IntentAttach}}
142			}
143			return result
144		},
145	}
146
147	wireTransport, err := (acceptanceRemoteDialer{
148		target:  "picker-wire.example",
149		handoff: ports.AttachTarget{Endpoint: "picked.example", Session: "picked", Intent: ports.IntentAttach},
150	}).Dial(context.Background())
151	require.NoError(t, err)
152	require.NoError(t, wireTransport.Send(ports.Frame{Type: ports.MsgHello, Payload: ports.MarshalHello(ports.Hello{
153		Version: ports.ProtocolVersion, Intent: ports.IntentAttach, Name: "work", Size: domain.Size{Cols: 80, Rows: 24},
154	})}))
155	welcome, err := wireTransport.Recv()
156	require.NoError(t, err)
157	require.Equal(t, ports.MsgWelcome, welcome.Type)
158	require.NoError(t, wireTransport.Send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(ports.Theme{})}))
159	attachTarget, err := wireTransport.Recv()
160	require.NoError(t, err)
161	target, err := ports.UnmarshalAttachTarget(attachTarget.Payload)
162	require.NoError(t, err)
163	require.Equal(t, ports.MsgAttachTarget, attachTarget.Type)
164	require.Equal(t, "picked.example", target.Endpoint)
165	require.NoError(t, wireTransport.Close())
166
167	require.NoError(t, runAttachWithDeps(context.Background(), ports.IntentAttach, "direct", "direct.example", "", nil, deps))
168	require.NoError(t, runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "picker.example", "", nil, deps))
169	factory.mu.Lock()
170	calls := append([]string(nil), factory.calls...)
171	factory.mu.Unlock()
172	require.Equal(t, []string{"direct.example", "picker.example", "picked.example"}, calls)
173	require.Zero(t, localCalls, "remote direct and picker connections must not create a local shadow")
174	require.Len(t, terminals, 3)
175	require.Equal(t, []string{"remote:direct.example", "remote:picker.example", "remote:picked.example"}, []string{<-outputs, <-outputs, <-outputs})
176}

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