v0.1.0 7deb448

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

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