v0.3.0 99dc266

cmd_test.go RawHistoryFolder

1package app
2
3import (
4	"context"
5	"errors"
6	"io"
7	"math"
8	"reflect"
9	"strconv"
10	"strings"
11	"testing"
12	"time"
13
14	"github.com/bnema/vev/internal/ports"
15	portsmocks "github.com/bnema/vev/internal/ports/mocks"
16	"github.com/bnema/vev/internal/usecase/daemon"
17	"github.com/stretchr/testify/mock"
18	"github.com/stretchr/testify/require"
19)
20
21func TestParseCmdArgs(t *testing.T) {
22	tests := []struct {
23		name    string
24		args    []string
25		want    cmdInvocation
26		wantErr bool
27	}{
28		{"bare cmd is help", []string{"cmd"}, cmdInvocation{help: true}, false},
29		{"cmd help", []string{"cmd", "--help"}, cmdInvocation{help: true}, false},
30		{"slug help", []string{"cmd", "split-right", "--help"}, cmdInvocation{slug: "split-right", help: true}, false},
31		{"simple", []string{"cmd", "split-right"}, cmdInvocation{slug: "split-right"}, false},
32		{"resize one-shot", []string{"cmd", "grow-pane-width"}, cmdInvocation{slug: "grow-pane-width"}, false},
33		{"consume or expel pane left", []string{"cmd", "consume-or-expel-pane-left"}, cmdInvocation{slug: "consume-or-expel-pane-left"}, false},
34		{"consume or expel pane right", []string{"cmd", "consume-or-expel-pane-right"}, cmdInvocation{slug: "consume-or-expel-pane-right"}, false},
35		{"resize modal is not scriptable", []string{"cmd", "resize-pane"}, cmdInvocation{}, true},
36		{"session flag", []string{"cmd", "-s", "dev", "new-tab"}, cmdInvocation{slug: "new-tab", session: "dev"}, false},
37		{"json flag", []string{"cmd", "list-panes", "--json"}, cmdInvocation{slug: "list-panes", jsonOut: true}, false},
38		{"self flag", []string{"cmd", "--self", "split-right"}, cmdInvocation{slug: "split-right", self: true}, false},
39		{"self and session conflict", []string{"cmd", "-s", "other", "--self", "split-right"}, cmdInvocation{}, true},
40		{"passthrough args", []string{"cmd", "toast", "-l", "warn", "hello"}, cmdInvocation{slug: "toast", args: []string{"-l", "warn", "hello"}}, false},
41		{"unknown slug", []string{"cmd", "frobnicate"}, cmdInvocation{}, true},
42		{"non-scriptable slug", []string{"cmd", "detach"}, cmdInvocation{}, true},
43		{"session missing value", []string{"cmd", "-s"}, cmdInvocation{}, true},
44		{"unknown leading flag", []string{"cmd", "--wat"}, cmdInvocation{}, true},
45	}
46	for _, tt := range tests {
47		t.Run(tt.name, func(t *testing.T) {
48			got, err := parseArgs(tt.args)
49			if tt.wantErr {
50				var usage *usageError
51				if !errors.As(err, &usage) {
52					t.Fatalf("parseArgs(%q) error = %v, want usage error", tt.args, err)
53				}
54				return
55			}
56			if err != nil {
57				t.Fatalf("parseArgs(%q): %v", tt.args, err)
58			}
59			if got.kind != kindCmd || !reflect.DeepEqual(got.cmd, tt.want) {
60				t.Fatalf("parseArgs(%q) = kind %v, cmd %#v, want %#v", tt.args, got.kind, got.cmd, tt.want)
61			}
62		})
63	}
64}
65
66func TestExitCodes(t *testing.T) {
67	tests := []struct {
68		name string
69		err  error
70		want int
71	}{
72		{"success", nil, 0},
73		{"command", errors.New("command failed"), 1},
74		{"usage", &usageError{msg: "bad args"}, 2},
75		{"unreachable", &exitCoded{code: 3, err: errDaemonUnreachable}, 3},
76	}
77	for _, tt := range tests {
78		t.Run(tt.name, func(t *testing.T) {
79			if got := ExitCode(tt.err); got != tt.want {
80				t.Fatalf("ExitCode(%v) = %d, want %d", tt.err, got, tt.want)
81			}
82		})
83	}
84}
85
86func TestCmdHelpUsesRegistryWithoutDialing(t *testing.T) {
87	called := false
88	out := new(strings.Builder)
89	err := runCmdWithDeps(context.Background(), cmdInvocation{help: true}, cmdDeps{
90		stdout: out,
91		getenv: func(string) string { return "" },
92		dial: func(context.Context, string) (ports.Transport, error) {
93			called = true
94			return nil, errors.New("must not dial")
95		},
96	})
97	if err != nil {
98		t.Fatalf("runCmdWithDeps(help): %v", err)
99	}
100	if called {
101		t.Fatal("help dialed daemon")
102	}
103	for _, want := range []string{"usage: vev cmd", "split-right", "consume-or-expel-pane-left", "consume-or-expel-pane-right", "grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes", "list-panes"} {
104		if !strings.Contains(out.String(), want) {
105			t.Fatalf("help %q missing %q", out.String(), want)
106		}
107	}
108	for _, hidden := range []string{"session-picker", "resize-pane"} {
109		if strings.Contains(out.String(), hidden) {
110			t.Fatalf("help leaked non-scriptable command %q: %q", hidden, out.String())
111		}
112	}
113}
114
115func TestMoveCommandHelpUsesExactUsages(t *testing.T) {
116	for _, tt := range []struct {
117		slug, usage, desc string
118	}{
119		{"move-pane", "move-pane <destination-session> <destination-tab-id>", "Move focused pane to tab"},
120		{"move-tab", "move-tab <destination-session>", "Move active tab to session"},
121	} {
122		t.Run(tt.slug, func(t *testing.T) {
123			got := cmdHelp(cmdInvocation{slug: tt.slug})
124			want := "usage: vev cmd [-s <session>] [--self] " + tt.usage + "\n\n" + tt.desc
125			if got != want {
126				t.Fatalf("cmdHelp() = %q, want %q", got, want)
127			}
128		})
129	}
130}
131
132func TestMoveCmdPreservesPositionalArguments(t *testing.T) {
133	for _, tt := range []struct {
134		slug string
135		args []string
136	}{
137		{slug: "move-pane", args: []string{"work", "t_dest"}},
138		{slug: "move-tab", args: []string{"work"}},
139	} {
140		t.Run(tt.slug, func(t *testing.T) {
141			parsed, err := parseArgs(append([]string{"cmd", tt.slug}, tt.args...))
142			require.NoError(t, err)
143			transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
144			require.NoError(t, runCmdWithDeps(context.Background(), parsed.cmd, cmdDeps{
145				stdout: io.Discard,
146				getenv: func(string) string { return "" },
147				dial:   func(context.Context, string) (ports.Transport, error) { return transport, nil },
148			}))
149			require.Len(t, transport.sent, 1)
150			request, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
151			require.NoError(t, err)
152			require.Equal(t, tt.slug, request.Slug)
153			require.Equal(t, tt.args, request.Args)
154			require.Equal(t, ports.ProtocolVersion, request.Version)
155		})
156	}
157}
158
159func TestRemoteCatalogCommandEnsuresDaemon(t *testing.T) {
160	transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
161	ensureCalls := 0
162	err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "remote-catalog", jsonOut: true}, cmdDeps{
163		stdout: io.Discard,
164		getenv: func(string) string { return "" },
165		dial: func(context.Context, string) (ports.Transport, error) {
166			t.Fatal("remote catalog used non-starting daemon dial")
167			return nil, errors.New("unexpected dial")
168		},
169		ensure: func(context.Context, string) (ports.Transport, error) {
170			ensureCalls++
171			return transport, nil
172		},
173	})
174	require.NoError(t, err)
175	require.Equal(t, 1, ensureCalls)
176}
177
178func TestMoveCmdInvalidArgumentResultExitsTwo(t *testing.T) {
179	for _, invocation := range []cmdInvocation{
180		{slug: "move-pane"},
181		{slug: "move-pane", args: []string{"work", "t_dest", "extra"}},
182		{slug: "move-tab"},
183		{slug: "move-tab", args: []string{"work", "extra"}},
184	} {
185		t.Run(invocation.slug+"/"+strconv.Itoa(len(invocation.args)), func(t *testing.T) {
186			transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{
187				Code: ports.ErrInvalidCommandArgs, Text: "invalid command arguments",
188			})}}
189			err := runCmdWithDeps(context.Background(), invocation, cmdDeps{
190				stdout: io.Discard,
191				getenv: func(string) string { return "" },
192				dial:   func(context.Context, string) (ports.Transport, error) { return transport, nil },
193			})
194			if ExitCode(err) != 2 {
195				t.Fatalf("ExitCode(%v) = %d, want 2", err, ExitCode(err))
196			}
197		})
198	}
199}
200
201func TestTargetPaneCmdsParseAndUseSelfTarget(t *testing.T) {
202	for _, slug := range []string{
203		"grow-pane-width", "shrink-pane-width", "grow-pane-height", "shrink-pane-height", "equalize-panes",
204		"consume-or-expel-pane-left", "consume-or-expel-pane-right",
205	} {
206		t.Run(slug, func(t *testing.T) {
207			invocation, err := parseArgs([]string{"cmd", "--self", slug})
208			require.NoError(t, err)
209			transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
210			require.NoError(t, runCmdWithDeps(context.Background(), invocation.cmd, cmdDeps{
211				stdout: io.Discard,
212				getenv: func(string) string { return "session=work,tab=t_abc,pane=p_def" },
213				dial:   func(context.Context, string) (ports.Transport, error) { return transport, nil },
214			}))
215			require.Len(t, transport.sent, 1)
216			request, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
217			require.NoError(t, err)
218			require.Equal(t, slug, request.Slug)
219			require.True(t, request.Self)
220			require.Equal(t, "work", request.TargetSession)
221			require.Equal(t, "t_abc", request.TargetTab)
222			require.Equal(t, "p_def", request.TargetPane)
223		})
224	}
225}
226
227func TestRunCmdBuildsOneShotTargetedRequest(t *testing.T) {
228	transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true, Output: "done"})}}
229	out := new(strings.Builder)
230	err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right", self: true}, cmdDeps{
231		stdout: out,
232		getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
233		dial:   func(context.Context, string) (ports.Transport, error) { return transport, nil },
234	})
235	if err != nil {
236		t.Fatalf("runCmdWithDeps: %v", err)
237	}
238	if len(transport.sent) != 1 || transport.recvCalls != 1 || transport.closeCalls != 1 {
239		t.Fatalf("transport calls: sent=%d recv=%d close=%d", len(transport.sent), transport.recvCalls, transport.closeCalls)
240	}
241	req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
242	if err != nil {
243		t.Fatalf("decode request: %v", err)
244	}
245	if transport.sent[0].Type != ports.MsgCommand || req.Slug != "split-right" || !req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" {
246		t.Fatalf("request = type %d %+v", transport.sent[0].Type, req)
247	}
248	if out.String() != "done\n" {
249		t.Fatalf("output = %q", out.String())
250	}
251}
252
253func TestRunCmdVEVWithoutSelfUsesIDsOnlyAsSessionLocator(t *testing.T) {
254	transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
255	err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
256		stdout: io.Discard,
257		getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
258		dial:   func(context.Context, string) (ports.Transport, error) { return transport, nil },
259	})
260	if err != nil {
261		t.Fatalf("runCmdWithDeps: %v", err)
262	}
263	req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
264	if err != nil {
265		t.Fatalf("decode request: %v", err)
266	}
267	if req.Self || req.TargetSession != "old" || req.TargetTab != "t_abc" || req.TargetPane != "p_def" {
268		t.Fatalf("VEV locator request = %+v", req)
269	}
270}
271
272func TestRunCmdExplicitSessionDoesNotUseEnvIDsWithoutSelf(t *testing.T) {
273	transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{OK: true})}}
274	err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "new-tab", session: "current"}, cmdDeps{
275		stdout: io.Discard,
276		getenv: func(string) string { return "session=old,tab=t_abc,pane=p_def" },
277		dial:   func(context.Context, string) (ports.Transport, error) { return transport, nil },
278	})
279	if err != nil {
280		t.Fatalf("runCmdWithDeps: %v", err)
281	}
282	req, err := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
283	if err != nil {
284		t.Fatalf("decode request: %v", err)
285	}
286	if req.TargetSession != "current" || req.TargetTab != "" || req.TargetPane != "" {
287		t.Fatalf("explicit targeting request = %+v", req)
288	}
289}
290
291func TestRunCmdRejectsTooManyArgumentsBeforeDialing(t *testing.T) {
292	dialed := false
293	err := runCmdWithDeps(context.Background(), cmdInvocation{
294		slug: "toast",
295		args: make([]string, math.MaxUint16+1),
296	}, cmdDeps{
297		stdout: io.Discard,
298		getenv: func(string) string { return "" },
299		dial: func(context.Context, string) (ports.Transport, error) {
300			dialed = true
301			return nil, errors.New("must not dial")
302		},
303	})
304
305	if ExitCode(err) != 2 {
306		t.Fatalf("error=%v code=%d, want usage error/2", err, ExitCode(err))
307	}
308	if dialed {
309		t.Fatal("oversized command dialed daemon")
310	}
311}
312
313func TestRunCmdTimesOutPendingRequest(t *testing.T) {
314	timerCh := make(chan time.Time, 1)
315	timer := portsmocks.NewMockTimer(t)
316	timer.EXPECT().C().Return(timerCh).Once()
317	timer.EXPECT().Stop().Return(true).Once()
318	delayCh := make(chan time.Duration, 1)
319	clock := portsmocks.NewMockClock(t)
320	clock.EXPECT().NewTimer(mock.Anything).Run(func(delay time.Duration) {
321		delayCh <- delay
322	}).Return(timer).Once()
323	recvStarted := make(chan struct{})
324	recvGate := make(chan struct{})
325	transport := &cmdTestTransport{recvStarted: recvStarted, recvGate: recvGate}
326	done := make(chan error, 1)
327	go func() {
328		done <- runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
329			stdout: io.Discard,
330			getenv: func(string) string { return "" },
331			dial:   func(context.Context, string) (ports.Transport, error) { return transport, nil },
332			clock:  clock,
333		})
334	}()
335	<-recvStarted
336	if delay := <-delayCh; delay != daemon.CommandRequestTimeout {
337		t.Fatalf("command timer delay = %s, want %s", delay, daemon.CommandRequestTimeout)
338	}
339	timerCh <- time.Time{}
340	err := <-done
341	close(recvGate)
342	if ExitCode(err) != 3 || !errors.Is(err, daemon.ErrCommandRequestTimeout) {
343		t.Fatalf("timeout error=%v code=%d", err, ExitCode(err))
344	}
345	request, decodeErr := ports.UnmarshalCommandRequest(transport.sent[0].Payload)
346	if decodeErr != nil || request.RequestID == 0 {
347		t.Fatalf("request=%+v decode=%v, want non-zero request ID", request, decodeErr)
348	}
349}
350
351func TestRunCmdZeroRequestIDReplyReturnsDaemonError(t *testing.T) {
352	const want = "daemon error"
353	transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(ports.CommandResult{
354		Code: ports.ErrInternal, Text: want,
355	})}}
356	err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
357		stdout: io.Discard,
358		getenv: func(string) string { return "" },
359		dial:   func(context.Context, string) (ports.Transport, error) { return transport, nil },
360	})
361	if err == nil || err.Error() != want {
362		t.Fatalf("error = %v, want daemon error %q", err, want)
363	}
364	if errors.Is(err, daemon.ErrCommandRequestTimeout) {
365		t.Fatalf("error = %v, want daemon error instead of timeout", err)
366	}
367}
368
369func TestRunCmdDoesNotAutostartAndClassifiesDialFailure(t *testing.T) {
370	dials := 0
371	dialErr := errors.New("unreachable")
372	err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
373		stdout: io.Discard,
374		getenv: func(string) string { return "" },
375		dial: func(context.Context, string) (ports.Transport, error) {
376			dials++
377			return nil, dialErr
378		},
379	})
380	if ExitCode(err) != 3 || dials != 1 {
381		t.Fatalf("error=%v code=%d dials=%d, want unreachable/3 and one dial", err, ExitCode(err), dials)
382	}
383	if !errors.Is(err, dialErr) {
384		t.Fatalf("error=%v does not wrap dial error", err)
385	}
386}
387
388func TestRunCmdClassifiesDaemonCommandErrors(t *testing.T) {
389	tests := []struct {
390		name     string
391		result   ports.CommandResult
392		wantCode int
393	}{
394		{
395			name:     "invalid command arguments are usage errors",
396			result:   ports.CommandResult{Code: ports.ErrInvalidCommandArgs, Text: "usage: toast [-l level] <message>"},
397			wantCode: 2,
398		},
399		{
400			name:     "missing runtime target is a command failure",
401			result:   ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "no live sessions"},
402			wantCode: 1,
403		},
404		{
405			name:     "resize not in split remains an exit one failure",
406			result:   ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "pane is not in a split"},
407			wantCode: 1,
408		},
409		{
410			name:     "resize minimum remains an exit one failure",
411			result:   ports.CommandResult{Code: ports.ErrNoSuchTarget, Text: "pane cannot be resized further"},
412			wantCode: 1,
413		},
414	}
415	for _, tt := range tests {
416		t.Run(tt.name, func(t *testing.T) {
417			transport := &cmdTestTransport{recv: ports.Frame{Type: ports.MsgCommandResult, Payload: ports.MarshalCommandResult(tt.result)}}
418			err := runCmdWithDeps(context.Background(), cmdInvocation{slug: "split-right"}, cmdDeps{
419				stdout: io.Discard,
420				getenv: func(string) string { return "" },
421				dial:   func(context.Context, string) (ports.Transport, error) { return transport, nil },
422			})
423			if err == nil || ExitCode(err) != tt.wantCode || err.Error() != tt.result.Text {
424				t.Fatalf("error=%v code=%d, want message %q and code %d", err, ExitCode(err), tt.result.Text, tt.wantCode)
425			}
426		})
427	}
428}
429
430type cmdTestTransport struct {
431	sent        []ports.Frame
432	recv        ports.Frame
433	recvErr     error
434	recvCalls   int
435	closeCalls  int
436	recvStarted chan struct{}
437	recvGate    <-chan struct{}
438}
439
440func (t *cmdTestTransport) Send(frame ports.Frame) error {
441	t.sent = append(t.sent, frame)
442	return nil
443}
444
445func (t *cmdTestTransport) Recv() (ports.Frame, error) {
446	t.recvCalls++
447	if t.recvStarted != nil {
448		close(t.recvStarted)
449	}
450	if t.recvGate != nil {
451		<-t.recvGate
452	}
453	return t.recv, t.recvErr
454}
455
456func (t *cmdTestTransport) Close() error {
457	t.closeCalls++
458	return nil
459}

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