v0.3.0 99dc266

remote_test.go RawHistoryFolder

1package app
2
3import (
4	"bytes"
5	"context"
6	"errors"
7	"path/filepath"
8	"strings"
9	"testing"
10
11	"github.com/bnema/vev/internal/domain"
12	"github.com/bnema/vev/internal/ports"
13	portsmocks "github.com/bnema/vev/internal/ports/mocks"
14	"github.com/bnema/vev/internal/usecase/client"
15	"github.com/stretchr/testify/mock"
16	"github.com/stretchr/testify/require"
17)
18
19func TestParseRemoteAttachTarget(t *testing.T) {
20	tests := []struct {
21		name, input, target, session string
22		ok                           bool
23	}{
24		{name: "local", input: "work"},
25		{name: "remote", input: "user@example.com", target: "user@example.com", ok: true},
26		{name: "remote session", input: "user@example.com:work", target: "user@example.com", session: "work", ok: true},
27		{name: "empty session is ephemeral", input: "user@example.com:", target: "user@example.com", ok: true},
28		{name: "ipv6", input: "user@[2001:db8::1]:work", target: "user@[2001:db8::1]", session: "work", ok: true},
29		{name: "missing user", input: "@example.com"},
30		{name: "missing host", input: "user@"},
31		{name: "multiple user separators", input: "user@example@com"},
32		{name: "missing bracketed ipv6 host", input: "user@[]:work"},
33		{name: "unclosed ipv6", input: "user@[2001:db8::1:work"},
34		{name: "unexpected closing ipv6 bracket", input: "user@2001:db8::1]:work"},
35		{name: "unbracketed ipv6", input: "user@2001:db8::1:work"},
36	}
37	for _, tt := range tests {
38		t.Run(tt.name, func(t *testing.T) {
39			target, session, ok := parseRemoteAttachTarget(tt.input)
40			require.Equal(t, tt.target, target)
41			require.Equal(t, tt.session, session)
42			require.Equal(t, tt.ok, ok)
43		})
44	}
45}
46
47func TestRemoteHostDepsDefaultsStore(t *testing.T) {
48	stateDir := filepath.Join(t.TempDir(), "state")
49	deps := (remoteHostDeps{stateDir: func() string { return stateDir }}).withDefaults()
50	require.NotNil(t, deps.store)
51	require.Same(t, deps.store, deps.hostStore())
52	require.NoError(t, deps.store.AddPinned("arch"))
53	require.FileExists(t, filepath.Join(stateDir, "hosts.json"))
54}
55
56func TestDecodeSessionListErrorReply(t *testing.T) {
57	t.Run("valid", func(t *testing.T) {
58		_, err := decodeSessionListReply(ports.Frame{
59			Type:    ports.MsgError,
60			Payload: ports.MarshalErrorMsg(ports.ErrorMsg{Text: "denied"}),
61		})
62		require.EqualError(t, err, "vev: denied")
63	})
64
65	t.Run("malformed", func(t *testing.T) {
66		_, err := decodeSessionListReply(ports.Frame{Type: ports.MsgError, Payload: []byte{0}})
67		require.ErrorContains(t, err, "vev: decoding error reply")
68	})
69}
70
71func TestMergeRemoteHostsOrderAndSource(t *testing.T) {
72	got := mergeRemoteHosts([]string{"zebra", "arch"}, []string{"mule", "arch", "beta"})
73	require.Equal(t, []domain.RemoteHost{
74		{Target: "zebra", Pinned: true},
75		{Target: "arch", Pinned: true, Learned: true},
76		{Target: "beta", Learned: true},
77		{Target: "mule", Learned: true},
78	}, got)
79}
80
81func TestRemoteHostCommands(t *testing.T) {
82	newDeps := func(store ports.RemoteHostStore, out *bytes.Buffer) remoteHostDeps {
83		return remoteHostDeps{
84			store:  store,
85			stdout: out,
86		}
87	}
88
89	tests := []struct {
90		name string
91		run  func(t *testing.T)
92	}{
93		{
94			name: "add pins in state store",
95			run: func(t *testing.T) {
96				store := portsmocks.NewMockRemoteHostStore(t)
97				store.EXPECT().AddPinned("mule").Return(nil).Once()
98				err := runHostCommand(context.Background(), command{hostAction: hostActionAdd, hostTarget: "mule"}, newDeps(store, nil))
99				require.NoError(t, err)
100			},
101		},
102		{
103			name: "remove known host uses one atomic store call",
104			run: func(t *testing.T) {
105				store := portsmocks.NewMockRemoteHostStore(t)
106				store.EXPECT().Remove("mule").Return(true, nil).Once()
107				err := runHostCommand(context.Background(), command{hostAction: hostActionRm, hostTarget: "mule"}, newDeps(store, nil))
108				require.NoError(t, err)
109			},
110		},
111		{
112			name: "remove unknown host uses one atomic store call",
113			run: func(t *testing.T) {
114				store := portsmocks.NewMockRemoteHostStore(t)
115				store.EXPECT().Remove("mule").Return(false, nil).Once()
116				err := runHostCommand(context.Background(), command{hostAction: hostActionRm, hostTarget: "mule"}, newDeps(store, nil))
117				require.ErrorContains(t, err, `unknown host "mule"`)
118			},
119		},
120		{
121			name: "remove atomic store error is surfaced",
122			run: func(t *testing.T) {
123				store := portsmocks.NewMockRemoteHostStore(t)
124				removeErr := errors.New("rename hosts")
125				store.EXPECT().Remove("mule").Return(false, removeErr).Once()
126				err := runHostCommand(context.Background(), command{hostAction: hostActionRm, hostTarget: "mule"}, newDeps(store, nil))
127				require.ErrorIs(t, err, removeErr)
128			},
129		},
130		{
131			name: "list preserves source markers and order",
132			run: func(t *testing.T) {
133				store := portsmocks.NewMockRemoteHostStore(t)
134				store.EXPECT().Hosts().Return([]string{"zebra", "arch"}, []string{"mule", "arch", "beta"}, nil).Once()
135				var out bytes.Buffer
136				err := runHostCommand(context.Background(), command{hostAction: hostActionList}, newDeps(store, &out))
137				require.NoError(t, err)
138				got := out.String()
139				require.Contains(t, got, "pinned,learned")
140				require.True(t, strings.Index(got, "zebra") < strings.Index(got, "arch"))
141				require.True(t, strings.Index(got, "arch") < strings.Index(got, "beta"))
142				require.True(t, strings.Index(got, "beta") < strings.Index(got, "mule"))
143			},
144		},
145		{
146			name: "malformed store is surfaced",
147			run: func(t *testing.T) {
148				store := portsmocks.NewMockRemoteHostStore(t)
149				store.EXPECT().Hosts().Return(nil, nil, errors.New("malformed hosts file")).Once()
150				err := runHostCommand(context.Background(), command{hostAction: hostActionList}, newDeps(store, nil))
151				require.ErrorContains(t, err, "malformed hosts file")
152			},
153		},
154		{
155			name: "list is active without config",
156			run: func(t *testing.T) {
157				store := portsmocks.NewMockRemoteHostStore(t)
158				store.EXPECT().Hosts().Return(nil, nil, nil).Once()
159				var out bytes.Buffer
160				err := runHostCommand(context.Background(), command{hostAction: hostActionList}, newDeps(store, &out))
161				require.NoError(t, err)
162			},
163		},
164	}
165	for _, tt := range tests {
166		t.Run(tt.name, tt.run)
167	}
168}
169
170func TestRemoteHostListingUsesUnifiedStore(t *testing.T) {
171	store := portsmocks.NewMockRemoteHostStore(t)
172	store.EXPECT().Hosts().Return([]string{"arch"}, []string{"beta"}, nil).Once()
173	catalog := portsmocks.NewMockRemoteCatalogClient(t)
174	catalog.EXPECT().List(mock.Anything, "arch").Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "build", State: "up"}}}, nil).Once()
175	var out bytes.Buffer
176	err := runRemoteList(context.Background(), command{listHost: "arch"}, remoteHostDeps{
177		store:   store,
178		catalog: catalog,
179		stdout:  &out,
180	})
181	require.NoError(t, err)
182	require.Contains(t, out.String(), "build@arch")
183}
184
185func TestListAllSessionsInvariants(t *testing.T) {
186	tests := []struct {
187		name string
188		run  func(t *testing.T)
189	}{
190		{
191			name: "prints partial local results on error",
192			run: func(t *testing.T) {
193				localErr := errors.New("local release failed")
194				var out bytes.Buffer
195				err := listAllSessions(context.Background(), remoteHostDeps{
196					localList: func(context.Context) ([]ports.SessionInfo, error) {
197						return []ports.SessionInfo{{Name: "local", State: ports.SessionUp}}, localErr
198					},
199					stdout: &out,
200				}, nil)
201				require.ErrorIs(t, err, localErr)
202				require.Contains(t, out.String(), "local")
203			},
204		},
205		{
206			name: "prints accumulated results and stops before next host on cancellation",
207			run: func(t *testing.T) {
208				ctx, cancel := context.WithCancel(context.Background())
209				defer cancel()
210				catalog := portsmocks.NewMockRemoteCatalogClient(t)
211				catalog.EXPECT().List(mock.Anything, "arch").RunAndReturn(func(context.Context, string) (ports.RemoteCatalog, error) {
212					cancel()
213					return ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "dev", State: "up"}}}, nil
214				}).Once()
215				var out bytes.Buffer
216				err := listAllSessions(ctx, remoteHostDeps{
217					catalog: catalog,
218					localList: func(context.Context) ([]ports.SessionInfo, error) {
219						return []ports.SessionInfo{{Name: "local", State: ports.SessionUp}}, nil
220					},
221					stdout: &out,
222				}, []domain.RemoteHost{{Target: "arch"}, {Target: "mule"}})
223				require.ErrorIs(t, err, context.Canceled)
224				require.Contains(t, out.String(), "local")
225				require.Contains(t, out.String(), "dev@arch")
226				require.NotContains(t, out.String(), "@mule")
227			},
228		},
229		{
230			name: "lists hosts sequentially in supplied order",
231			run: func(t *testing.T) {
232				catalog := portsmocks.NewMockRemoteCatalogClient(t)
233				var calls []string
234				catalog.EXPECT().List(mock.Anything, "arch").Run(func(_ context.Context, target string) {
235					calls = append(calls, target)
236				}).Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "dev", State: "up"}}}, nil).Once()
237				catalog.EXPECT().List(mock.Anything, "mule").Run(func(_ context.Context, target string) {
238					calls = append(calls, target)
239				}).Return(ports.RemoteCatalog{Sessions: []ports.RemoteCatalogSession{{Name: "ops", State: "up"}}}, nil).Once()
240				var out bytes.Buffer
241				err := listAllSessions(context.Background(), remoteHostDeps{
242					catalog: catalog,
243					localList: func(context.Context) ([]ports.SessionInfo, error) {
244						return []ports.SessionInfo{{Name: "local", State: ports.SessionUp}}, nil
245					},
246					stdout: &out,
247				}, []domain.RemoteHost{{Target: "arch"}, {Target: "mule"}})
248				require.NoError(t, err)
249				require.Equal(t, []string{"arch", "mule"}, calls)
250				got := out.String()
251				require.Less(t, strings.Index(got, "local"), strings.Index(got, "dev@arch"))
252				require.Less(t, strings.Index(got, "dev@arch"), strings.Index(got, "ops@mule"))
253			},
254		},
255	}
256	for _, tt := range tests {
257		t.Run(tt.name, tt.run)
258	}
259}
260
261func TestCatalogSessionsAsInfoInvariants(t *testing.T) {
262	tests := []struct {
263		name      string
264		session   ports.RemoteCatalogSession
265		wantState ports.SessionState
266		wantTabs  uint16
267	}{
268		{name: "up", session: ports.RemoteCatalogSession{Name: "dev", State: "up", Tabs: 2}, wantState: ports.SessionUp, wantTabs: 2},
269		{name: "down", session: ports.RemoteCatalogSession{Name: "dev", State: "down", Tabs: 2}, wantState: ports.SessionDown, wantTabs: 2},
270		{name: "broken", session: ports.RemoteCatalogSession{Name: "dev", State: "broken", Tabs: 2}, wantState: ports.SessionBroken, wantTabs: 2},
271		{name: "unknown fails closed", session: ports.RemoteCatalogSession{Name: "dev", State: "unknown", Tabs: 2}, wantState: ports.SessionBroken, wantTabs: 2},
272	}
273	for _, tt := range tests {
274		t.Run(tt.name, func(t *testing.T) {
275			infos := catalogSessionsAsInfo("arch", []ports.RemoteCatalogSession{tt.session})
276			require.Len(t, infos, 1)
277			require.Equal(t, "dev@arch", infos[0].Name)
278			require.Equal(t, tt.wantState, infos[0].State)
279			require.Equal(t, tt.wantTabs, infos[0].Tabs)
280		})
281	}
282}
283
284func TestRunAttachWithDepsRemoteLearning(t *testing.T) {
285	store := portsmocks.NewMockRemoteHostStore(t)
286	store.EXPECT().Remember("build@mule").Return(nil).Once()
287	factory := portsmocks.NewMockRemoteDialerFactory(t)
288	factory.EXPECT().DialerForRemote("build@mule", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
289
290	var learner ports.RemoteHostLearner
291	err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "build@mule", "", nil, runAttachDeps{
292		remoteDialerFactory: factory,
293		hostStore:           store,
294		runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
295			learner = deps.RemoteHostLearner
296			return nil
297		},
298	})
299	require.NoError(t, err)
300	require.NotNil(t, learner)
301	require.NoError(t, learner.RememberRemoteHost())
302}
303
304func TestRunAttachWithDepsAlwaysLearnsRemoteHost(t *testing.T) {
305	store := portsmocks.NewMockRemoteHostStore(t)
306	store.EXPECT().Remember("arch").Return(nil).Once()
307	factory := portsmocks.NewMockRemoteDialerFactory(t)
308	factory.EXPECT().DialerForRemote("arch", "work", ports.RemoteTransportUDP, mock.Anything).Return(namedDialer{name: "remote"}, nil).Once()
309	var learner ports.RemoteHostLearner
310	err := runAttachWithDeps(context.Background(), ports.IntentAttach, "work", "arch", "", nil, runAttachDeps{
311		remoteDialerFactory: factory,
312		hostStore:           store,
313		runClient: func(_ context.Context, deps client.Dependencies, _ client.AttachRequest) error {
314			learner = deps.RemoteHostLearner
315			return nil
316		},
317	})
318	require.NoError(t, err)
319	require.NotNil(t, learner)
320	require.NoError(t, learner.RememberRemoteHost())
321}

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